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/Ctrl-P/Ctrl-N: Move between options
8//! - Enter/Space: Select option
9//! - Esc: Cancel (deny)
10
11use 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
25/// Default configuration values for PermissionPanel
26pub mod defaults {
27    /// Maximum percentage of screen height the panel can use
28    pub const MAX_PANEL_PERCENT: u16 = 50;
29    /// Selection indicator for focused items
30    pub const SELECTION_INDICATOR: &str = " \u{203A} ";
31    /// Blank space for non-focused items (same width as indicator)
32    pub const NO_INDICATOR: &str = "   ";
33    /// Panel title
34    pub const TITLE: &str = " Permission Required ";
35    /// Help text
36    pub const HELP_TEXT: &str = " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
37    /// Category icons
38    pub const ICON_FILE_READ: &str = "\u{1F4C4}";     // Document
39    pub const ICON_DIRECTORY_READ: &str = "\u{1F4C2}"; // Open folder
40    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    /// Resource tree characters
46    pub const TREE_BRANCH: &str = "   \u{251C}\u{2500} ";
47    pub const TREE_LAST: &str = "   \u{2514}\u{2500} ";
48}
49
50/// Configuration for PermissionPanel widget
51#[derive(Clone)]
52pub struct PermissionPanelConfig {
53    /// Maximum percentage of screen height the panel can use
54    pub max_panel_percent: u16,
55    /// Selection indicator for focused items
56    pub selection_indicator: String,
57    /// Blank space for non-focused items
58    pub no_indicator: String,
59    /// Panel title
60    pub title: String,
61    /// Help text
62    pub help_text: String,
63    /// Category icon for file read
64    pub icon_file_read: String,
65    /// Category icon for directory read
66    pub icon_directory_read: String,
67    /// Category icon for file write
68    pub icon_file_write: String,
69    /// Category icon for file delete
70    pub icon_file_delete: String,
71    /// Category icon for network
72    pub icon_network: String,
73    /// Category icon for system
74    pub icon_system: String,
75    /// Category icon for other
76    pub icon_other: String,
77    /// Tree branch character
78    pub tree_branch: String,
79    /// Tree last item character
80    pub tree_last: String,
81}
82
83impl Default for PermissionPanelConfig {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl PermissionPanelConfig {
90    /// Create a new PermissionPanelConfig with default values
91    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    /// Set the maximum panel height percentage
111    pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
112        self.max_panel_percent = percent;
113        self
114    }
115
116    /// Set the selection indicator
117    pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
118        self.selection_indicator = indicator.into();
119        self
120    }
121
122    /// Set the panel title
123    pub fn with_title(mut self, title: impl Into<String>) -> Self {
124        self.title = title.into();
125        self
126    }
127
128    /// Set the help text
129    pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
130        self.help_text = text.into();
131        self
132    }
133
134    /// Set category icons
135    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/// Options available for the user to select
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum PermissionOption {
159    /// Grant permission for this request only
160    GrantOnce,
161    /// Grant permission for the remainder of the session (this specific resource)
162    GrantSession,
163    /// Grant permission for ALL operations in this category for the session
164    GrantCategorySession,
165    /// Deny the permission request
166    Deny,
167}
168
169impl PermissionOption {
170    /// Get all options in display order
171    pub fn all() -> &'static [PermissionOption] {
172        &[
173            PermissionOption::GrantOnce,
174            PermissionOption::GrantSession,
175            PermissionOption::GrantCategorySession,
176            PermissionOption::Deny,
177        ]
178    }
179
180    /// Get the display label for this option
181    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    /// Get the description for this option
191    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    /// Convert to a PermissionResponse
201    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/// Result of handling a key event
228#[derive(Debug, Clone, PartialEq)]
229pub enum KeyAction {
230    /// No action taken
231    None,
232    /// User selected an option (includes tool_use_id and response)
233    Selected(String, PermissionResponse),
234    /// User cancelled (pressed Escape)
235    Cancelled(String),
236}
237
238/// State for the permission panel
239pub struct PermissionPanel {
240    /// Whether the panel is active/visible
241    active: bool,
242    /// Tool use ID for this permission request
243    tool_use_id: String,
244    /// Session ID
245    session_id: i64,
246    /// The permission request to display
247    request: PermissionRequest,
248    /// Turn ID for context
249    turn_id: Option<TurnId>,
250    /// Currently selected option index
251    selected_idx: usize,
252    /// Configuration for display customization
253    config: PermissionPanelConfig,
254}
255
256impl PermissionPanel {
257    /// Create a new inactive permission panel
258    pub fn new() -> Self {
259        Self::with_config(PermissionPanelConfig::new())
260    }
261
262    /// Create a new inactive permission panel with custom configuration
263    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    /// Get the current configuration
281    pub fn config(&self) -> &PermissionPanelConfig {
282        &self.config
283    }
284
285    /// Set a new configuration
286    pub fn set_config(&mut self, config: PermissionPanelConfig) {
287        self.config = config;
288    }
289
290    /// Activate the panel with a permission request
291    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; // Default to first option (Grant Once)
304    }
305
306    /// Deactivate the panel
307    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    /// Check if the panel is active
318    pub fn is_active(&self) -> bool {
319        self.active
320    }
321
322    /// Get the current tool use ID
323    pub fn tool_use_id(&self) -> &str {
324        &self.tool_use_id
325    }
326
327    /// Get the session ID
328    pub fn session_id(&self) -> i64 {
329        self.session_id
330    }
331
332    /// Get the current request
333    pub fn request(&self) -> &PermissionRequest {
334        &self.request
335    }
336
337    /// Get the turn ID
338    pub fn turn_id(&self) -> Option<&TurnId> {
339        self.turn_id.as_ref()
340    }
341
342    /// Get the currently selected option
343    pub fn selected_option(&self) -> PermissionOption {
344        PermissionOption::all()[self.selected_idx]
345    }
346
347    /// Move selection to the next option
348    pub fn select_next(&mut self) {
349        let options = PermissionOption::all();
350        self.selected_idx = (self.selected_idx + 1) % options.len();
351    }
352
353    /// Move selection to the previous option
354    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    /// Handle a key event
364    ///
365    /// Returns the action that should be taken based on the key press.
366    pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
367        if !self.active {
368            return KeyAction::None;
369        }
370
371        match key.code {
372            // Navigation
373            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            // Selection
391            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                // Note: don't deactivate here - let the caller do it after processing
396                KeyAction::Selected(tool_use_id, response)
397            }
398
399            // Cancel
400            KeyCode::Esc => {
401                let tool_use_id = self.tool_use_id.clone();
402                // Note: don't deactivate here - let the caller do it after processing
403                KeyAction::Cancelled(tool_use_id)
404            }
405
406            _ => KeyAction::None,
407        }
408    }
409
410    /// Calculate the height needed for the panel
411    pub fn panel_height(&self, max_height: u16) -> u16 {
412        // Calculate height needed:
413        // - Title: 1 line
414        // - Blank: 1 line
415        // - Category: 1 line
416        // - Action: 1 line (may wrap, but estimate 1)
417        // - Reason (if present): 1 line
418        // - Resources header + items: 1 + resources.len()
419        // - Blank: 1 line
420        // - Options: 3 lines (one per option)
421        // - Blank: 1 line
422        // - Help: 1 line
423        // - Borders: 2 lines
424
425        let mut lines = 0u16;
426
427        // Header
428        lines += 2; // Title + blank
429
430        // Content
431        lines += 1; // Category
432        lines += 1; // Action
433        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; // Header + up to 5 resources
438        }
439
440        // Options
441        lines += 1; // Blank before options
442        lines += PermissionOption::all().len() as u16;
443
444        // Help and borders
445        lines += 1; // Blank before help
446        lines += 1; // Help text
447        lines += 2; // Borders
448
449        // Cap at percentage of available height
450        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    /// Render the panel
455    ///
456    /// # Arguments
457    /// * `frame` - The Ratatui frame to render into
458    /// * `area` - The area to render the panel in
459    /// * `theme` - Theme implementation for styling
460    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
461        if !self.active {
462            return;
463        }
464
465        // Clear the area first
466        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        // Help text at top
472        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("")); // Blank line
477
478        // Category with icon
479        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        // Action
500        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        // Reason (if present)
509        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        // Resources (if present)
520        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        // Blank line before options
548        lines.push(Line::from(""));
549
550        // Options
551        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        // Build the block
594        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
613// --- Widget trait implementation ---
614
615use 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 // High priority - modal panel
625    }
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        // Use NavigationHelper for navigation keys
637        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        // Selection using nav helper
647        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        // Cancel using nav helper
658        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        // j/k vim-style navigation (kept for consistency)
664        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
710/// Truncate text to fit within a maximum width
711fn 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        // Default is first option
786        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
787
788        // Move down
789        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        // Wrap around
799        panel.select_next();
800        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
801
802        // Move up (wrap)
803        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        // Down key
819        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        // Up key
824        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        // Enter to select
841        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 doesn't deactivate itself - caller must do it
851        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        // Escape to cancel
867        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 doesn't deactivate itself - caller must do it
875        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}