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