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_READ: &str = "\u{1F4C4}"; pub const ICON_DIRECTORY_READ: &str = "\u{1F4C2}"; pub const ICON_FILE_WRITE: &str = "\u{270E}";
41 pub const ICON_FILE_DELETE: &str = "\u{2717}";
42 pub const ICON_NETWORK: &str = "\u{2194}";
43 pub const ICON_SYSTEM: &str = "\u{2295}";
44 pub const ICON_OTHER: &str = "\u{25CB}";
45 pub const TREE_BRANCH: &str = " \u{251C}\u{2500} ";
47 pub const TREE_LAST: &str = " \u{2514}\u{2500} ";
48}
49
50#[derive(Clone)]
52pub struct PermissionPanelConfig {
53 pub max_panel_percent: u16,
55 pub selection_indicator: String,
57 pub no_indicator: String,
59 pub title: String,
61 pub help_text: String,
63 pub icon_file_read: String,
65 pub icon_directory_read: String,
67 pub icon_file_write: String,
69 pub icon_file_delete: String,
71 pub icon_network: String,
73 pub icon_system: String,
75 pub icon_other: String,
77 pub tree_branch: String,
79 pub tree_last: String,
81}
82
83impl Default for PermissionPanelConfig {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl PermissionPanelConfig {
90 pub fn new() -> Self {
92 Self {
93 max_panel_percent: defaults::MAX_PANEL_PERCENT,
94 selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
95 no_indicator: defaults::NO_INDICATOR.to_string(),
96 title: defaults::TITLE.to_string(),
97 help_text: defaults::HELP_TEXT.to_string(),
98 icon_file_read: defaults::ICON_FILE_READ.to_string(),
99 icon_directory_read: defaults::ICON_DIRECTORY_READ.to_string(),
100 icon_file_write: defaults::ICON_FILE_WRITE.to_string(),
101 icon_file_delete: defaults::ICON_FILE_DELETE.to_string(),
102 icon_network: defaults::ICON_NETWORK.to_string(),
103 icon_system: defaults::ICON_SYSTEM.to_string(),
104 icon_other: defaults::ICON_OTHER.to_string(),
105 tree_branch: defaults::TREE_BRANCH.to_string(),
106 tree_last: defaults::TREE_LAST.to_string(),
107 }
108 }
109
110 pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
112 self.max_panel_percent = percent;
113 self
114 }
115
116 pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
118 self.selection_indicator = indicator.into();
119 self
120 }
121
122 pub fn with_title(mut self, title: impl Into<String>) -> Self {
124 self.title = title.into();
125 self
126 }
127
128 pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
130 self.help_text = text.into();
131 self
132 }
133
134 pub fn with_category_icons(
136 mut self,
137 file_read: impl Into<String>,
138 directory_read: impl Into<String>,
139 file_write: impl Into<String>,
140 file_delete: impl Into<String>,
141 network: impl Into<String>,
142 system: impl Into<String>,
143 other: impl Into<String>,
144 ) -> Self {
145 self.icon_file_read = file_read.into();
146 self.icon_directory_read = directory_read.into();
147 self.icon_file_write = file_write.into();
148 self.icon_file_delete = file_delete.into();
149 self.icon_network = network.into();
150 self.icon_system = system.into();
151 self.icon_other = other.into();
152 self
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum PermissionOption {
159 GrantOnce,
161 GrantSession,
163 GrantCategorySession,
165 Deny,
167}
168
169impl PermissionOption {
170 pub fn all() -> &'static [PermissionOption] {
172 &[
173 PermissionOption::GrantOnce,
174 PermissionOption::GrantSession,
175 PermissionOption::GrantCategorySession,
176 PermissionOption::Deny,
177 ]
178 }
179
180 pub fn label(&self) -> &'static str {
182 match self {
183 PermissionOption::GrantOnce => "Grant Once",
184 PermissionOption::GrantSession => "Grant for Session",
185 PermissionOption::GrantCategorySession => "Grant All in Category",
186 PermissionOption::Deny => "Deny",
187 }
188 }
189
190 pub fn description(&self) -> &'static str {
192 match self {
193 PermissionOption::GrantOnce => "Allow this action this one time",
194 PermissionOption::GrantSession => "Allow this action for the rest of the session",
195 PermissionOption::GrantCategorySession => "Allow all actions in this category for the session",
196 PermissionOption::Deny => "Reject this permission request",
197 }
198 }
199
200 pub fn to_response(&self) -> PermissionResponse {
202 match self {
203 PermissionOption::GrantOnce => PermissionResponse {
204 granted: true,
205 scope: Some(PermissionScope::Once),
206 message: None,
207 },
208 PermissionOption::GrantSession => PermissionResponse {
209 granted: true,
210 scope: Some(PermissionScope::Session),
211 message: None,
212 },
213 PermissionOption::GrantCategorySession => PermissionResponse {
214 granted: true,
215 scope: Some(PermissionScope::CategorySession),
216 message: None,
217 },
218 PermissionOption::Deny => PermissionResponse {
219 granted: false,
220 scope: None,
221 message: None,
222 },
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq)]
229pub enum KeyAction {
230 None,
232 Selected(String, PermissionResponse),
234 Cancelled(String),
236}
237
238pub struct PermissionPanel {
240 active: bool,
242 tool_use_id: String,
244 session_id: i64,
246 request: PermissionRequest,
248 turn_id: Option<TurnId>,
250 selected_idx: usize,
252 config: PermissionPanelConfig,
254}
255
256impl PermissionPanel {
257 pub fn new() -> Self {
259 Self::with_config(PermissionPanelConfig::new())
260 }
261
262 pub fn with_config(config: PermissionPanelConfig) -> Self {
264 Self {
265 active: false,
266 tool_use_id: String::new(),
267 session_id: 0,
268 request: PermissionRequest {
269 action: String::new(),
270 reason: None,
271 resources: Vec::new(),
272 category: PermissionCategory::Other,
273 },
274 turn_id: None,
275 selected_idx: 0,
276 config,
277 }
278 }
279
280 pub fn config(&self) -> &PermissionPanelConfig {
282 &self.config
283 }
284
285 pub fn set_config(&mut self, config: PermissionPanelConfig) {
287 self.config = config;
288 }
289
290 pub fn activate(
292 &mut self,
293 tool_use_id: String,
294 session_id: i64,
295 request: PermissionRequest,
296 turn_id: Option<TurnId>,
297 ) {
298 self.active = true;
299 self.tool_use_id = tool_use_id;
300 self.session_id = session_id;
301 self.request = request;
302 self.turn_id = turn_id;
303 self.selected_idx = 0; }
305
306 pub fn deactivate(&mut self) {
308 self.active = false;
309 self.tool_use_id.clear();
310 self.request.action.clear();
311 self.request.reason = None;
312 self.request.resources.clear();
313 self.turn_id = None;
314 self.selected_idx = 0;
315 }
316
317 pub fn is_active(&self) -> bool {
319 self.active
320 }
321
322 pub fn tool_use_id(&self) -> &str {
324 &self.tool_use_id
325 }
326
327 pub fn session_id(&self) -> i64 {
329 self.session_id
330 }
331
332 pub fn request(&self) -> &PermissionRequest {
334 &self.request
335 }
336
337 pub fn turn_id(&self) -> Option<&TurnId> {
339 self.turn_id.as_ref()
340 }
341
342 pub fn selected_option(&self) -> PermissionOption {
344 PermissionOption::all()[self.selected_idx]
345 }
346
347 pub fn select_next(&mut self) {
349 let options = PermissionOption::all();
350 self.selected_idx = (self.selected_idx + 1) % options.len();
351 }
352
353 pub fn select_prev(&mut self) {
355 let options = PermissionOption::all();
356 if self.selected_idx == 0 {
357 self.selected_idx = options.len() - 1;
358 } else {
359 self.selected_idx -= 1;
360 }
361 }
362
363 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
367 if !self.active {
368 return KeyAction::None;
369 }
370
371 match key.code {
372 KeyCode::Up | KeyCode::Char('k') => {
374 self.select_prev();
375 KeyAction::None
376 }
377 KeyCode::Down | KeyCode::Char('j') => {
378 self.select_next();
379 KeyAction::None
380 }
381 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
382 self.select_prev();
383 KeyAction::None
384 }
385 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
386 self.select_next();
387 KeyAction::None
388 }
389
390 KeyCode::Enter | KeyCode::Char(' ') => {
392 let option = self.selected_option();
393 let response = option.to_response();
394 let tool_use_id = self.tool_use_id.clone();
395 KeyAction::Selected(tool_use_id, response)
397 }
398
399 KeyCode::Esc => {
401 let tool_use_id = self.tool_use_id.clone();
402 KeyAction::Cancelled(tool_use_id)
404 }
405
406 _ => KeyAction::None,
407 }
408 }
409
410 pub fn panel_height(&self, max_height: u16) -> u16 {
412 let mut lines = 0u16;
426
427 lines += 2; lines += 1; lines += 1; if self.request.reason.is_some() {
434 lines += 1;
435 }
436 if !self.request.resources.is_empty() {
437 lines += 1 + self.request.resources.len().min(5) as u16; }
439
440 lines += 1; lines += PermissionOption::all().len() as u16;
443
444 lines += 1; lines += 1; lines += 2; let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
451 lines.min(max_from_percent).min(max_height.saturating_sub(6))
452 }
453
454 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
461 if !self.active {
462 return;
463 }
464
465 frame.render_widget(Clear, area);
467
468 let inner_width = area.width.saturating_sub(4) as usize;
469 let mut lines: Vec<Line> = Vec::new();
470
471 lines.push(Line::from(Span::styled(
473 truncate_text(&self.config.help_text, inner_width),
474 theme.help_text(),
475 )));
476 lines.push(Line::from("")); let category_icon = match self.request.category {
480 PermissionCategory::FileRead => &self.config.icon_file_read,
481 PermissionCategory::DirectoryRead => &self.config.icon_directory_read,
482 PermissionCategory::FileWrite => &self.config.icon_file_write,
483 PermissionCategory::FileDelete => &self.config.icon_file_delete,
484 PermissionCategory::Network => &self.config.icon_network,
485 PermissionCategory::System => &self.config.icon_system,
486 PermissionCategory::Other => &self.config.icon_other,
487 };
488 lines.push(Line::from(vec![
489 Span::styled(
490 format!(" {} ", category_icon),
491 theme.category(),
492 ),
493 Span::styled(
494 format!("{}", self.request.category),
495 theme.category().add_modifier(Modifier::BOLD),
496 ),
497 ]));
498
499 lines.push(Line::from(vec![
501 Span::styled(" Action: ", theme.muted_text()),
502 Span::styled(
503 truncate_text(&self.request.action, inner_width - 10),
504 Style::default().add_modifier(Modifier::BOLD),
505 ),
506 ]));
507
508 if let Some(ref reason) = self.request.reason {
510 lines.push(Line::from(vec![
511 Span::styled(" Reason: ", theme.muted_text()),
512 Span::styled(
513 truncate_text(reason, inner_width - 10),
514 theme.muted_text(),
515 ),
516 ]));
517 }
518
519 if !self.request.resources.is_empty() {
521 lines.push(Line::from(Span::styled(
522 " Resources:",
523 theme.muted_text(),
524 )));
525 for (i, resource) in self.request.resources.iter().take(5).enumerate() {
526 let prefix = if i < self.request.resources.len() - 1 || self.request.resources.len() <= 5 {
527 &self.config.tree_branch
528 } else {
529 &self.config.tree_last
530 };
531 lines.push(Line::from(vec![
532 Span::raw(prefix.clone()),
533 Span::styled(
534 truncate_text(resource, inner_width - 8),
535 theme.resource(),
536 ),
537 ]));
538 }
539 if self.request.resources.len() > 5 {
540 lines.push(Line::from(Span::styled(
541 format!(" ... and {} more", self.request.resources.len() - 5),
542 theme.muted_text(),
543 )));
544 }
545 }
546
547 lines.push(Line::from(""));
549
550 for (idx, option) in PermissionOption::all().iter().enumerate() {
552 let is_selected = idx == self.selected_idx;
553 let prefix = if is_selected { &self.config.selection_indicator } else { &self.config.no_indicator };
554
555 let (label_style, desc_style) = if is_selected {
556 match option {
557 PermissionOption::GrantOnce
558 | PermissionOption::GrantSession
559 | PermissionOption::GrantCategorySession => {
560 (theme.button_confirm_focused(), theme.focused_text())
561 }
562 PermissionOption::Deny => {
563 (theme.button_cancel_focused(), theme.focused_text())
564 }
565 }
566 } else {
567 match option {
568 PermissionOption::GrantOnce
569 | PermissionOption::GrantSession
570 | PermissionOption::GrantCategorySession => {
571 (theme.button_confirm(), theme.muted_text())
572 }
573 PermissionOption::Deny => {
574 (theme.button_cancel(), theme.muted_text())
575 }
576 }
577 };
578
579 let indicator_style = if is_selected {
580 theme.focus_indicator()
581 } else {
582 theme.muted_text()
583 };
584
585 lines.push(Line::from(vec![
586 Span::styled(prefix.clone(), indicator_style),
587 Span::styled(option.label(), label_style),
588 Span::styled(" - ", theme.muted_text()),
589 Span::styled(option.description(), desc_style),
590 ]));
591 }
592
593 let block = Block::default()
595 .borders(Borders::ALL)
596 .border_style(theme.warning())
597 .title(Span::styled(
598 self.config.title.clone(),
599 theme.warning().add_modifier(Modifier::BOLD),
600 ));
601
602 let paragraph = Paragraph::new(lines).block(block);
603 frame.render_widget(paragraph, area);
604 }
605}
606
607impl Default for PermissionPanel {
608 fn default() -> Self {
609 Self::new()
610 }
611}
612
613use std::any::Any;
616use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
617
618impl Widget for PermissionPanel {
619 fn id(&self) -> &'static str {
620 widget_ids::PERMISSION_PANEL
621 }
622
623 fn priority(&self) -> u8 {
624 200 }
626
627 fn is_active(&self) -> bool {
628 self.active
629 }
630
631 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
632 if !self.active {
633 return WidgetKeyResult::NotHandled;
634 }
635
636 if ctx.nav.is_move_up(&key) {
638 self.select_prev();
639 return WidgetKeyResult::Handled;
640 }
641 if ctx.nav.is_move_down(&key) {
642 self.select_next();
643 return WidgetKeyResult::Handled;
644 }
645
646 if ctx.nav.is_select(&key) {
648 let option = self.selected_option();
649 let response = option.to_response();
650 let tool_use_id = self.tool_use_id.clone();
651 return WidgetKeyResult::Action(WidgetAction::SubmitPermission {
652 tool_use_id,
653 response,
654 });
655 }
656
657 if ctx.nav.is_cancel(&key) {
659 let tool_use_id = self.tool_use_id.clone();
660 return WidgetKeyResult::Action(WidgetAction::CancelPermission { tool_use_id });
661 }
662
663 match key.code {
665 KeyCode::Char('k') => {
666 self.select_prev();
667 WidgetKeyResult::Handled
668 }
669 KeyCode::Char('j') => {
670 self.select_next();
671 WidgetKeyResult::Handled
672 }
673 _ => WidgetKeyResult::Handled,
674 }
675 }
676
677 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
678 self.render_panel(frame, area, theme);
679 }
680
681 fn required_height(&self, max_height: u16) -> u16 {
682 if self.active {
683 self.panel_height(max_height)
684 } else {
685 0
686 }
687 }
688
689 fn blocks_input(&self) -> bool {
690 self.active
691 }
692
693 fn is_overlay(&self) -> bool {
694 false
695 }
696
697 fn as_any(&self) -> &dyn Any {
698 self
699 }
700
701 fn as_any_mut(&mut self) -> &mut dyn Any {
702 self
703 }
704
705 fn into_any(self: Box<Self>) -> Box<dyn Any> {
706 self
707 }
708}
709
710fn truncate_text(text: &str, max_width: usize) -> String {
712 if text.chars().count() <= max_width {
713 text.to_string()
714 } else {
715 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
716 format!("{}...", truncated)
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn test_permission_option_all() {
726 let options = PermissionOption::all();
727 assert_eq!(options.len(), 4);
728 assert_eq!(options[0], PermissionOption::GrantOnce);
729 assert_eq!(options[1], PermissionOption::GrantSession);
730 assert_eq!(options[2], PermissionOption::GrantCategorySession);
731 assert_eq!(options[3], PermissionOption::Deny);
732 }
733
734 #[test]
735 fn test_permission_option_to_response() {
736 let once = PermissionOption::GrantOnce.to_response();
737 assert!(once.granted);
738 assert_eq!(once.scope, Some(PermissionScope::Once));
739
740 let session = PermissionOption::GrantSession.to_response();
741 assert!(session.granted);
742 assert_eq!(session.scope, Some(PermissionScope::Session));
743
744 let category_session = PermissionOption::GrantCategorySession.to_response();
745 assert!(category_session.granted);
746 assert_eq!(category_session.scope, Some(PermissionScope::CategorySession));
747
748 let deny = PermissionOption::Deny.to_response();
749 assert!(!deny.granted);
750 assert!(deny.scope.is_none());
751 }
752
753 #[test]
754 fn test_panel_activation() {
755 let mut panel = PermissionPanel::new();
756 assert!(!panel.is_active());
757
758 let request = PermissionRequest {
759 action: "Delete file".to_string(),
760 reason: Some("Cleanup".to_string()),
761 resources: vec!["/tmp/foo.txt".to_string()],
762 category: PermissionCategory::FileDelete,
763 };
764
765 panel.activate("tool_123".to_string(), 1, request, None);
766 assert!(panel.is_active());
767 assert_eq!(panel.tool_use_id(), "tool_123");
768 assert_eq!(panel.session_id(), 1);
769
770 panel.deactivate();
771 assert!(!panel.is_active());
772 }
773
774 #[test]
775 fn test_navigation() {
776 let mut panel = PermissionPanel::new();
777 let request = PermissionRequest {
778 action: "Test".to_string(),
779 reason: None,
780 resources: vec![],
781 category: PermissionCategory::Other,
782 };
783 panel.activate("tool_1".to_string(), 1, request, None);
784
785 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
787
788 panel.select_next();
790 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
791
792 panel.select_next();
793 assert_eq!(panel.selected_option(), PermissionOption::GrantCategorySession);
794
795 panel.select_next();
796 assert_eq!(panel.selected_option(), PermissionOption::Deny);
797
798 panel.select_next();
800 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
801
802 panel.select_prev();
804 assert_eq!(panel.selected_option(), PermissionOption::Deny);
805 }
806
807 #[test]
808 fn test_handle_key_navigation() {
809 let mut panel = PermissionPanel::new();
810 let request = PermissionRequest {
811 action: "Test".to_string(),
812 reason: None,
813 resources: vec![],
814 category: PermissionCategory::Other,
815 };
816 panel.activate("tool_1".to_string(), 1, request, None);
817
818 let action = panel.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
820 assert_eq!(action, KeyAction::None);
821 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
822
823 let action = panel.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
825 assert_eq!(action, KeyAction::None);
826 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
827 }
828
829 #[test]
830 fn test_handle_key_selection() {
831 let mut panel = PermissionPanel::new();
832 let request = PermissionRequest {
833 action: "Test".to_string(),
834 reason: None,
835 resources: vec![],
836 category: PermissionCategory::Other,
837 };
838 panel.activate("tool_1".to_string(), 1, request, None);
839
840 let action = panel.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
842 match action {
843 KeyAction::Selected(tool_use_id, response) => {
844 assert_eq!(tool_use_id, "tool_1");
845 assert!(response.granted);
846 assert_eq!(response.scope, Some(PermissionScope::Once));
847 }
848 _ => panic!("Expected Selected action"),
849 }
850 panel.deactivate();
852 assert!(!panel.is_active());
853 }
854
855 #[test]
856 fn test_handle_key_cancel() {
857 let mut panel = PermissionPanel::new();
858 let request = PermissionRequest {
859 action: "Test".to_string(),
860 reason: None,
861 resources: vec![],
862 category: PermissionCategory::Other,
863 };
864 panel.activate("tool_1".to_string(), 1, request, None);
865
866 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
868 match action {
869 KeyAction::Cancelled(tool_use_id) => {
870 assert_eq!(tool_use_id, "tool_1");
871 }
872 _ => panic!("Expected Cancelled action"),
873 }
874 panel.deactivate();
876 assert!(!panel.is_active());
877 }
878
879 #[test]
880 fn test_truncate_text() {
881 assert_eq!(truncate_text("short", 10), "short");
882 assert_eq!(truncate_text("this is a longer text", 10), "this is...");
883 assert_eq!(truncate_text("exact", 5), "exact");
884 }
885}