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_WRITE: &str = "\u{270E}";
39    pub const ICON_FILE_DELETE: &str = "\u{2717}";
40    pub const ICON_NETWORK: &str = "\u{2194}";
41    pub const ICON_SYSTEM: &str = "\u{2295}";
42    pub const ICON_OTHER: &str = "\u{25CB}";
43    /// Resource tree characters
44    pub const TREE_BRANCH: &str = "   \u{251C}\u{2500} ";
45    pub const TREE_LAST: &str = "   \u{2514}\u{2500} ";
46}
47
48/// Configuration for PermissionPanel widget
49#[derive(Clone)]
50pub struct PermissionPanelConfig {
51    /// Maximum percentage of screen height the panel can use
52    pub max_panel_percent: u16,
53    /// Selection indicator for focused items
54    pub selection_indicator: String,
55    /// Blank space for non-focused items
56    pub no_indicator: String,
57    /// Panel title
58    pub title: String,
59    /// Help text
60    pub help_text: String,
61    /// Category icon for file write
62    pub icon_file_write: String,
63    /// Category icon for file delete
64    pub icon_file_delete: String,
65    /// Category icon for network
66    pub icon_network: String,
67    /// Category icon for system
68    pub icon_system: String,
69    /// Category icon for other
70    pub icon_other: String,
71    /// Tree branch character
72    pub tree_branch: String,
73    /// Tree last item character
74    pub tree_last: String,
75}
76
77impl Default for PermissionPanelConfig {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl PermissionPanelConfig {
84    /// Create a new PermissionPanelConfig with default values
85    pub fn new() -> Self {
86        Self {
87            max_panel_percent: defaults::MAX_PANEL_PERCENT,
88            selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
89            no_indicator: defaults::NO_INDICATOR.to_string(),
90            title: defaults::TITLE.to_string(),
91            help_text: defaults::HELP_TEXT.to_string(),
92            icon_file_write: defaults::ICON_FILE_WRITE.to_string(),
93            icon_file_delete: defaults::ICON_FILE_DELETE.to_string(),
94            icon_network: defaults::ICON_NETWORK.to_string(),
95            icon_system: defaults::ICON_SYSTEM.to_string(),
96            icon_other: defaults::ICON_OTHER.to_string(),
97            tree_branch: defaults::TREE_BRANCH.to_string(),
98            tree_last: defaults::TREE_LAST.to_string(),
99        }
100    }
101
102    /// Set the maximum panel height percentage
103    pub fn with_max_panel_percent(mut self, percent: u16) -> Self {
104        self.max_panel_percent = percent;
105        self
106    }
107
108    /// Set the selection indicator
109    pub fn with_selection_indicator(mut self, indicator: impl Into<String>) -> Self {
110        self.selection_indicator = indicator.into();
111        self
112    }
113
114    /// Set the panel title
115    pub fn with_title(mut self, title: impl Into<String>) -> Self {
116        self.title = title.into();
117        self
118    }
119
120    /// Set the help text
121    pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
122        self.help_text = text.into();
123        self
124    }
125
126    /// Set category icons
127    pub fn with_category_icons(
128        mut self,
129        file_write: impl Into<String>,
130        file_delete: impl Into<String>,
131        network: impl Into<String>,
132        system: impl Into<String>,
133        other: impl Into<String>,
134    ) -> Self {
135        self.icon_file_write = file_write.into();
136        self.icon_file_delete = file_delete.into();
137        self.icon_network = network.into();
138        self.icon_system = system.into();
139        self.icon_other = other.into();
140        self
141    }
142}
143
144/// Options available for the user to select
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum PermissionOption {
147    /// Grant permission for this request only
148    GrantOnce,
149    /// Grant permission for the remainder of the session
150    GrantSession,
151    /// Deny the permission request
152    Deny,
153}
154
155impl PermissionOption {
156    /// Get all options in display order
157    pub fn all() -> &'static [PermissionOption] {
158        &[
159            PermissionOption::GrantOnce,
160            PermissionOption::GrantSession,
161            PermissionOption::Deny,
162        ]
163    }
164
165    /// Get the display label for this option
166    pub fn label(&self) -> &'static str {
167        match self {
168            PermissionOption::GrantOnce => "Grant Once",
169            PermissionOption::GrantSession => "Grant for Session",
170            PermissionOption::Deny => "Deny",
171        }
172    }
173
174    /// Get the description for this option
175    pub fn description(&self) -> &'static str {
176        match self {
177            PermissionOption::GrantOnce => "Allow this action this one time",
178            PermissionOption::GrantSession => "Allow this action for the rest of the session",
179            PermissionOption::Deny => "Reject this permission request",
180        }
181    }
182
183    /// Convert to a PermissionResponse
184    pub fn to_response(&self) -> PermissionResponse {
185        match self {
186            PermissionOption::GrantOnce => PermissionResponse {
187                granted: true,
188                scope: Some(PermissionScope::Once),
189                message: None,
190            },
191            PermissionOption::GrantSession => PermissionResponse {
192                granted: true,
193                scope: Some(PermissionScope::Session),
194                message: None,
195            },
196            PermissionOption::Deny => PermissionResponse {
197                granted: false,
198                scope: None,
199                message: None,
200            },
201        }
202    }
203}
204
205/// Result of handling a key event
206#[derive(Debug, Clone, PartialEq)]
207pub enum KeyAction {
208    /// No action taken
209    None,
210    /// User selected an option (includes tool_use_id and response)
211    Selected(String, PermissionResponse),
212    /// User cancelled (pressed Escape)
213    Cancelled(String),
214}
215
216/// State for the permission panel
217pub struct PermissionPanel {
218    /// Whether the panel is active/visible
219    active: bool,
220    /// Tool use ID for this permission request
221    tool_use_id: String,
222    /// Session ID
223    session_id: i64,
224    /// The permission request to display
225    request: PermissionRequest,
226    /// Turn ID for context
227    turn_id: Option<TurnId>,
228    /// Currently selected option index
229    selected_idx: usize,
230    /// Configuration for display customization
231    config: PermissionPanelConfig,
232}
233
234impl PermissionPanel {
235    /// Create a new inactive permission panel
236    pub fn new() -> Self {
237        Self::with_config(PermissionPanelConfig::new())
238    }
239
240    /// Create a new inactive permission panel with custom configuration
241    pub fn with_config(config: PermissionPanelConfig) -> Self {
242        Self {
243            active: false,
244            tool_use_id: String::new(),
245            session_id: 0,
246            request: PermissionRequest {
247                action: String::new(),
248                reason: None,
249                resources: Vec::new(),
250                category: PermissionCategory::Other,
251            },
252            turn_id: None,
253            selected_idx: 0,
254            config,
255        }
256    }
257
258    /// Get the current configuration
259    pub fn config(&self) -> &PermissionPanelConfig {
260        &self.config
261    }
262
263    /// Set a new configuration
264    pub fn set_config(&mut self, config: PermissionPanelConfig) {
265        self.config = config;
266    }
267
268    /// Activate the panel with a permission request
269    pub fn activate(
270        &mut self,
271        tool_use_id: String,
272        session_id: i64,
273        request: PermissionRequest,
274        turn_id: Option<TurnId>,
275    ) {
276        self.active = true;
277        self.tool_use_id = tool_use_id;
278        self.session_id = session_id;
279        self.request = request;
280        self.turn_id = turn_id;
281        self.selected_idx = 0; // Default to first option (Grant Once)
282    }
283
284    /// Deactivate the panel
285    pub fn deactivate(&mut self) {
286        self.active = false;
287        self.tool_use_id.clear();
288        self.request.action.clear();
289        self.request.reason = None;
290        self.request.resources.clear();
291        self.turn_id = None;
292        self.selected_idx = 0;
293    }
294
295    /// Check if the panel is active
296    pub fn is_active(&self) -> bool {
297        self.active
298    }
299
300    /// Get the current tool use ID
301    pub fn tool_use_id(&self) -> &str {
302        &self.tool_use_id
303    }
304
305    /// Get the session ID
306    pub fn session_id(&self) -> i64 {
307        self.session_id
308    }
309
310    /// Get the current request
311    pub fn request(&self) -> &PermissionRequest {
312        &self.request
313    }
314
315    /// Get the turn ID
316    pub fn turn_id(&self) -> Option<&TurnId> {
317        self.turn_id.as_ref()
318    }
319
320    /// Get the currently selected option
321    pub fn selected_option(&self) -> PermissionOption {
322        PermissionOption::all()[self.selected_idx]
323    }
324
325    /// Move selection to the next option
326    pub fn select_next(&mut self) {
327        let options = PermissionOption::all();
328        self.selected_idx = (self.selected_idx + 1) % options.len();
329    }
330
331    /// Move selection to the previous option
332    pub fn select_prev(&mut self) {
333        let options = PermissionOption::all();
334        if self.selected_idx == 0 {
335            self.selected_idx = options.len() - 1;
336        } else {
337            self.selected_idx -= 1;
338        }
339    }
340
341    /// Handle a key event
342    ///
343    /// Returns the action that should be taken based on the key press.
344    pub fn process_key(&mut self, key: KeyEvent) -> KeyAction {
345        if !self.active {
346            return KeyAction::None;
347        }
348
349        match key.code {
350            // Navigation
351            KeyCode::Up | KeyCode::Char('k') => {
352                self.select_prev();
353                KeyAction::None
354            }
355            KeyCode::Down | KeyCode::Char('j') => {
356                self.select_next();
357                KeyAction::None
358            }
359            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
360                self.select_prev();
361                KeyAction::None
362            }
363            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
364                self.select_next();
365                KeyAction::None
366            }
367
368            // Selection
369            KeyCode::Enter | KeyCode::Char(' ') => {
370                let option = self.selected_option();
371                let response = option.to_response();
372                let tool_use_id = self.tool_use_id.clone();
373                // Note: don't deactivate here - let the caller do it after processing
374                KeyAction::Selected(tool_use_id, response)
375            }
376
377            // Cancel
378            KeyCode::Esc => {
379                let tool_use_id = self.tool_use_id.clone();
380                // Note: don't deactivate here - let the caller do it after processing
381                KeyAction::Cancelled(tool_use_id)
382            }
383
384            _ => KeyAction::None,
385        }
386    }
387
388    /// Calculate the height needed for the panel
389    pub fn panel_height(&self, max_height: u16) -> u16 {
390        // Calculate height needed:
391        // - Title: 1 line
392        // - Blank: 1 line
393        // - Category: 1 line
394        // - Action: 1 line (may wrap, but estimate 1)
395        // - Reason (if present): 1 line
396        // - Resources header + items: 1 + resources.len()
397        // - Blank: 1 line
398        // - Options: 3 lines (one per option)
399        // - Blank: 1 line
400        // - Help: 1 line
401        // - Borders: 2 lines
402
403        let mut lines = 0u16;
404
405        // Header
406        lines += 2; // Title + blank
407
408        // Content
409        lines += 1; // Category
410        lines += 1; // Action
411        if self.request.reason.is_some() {
412            lines += 1;
413        }
414        if !self.request.resources.is_empty() {
415            lines += 1 + self.request.resources.len().min(5) as u16; // Header + up to 5 resources
416        }
417
418        // Options
419        lines += 1; // Blank before options
420        lines += PermissionOption::all().len() as u16;
421
422        // Help and borders
423        lines += 1; // Blank before help
424        lines += 1; // Help text
425        lines += 2; // Borders
426
427        // Cap at percentage of available height
428        let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
429        lines.min(max_from_percent).min(max_height.saturating_sub(6))
430    }
431
432    /// Render the panel
433    ///
434    /// # Arguments
435    /// * `frame` - The Ratatui frame to render into
436    /// * `area` - The area to render the panel in
437    /// * `theme` - Theme implementation for styling
438    pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
439        if !self.active {
440            return;
441        }
442
443        // Clear the area first
444        frame.render_widget(Clear, area);
445
446        let inner_width = area.width.saturating_sub(4) as usize;
447        let mut lines: Vec<Line> = Vec::new();
448
449        // Help text at top
450        lines.push(Line::from(Span::styled(
451            truncate_text(&self.config.help_text, inner_width),
452            theme.help_text(),
453        )));
454        lines.push(Line::from("")); // Blank line
455
456        // Category with icon
457        let category_icon = match self.request.category {
458            PermissionCategory::FileWrite => &self.config.icon_file_write,
459            PermissionCategory::FileDelete => &self.config.icon_file_delete,
460            PermissionCategory::Network => &self.config.icon_network,
461            PermissionCategory::System => &self.config.icon_system,
462            PermissionCategory::Other => &self.config.icon_other,
463        };
464        lines.push(Line::from(vec![
465            Span::styled(
466                format!(" {} ", category_icon),
467                theme.category(),
468            ),
469            Span::styled(
470                format!("{}", self.request.category),
471                theme.category().add_modifier(Modifier::BOLD),
472            ),
473        ]));
474
475        // Action
476        lines.push(Line::from(vec![
477            Span::styled(" Action: ", theme.muted_text()),
478            Span::styled(
479                truncate_text(&self.request.action, inner_width - 10),
480                Style::default().add_modifier(Modifier::BOLD),
481            ),
482        ]));
483
484        // Reason (if present)
485        if let Some(ref reason) = self.request.reason {
486            lines.push(Line::from(vec![
487                Span::styled(" Reason: ", theme.muted_text()),
488                Span::styled(
489                    truncate_text(reason, inner_width - 10),
490                    theme.muted_text(),
491                ),
492            ]));
493        }
494
495        // Resources (if present)
496        if !self.request.resources.is_empty() {
497            lines.push(Line::from(Span::styled(
498                " Resources:",
499                theme.muted_text(),
500            )));
501            for (i, resource) in self.request.resources.iter().take(5).enumerate() {
502                let prefix = if i < self.request.resources.len() - 1 || self.request.resources.len() <= 5 {
503                    &self.config.tree_branch
504                } else {
505                    &self.config.tree_last
506                };
507                lines.push(Line::from(vec![
508                    Span::raw(prefix.clone()),
509                    Span::styled(
510                        truncate_text(resource, inner_width - 8),
511                        theme.resource(),
512                    ),
513                ]));
514            }
515            if self.request.resources.len() > 5 {
516                lines.push(Line::from(Span::styled(
517                    format!("   ... and {} more", self.request.resources.len() - 5),
518                    theme.muted_text(),
519                )));
520            }
521        }
522
523        // Blank line before options
524        lines.push(Line::from(""));
525
526        // Options
527        for (idx, option) in PermissionOption::all().iter().enumerate() {
528            let is_selected = idx == self.selected_idx;
529            let prefix = if is_selected { &self.config.selection_indicator } else { &self.config.no_indicator };
530
531            let (label_style, desc_style) = if is_selected {
532                match option {
533                    PermissionOption::GrantOnce | PermissionOption::GrantSession => {
534                        (theme.button_confirm_focused(), theme.focused_text())
535                    }
536                    PermissionOption::Deny => {
537                        (theme.button_cancel_focused(), theme.focused_text())
538                    }
539                }
540            } else {
541                match option {
542                    PermissionOption::GrantOnce | PermissionOption::GrantSession => {
543                        (theme.button_confirm(), theme.muted_text())
544                    }
545                    PermissionOption::Deny => {
546                        (theme.button_cancel(), theme.muted_text())
547                    }
548                }
549            };
550
551            let indicator_style = if is_selected {
552                theme.focus_indicator()
553            } else {
554                theme.muted_text()
555            };
556
557            lines.push(Line::from(vec![
558                Span::styled(prefix.clone(), indicator_style),
559                Span::styled(option.label(), label_style),
560                Span::styled(" - ", theme.muted_text()),
561                Span::styled(option.description(), desc_style),
562            ]));
563        }
564
565        // Build the block
566        let block = Block::default()
567            .borders(Borders::ALL)
568            .border_style(theme.warning())
569            .title(Span::styled(
570                self.config.title.clone(),
571                theme.warning().add_modifier(Modifier::BOLD),
572            ));
573
574        let paragraph = Paragraph::new(lines).block(block);
575        frame.render_widget(paragraph, area);
576    }
577}
578
579impl Default for PermissionPanel {
580    fn default() -> Self {
581        Self::new()
582    }
583}
584
585// --- Widget trait implementation ---
586
587use std::any::Any;
588use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
589
590impl Widget for PermissionPanel {
591    fn id(&self) -> &'static str {
592        widget_ids::PERMISSION_PANEL
593    }
594
595    fn priority(&self) -> u8 {
596        200 // High priority - modal panel
597    }
598
599    fn is_active(&self) -> bool {
600        self.active
601    }
602
603    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
604        if !self.active {
605            return WidgetKeyResult::NotHandled;
606        }
607
608        // Use NavigationHelper for navigation keys
609        if ctx.nav.is_move_up(&key) {
610            self.select_prev();
611            return WidgetKeyResult::Handled;
612        }
613        if ctx.nav.is_move_down(&key) {
614            self.select_next();
615            return WidgetKeyResult::Handled;
616        }
617
618        // Selection using nav helper
619        if ctx.nav.is_select(&key) {
620            let option = self.selected_option();
621            let response = option.to_response();
622            let tool_use_id = self.tool_use_id.clone();
623            return WidgetKeyResult::Action(WidgetAction::SubmitPermission {
624                tool_use_id,
625                response,
626            });
627        }
628
629        // Cancel using nav helper
630        if ctx.nav.is_cancel(&key) {
631            let tool_use_id = self.tool_use_id.clone();
632            return WidgetKeyResult::Action(WidgetAction::CancelPermission { tool_use_id });
633        }
634
635        // j/k vim-style navigation (kept for consistency)
636        match key.code {
637            KeyCode::Char('k') => {
638                self.select_prev();
639                WidgetKeyResult::Handled
640            }
641            KeyCode::Char('j') => {
642                self.select_next();
643                WidgetKeyResult::Handled
644            }
645            _ => WidgetKeyResult::Handled,
646        }
647    }
648
649    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
650        self.render_panel(frame, area, theme);
651    }
652
653    fn required_height(&self, max_height: u16) -> u16 {
654        if self.active {
655            self.panel_height(max_height)
656        } else {
657            0
658        }
659    }
660
661    fn blocks_input(&self) -> bool {
662        self.active
663    }
664
665    fn is_overlay(&self) -> bool {
666        false
667    }
668
669    fn as_any(&self) -> &dyn Any {
670        self
671    }
672
673    fn as_any_mut(&mut self) -> &mut dyn Any {
674        self
675    }
676
677    fn into_any(self: Box<Self>) -> Box<dyn Any> {
678        self
679    }
680}
681
682/// Truncate text to fit within a maximum width
683fn truncate_text(text: &str, max_width: usize) -> String {
684    if text.chars().count() <= max_width {
685        text.to_string()
686    } else {
687        let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
688        format!("{}...", truncated)
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn test_permission_option_all() {
698        let options = PermissionOption::all();
699        assert_eq!(options.len(), 3);
700        assert_eq!(options[0], PermissionOption::GrantOnce);
701        assert_eq!(options[1], PermissionOption::GrantSession);
702        assert_eq!(options[2], PermissionOption::Deny);
703    }
704
705    #[test]
706    fn test_permission_option_to_response() {
707        let once = PermissionOption::GrantOnce.to_response();
708        assert!(once.granted);
709        assert_eq!(once.scope, Some(PermissionScope::Once));
710
711        let session = PermissionOption::GrantSession.to_response();
712        assert!(session.granted);
713        assert_eq!(session.scope, Some(PermissionScope::Session));
714
715        let deny = PermissionOption::Deny.to_response();
716        assert!(!deny.granted);
717        assert!(deny.scope.is_none());
718    }
719
720    #[test]
721    fn test_panel_activation() {
722        let mut panel = PermissionPanel::new();
723        assert!(!panel.is_active());
724
725        let request = PermissionRequest {
726            action: "Delete file".to_string(),
727            reason: Some("Cleanup".to_string()),
728            resources: vec!["/tmp/foo.txt".to_string()],
729            category: PermissionCategory::FileDelete,
730        };
731
732        panel.activate("tool_123".to_string(), 1, request, None);
733        assert!(panel.is_active());
734        assert_eq!(panel.tool_use_id(), "tool_123");
735        assert_eq!(panel.session_id(), 1);
736
737        panel.deactivate();
738        assert!(!panel.is_active());
739    }
740
741    #[test]
742    fn test_navigation() {
743        let mut panel = PermissionPanel::new();
744        let request = PermissionRequest {
745            action: "Test".to_string(),
746            reason: None,
747            resources: vec![],
748            category: PermissionCategory::Other,
749        };
750        panel.activate("tool_1".to_string(), 1, request, None);
751
752        // Default is first option
753        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
754
755        // Move down
756        panel.select_next();
757        assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
758
759        panel.select_next();
760        assert_eq!(panel.selected_option(), PermissionOption::Deny);
761
762        // Wrap around
763        panel.select_next();
764        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
765
766        // Move up (wrap)
767        panel.select_prev();
768        assert_eq!(panel.selected_option(), PermissionOption::Deny);
769    }
770
771    #[test]
772    fn test_handle_key_navigation() {
773        let mut panel = PermissionPanel::new();
774        let request = PermissionRequest {
775            action: "Test".to_string(),
776            reason: None,
777            resources: vec![],
778            category: PermissionCategory::Other,
779        };
780        panel.activate("tool_1".to_string(), 1, request, None);
781
782        // Down key
783        let action = panel.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
784        assert_eq!(action, KeyAction::None);
785        assert_eq!(panel.selected_option(), PermissionOption::GrantSession);
786
787        // Up key
788        let action = panel.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
789        assert_eq!(action, KeyAction::None);
790        assert_eq!(panel.selected_option(), PermissionOption::GrantOnce);
791    }
792
793    #[test]
794    fn test_handle_key_selection() {
795        let mut panel = PermissionPanel::new();
796        let request = PermissionRequest {
797            action: "Test".to_string(),
798            reason: None,
799            resources: vec![],
800            category: PermissionCategory::Other,
801        };
802        panel.activate("tool_1".to_string(), 1, request, None);
803
804        // Enter to select
805        let action = panel.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
806        match action {
807            KeyAction::Selected(tool_use_id, response) => {
808                assert_eq!(tool_use_id, "tool_1");
809                assert!(response.granted);
810                assert_eq!(response.scope, Some(PermissionScope::Once));
811            }
812            _ => panic!("Expected Selected action"),
813        }
814        // Panel doesn't deactivate itself - caller must do it
815        panel.deactivate();
816        assert!(!panel.is_active());
817    }
818
819    #[test]
820    fn test_handle_key_cancel() {
821        let mut panel = PermissionPanel::new();
822        let request = PermissionRequest {
823            action: "Test".to_string(),
824            reason: None,
825            resources: vec![],
826            category: PermissionCategory::Other,
827        };
828        panel.activate("tool_1".to_string(), 1, request, None);
829
830        // Escape to cancel
831        let action = panel.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
832        match action {
833            KeyAction::Cancelled(tool_use_id) => {
834                assert_eq!(tool_use_id, "tool_1");
835            }
836            _ => panic!("Expected Cancelled action"),
837        }
838        // Panel doesn't deactivate itself - caller must do it
839        panel.deactivate();
840        assert!(!panel.is_active());
841    }
842
843    #[test]
844    fn test_truncate_text() {
845        assert_eq!(truncate_text("short", 10), "short");
846        assert_eq!(truncate_text("this is a longer text", 10), "this is...");
847        assert_eq!(truncate_text("exact", 5), "exact");
848    }
849}