1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
12use crate::controller::{
13 PermissionCategory, PermissionRequest, PermissionResponse, PermissionScope, TurnId,
14};
15use ratatui::{
16 layout::Rect,
17 style::{Modifier, Style},
18 text::{Line, Span},
19 widgets::{Block, Borders, Clear, Paragraph},
20 Frame,
21};
22
23use crate::tui::themes::Theme;
24
25const MAX_PANEL_PERCENT: u16 = 50;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PermissionOption {
31 GrantOnce,
33 GrantSession,
35 Deny,
37}
38
39impl PermissionOption {
40 pub fn all() -> &'static [PermissionOption] {
42 &[
43 PermissionOption::GrantOnce,
44 PermissionOption::GrantSession,
45 PermissionOption::Deny,
46 ]
47 }
48
49 pub fn label(&self) -> &'static str {
51 match self {
52 PermissionOption::GrantOnce => "Grant Once",
53 PermissionOption::GrantSession => "Grant for Session",
54 PermissionOption::Deny => "Deny",
55 }
56 }
57
58 pub fn description(&self) -> &'static str {
60 match self {
61 PermissionOption::GrantOnce => "Allow this action this one time",
62 PermissionOption::GrantSession => "Allow this action for the rest of the session",
63 PermissionOption::Deny => "Reject this permission request",
64 }
65 }
66
67 pub fn to_response(&self) -> PermissionResponse {
69 match self {
70 PermissionOption::GrantOnce => PermissionResponse {
71 granted: true,
72 scope: Some(PermissionScope::Once),
73 message: None,
74 },
75 PermissionOption::GrantSession => PermissionResponse {
76 granted: true,
77 scope: Some(PermissionScope::Session),
78 message: None,
79 },
80 PermissionOption::Deny => PermissionResponse {
81 granted: false,
82 scope: None,
83 message: None,
84 },
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub enum KeyAction {
92 None,
94 Selected(String, PermissionResponse),
96 Cancelled(String),
98}
99
100pub struct PermissionPanel {
102 active: bool,
104 tool_use_id: String,
106 session_id: i64,
108 request: PermissionRequest,
110 turn_id: Option<TurnId>,
112 selected_idx: usize,
114}
115
116impl PermissionPanel {
117 pub fn new() -> Self {
119 Self {
120 active: false,
121 tool_use_id: String::new(),
122 session_id: 0,
123 request: PermissionRequest {
124 action: String::new(),
125 reason: None,
126 resources: Vec::new(),
127 category: PermissionCategory::Other,
128 },
129 turn_id: None,
130 selected_idx: 0,
131 }
132 }
133
134 pub fn activate(
136 &mut self,
137 tool_use_id: String,
138 session_id: i64,
139 request: PermissionRequest,
140 turn_id: Option<TurnId>,
141 ) {
142 self.active = true;
143 self.tool_use_id = tool_use_id;
144 self.session_id = session_id;
145 self.request = request;
146 self.turn_id = turn_id;
147 self.selected_idx = 0; }
149
150 pub fn deactivate(&mut self) {
152 self.active = false;
153 self.tool_use_id.clear();
154 self.request.action.clear();
155 self.request.reason = None;
156 self.request.resources.clear();
157 self.turn_id = None;
158 self.selected_idx = 0;
159 }
160
161 pub fn is_active(&self) -> bool {
163 self.active
164 }
165
166 pub fn tool_use_id(&self) -> &str {
168 &self.tool_use_id
169 }
170
171 pub fn session_id(&self) -> i64 {
173 self.session_id
174 }
175
176 pub fn request(&self) -> &PermissionRequest {
178 &self.request
179 }
180
181 pub fn turn_id(&self) -> Option<&TurnId> {
183 self.turn_id.as_ref()
184 }
185
186 pub fn selected_option(&self) -> PermissionOption {
188 PermissionOption::all()[self.selected_idx]
189 }
190
191 pub fn select_next(&mut self) {
193 let options = PermissionOption::all();
194 self.selected_idx = (self.selected_idx + 1) % options.len();
195 }
196
197 pub fn select_prev(&mut self) {
199 let options = PermissionOption::all();
200 if self.selected_idx == 0 {
201 self.selected_idx = options.len() - 1;
202 } else {
203 self.selected_idx -= 1;
204 }
205 }
206
207 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
211 if !self.active {
212 return KeyAction::None;
213 }
214
215 match key.code {
216 KeyCode::Up | KeyCode::Char('k') => {
218 self.select_prev();
219 KeyAction::None
220 }
221 KeyCode::Down | KeyCode::Char('j') => {
222 self.select_next();
223 KeyAction::None
224 }
225 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
226 self.select_prev();
227 KeyAction::None
228 }
229 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
230 self.select_next();
231 KeyAction::None
232 }
233
234 KeyCode::Enter | KeyCode::Char(' ') => {
236 let option = self.selected_option();
237 let response = option.to_response();
238 let tool_use_id = self.tool_use_id.clone();
239 KeyAction::Selected(tool_use_id, response)
241 }
242
243 KeyCode::Esc => {
245 let tool_use_id = self.tool_use_id.clone();
246 KeyAction::Cancelled(tool_use_id)
248 }
249
250 _ => KeyAction::None,
251 }
252 }
253
254 pub fn panel_height(&self, max_height: u16) -> u16 {
256 let mut lines = 0u16;
270
271 lines += 2; lines += 1; lines += 1; if self.request.reason.is_some() {
278 lines += 1;
279 }
280 if !self.request.resources.is_empty() {
281 lines += 1 + self.request.resources.len().min(5) as u16; }
283
284 lines += 1; lines += PermissionOption::all().len() as u16;
287
288 lines += 1; lines += 1; lines += 2; let max_from_percent = (max_height * MAX_PANEL_PERCENT) / 100;
295 lines.min(max_from_percent).min(max_height.saturating_sub(6))
296 }
297
298 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
305 if !self.active {
306 return;
307 }
308
309 frame.render_widget(Clear, area);
311
312 let inner_width = area.width.saturating_sub(4) as usize;
313 let mut lines: Vec<Line> = Vec::new();
314
315 let help = " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
317 lines.push(Line::from(Span::styled(
318 truncate_text(help, inner_width),
319 theme.help_text(),
320 )));
321 lines.push(Line::from("")); let category_icon = match self.request.category {
325 PermissionCategory::FileWrite => "\u{270E}", PermissionCategory::FileDelete => "\u{2717}", PermissionCategory::Network => "\u{2194}", PermissionCategory::System => "\u{2295}", PermissionCategory::Other => "\u{25CB}", };
331 lines.push(Line::from(vec![
332 Span::styled(
333 format!(" {} ", category_icon),
334 theme.category(),
335 ),
336 Span::styled(
337 format!("{}", self.request.category),
338 theme.category().add_modifier(Modifier::BOLD),
339 ),
340 ]));
341
342 lines.push(Line::from(vec![
344 Span::styled(" Action: ", theme.muted_text()),
345 Span::styled(
346 truncate_text(&self.request.action, inner_width - 10),
347 Style::default().add_modifier(Modifier::BOLD),
348 ),
349 ]));
350
351 if let Some(ref reason) = self.request.reason {
353 lines.push(Line::from(vec![
354 Span::styled(" Reason: ", theme.muted_text()),
355 Span::styled(
356 truncate_text(reason, inner_width - 10),
357 theme.muted_text(),
358 ),
359 ]));
360 }
361
362 if !self.request.resources.is_empty() {
364 lines.push(Line::from(Span::styled(
365 " Resources:",
366 theme.muted_text(),
367 )));
368 for (i, resource) in self.request.resources.iter().take(5).enumerate() {
369 let prefix = if i < self.request.resources.len() - 1 || self.request.resources.len() <= 5 {
370 " \u{251C}\u{2500} " } else {
372 " \u{2514}\u{2500} " };
374 lines.push(Line::from(vec![
375 Span::raw(prefix),
376 Span::styled(
377 truncate_text(resource, inner_width - 8),
378 theme.resource(),
379 ),
380 ]));
381 }
382 if self.request.resources.len() > 5 {
383 lines.push(Line::from(Span::styled(
384 format!(" ... and {} more", self.request.resources.len() - 5),
385 theme.muted_text(),
386 )));
387 }
388 }
389
390 lines.push(Line::from(""));
392
393 const INDICATOR: &str = " \u{203A} "; const NO_INDICATOR: &str = " ";
396
397 for (idx, option) in PermissionOption::all().iter().enumerate() {
399 let is_selected = idx == self.selected_idx;
400 let prefix = if is_selected { INDICATOR } else { NO_INDICATOR };
401
402 let (label_style, desc_style) = if is_selected {
403 match option {
404 PermissionOption::GrantOnce | PermissionOption::GrantSession => {
405 (theme.button_confirm_focused(), theme.focused_text())
406 }
407 PermissionOption::Deny => {
408 (theme.button_cancel_focused(), theme.focused_text())
409 }
410 }
411 } else {
412 match option {
413 PermissionOption::GrantOnce | PermissionOption::GrantSession => {
414 (theme.button_confirm(), theme.muted_text())
415 }
416 PermissionOption::Deny => {
417 (theme.button_cancel(), theme.muted_text())
418 }
419 }
420 };
421
422 let indicator_style = if is_selected {
423 theme.focus_indicator()
424 } else {
425 theme.muted_text()
426 };
427
428 lines.push(Line::from(vec![
429 Span::styled(prefix, indicator_style),
430 Span::styled(option.label(), label_style),
431 Span::styled(" - ", theme.muted_text()),
432 Span::styled(option.description(), desc_style),
433 ]));
434 }
435
436 let block = Block::default()
438 .borders(Borders::ALL)
439 .border_style(theme.warning())
440 .title(Span::styled(
441 " Permission Required ",
442 theme.warning().add_modifier(Modifier::BOLD),
443 ));
444
445 let paragraph = Paragraph::new(lines).block(block);
446 frame.render_widget(paragraph, area);
447 }
448}
449
450impl Default for PermissionPanel {
451 fn default() -> Self {
452 Self::new()
453 }
454}
455
456use std::any::Any;
459use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
460
461impl Widget for PermissionPanel {
462 fn id(&self) -> &'static str {
463 widget_ids::PERMISSION_PANEL
464 }
465
466 fn priority(&self) -> u8 {
467 200 }
469
470 fn is_active(&self) -> bool {
471 self.active
472 }
473
474 fn handle_key(&mut self, key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
475 if !self.active {
476 return WidgetKeyResult::NotHandled;
477 }
478
479 match self.process_key(key) {
480 KeyAction::Selected(tool_use_id, response) => {
481 WidgetKeyResult::Action(WidgetAction::SubmitPermission {
482 tool_use_id,
483 response,
484 })
485 }
486 KeyAction::Cancelled(tool_use_id) => {
487 WidgetKeyResult::Action(WidgetAction::CancelPermission { tool_use_id })
488 }
489 KeyAction::None => WidgetKeyResult::Handled,
490 }
491 }
492
493 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
494 self.render_panel(frame, area, theme);
495 }
496
497 fn required_height(&self, max_height: u16) -> u16 {
498 if self.active {
499 self.panel_height(max_height)
500 } else {
501 0
502 }
503 }
504
505 fn blocks_input(&self) -> bool {
506 self.active
507 }
508
509 fn is_overlay(&self) -> bool {
510 false
511 }
512
513 fn as_any(&self) -> &dyn Any {
514 self
515 }
516
517 fn as_any_mut(&mut self) -> &mut dyn Any {
518 self
519 }
520
521 fn into_any(self: Box<Self>) -> Box<dyn Any> {
522 self
523 }
524}
525
526fn truncate_text(text: &str, max_width: usize) -> String {
528 if text.chars().count() <= max_width {
529 text.to_string()
530 } else {
531 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
532 format!("{}...", truncated)
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn test_permission_option_all() {
542 let options = PermissionOption::all();
543 assert_eq!(options.len(), 3);
544 assert_eq!(options[0], PermissionOption::GrantOnce);
545 assert_eq!(options[1], PermissionOption::GrantSession);
546 assert_eq!(options[2], PermissionOption::Deny);
547 }
548
549 #[test]
550 fn test_permission_option_to_response() {
551 let once = PermissionOption::GrantOnce.to_response();
552 assert!(once.granted);
553 assert_eq!(once.scope, Some(PermissionScope::Once));
554
555 let session = PermissionOption::GrantSession.to_response();
556 assert!(session.granted);
557 assert_eq!(session.scope, Some(PermissionScope::Session));
558
559 let deny = PermissionOption::Deny.to_response();
560 assert!(!deny.granted);
561 assert!(deny.scope.is_none());
562 }
563
564 #[test]
565 fn test_panel_activation() {
566 let mut panel = PermissionPanel::new();
567 assert!(!panel.is_active());
568
569 let request = PermissionRequest {
570 action: "Delete file".to_string(),
571 reason: Some("Cleanup".to_string()),
572 resources: vec!["/tmp/foo.txt".to_string()],
573 category: PermissionCategory::FileDelete,
574 };
575
576 panel.activate("tool_123".to_string(), 1, request, None);
577 assert!(panel.is_active());
578 assert_eq!(panel.tool_use_id(), "tool_123");
579 assert_eq!(panel.session_id(), 1);
580
581 panel.deactivate();
582 assert!(!panel.is_active());
583 }
584
585 #[test]
586 fn test_navigation() {
587 let mut panel = PermissionPanel::new();
588 let request = PermissionRequest {
589 action: "Test".to_string(),
590 reason: None,
591 resources: vec![],
592 category: PermissionCategory::Other,
593 };
594 panel.activate("tool_1".to_string(), 1, request, None);
595
596 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
598
599 panel.select_next();
601 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
602
603 panel.select_next();
604 assert_eq!(panel.selected_option(), PermissionOption::Deny);
605
606 panel.select_next();
608 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
609
610 panel.select_prev();
612 assert_eq!(panel.selected_option(), PermissionOption::Deny);
613 }
614
615 #[test]
616 fn test_handle_key_navigation() {
617 let mut panel = PermissionPanel::new();
618 let request = PermissionRequest {
619 action: "Test".to_string(),
620 reason: None,
621 resources: vec![],
622 category: PermissionCategory::Other,
623 };
624 panel.activate("tool_1".to_string(), 1, request, None);
625
626 let action = panel.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
628 assert_eq!(action, KeyAction::None);
629 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
630
631 let action = panel.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
633 assert_eq!(action, KeyAction::None);
634 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
635 }
636
637 #[test]
638 fn test_handle_key_selection() {
639 let mut panel = PermissionPanel::new();
640 let request = PermissionRequest {
641 action: "Test".to_string(),
642 reason: None,
643 resources: vec![],
644 category: PermissionCategory::Other,
645 };
646 panel.activate("tool_1".to_string(), 1, request, None);
647
648 let action = panel.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
650 match action {
651 KeyAction::Selected(tool_use_id, response) => {
652 assert_eq!(tool_use_id, "tool_1");
653 assert!(response.granted);
654 assert_eq!(response.scope, Some(PermissionScope::Once));
655 }
656 _ => panic!("Expected Selected action"),
657 }
658 panel.deactivate();
660 assert!(!panel.is_active());
661 }
662
663 #[test]
664 fn test_handle_key_cancel() {
665 let mut panel = PermissionPanel::new();
666 let request = PermissionRequest {
667 action: "Test".to_string(),
668 reason: None,
669 resources: vec![],
670 category: PermissionCategory::Other,
671 };
672 panel.activate("tool_1".to_string(), 1, request, None);
673
674 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
676 match action {
677 KeyAction::Cancelled(tool_use_id) => {
678 assert_eq!(tool_use_id, "tool_1");
679 }
680 _ => panic!("Expected Cancelled action"),
681 }
682 panel.deactivate();
684 assert!(!panel.is_active());
685 }
686
687 #[test]
688 fn test_truncate_text() {
689 assert_eq!(truncate_text("short", 10), "short");
690 assert_eq!(truncate_text("this is a longer text", 10), "this is...");
691 assert_eq!(truncate_text("exact", 5), "exact");
692 }
693}