1use crate::controller::{PermissionPanelResponse, TurnId};
12use crate::permissions::{Grant, GrantTarget, PermissionLevel, PermissionRequest};
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14use ratatui::{
15 Frame,
16 layout::Rect,
17 style::Modifier,
18 text::{Line, Span},
19 widgets::{Block, Borders, Clear, Paragraph},
20};
21
22use crate::themes::Theme;
23
24pub mod defaults {
26 pub const MAX_PANEL_PERCENT: u16 = 50;
28 pub const SELECTION_INDICATOR: &str = " \u{203A} ";
30 pub const NO_INDICATOR: &str = " ";
32 pub const TITLE: &str = " Permission Request ";
34 pub const HELP_TEXT: &str =
36 " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
37 pub const ICON_PATH: &str = "\u{1F4C4}"; pub const ICON_DOMAIN: &str = "\u{2194}"; pub const ICON_COMMAND: &str = "\u{2295}"; pub const ICON_OTHER: &str = "\u{25CB}"; }
43
44#[derive(Clone)]
46pub struct PermissionPanelConfig {
47 pub max_panel_percent: u16,
49 pub selection_indicator: String,
51 pub no_indicator: String,
53 pub title: String,
55 pub help_text: String,
57 pub icon_path: String,
59 pub icon_domain: String,
61 pub icon_command: String,
63 pub icon_other: String,
65}
66
67impl Default for PermissionPanelConfig {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl PermissionPanelConfig {
74 pub fn new() -> Self {
76 Self {
77 max_panel_percent: defaults::MAX_PANEL_PERCENT,
78 selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
79 no_indicator: defaults::NO_INDICATOR.to_string(),
80 title: defaults::TITLE.to_string(),
81 help_text: defaults::HELP_TEXT.to_string(),
82 icon_path: defaults::ICON_PATH.to_string(),
83 icon_domain: defaults::ICON_DOMAIN.to_string(),
84 icon_command: defaults::ICON_COMMAND.to_string(),
85 icon_other: defaults::ICON_OTHER.to_string(),
86 }
87 }
88
89 pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
91 self.max_panel_percent = percent;
92 self
93 }
94
95 pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
97 self.selection_indicator = indicator.into();
98 self
99 }
100
101 pub fn with_title(mut self, title: impl Into<String>) -> Self {
103 self.title = title.into();
104 self
105 }
106
107 pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
109 self.help_text = text.into();
110 self
111 }
112
113 pub fn with_target_icons(
115 mut self,
116 path: impl Into<String>,
117 domain: impl Into<String>,
118 command: impl Into<String>,
119 other: impl Into<String>,
120 ) -> Self {
121 self.icon_path = path.into();
122 self.icon_domain = domain.into();
123 self.icon_command = command.into();
124 self.icon_other = other.into();
125 self
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum PermissionOption {
132 GrantOnce,
134 GrantSession,
136 GrantAllSession,
138 Deny,
140}
141
142impl PermissionOption {
143 pub fn all() -> &'static [PermissionOption] {
145 &[
146 PermissionOption::GrantOnce,
147 PermissionOption::GrantSession,
148 PermissionOption::GrantAllSession,
149 PermissionOption::Deny,
150 ]
151 }
152
153 pub fn label(&self) -> &'static str {
155 match self {
156 PermissionOption::GrantOnce => "Grant Once",
157 PermissionOption::GrantSession => "Grant for Session",
158 PermissionOption::GrantAllSession => "Allow All Similar",
159 PermissionOption::Deny => "Deny",
160 }
161 }
162
163 pub fn description(&self, request: &PermissionRequest) -> String {
165 match self {
166 PermissionOption::GrantOnce => "Allow only this request".to_string(),
167 PermissionOption::GrantSession => match (&request.target, request.required_level) {
168 (GrantTarget::Path { .. }, PermissionLevel::Read) => {
169 "Allow reading this file".to_string()
170 }
171 (GrantTarget::Path { .. }, PermissionLevel::Write) => {
172 "Allow writing this file".to_string()
173 }
174 (GrantTarget::Command { .. }, _) => "Allow this command".to_string(),
175 (GrantTarget::Domain { .. }, _) => "Allow this domain".to_string(),
176 _ => "Allow for the session".to_string(),
177 },
178 PermissionOption::GrantAllSession => match (&request.target, request.required_level) {
179 (GrantTarget::Path { .. }, PermissionLevel::Read) => {
180 "Allow reading any file".to_string()
181 }
182 (GrantTarget::Path { .. }, PermissionLevel::Write) => {
183 "Allow writing any file".to_string()
184 }
185 (GrantTarget::Command { .. }, _) => "Allow all commands".to_string(),
186 (GrantTarget::Domain { .. }, _) => "Allow all domains".to_string(),
187 _ => "Allow all similar actions".to_string(),
188 },
189 PermissionOption::Deny => "Deny this request".to_string(),
190 }
191 }
192
193 pub fn is_positive(&self) -> bool {
195 !matches!(self, PermissionOption::Deny)
196 }
197
198 pub fn to_response(&self, request: &PermissionRequest) -> PermissionPanelResponse {
200 match self {
201 PermissionOption::GrantOnce => PermissionPanelResponse {
202 granted: true,
203 grant: None, message: None,
205 },
206 PermissionOption::GrantSession => {
207 let grant = Grant::new(request.target.clone(), request.required_level);
209 PermissionPanelResponse {
210 granted: true,
211 grant: Some(grant),
212 message: None,
213 }
214 }
215 PermissionOption::GrantAllSession => {
216 let grant = match &request.target {
218 GrantTarget::Path { .. } => {
219 Grant::new(GrantTarget::path("/", true), request.required_level)
220 }
221 GrantTarget::Domain { .. } => Grant::domain("*", request.required_level),
222 GrantTarget::Command { .. } => Grant::command("*", request.required_level),
223 GrantTarget::Tool { tool_name } => {
224 Grant::tool(tool_name, request.required_level)
225 }
226 };
227 PermissionPanelResponse {
228 granted: true,
229 grant: Some(grant),
230 message: None,
231 }
232 }
233 PermissionOption::Deny => PermissionPanelResponse {
234 granted: false,
235 grant: None,
236 message: None,
237 },
238 }
239 }
240}
241
242#[derive(Debug, Clone)]
244pub enum KeyAction {
245 None,
247 Selected(String, PermissionPanelResponse),
249 Cancelled(String),
251}
252
253pub struct PermissionPanel {
255 active: bool,
257 tool_use_id: String,
259 session_id: i64,
261 request: PermissionRequest,
263 turn_id: Option<TurnId>,
265 selected_idx: usize,
267 config: PermissionPanelConfig,
269}
270
271impl PermissionPanel {
272 pub fn new() -> Self {
274 Self::with_config(PermissionPanelConfig::new())
275 }
276
277 pub fn with_config(config: PermissionPanelConfig) -> Self {
279 Self {
280 active: false,
281 tool_use_id: String::new(),
282 session_id: 0,
283 request: PermissionRequest::new(
284 "",
285 GrantTarget::path("/", false),
286 PermissionLevel::None,
287 "",
288 ),
289 turn_id: None,
290 selected_idx: 0,
291 config,
292 }
293 }
294
295 pub fn config(&self) -> &PermissionPanelConfig {
297 &self.config
298 }
299
300 pub fn set_config(&mut self, config: PermissionPanelConfig) {
302 self.config = config;
303 }
304
305 pub fn activate(
307 &mut self,
308 tool_use_id: String,
309 session_id: i64,
310 request: PermissionRequest,
311 turn_id: Option<TurnId>,
312 ) {
313 self.active = true;
314 self.tool_use_id = tool_use_id;
315 self.session_id = session_id;
316 self.request = request;
317 self.turn_id = turn_id;
318 self.selected_idx = 0; }
320
321 pub fn deactivate(&mut self) {
323 self.active = false;
324 self.tool_use_id.clear();
325 self.request =
326 PermissionRequest::new("", GrantTarget::path("/", false), PermissionLevel::None, "");
327 self.turn_id = None;
328 self.selected_idx = 0;
329 }
330
331 pub fn is_active(&self) -> bool {
333 self.active
334 }
335
336 pub fn tool_use_id(&self) -> &str {
338 &self.tool_use_id
339 }
340
341 pub fn session_id(&self) -> i64 {
343 self.session_id
344 }
345
346 pub fn request(&self) -> &PermissionRequest {
348 &self.request
349 }
350
351 pub fn turn_id(&self) -> Option<&TurnId> {
353 self.turn_id.as_ref()
354 }
355
356 fn available_options(&self) -> Vec<PermissionOption> {
361 match &self.request.target {
362 GrantTarget::Command { .. } => {
363 vec![
365 PermissionOption::GrantOnce,
366 PermissionOption::GrantSession,
367 PermissionOption::Deny,
368 ]
369 }
370 _ => {
371 vec![
373 PermissionOption::GrantOnce,
374 PermissionOption::GrantSession,
375 PermissionOption::GrantAllSession,
376 PermissionOption::Deny,
377 ]
378 }
379 }
380 }
381
382 pub fn selected_option(&self) -> PermissionOption {
384 let options = self.available_options();
385 options[self.selected_idx.min(options.len() - 1)]
386 }
387
388 pub fn select_next(&mut self) {
390 let options = self.available_options();
391 self.selected_idx = (self.selected_idx + 1) % options.len();
392 }
393
394 pub fn select_prev(&mut self) {
396 let options = self.available_options();
397 if self.selected_idx == 0 {
398 self.selected_idx = options.len() - 1;
399 } else {
400 self.selected_idx -= 1;
401 }
402 }
403
404 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
406 if !self.active {
407 return KeyAction::None;
408 }
409
410 match key.code {
411 KeyCode::Up | KeyCode::Char('k') => {
413 self.select_prev();
414 KeyAction::None
415 }
416 KeyCode::Down | KeyCode::Char('j') => {
417 self.select_next();
418 KeyAction::None
419 }
420 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
421 self.select_prev();
422 KeyAction::None
423 }
424 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
425 self.select_next();
426 KeyAction::None
427 }
428
429 KeyCode::Enter | KeyCode::Char(' ') => {
431 let option = self.selected_option();
432 let response = option.to_response(&self.request);
433 let tool_use_id = self.tool_use_id.clone();
434 KeyAction::Selected(tool_use_id, response)
435 }
436
437 KeyCode::Esc => {
439 let tool_use_id = self.tool_use_id.clone();
440 KeyAction::Cancelled(tool_use_id)
441 }
442
443 _ => KeyAction::None,
444 }
445 }
446
447 pub fn panel_height(&self, max_height: u16) -> u16 {
449 let mut lines = 6u16; lines += self.available_options().len() as u16;
459 lines += 2; let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
462 lines
463 .min(max_from_percent)
464 .min(max_height.saturating_sub(6))
465 }
466
467 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
469 if !self.active {
470 return;
471 }
472
473 frame.render_widget(Clear, area);
475
476 let inner_width = area.width.saturating_sub(4) as usize;
477 let mut lines: Vec<Line> = Vec::new();
478
479 lines.push(Line::from(Span::styled(
481 truncate_text(&self.config.help_text, inner_width),
482 theme.help_text(),
483 )));
484 lines.push(Line::from("")); let (icon, level_str, target_desc) = match &self.request.target {
488 GrantTarget::Path { path, recursive } => {
489 let rec_suffix = if *recursive { " (recursive)" } else { "" };
490 let level = format_level(self.request.required_level);
491 (
492 &self.config.icon_path,
493 level,
494 format!("{}{}", path.display(), rec_suffix),
495 )
496 }
497 GrantTarget::Domain { pattern } => {
498 (&self.config.icon_domain, "Access Domain", pattern.clone())
499 }
500 GrantTarget::Command { pattern } => {
501 (&self.config.icon_command, "Execute", pattern.clone())
502 }
503 GrantTarget::Tool { tool_name } => {
504 (&self.config.icon_other, "Use Tool", tool_name.clone())
505 }
506 };
507
508 lines.push(Line::from(vec![
509 Span::styled(" ", theme.muted_text()),
510 Span::styled(format!("{} ", icon), theme.category()),
511 Span::styled(format!("{}: ", level_str), theme.muted_text()),
512 Span::styled(
513 truncate_text(&target_desc, inner_width.saturating_sub(20)),
514 theme.resource(),
515 ),
516 ]));
517
518 lines.push(Line::from(""));
520
521 let options = self.available_options();
523 for (idx, option) in options.iter().enumerate() {
524 let is_selected = idx == self.selected_idx;
525 let prefix = if is_selected {
526 &self.config.selection_indicator
527 } else {
528 &self.config.no_indicator
529 };
530
531 let description = option.description(&self.request);
532
533 let (label_style, desc_style) = if is_selected {
534 if option.is_positive() {
535 (theme.button_confirm_focused(), theme.focused_text())
536 } else {
537 (theme.button_cancel_focused(), theme.focused_text())
538 }
539 } else if option.is_positive() {
540 (theme.button_confirm(), theme.muted_text())
541 } else {
542 (theme.button_cancel(), theme.muted_text())
543 };
544
545 let indicator_style = if is_selected {
546 theme.focus_indicator()
547 } else {
548 theme.muted_text()
549 };
550
551 lines.push(Line::from(vec![
552 Span::styled(prefix.clone(), indicator_style),
553 Span::styled(option.label(), label_style),
554 Span::styled(" - ", theme.muted_text()),
555 Span::styled(description, desc_style),
556 ]));
557 }
558
559 lines.push(Line::from(""));
561
562 let block = Block::default()
564 .borders(Borders::ALL)
565 .border_style(theme.warning())
566 .title(Span::styled(
567 self.config.title.clone(),
568 theme.warning().add_modifier(Modifier::BOLD),
569 ));
570
571 let paragraph = Paragraph::new(lines).block(block);
572 frame.render_widget(paragraph, area);
573 }
574}
575
576impl Default for PermissionPanel {
577 fn default() -> Self {
578 Self::new()
579 }
580}
581
582fn format_level(level: PermissionLevel) -> &'static str {
584 match level {
585 PermissionLevel::None => "None",
586 PermissionLevel::Read => "Read File",
587 PermissionLevel::Write => "Write File",
588 PermissionLevel::Execute => "Execute",
589 PermissionLevel::Admin => "Admin",
590 }
591}
592
593fn truncate_text(text: &str, max_width: usize) -> String {
595 if text.chars().count() <= max_width {
596 text.to_string()
597 } else {
598 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
599 format!("{}...", truncated)
600 }
601}
602
603use super::{Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, widget_ids};
606use std::any::Any;
607
608impl Widget for PermissionPanel {
609 fn id(&self) -> &'static str {
610 widget_ids::PERMISSION_PANEL
611 }
612
613 fn priority(&self) -> u8 {
614 200 }
616
617 fn is_active(&self) -> bool {
618 self.active
619 }
620
621 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
622 if !self.active {
623 return WidgetKeyResult::NotHandled;
624 }
625
626 if ctx.nav.is_move_up(&key) {
628 self.select_prev();
629 return WidgetKeyResult::Handled;
630 }
631 if ctx.nav.is_move_down(&key) {
632 self.select_next();
633 return WidgetKeyResult::Handled;
634 }
635
636 if ctx.nav.is_select(&key) {
638 let option = self.selected_option();
639 let response = option.to_response(&self.request);
640 let tool_use_id = self.tool_use_id.clone();
641 return WidgetKeyResult::Action(WidgetAction::SubmitPermission {
642 tool_use_id,
643 response,
644 });
645 }
646
647 if ctx.nav.is_cancel(&key) {
649 let tool_use_id = self.tool_use_id.clone();
650 return WidgetKeyResult::Action(WidgetAction::CancelPermission { tool_use_id });
651 }
652
653 match key.code {
655 KeyCode::Char('k') => {
656 self.select_prev();
657 WidgetKeyResult::Handled
658 }
659 KeyCode::Char('j') => {
660 self.select_next();
661 WidgetKeyResult::Handled
662 }
663 _ => WidgetKeyResult::NotHandled,
664 }
665 }
666
667 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
668 self.render_panel(frame, area, theme);
669 }
670
671 fn required_height(&self, max_height: u16) -> u16 {
672 if self.active {
673 self.panel_height(max_height)
674 } else {
675 0
676 }
677 }
678
679 fn blocks_input(&self) -> bool {
680 self.active
681 }
682
683 fn is_overlay(&self) -> bool {
684 false
685 }
686
687 fn as_any(&self) -> &dyn Any {
688 self
689 }
690
691 fn as_any_mut(&mut self) -> &mut dyn Any {
692 self
693 }
694
695 fn into_any(self: Box<Self>) -> Box<dyn Any> {
696 self
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn create_test_request() -> PermissionRequest {
705 PermissionRequest::file_write("test-1", "/tmp/foo.txt")
706 }
707
708 #[test]
709 fn test_permission_option_all() {
710 let options = PermissionOption::all();
711 assert_eq!(options.len(), 4);
712 assert_eq!(options[0], PermissionOption::GrantOnce);
713 assert_eq!(options[1], PermissionOption::GrantSession);
714 assert_eq!(options[2], PermissionOption::GrantAllSession);
715 assert_eq!(options[3], PermissionOption::Deny);
716 }
717
718 #[test]
719 fn test_permission_option_to_response() {
720 let request = create_test_request();
721
722 let once = PermissionOption::GrantOnce.to_response(&request);
723 assert!(once.granted);
724 assert!(once.grant.is_none()); let session = PermissionOption::GrantSession.to_response(&request);
727 assert!(session.granted);
728 assert!(session.grant.is_some()); let all_session = PermissionOption::GrantAllSession.to_response(&request);
731 assert!(all_session.granted);
732 assert!(all_session.grant.is_some()); let deny = PermissionOption::Deny.to_response(&request);
735 assert!(!deny.granted);
736 assert!(deny.grant.is_none());
737 }
738
739 #[test]
740 fn test_panel_activation() {
741 let mut panel = PermissionPanel::new();
742 assert!(!panel.is_active());
743
744 let request = PermissionRequest::file_write("test-1", "/tmp/foo.txt");
745
746 panel.activate("tool_123".to_string(), 1, request, None);
747 assert!(panel.is_active());
748 assert_eq!(panel.tool_use_id(), "tool_123");
749 assert_eq!(panel.session_id(), 1);
750
751 panel.deactivate();
752 assert!(!panel.is_active());
753 }
754
755 #[test]
756 fn test_navigation() {
757 let mut panel = PermissionPanel::new();
758 let request = create_test_request();
759 panel.activate("tool_1".to_string(), 1, request, None);
760
761 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
763
764 panel.select_next();
766 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
767
768 panel.select_next();
769 assert_eq!(panel.selected_option(), PermissionOption::GrantAllSession);
770
771 panel.select_next();
772 assert_eq!(panel.selected_option(), PermissionOption::Deny);
773
774 panel.select_next();
776 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
777
778 panel.select_prev();
780 assert_eq!(panel.selected_option(), PermissionOption::Deny);
781 }
782
783 #[test]
784 fn test_handle_key_navigation() {
785 let mut panel = PermissionPanel::new();
786 let request = create_test_request();
787 panel.activate("tool_1".to_string(), 1, request, None);
788
789 let action = panel.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
791 matches!(action, KeyAction::None);
792 assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
793
794 let action = panel.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
796 matches!(action, KeyAction::None);
797 assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
798 }
799
800 #[test]
801 fn test_handle_key_selection() {
802 let mut panel = PermissionPanel::new();
803 let request = create_test_request();
804 panel.activate("tool_1".to_string(), 1, request, None);
805
806 let action = panel.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
808 match action {
809 KeyAction::Selected(tool_use_id, response) => {
810 assert_eq!(tool_use_id, "tool_1");
811 assert!(response.granted);
812 assert!(response.grant.is_none()); }
814 _ => panic!("Expected Selected action"),
815 }
816 panel.deactivate();
817 assert!(!panel.is_active());
818 }
819
820 #[test]
821 fn test_handle_key_cancel() {
822 let mut panel = PermissionPanel::new();
823 let request = create_test_request();
824 panel.activate("tool_1".to_string(), 1, request, None);
825
826 let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
828 match action {
829 KeyAction::Cancelled(tool_use_id) => {
830 assert_eq!(tool_use_id, "tool_1");
831 }
832 _ => panic!("Expected Cancelled action"),
833 }
834 panel.deactivate();
835 assert!(!panel.is_active());
836 }
837
838 #[test]
839 fn test_option_descriptions() {
840 let read_request = PermissionRequest::file_read("1", "/tmp/foo.txt");
841 let write_request = PermissionRequest::file_write("2", "/tmp/bar.txt");
842 let cmd_request = PermissionRequest::command_execute("3", "git status");
843
844 assert_eq!(
846 PermissionOption::GrantOnce.description(&read_request),
847 "Allow only this request"
848 );
849
850 assert_eq!(
852 PermissionOption::GrantSession.description(&read_request),
853 "Allow reading this file"
854 );
855 assert_eq!(
856 PermissionOption::GrantSession.description(&write_request),
857 "Allow writing this file"
858 );
859 assert_eq!(
860 PermissionOption::GrantSession.description(&cmd_request),
861 "Allow this command"
862 );
863
864 assert_eq!(
866 PermissionOption::GrantAllSession.description(&read_request),
867 "Allow reading any file"
868 );
869 assert_eq!(
870 PermissionOption::GrantAllSession.description(&write_request),
871 "Allow writing any file"
872 );
873 assert_eq!(
874 PermissionOption::GrantAllSession.description(&cmd_request),
875 "Allow all commands"
876 );
877
878 assert_eq!(
880 PermissionOption::Deny.description(&read_request),
881 "Deny this request"
882 );
883 }
884
885 #[test]
886 fn test_truncate_text() {
887 assert_eq!(truncate_text("short", 10), "short");
888 assert_eq!(truncate_text("this is a longer text", 10), "this is...");
889 assert_eq!(truncate_text("exact", 5), "exact");
890 }
891
892 #[test]
893 fn test_command_hides_allow_all() {
894 let mut panel = PermissionPanel::new();
895
896 let read_request = PermissionRequest::file_read("1", "/tmp/foo.txt");
898 panel.activate("tool_1".to_string(), 1, read_request, None);
899 let options = panel.available_options();
900 assert_eq!(options.len(), 4);
901 assert!(options.contains(&PermissionOption::GrantAllSession));
902 panel.deactivate();
903
904 let cmd_request = PermissionRequest::command_execute("2", "git status");
906 panel.activate("tool_2".to_string(), 1, cmd_request, None);
907 let options = panel.available_options();
908 assert_eq!(options.len(), 3);
909 assert!(!options.contains(&PermissionOption::GrantAllSession));
910 }
911}