1use crate::controller::{PermissionPanelResponse, TurnId};
12use crate::permissions::{Grant, GrantTarget, PermissionLevel, PermissionRequest};
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14use ratatui::{
15 layout::Rect,
16 style::Modifier,
17 text::{Line, Span},
18 widgets::{Block, Borders, Clear, Paragraph},
19 Frame,
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 };
224 PermissionPanelResponse {
225 granted: true,
226 grant: Some(grant),
227 message: None,
228 }
229 }
230 PermissionOption::Deny => PermissionPanelResponse {
231 granted: false,
232 grant: None,
233 message: None,
234 },
235 }
236 }
237}
238
239#[derive(Debug, Clone)]
241pub enum KeyAction {
242 None,
244 Selected(String, PermissionPanelResponse),
246 Cancelled(String),
248}
249
250pub struct PermissionPanel {
252 active: bool,
254 tool_use_id: String,
256 session_id: i64,
258 request: PermissionRequest,
260 turn_id: Option<TurnId>,
262 selected_idx: usize,
264 config: PermissionPanelConfig,
266}
267
268impl PermissionPanel {
269 pub fn new() -> Self {
271 Self::with_config(PermissionPanelConfig::new())
272 }
273
274 pub fn with_config(config: PermissionPanelConfig) -> Self {
276 Self {
277 active: false,
278 tool_use_id: String::new(),
279 session_id: 0,
280 request: PermissionRequest::new(
281 "",
282 GrantTarget::path("/", false),
283 PermissionLevel::None,
284 "",
285 ),
286 turn_id: None,
287 selected_idx: 0,
288 config,
289 }
290 }
291
292 pub fn config(&self) -> &PermissionPanelConfig {
294 &self.config
295 }
296
297 pub fn set_config(&mut self, config: PermissionPanelConfig) {
299 self.config = config;
300 }
301
302 pub fn activate(
304 &mut self,
305 tool_use_id: String,
306 session_id: i64,
307 request: PermissionRequest,
308 turn_id: Option<TurnId>,
309 ) {
310 self.active = true;
311 self.tool_use_id = tool_use_id;
312 self.session_id = session_id;
313 self.request = request;
314 self.turn_id = turn_id;
315 self.selected_idx = 0; }
317
318 pub fn deactivate(&mut self) {
320 self.active = false;
321 self.tool_use_id.clear();
322 self.request = PermissionRequest::new(
323 "",
324 GrantTarget::path("/", false),
325 PermissionLevel::None,
326 "",
327 );
328 self.turn_id = None;
329 self.selected_idx = 0;
330 }
331
332 pub fn is_active(&self) -> bool {
334 self.active
335 }
336
337 pub fn tool_use_id(&self) -> &str {
339 &self.tool_use_id
340 }
341
342 pub fn session_id(&self) -> i64 {
344 self.session_id
345 }
346
347 pub fn request(&self) -> &PermissionRequest {
349 &self.request
350 }
351
352 pub fn turn_id(&self) -> Option<&TurnId> {
354 self.turn_id.as_ref()
355 }
356
357 fn available_options(&self) -> Vec<PermissionOption> {
362 match &self.request.target {
363 GrantTarget::Command { .. } => {
364 vec![
366 PermissionOption::GrantOnce,
367 PermissionOption::GrantSession,
368 PermissionOption::Deny,
369 ]
370 }
371 _ => {
372 vec![
374 PermissionOption::GrantOnce,
375 PermissionOption::GrantSession,
376 PermissionOption::GrantAllSession,
377 PermissionOption::Deny,
378 ]
379 }
380 }
381 }
382
383 pub fn selected_option(&self) -> PermissionOption {
385 let options = self.available_options();
386 options[self.selected_idx.min(options.len() - 1)]
387 }
388
389 pub fn select_next(&mut self) {
391 let options = self.available_options();
392 self.selected_idx = (self.selected_idx + 1) % options.len();
393 }
394
395 pub fn select_prev(&mut self) {
397 let options = self.available_options();
398 if self.selected_idx == 0 {
399 self.selected_idx = options.len() - 1;
400 } else {
401 self.selected_idx -= 1;
402 }
403 }
404
405 pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
407 if !self.active {
408 return KeyAction::None;
409 }
410
411 match key.code {
412 KeyCode::Up | KeyCode::Char('k') => {
414 self.select_prev();
415 KeyAction::None
416 }
417 KeyCode::Down | KeyCode::Char('j') => {
418 self.select_next();
419 KeyAction::None
420 }
421 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
422 self.select_prev();
423 KeyAction::None
424 }
425 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
426 self.select_next();
427 KeyAction::None
428 }
429
430 KeyCode::Enter | KeyCode::Char(' ') => {
432 let option = self.selected_option();
433 let response = option.to_response(&self.request);
434 let tool_use_id = self.tool_use_id.clone();
435 KeyAction::Selected(tool_use_id, response)
436 }
437
438 KeyCode::Esc => {
440 let tool_use_id = self.tool_use_id.clone();
441 KeyAction::Cancelled(tool_use_id)
442 }
443
444 _ => KeyAction::None,
445 }
446 }
447
448 pub fn panel_height(&self, max_height: u16) -> u16 {
450 let mut lines = 6u16; lines += self.available_options().len() as u16;
460 lines += 2; let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
463 lines.min(max_from_percent).min(max_height.saturating_sub(6))
464 }
465
466 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
468 if !self.active {
469 return;
470 }
471
472 frame.render_widget(Clear, area);
474
475 let inner_width = area.width.saturating_sub(4) as usize;
476 let mut lines: Vec<Line> = Vec::new();
477
478 lines.push(Line::from(Span::styled(
480 truncate_text(&self.config.help_text, inner_width),
481 theme.help_text(),
482 )));
483 lines.push(Line::from("")); let (icon, level_str, target_desc) = match &self.request.target {
487 GrantTarget::Path { path, recursive } => {
488 let rec_suffix = if *recursive { " (recursive)" } else { "" };
489 let level = format_level(self.request.required_level);
490 (
491 &self.config.icon_path,
492 level,
493 format!("{}{}", path.display(), rec_suffix),
494 )
495 }
496 GrantTarget::Domain { pattern } => (
497 &self.config.icon_domain,
498 "Access Domain",
499 pattern.clone(),
500 ),
501 GrantTarget::Command { pattern } => (
502 &self.config.icon_command,
503 "Execute",
504 pattern.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_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
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}