Skip to main content

agent_core_tui/widgets/
permission_panel.rs

1//! Permission panel widget for AskForPermissions tool
2//!
3//! A reusable Ratatui panel that displays permission requests from the LLM
4//! and collects user responses (Grant Once / Grant Session / Deny).
5//!
6//! # Navigation
7//! - Up/Down: Move between options
8//! - Enter/Space: Select option
9//! - Esc: Cancel (deny)
10
11use 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
24/// Default configuration values for PermissionPanel
25pub mod defaults {
26    /// Maximum percentage of screen height the panel can use
27    pub const MAX_PANEL_PERCENT: u16 = 50;
28    /// Selection indicator for focused items
29    pub const SELECTION_INDICATOR: &str = " \u{203A} ";
30    /// Blank space for non-focused items (same width as indicator)
31    pub const NO_INDICATOR: &str = "   ";
32    /// Panel title
33    pub const TITLE: &str = " Permission Request ";
34    /// Help text
35    pub const HELP_TEXT: &str =
36        " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
37    /// Target type icons
38    pub const ICON_PATH: &str = "\u{1F4C4}"; // Document (for paths)
39    pub const ICON_DOMAIN: &str = "\u{2194}"; // Network arrow (for domains)
40    pub const ICON_COMMAND: &str = "\u{2295}"; // Command/terminal (for commands)
41    pub const ICON_OTHER: &str = "\u{25CB}"; // Default fallback
42}
43
44/// Configuration for PermissionPanel widget
45#[derive(Clone)]
46pub struct PermissionPanelConfig {
47    /// Maximum percentage of screen height the panel can use
48    pub max_panel_percent: u16,
49    /// Selection indicator for focused items
50    pub selection_indicator: String,
51    /// Blank space for non-focused items
52    pub no_indicator: String,
53    /// Panel title
54    pub title: String,
55    /// Help text
56    pub help_text: String,
57    /// Icon for path targets
58    pub icon_path: String,
59    /// Icon for domain targets
60    pub icon_domain: String,
61    /// Icon for command targets
62    pub icon_command: String,
63    /// Icon for other/unknown targets
64    pub icon_other: String,
65}
66
67impl Default for PermissionPanelConfig {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl PermissionPanelConfig {
74    /// Create a new PermissionPanelConfig with default values
75    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    /// Set the maximum panel height percentage
90    pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
91        self.max_panel_percent = percent;
92        self
93    }
94
95    /// Set the selection indicator
96    pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
97        self.selection_indicator = indicator.into();
98        self
99    }
100
101    /// Set the panel title
102    pub fn with_title(mut self, title: impl Into<String>) -> Self {
103        self.title = title.into();
104        self
105    }
106
107    /// Set the help text
108    pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
109        self.help_text = text.into();
110        self
111    }
112
113    /// Set target type icons
114    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/// Options available for the user to select
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum PermissionOption {
132    /// Grant permission for this request only (no persistent grant)
133    GrantOnce,
134    /// Grant permission for the remainder of the session (this specific resource)
135    GrantSession,
136    /// Grant permission for ALL operations of this type for the session
137    GrantAllSession,
138    /// Deny the permission request
139    Deny,
140}
141
142impl PermissionOption {
143    /// Get all options in display order
144    pub fn all() -> &'static [PermissionOption] {
145        &[
146            PermissionOption::GrantOnce,
147            PermissionOption::GrantSession,
148            PermissionOption::GrantAllSession,
149            PermissionOption::Deny,
150        ]
151    }
152
153    /// Get the display label for this option
154    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    /// Get the description for this option based on request context
164    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    /// Returns true if this is a positive (grant) option
194    pub fn is_positive(&self) -> bool {
195        !matches!(self, PermissionOption::Deny)
196    }
197
198    /// Convert to a response with optional grant based on the request
199    pub fn to_response(&self, request: &PermissionRequest) -> PermissionPanelResponse {
200        match self {
201            PermissionOption::GrantOnce => PermissionPanelResponse {
202                granted: true,
203                grant: None, // No persistent grant
204                message: None,
205            },
206            PermissionOption::GrantSession => {
207                // Create a grant matching the exact request
208                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                // Create a broader grant based on target type, using the requested level
217                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/// Result of handling a key event
240#[derive(Debug, Clone)]
241pub enum KeyAction {
242    /// No action taken
243    None,
244    /// User selected an option (includes tool_use_id and response)
245    Selected(String, PermissionPanelResponse),
246    /// User cancelled (pressed Escape)
247    Cancelled(String),
248}
249
250/// State for the permission panel
251pub struct PermissionPanel {
252    /// Whether the panel is active/visible
253    active: bool,
254    /// Tool use ID for this permission request
255    tool_use_id: String,
256    /// Session ID
257    session_id: i64,
258    /// The permission request to display
259    request: PermissionRequest,
260    /// Turn ID for context
261    turn_id: Option<TurnId>,
262    /// Currently selected option index
263    selected_idx: usize,
264    /// Configuration for display customization
265    config: PermissionPanelConfig,
266}
267
268impl PermissionPanel {
269    /// Create a new inactive permission panel
270    pub fn new() -> Self {
271        Self::with_config(PermissionPanelConfig::new())
272    }
273
274    /// Create a new inactive permission panel with custom configuration
275    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    /// Get the current configuration
293    pub fn config(&self) -> &PermissionPanelConfig {
294        &self.config
295    }
296
297    /// Set a new configuration
298    pub fn set_config(&mut self, config: PermissionPanelConfig) {
299        self.config = config;
300    }
301
302    /// Activate the panel with a permission request
303    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; // Default to first option (Grant Once)
316    }
317
318    /// Deactivate the panel
319    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    /// Check if the panel is active
333    pub fn is_active(&self) -> bool {
334        self.active
335    }
336
337    /// Get the current tool use ID
338    pub fn tool_use_id(&self) -> &str {
339        &self.tool_use_id
340    }
341
342    /// Get the session ID
343    pub fn session_id(&self) -> i64 {
344        self.session_id
345    }
346
347    /// Get the current request
348    pub fn request(&self) -> &PermissionRequest {
349        &self.request
350    }
351
352    /// Get the turn ID
353    pub fn turn_id(&self) -> Option<&TurnId> {
354        self.turn_id.as_ref()
355    }
356
357    /// Get available options based on request type.
358    ///
359    /// For commands, "Allow All Similar" is not shown (too risky).
360    /// For file reads/writes, all 4 options are shown.
361    fn available_options(&self) -> Vec<PermissionOption> {
362        match &self.request.target {
363            GrantTarget::Command { .. } => {
364                // No "Allow All" for commands - too risky
365                vec![
366                    PermissionOption::GrantOnce,
367                    PermissionOption::GrantSession,
368                    PermissionOption::Deny,
369                ]
370            }
371            _ => {
372                // File reads/writes and domains show all options
373                vec![
374                    PermissionOption::GrantOnce,
375                    PermissionOption::GrantSession,
376                    PermissionOption::GrantAllSession,
377                    PermissionOption::Deny,
378                ]
379            }
380        }
381    }
382
383    /// Get the currently selected option
384    pub fn selected_option(&self) -> PermissionOption {
385        let options = self.available_options();
386        options[self.selected_idx.min(options.len() - 1)]
387    }
388
389    /// Move selection to the next option
390    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    /// Move selection to the previous option
396    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    /// Handle a key event
406    pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
407        if !self.active {
408            return KeyAction::None;
409        }
410
411        match key.code {
412            // Navigation
413            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            // Selection
431            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            // Cancel
439            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    /// Calculate the height needed for the panel
449    pub fn panel_height(&self, max_height: u16) -> u16 {
450        // Help text: 1
451        // Blank after help: 1
452        // Permission line: 1
453        // Blank (separator): 1
454        // Options: 3 or 4 (depending on request type)
455        // Bottom padding: 1
456        // Borders: 2
457
458        let mut lines = 6u16; // Help (1) + blank (1) + permission (1) + separator (1) + padding (1) + 1
459        lines += self.available_options().len() as u16;
460        lines += 2; // Borders
461
462        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    /// Render the panel
467    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
468        if !self.active {
469            return;
470        }
471
472        // Clear the area first
473        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        // Help text at top
479        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("")); // Blank line
484
485        // Single permission line: icon + level + target
486        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        // Separator line
519        lines.push(Line::from(""));
520
521        // Options
522        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        // Bottom padding
560        lines.push(Line::from(""));
561
562        // Build the block
563        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
582/// Format a permission level for display
583fn 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
593/// Truncate text to fit within a maximum width
594fn 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
603// --- Widget trait implementation ---
604
605use 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 // High priority - modal panel
615    }
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        // Use NavigationHelper for navigation keys
627        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        // Selection using nav helper
637        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        // Cancel using nav helper
648        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        // j/k vim-style navigation (kept for consistency)
654        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()); // Grant once doesn't persist
725
726        let session = PermissionOption::GrantSession.to_response(&request);
727        assert!(session.granted);
728        assert!(session.grant.is_some()); // Session grant persists
729
730        let all_session = PermissionOption::GrantAllSession.to_response(&request);
731        assert!(all_session.granted);
732        assert!(all_session.grant.is_some()); // All session grant persists
733
734        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        // Default is first option
762        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
763
764        // Move down
765        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        // Wrap around
775        panel.select_next();
776        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
777
778        // Move up (wrap)
779        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        // Down key
790        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        // Up key
795        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        // Enter to select
807        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()); // GrantOnce doesn't persist
813            }
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        // Escape to cancel
827        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        // GrantOnce is always the same
845        assert_eq!(
846            PermissionOption::GrantOnce.description(&read_request),
847            "Allow only this request"
848        );
849
850        // GrantSession varies by type
851        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        // GrantAllSession varies by type
865        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        // Deny is always the same
879        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        // File read: should have 4 options including GrantAllSession
897        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        // Command: should have 3 options, NO GrantAllSession
905        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}