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
25pub mod defaults {
27 pub const MAX_PANEL_PERCENT: u16 = 50;
29 pub const SELECTION_INDICATOR: &str = " \u{203A} ";
31 pub const NO_INDICATOR: &str = " ";
33 pub const TITLE: &str = " Permission Required ";
35 pub const HELP_TEXT: &str = " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
37 pub const ICON_FILE_WRITE: &str = "\u{270E}";
39 pub const ICON_FILE_DELETE: &str = "\u{2717}";
40 pub const ICON_NETWORK: &str = "\u{2194}";
41 pub const ICON_SYSTEM: &str = "\u{2295}";
42 pub const ICON_OTHER: &str = "\u{25CB}";
43 pub const TREE_BRANCH: &str = " \u{251C}\u{2500} ";
45 pub const TREE_LAST: &str = " \u{2514}\u{2500} ";
46}
47
48#[derive(Clone)]
50pub struct PermissionPanelConfig {
51 pub max_panel_percent: u16,
53 pub selection_indicator: String,
55 pub no_indicator: String,
57 pub title: String,
59 pub help_text: String,
61 pub icon_file_write: String,
63 pub icon_file_delete: String,
65 pub icon_network: String,
67 pub icon_system: String,
69 pub icon_other: String,
71 pub tree_branch: String,
73 pub tree_last: String,
75}
76
77impl Default for PermissionPanelConfig {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl PermissionPanelConfig {
84 pub fn new() -> Self {
86 Self {
87 max_panel_percent: defaults::MAX_PANEL_PERCENT,
88 selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
89 no_indicator: defaults::NO_INDICATOR.to_string(),
90 title: defaults::TITLE.to_string(),
91 help_text: defaults::HELP_TEXT.to_string(),
92 icon_file_write: defaults::ICON_FILE_WRITE.to_string(),
93 icon_file_delete: defaults::ICON_FILE_DELETE.to_string(),
94 icon_network: defaults::ICON_NETWORK.to_string(),
95 icon_system: defaults::ICON_SYSTEM.to_string(),
96 icon_other: defaults::ICON_OTHER.to_string(),
97 tree_branch: defaults::TREE_BRANCH.to_string(),
98 tree_last: defaults::TREE_LAST.to_string(),
99 }
100 }
101
102 pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
104 self.max_panel_percent = percent;
105 self
106 }
107
108 pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
110 self.selection_indicator = indicator.into();
111 self
112 }
113
114 pub fn with_title(mut self, title: impl Into<String>) -> Self {
116 self.title = title.into();
117 self
118 }
119
120 pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
122 self.help_text = text.into();
123 self
124 }
125
126 pub fn with_category_icons(
128 mut self,
129 file_write: impl Into<String>,
130 file_delete: impl Into<String>,
131 network: impl Into<String>,
132 system: impl Into<String>,
133 other: impl Into<String>,
134 ) -> Self {
135 self.icon_file_write = file_write.into();
136 self.icon_file_delete = file_delete.into();
137 self.icon_network = network.into();
138 self.icon_system = system.into();
139 self.icon_other = other.into();
140 self
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum PermissionOption {
147 GrantOnce,
149 GrantSession,
151 Deny,
153}
154
155impl PermissionOption {
156 pub fn all() -> &'static [PermissionOption] {
158 &[
159 PermissionOption::GrantOnce,
160 PermissionOption::GrantSession,
161 PermissionOption::Deny,
162 ]
163 }
164
165 pub fn label(&self) -> &'static str {
167 match self {
168 PermissionOption::GrantOnce => "Grant Once",
169 PermissionOption::GrantSession => "Grant for Session",
170 PermissionOption::Deny => "Deny",
171 }
172 }
173
174 pub fn description(&self) -> &'static str {
176 match self {
177 PermissionOption::GrantOnce => "Allow this action this one time",
178 PermissionOption::GrantSession => "Allow this action for the rest of the session",
179 PermissionOption::Deny => "Reject this permission request",
180 }
181 }
182
183 pub fn to_response(&self) -> PermissionResponse {
185 match self {
186 PermissionOption::GrantOnce => PermissionResponse {
187 granted: true,
188 scope: Some(PermissionScope::Once),
189 message: None,
190 },
191 PermissionOption::GrantSession => PermissionResponse {
192 granted: true,
193 scope: Some(PermissionScope::Session),
194 message: None,
195 },
196 PermissionOption::Deny => PermissionResponse {
197 granted: false,
198 scope: None,
199 message: None,
200 },
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq)]
207pub enum KeyAction {
208 None,
210 Selected(String, PermissionResponse),
212 Cancelled(String),
214}
215
216pub struct PermissionPanel {
218 active: bool,
220 tool_use_id: String,
222 session_id: i64,
224 request: PermissionRequest,
226 turn_id: Option<TurnId>,
228 selected_idx: usize,
230 config: PermissionPanelConfig,
232}
233
234impl PermissionPanel {
235 pub fn new() -> Self {
237 Self::with_config(PermissionPanelConfig::new())
238 }
239
240 pub fn with_config(config: PermissionPanelConfig) -> Self {
242 Self {
243 active: false,
244 tool_use_id: String::new(),
245 session_id: 0,
246 request: PermissionRequest {
247 action: String::new(),
248 reason: None,
249 resources: Vec::new(),
250 category: PermissionCategory::Other,
251 },
252 turn_id: None,
253 selected_idx: 0,
254 config,
255 }
256 }
257
258 pub fn config(&self) -> &PermissionPanelConfig {
260 &self.config
261 }
262
263 pub fn set_config(&mut self, config: PermissionPanelConfig) {
265 self.config = config;
266 }
267
268 pub fn activate(
270 &mut self,
271 tool_use_id: String,
272 session_id: i64,
273 request: PermissionRequest,
274 turn_id: Option<TurnId>,
275 ) {
276 self.active = true;
277 self.tool_use_id = tool_use_id;
278 self.session_id = session_id;
279 self.request = request;
280 self.turn_id = turn_id;
281 self.selected_idx = 0; }
283
284 pub fn deactivate(&mut self) {
286 self.active = false;
287 self.tool_use_id.clear();
288 self.request.action.clear();
289 self.request.reason = None;
290 self.request.resources.clear();
291 self.turn_id = None;
292 self.selected_idx = 0;
293 }
294
295 pub fn is_active(&self) -> bool {
297 self.active
298 }
299
300 pub fn tool_use_id(&self) -> &str {
302 &self.tool_use_id
303 }
304
305 pub fn session_id(&self) -> i64 {
307 self.session_id
308 }
309
310 pub fn request(&self) -> &PermissionRequest {
312 &self.request
313 }
314
315 pub fn turn_id(&self) -> Option<&TurnId> {
317 self.turn_id.as_ref()
318 }
319
320 pub fn selected_option(&self) -> PermissionOption {
322 PermissionOption::all()[self.selected_idx]
323 }
324
325 pub fn select_next(&mut self) {
327 let options = PermissionOption::all();
328 self.selected_idx = (self.selected_idx + 1) % options.len();
329 }
330
331 pub fn select_prev(&mut self) {
333 let options = PermissionOption::all();
334 if self.selected_idx == 0 {
335 self.selected_idx = options.len() - 1;
336 } else {
337 self.selected_idx -= 1;
338 }
339 }
340
341 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
345 if !self.active {
346 return KeyAction::None;
347 }
348
349 match key.code {
350 KeyCode::Up | KeyCode::Char('k') => {
352 self.select_prev();
353 KeyAction::None
354 }
355 KeyCode::Down | KeyCode::Char('j') => {
356 self.select_next();
357 KeyAction::None
358 }
359 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
360 self.select_prev();
361 KeyAction::None
362 }
363 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
364 self.select_next();
365 KeyAction::None
366 }
367
368 KeyCode::Enter | KeyCode::Char(' ') => {
370 let option = self.selected_option();
371 let response = option.to_response();
372 let tool_use_id = self.tool_use_id.clone();
373 KeyAction::Selected(tool_use_id, response)
375 }
376
377 KeyCode::Esc => {
379 let tool_use_id = self.tool_use_id.clone();
380 KeyAction::Cancelled(tool_use_id)
382 }
383
384 _ => KeyAction::None,
385 }
386 }
387
388 pub fn panel_height(&self, max_height: u16) -> u16 {
390 let mut lines = 0u16;
404
405 lines += 2; lines += 1; lines += 1; if self.request.reason.is_some() {
412 lines += 1;
413 }
414 if !self.request.resources.is_empty() {
415 lines += 1 + self.request.resources.len().min(5) as u16; }
417
418 lines += 1; lines += PermissionOption::all().len() as u16;
421
422 lines += 1; lines += 1; lines += 2; let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
429 lines.min(max_from_percent).min(max_height.saturating_sub(6))
430 }
431
432 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
439 if !self.active {
440 return;
441 }
442
443 frame.render_widget(Clear, area);
445
446 let inner_width = area.width.saturating_sub(4) as usize;
447 let mut lines: Vec<Line> = Vec::new();
448
449 lines.push(Line::from(Span::styled(
451 truncate_text(&self.config.help_text, inner_width),
452 theme.help_text(),
453 )));
454 lines.push(Line::from("")); let category_icon = match self.request.category {
458 PermissionCategory::FileWrite => &self.config.icon_file_write,
459 PermissionCategory::FileDelete => &self.config.icon_file_delete,
460 PermissionCategory::Network => &self.config.icon_network,
461 PermissionCategory::System => &self.config.icon_system,
462 PermissionCategory::Other => &self.config.icon_other,
463 };
464 lines.push(Line::from(vec![
465 Span::styled(
466 format!(" {} ", category_icon),
467 theme.category(),
468 ),
469 Span::styled(
470 format!("{}", self.request.category),
471 theme.category().add_modifier(Modifier::BOLD),
472 ),
473 ]));
474
475 lines.push(Line::from(vec![
477 Span::styled(" Action: ", theme.muted_text()),
478 Span::styled(
479 truncate_text(&self.request.action, inner_width - 10),
480 Style::default().add_modifier(Modifier::BOLD),
481 ),
482 ]));
483
484 if let Some(ref reason) = self.request.reason {
486 lines.push(Line::from(vec![
487 Span::styled(" Reason: ", theme.muted_text()),
488 Span::styled(
489 truncate_text(reason, inner_width - 10),
490 theme.muted_text(),
491 ),
492 ]));
493 }
494
495 if !self.request.resources.is_empty() {
497 lines.push(Line::from(Span::styled(
498 " Resources:",
499 theme.muted_text(),
500 )));
501 for (i, resource) in self.request.resources.iter().take(5).enumerate() {
502 let prefix = if i < self.request.resources.len() - 1 || self.request.resources.len() <= 5 {
503 &self.config.tree_branch
504 } else {
505 &self.config.tree_last
506 };
507 lines.push(Line::from(vec![
508 Span::raw(prefix.clone()),
509 Span::styled(
510 truncate_text(resource, inner_width - 8),
511 theme.resource(),
512 ),
513 ]));
514 }
515 if self.request.resources.len() > 5 {
516 lines.push(Line::from(Span::styled(
517 format!(" ... and {} more", self.request.resources.len() - 5),
518 theme.muted_text(),
519 )));
520 }
521 }
522
523 lines.push(Line::from(""));
525
526 for (idx, option) in PermissionOption::all().iter().enumerate() {
528 let is_selected = idx == self.selected_idx;
529 let prefix = if is_selected { &self.config.selection_indicator } else { &self.config.no_indicator };
530
531 let (label_style, desc_style) = if is_selected {
532 match option {
533 PermissionOption::GrantOnce | PermissionOption::GrantSession => {
534 (theme.button_confirm_focused(), theme.focused_text())
535 }
536 PermissionOption::Deny => {
537 (theme.button_cancel_focused(), theme.focused_text())
538 }
539 }
540 } else {
541 match option {
542 PermissionOption::GrantOnce | PermissionOption::GrantSession => {
543 (theme.button_confirm(), theme.muted_text())
544 }
545 PermissionOption::Deny => {
546 (theme.button_cancel(), theme.muted_text())
547 }
548 }
549 };
550
551 let indicator_style = if is_selected {
552 theme.focus_indicator()
553 } else {
554 theme.muted_text()
555 };
556
557 lines.push(Line::from(vec![
558 Span::styled(prefix.clone(), indicator_style),
559 Span::styled(option.label(), label_style),
560 Span::styled(" - ", theme.muted_text()),
561 Span::styled(option.description(), desc_style),
562 ]));
563 }
564
565 let block = Block::default()
567 .borders(Borders::ALL)
568 .border_style(theme.warning())
569 .title(Span::styled(
570 self.config.title.clone(),
571 theme.warning().add_modifier(Modifier::BOLD),
572 ));
573
574 let paragraph = Paragraph::new(lines).block(block);
575 frame.render_widget(paragraph, area);
576 }
577}
578
579impl Default for PermissionPanel {
580 fn default() -> Self {
581 Self::new()
582 }
583}
584
585use std::any::Any;
588use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
589
590impl Widget for PermissionPanel {
591 fn id(&self) -> &'static str {
592 widget_ids::PERMISSION_PANEL
593 }
594
595 fn priority(&self) -> u8 {
596 200 }
598
599 fn is_active(&self) -> bool {
600 self.active
601 }
602
603 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
604 if !self.active {
605 return WidgetKeyResult::NotHandled;
606 }
607
608 if ctx.nav.is_move_up(&key) {
610 self.select_prev();
611 return WidgetKeyResult::Handled;
612 }
613 if ctx.nav.is_move_down(&key) {
614 self.select_next();
615 return WidgetKeyResult::Handled;
616 }
617
618 if ctx.nav.is_select(&key) {
620 let option = self.selected_option();
621 let response = option.to_response();
622 let tool_use_id = self.tool_use_id.clone();
623 return WidgetKeyResult::Action(WidgetAction::SubmitPermission {
624 tool_use_id,
625 response,
626 });
627 }
628
629 if ctx.nav.is_cancel(&key) {
631 let tool_use_id = self.tool_use_id.clone();
632 return WidgetKeyResult::Action(WidgetAction::CancelPermission { tool_use_id });
633 }
634
635 match key.code {
637 KeyCode::Char('k') => {
638 self.select_prev();
639 WidgetKeyResult::Handled
640 }
641 KeyCode::Char('j') => {
642 self.select_next();
643 WidgetKeyResult::Handled
644 }
645 _ => WidgetKeyResult::Handled,
646 }
647 }
648
649 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
650 self.render_panel(frame, area, theme);
651 }
652
653 fn required_height(&self, max_height: u16) -> u16 {
654 if self.active {
655 self.panel_height(max_height)
656 } else {
657 0
658 }
659 }
660
661 fn blocks_input(&self) -> bool {
662 self.active
663 }
664
665 fn is_overlay(&self) -> bool {
666 false
667 }
668
669 fn as_any(&self) -> &dyn Any {
670 self
671 }
672
673 fn as_any_mut(&mut self) -> &mut dyn Any {
674 self
675 }
676
677 fn into_any(self: Box<Self>) -> Box<dyn Any> {
678 self
679 }
680}
681
682fn truncate_text(text: &str, max_width: usize) -> String {
684 if text.chars().count() <= max_width {
685 text.to_string()
686 } else {
687 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
688 format!("{}...", truncated)
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn test_permission_option_all() {
698 let options = PermissionOption::all();
699 assert_eq!(options.len(), 3);
700 assert_eq!(options[0], PermissionOption::GrantOnce);
701 assert_eq!(options[1], PermissionOption::GrantSession);
702 assert_eq!(options[2], PermissionOption::Deny);
703 }
704
705 #[test]
706 fn test_permission_option_to_response() {
707 let once = PermissionOption::GrantOnce.to_response();
708 assert!(once.granted);
709 assert_eq!(once.scope, Some(PermissionScope::Once));
710
711 let session = PermissionOption::GrantSession.to_response();
712 assert!(session.granted);
713 assert_eq!(session.scope, Some(PermissionScope::Session));
714
715 let deny = PermissionOption::Deny.to_response();
716 assert!(!deny.granted);
717 assert!(deny.scope.is_none());
718 }
719
720 #[test]
721 fn test_panel_activation() {
722 let mut panel = PermissionPanel::new();
723 assert!(!panel.is_active());
724
725 let request = PermissionRequest {
726 action: "Delete file".to_string(),
727 reason: Some("Cleanup".to_string()),
728 resources: vec!["/tmp/foo.txt".to_string()],
729 category: PermissionCategory::FileDelete,
730 };
731
732 panel.activate("tool_123".to_string(), 1, request, None);
733 assert!(panel.is_active());
734 assert_eq!(panel.tool_use_id(), "tool_123");
735 assert_eq!(panel.session_id(), 1);
736
737 panel.deactivate();
738 assert!(!panel.is_active());
739 }
740
741 #[test]
742 fn test_navigation() {
743 let mut panel = PermissionPanel::new();
744 let request = PermissionRequest {
745 action: "Test".to_string(),
746 reason: None,
747 resources: vec![],
748 category: PermissionCategory::Other,
749 };
750 panel.activate("tool_1".to_string(), 1, request, None);
751
752 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
754
755 panel.select_next();
757 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
758
759 panel.select_next();
760 assert_eq!(panel.selected_option(), PermissionOption::Deny);
761
762 panel.select_next();
764 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
765
766 panel.select_prev();
768 assert_eq!(panel.selected_option(), PermissionOption::Deny);
769 }
770
771 #[test]
772 fn test_handle_key_navigation() {
773 let mut panel = PermissionPanel::new();
774 let request = PermissionRequest {
775 action: "Test".to_string(),
776 reason: None,
777 resources: vec![],
778 category: PermissionCategory::Other,
779 };
780 panel.activate("tool_1".to_string(), 1, request, None);
781
782 let action = panel.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
784 assert_eq!(action, KeyAction::None);
785 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
786
787 let action = panel.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
789 assert_eq!(action, KeyAction::None);
790 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
791 }
792
793 #[test]
794 fn test_handle_key_selection() {
795 let mut panel = PermissionPanel::new();
796 let request = PermissionRequest {
797 action: "Test".to_string(),
798 reason: None,
799 resources: vec![],
800 category: PermissionCategory::Other,
801 };
802 panel.activate("tool_1".to_string(), 1, request, None);
803
804 let action = panel.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
806 match action {
807 KeyAction::Selected(tool_use_id, response) => {
808 assert_eq!(tool_use_id, "tool_1");
809 assert!(response.granted);
810 assert_eq!(response.scope, Some(PermissionScope::Once));
811 }
812 _ => panic!("Expected Selected action"),
813 }
814 panel.deactivate();
816 assert!(!panel.is_active());
817 }
818
819 #[test]
820 fn test_handle_key_cancel() {
821 let mut panel = PermissionPanel::new();
822 let request = PermissionRequest {
823 action: "Test".to_string(),
824 reason: None,
825 resources: vec![],
826 category: PermissionCategory::Other,
827 };
828 panel.activate("tool_1".to_string(), 1, request, None);
829
830 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
832 match action {
833 KeyAction::Cancelled(tool_use_id) => {
834 assert_eq!(tool_use_id, "tool_1");
835 }
836 _ => panic!("Expected Cancelled action"),
837 }
838 panel.deactivate();
840 assert!(!panel.is_active());
841 }
842
843 #[test]
844 fn test_truncate_text() {
845 assert_eq!(truncate_text("short", 10), "short");
846 assert_eq!(truncate_text("this is a longer text", 10), "this is...");
847 assert_eq!(truncate_text("exact", 5), "exact");
848 }
849}