1use crate::controller::TurnId;
12use crate::permissions::{
13 BatchPermissionRequest, BatchPermissionResponse, Grant, GrantTarget, PermissionLevel,
14};
15use crossterm::event::{KeyCode, KeyEvent};
16use ratatui::{
17 Frame,
18 layout::Rect,
19 style::{Modifier, Style},
20 text::{Line, Span},
21 widgets::{Block, Borders, Clear, Paragraph},
22};
23use std::collections::HashSet;
24
25use crate::themes::Theme;
26
27pub mod defaults {
29 pub const MAX_PANEL_PERCENT: u16 = 70;
31 pub const SELECTION_INDICATOR: &str = " \u{203A} ";
33 pub const NO_INDICATOR: &str = " ";
35 pub const TITLE: &str = " Permission Request ";
37 pub const HELP_TEXT: &str =
39 " Up/Down: Navigate \u{00B7} Enter/Space: Select \u{00B7} Esc: Cancel";
40}
41
42#[derive(Clone)]
44pub struct BatchPermissionPanelConfig {
45 pub max_panel_percent: u16,
47 pub selection_indicator: String,
49 pub no_indicator: String,
51 pub title: String,
53 pub help_text: String,
55}
56
57impl Default for BatchPermissionPanelConfig {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl BatchPermissionPanelConfig {
64 pub fn new() -> Self {
66 Self {
67 max_panel_percent: defaults::MAX_PANEL_PERCENT,
68 selection_indicator: defaults::SELECTION_INDICATOR.to_string(),
69 no_indicator: defaults::NO_INDICATOR.to_string(),
70 title: defaults::TITLE.to_string(),
71 help_text: defaults::HELP_TEXT.to_string(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum BatchPermissionOption {
79 GrantOnce,
81 GrantSession,
83 GrantAllSimilar,
86 Deny,
88}
89
90impl BatchPermissionOption {
91 pub fn label(&self) -> &'static str {
93 match self {
94 BatchPermissionOption::GrantOnce => "Grant Once",
95 BatchPermissionOption::GrantSession => "Grant for Session",
96 BatchPermissionOption::GrantAllSimilar => "Allow All Similar",
97 BatchPermissionOption::Deny => "Deny",
98 }
99 }
100
101 pub fn description(&self, count: usize, batch_type: Option<PermissionLevel>) -> String {
103 match self {
104 BatchPermissionOption::GrantOnce => {
105 format!("Allow only these {} requests", count)
106 }
107 BatchPermissionOption::GrantSession => match batch_type {
108 Some(PermissionLevel::Read) => format!("Allow reading these {} files", count),
109 Some(PermissionLevel::Write) => format!("Allow writing these {} files", count),
110 _ => "Allow these for the session".to_string(),
111 },
112 BatchPermissionOption::GrantAllSimilar => match batch_type {
113 Some(PermissionLevel::Read) => "Allow reading any file".to_string(),
114 Some(PermissionLevel::Write) => "Allow writing any file".to_string(),
115 _ => "Allow all similar actions".to_string(),
116 },
117 BatchPermissionOption::Deny => "Deny all requests".to_string(),
118 }
119 }
120
121 pub fn is_positive(&self) -> bool {
123 !matches!(self, BatchPermissionOption::Deny)
124 }
125}
126
127#[derive(Debug, Clone)]
129pub enum BatchKeyAction {
130 None,
132 Submitted(BatchPermissionResponse),
134 Cancelled(String),
136}
137
138pub struct BatchPermissionPanel {
140 active: bool,
142 batch_id: String,
144 session_id: i64,
146 batch: BatchPermissionRequest,
148 turn_id: Option<TurnId>,
150 selected_idx: usize,
152 config: BatchPermissionPanelConfig,
154}
155
156impl BatchPermissionPanel {
157 pub fn new() -> Self {
159 Self::with_config(BatchPermissionPanelConfig::new())
160 }
161
162 pub fn with_config(config: BatchPermissionPanelConfig) -> Self {
164 Self {
165 active: false,
166 batch_id: String::new(),
167 session_id: 0,
168 batch: BatchPermissionRequest::new("", Vec::new()),
169 turn_id: None,
170 selected_idx: 0,
171 config,
172 }
173 }
174
175 pub fn activate(
177 &mut self,
178 session_id: i64,
179 batch: BatchPermissionRequest,
180 turn_id: Option<TurnId>,
181 ) {
182 self.active = true;
183 self.batch_id = batch.batch_id.clone();
184 self.session_id = session_id;
185 self.batch = batch;
186 self.turn_id = turn_id;
187 self.selected_idx = 0; }
189
190 pub fn deactivate(&mut self) {
192 self.active = false;
193 self.batch_id.clear();
194 self.batch = BatchPermissionRequest::new("", Vec::new());
195 self.turn_id = None;
196 self.selected_idx = 0;
197 }
198
199 pub fn is_active(&self) -> bool {
201 self.active
202 }
203
204 pub fn batch_id(&self) -> &str {
206 &self.batch_id
207 }
208
209 pub fn session_id(&self) -> i64 {
211 self.session_id
212 }
213
214 pub fn batch(&self) -> &BatchPermissionRequest {
216 &self.batch
217 }
218
219 pub fn turn_id(&self) -> Option<&TurnId> {
221 self.turn_id.as_ref()
222 }
223
224 fn homogeneous_file_level(&self) -> Option<PermissionLevel> {
230 if self.batch.requests.is_empty() {
231 return None;
232 }
233
234 let mut all_reads = true;
235 let mut all_writes = true;
236
237 for request in &self.batch.requests {
238 match &request.target {
240 GrantTarget::Path { .. } => {
241 if request.required_level != PermissionLevel::Read {
242 all_reads = false;
243 }
244 if request.required_level != PermissionLevel::Write {
245 all_writes = false;
246 }
247 }
248 GrantTarget::Domain { .. }
249 | GrantTarget::Command { .. }
250 | GrantTarget::Tool { .. } => {
251 return None;
253 }
254 }
255 }
256
257 if all_reads {
258 Some(PermissionLevel::Read)
259 } else if all_writes {
260 Some(PermissionLevel::Write)
261 } else {
262 None
263 }
264 }
265
266 fn available_options(&self) -> Vec<BatchPermissionOption> {
268 match self.homogeneous_file_level() {
269 Some(PermissionLevel::Read) | Some(PermissionLevel::Write) => {
270 vec![
272 BatchPermissionOption::GrantOnce,
273 BatchPermissionOption::GrantSession,
274 BatchPermissionOption::GrantAllSimilar,
275 BatchPermissionOption::Deny,
276 ]
277 }
278 _ => {
279 vec![
281 BatchPermissionOption::GrantOnce,
282 BatchPermissionOption::GrantSession,
283 BatchPermissionOption::Deny,
284 ]
285 }
286 }
287 }
288
289 pub fn selected_option(&self) -> BatchPermissionOption {
291 let options = self.available_options();
292 options[self.selected_idx.min(options.len() - 1)]
293 }
294
295 pub fn select_next(&mut self) {
297 let options = self.available_options();
298 self.selected_idx = (self.selected_idx + 1) % options.len();
299 }
300
301 pub fn select_prev(&mut self) {
303 let options = self.available_options();
304 if self.selected_idx == 0 {
305 self.selected_idx = options.len() - 1;
306 } else {
307 self.selected_idx -= 1;
308 }
309 }
310
311 fn build_response(&self, option: BatchPermissionOption) -> BatchPermissionResponse {
313 match option {
314 BatchPermissionOption::GrantOnce => {
315 BatchPermissionResponse {
317 batch_id: self.batch_id.clone(),
318 approved_grants: self
319 .batch
320 .requests
321 .iter()
322 .map(|r| Grant::new(r.target.clone(), r.required_level))
323 .collect(),
324 denied_requests: HashSet::new(),
325 auto_approved: HashSet::new(),
326 }
327 }
328 BatchPermissionOption::GrantSession => {
329 BatchPermissionResponse::all_granted(
331 &self.batch_id,
332 self.batch.suggested_grants.clone(),
333 )
334 }
335 BatchPermissionOption::GrantAllSimilar => {
336 let level = self
338 .homogeneous_file_level()
339 .unwrap_or(PermissionLevel::Read);
340 let broad_grant = Grant::new(GrantTarget::path("/", true), level);
341 BatchPermissionResponse::all_granted(&self.batch_id, vec![broad_grant])
342 }
343 BatchPermissionOption::Deny => {
344 let request_ids: Vec<String> =
345 self.batch.requests.iter().map(|r| r.id.clone()).collect();
346 BatchPermissionResponse::all_denied(&self.batch_id, request_ids)
347 }
348 }
349 }
350
351 pub fn process_key(&mut self, key: KeyEvent) -> BatchKeyAction {
353 if !self.active {
354 return BatchKeyAction::None;
355 }
356
357 match key.code {
358 KeyCode::Up | KeyCode::Char('k') => {
360 self.select_prev();
361 BatchKeyAction::None
362 }
363 KeyCode::Down | KeyCode::Char('j') => {
364 self.select_next();
365 BatchKeyAction::None
366 }
367
368 KeyCode::Enter | KeyCode::Char(' ') => {
370 let option = self.selected_option();
371 let response = self.build_response(option);
372 BatchKeyAction::Submitted(response)
373 }
374
375 KeyCode::Esc => {
377 let batch_id = self.batch_id.clone();
378 BatchKeyAction::Cancelled(batch_id)
379 }
380
381 _ => BatchKeyAction::None,
382 }
383 }
384
385 pub fn panel_height(&self, max_height: u16) -> u16 {
387 let mut lines = 6u16; lines += self.batch.requests.len().min(10) as u16; lines += self.available_options().len() as u16;
400 lines += 2; let max_from_percent = (max_height * self.config.max_panel_percent) / 100;
403 lines
404 .min(max_from_percent)
405 .min(max_height.saturating_sub(6))
406 }
407
408 pub fn render_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
410 if !self.active {
411 return;
412 }
413
414 frame.render_widget(Clear, area);
416
417 let inner_width = area.width.saturating_sub(4) as usize;
418 let mut lines: Vec<Line> = Vec::new();
419 let batch_type = self.homogeneous_file_level();
420
421 lines.push(Line::from(Span::styled(
423 truncate_text(&self.config.help_text, inner_width),
424 theme.help_text(),
425 )));
426 lines.push(Line::from("")); lines.push(Line::from(Span::styled(
430 " Permissions requested:",
431 theme.muted_text().add_modifier(Modifier::BOLD),
432 )));
433 lines.push(Line::from("")); let max_requests = 10; for (idx, request) in self.batch.requests.iter().take(max_requests).enumerate() {
438 let (target_icon, target_desc) = format_target(&request.target);
439 let level_str = format_level(request.required_level);
440
441 lines.push(Line::from(vec![
442 Span::styled(" ", Style::default()),
443 Span::styled(format!("{} ", target_icon), theme.category()),
444 Span::styled(format!("{}: ", level_str), theme.muted_text()),
445 Span::styled(
446 truncate_text(&target_desc, inner_width.saturating_sub(20)),
447 theme.resource(),
448 ),
449 ]));
450
451 if idx == max_requests - 1 && self.batch.requests.len() > max_requests {
453 let remaining = self.batch.requests.len() - max_requests;
454 lines.push(Line::from(vec![
455 Span::styled(" ", Style::default()),
456 Span::styled(format!(" ... and {} more", remaining), theme.muted_text()),
457 ]));
458 }
459 }
460
461 lines.push(Line::from(""));
463
464 let options = self.available_options();
466 let request_count = self.batch.requests.len();
467
468 for (idx, option) in options.iter().enumerate() {
469 let is_selected = idx == self.selected_idx;
470 let prefix = if is_selected {
471 &self.config.selection_indicator
472 } else {
473 &self.config.no_indicator
474 };
475
476 let description = option.description(request_count, batch_type);
477
478 let (label_style, desc_style) = if is_selected {
479 if option.is_positive() {
480 (theme.button_confirm_focused(), theme.focused_text())
481 } else {
482 (theme.button_cancel_focused(), theme.focused_text())
483 }
484 } else if option.is_positive() {
485 (theme.button_confirm(), theme.muted_text())
486 } else {
487 (theme.button_cancel(), theme.muted_text())
488 };
489
490 let indicator_style = if is_selected {
491 theme.focus_indicator()
492 } else {
493 theme.muted_text()
494 };
495
496 lines.push(Line::from(vec![
497 Span::styled(prefix.clone(), indicator_style),
498 Span::styled(option.label(), label_style),
499 Span::styled(" - ", theme.muted_text()),
500 Span::styled(description, desc_style),
501 ]));
502 }
503
504 lines.push(Line::from(""));
506
507 let block = Block::default()
509 .borders(Borders::ALL)
510 .border_style(theme.warning())
511 .title(Span::styled(
512 self.config.title.clone(),
513 theme.warning().add_modifier(Modifier::BOLD),
514 ));
515
516 let paragraph = Paragraph::new(lines).block(block);
517 frame.render_widget(paragraph, area);
518 }
519}
520
521impl Default for BatchPermissionPanel {
522 fn default() -> Self {
523 Self::new()
524 }
525}
526
527fn format_target(target: &GrantTarget) -> (&'static str, String) {
529 match target {
530 GrantTarget::Path { path, recursive } => {
531 let rec_suffix = if *recursive { " (recursive)" } else { "" };
532 ("\u{1F4C4}", format!("{}{}", path.display(), rec_suffix))
533 }
534 GrantTarget::Domain { pattern } => ("\u{2194}", pattern.clone()),
535 GrantTarget::Command { pattern } => ("\u{2295}", pattern.clone()),
536 GrantTarget::Tool { tool_name } => ("\u{1F527}", tool_name.clone()),
537 }
538}
539
540fn format_level(level: PermissionLevel) -> &'static str {
542 match level {
543 PermissionLevel::None => "None",
544 PermissionLevel::Read => "Read File",
545 PermissionLevel::Write => "Write File",
546 PermissionLevel::Execute => "Execute",
547 PermissionLevel::Admin => "Admin",
548 }
549}
550
551fn truncate_text(text: &str, max_width: usize) -> String {
553 if text.chars().count() <= max_width {
554 text.to_string()
555 } else {
556 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
557 format!("{}...", truncated)
558 }
559}
560
561use super::{Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, widget_ids};
564use std::any::Any;
565
566impl Widget for BatchPermissionPanel {
567 fn id(&self) -> &'static str {
568 widget_ids::BATCH_PERMISSION_PANEL
569 }
570
571 fn priority(&self) -> u8 {
572 200 }
574
575 fn is_active(&self) -> bool {
576 self.active
577 }
578
579 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
580 if !self.active {
581 return WidgetKeyResult::NotHandled;
582 }
583
584 if ctx.nav.is_move_up(&key) {
586 self.select_prev();
587 return WidgetKeyResult::Handled;
588 }
589 if ctx.nav.is_move_down(&key) {
590 self.select_next();
591 return WidgetKeyResult::Handled;
592 }
593
594 if ctx.nav.is_select(&key) {
596 let option = self.selected_option();
597 let response = self.build_response(option);
598 return WidgetKeyResult::Action(WidgetAction::SubmitBatchPermission {
599 batch_id: self.batch_id.clone(),
600 response,
601 });
602 }
603
604 if ctx.nav.is_cancel(&key) {
606 let batch_id = self.batch_id.clone();
607 return WidgetKeyResult::Action(WidgetAction::CancelBatchPermission { batch_id });
608 }
609
610 match key.code {
612 KeyCode::Char('k') => {
613 self.select_prev();
614 WidgetKeyResult::Handled
615 }
616 KeyCode::Char('j') => {
617 self.select_next();
618 WidgetKeyResult::Handled
619 }
620 _ => WidgetKeyResult::NotHandled,
621 }
622 }
623
624 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
625 self.render_panel(frame, area, theme);
626 }
627
628 fn required_height(&self, max_height: u16) -> u16 {
629 if self.active {
630 self.panel_height(max_height)
631 } else {
632 0
633 }
634 }
635
636 fn blocks_input(&self) -> bool {
637 self.active
638 }
639
640 fn is_overlay(&self) -> bool {
641 false
642 }
643
644 fn as_any(&self) -> &dyn Any {
645 self
646 }
647
648 fn as_any_mut(&mut self) -> &mut dyn Any {
649 self
650 }
651
652 fn into_any(self: Box<Self>) -> Box<dyn Any> {
653 self
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::permissions::PermissionRequest;
661
662 fn create_read_batch() -> BatchPermissionRequest {
663 let requests = vec![
664 PermissionRequest::file_read("1", "/project/src/main.rs"),
665 PermissionRequest::file_read("2", "/project/src/lib.rs"),
666 PermissionRequest::file_read("3", "/project/src/utils.rs"),
667 ];
668 BatchPermissionRequest::new("batch-1", requests)
669 }
670
671 fn create_write_batch() -> BatchPermissionRequest {
672 let requests = vec![
673 PermissionRequest::file_write("1", "/project/src/main.rs"),
674 PermissionRequest::file_write("2", "/project/src/lib.rs"),
675 ];
676 BatchPermissionRequest::new("batch-2", requests)
677 }
678
679 fn create_mixed_batch() -> BatchPermissionRequest {
680 let requests = vec![
681 PermissionRequest::file_read("1", "/project/src/main.rs"),
682 PermissionRequest::file_write("2", "/project/src/lib.rs"),
683 ];
684 BatchPermissionRequest::new("batch-3", requests)
685 }
686
687 fn create_command_batch() -> BatchPermissionRequest {
688 let requests = vec![
689 PermissionRequest::command_execute("1", "git status"),
690 PermissionRequest::command_execute("2", "git diff"),
691 ];
692 BatchPermissionRequest::new("batch-4", requests)
693 }
694
695 #[test]
696 fn test_panel_activation() {
697 let mut panel = BatchPermissionPanel::new();
698 assert!(!panel.is_active());
699
700 let batch = create_read_batch();
701 panel.activate(1, batch, None);
702
703 assert!(panel.is_active());
704 assert_eq!(panel.batch_id(), "batch-1");
705 assert_eq!(panel.session_id(), 1);
706 assert_eq!(panel.selected_idx, 0);
707
708 panel.deactivate();
709 assert!(!panel.is_active());
710 }
711
712 #[test]
713 fn test_homogeneous_read_batch() {
714 let mut panel = BatchPermissionPanel::new();
715 let batch = create_read_batch();
716 panel.activate(1, batch, None);
717
718 assert_eq!(panel.homogeneous_file_level(), Some(PermissionLevel::Read));
719 assert_eq!(panel.available_options().len(), 4); }
721
722 #[test]
723 fn test_homogeneous_write_batch() {
724 let mut panel = BatchPermissionPanel::new();
725 let batch = create_write_batch();
726 panel.activate(1, batch, None);
727
728 assert_eq!(panel.homogeneous_file_level(), Some(PermissionLevel::Write));
729 assert_eq!(panel.available_options().len(), 4); }
731
732 #[test]
733 fn test_mixed_batch_no_grant_all() {
734 let mut panel = BatchPermissionPanel::new();
735 let batch = create_mixed_batch();
736 panel.activate(1, batch, None);
737
738 assert_eq!(panel.homogeneous_file_level(), None);
739 assert_eq!(panel.available_options().len(), 3); }
741
742 #[test]
743 fn test_command_batch_no_grant_all() {
744 let mut panel = BatchPermissionPanel::new();
745 let batch = create_command_batch();
746 panel.activate(1, batch, None);
747
748 assert_eq!(panel.homogeneous_file_level(), None);
749 assert_eq!(panel.available_options().len(), 3); }
751
752 #[test]
753 fn test_navigation() {
754 let mut panel = BatchPermissionPanel::new();
755 let batch = create_read_batch();
756 panel.activate(1, batch, None);
757
758 assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
760
761 panel.select_next();
763 assert_eq!(panel.selected_option(), BatchPermissionOption::GrantSession);
764
765 panel.select_next();
766 assert_eq!(
767 panel.selected_option(),
768 BatchPermissionOption::GrantAllSimilar
769 );
770
771 panel.select_next();
772 assert_eq!(panel.selected_option(), BatchPermissionOption::Deny);
773
774 panel.select_next();
776 assert_eq!(panel.selected_option(), BatchPermissionOption::GrantOnce);
777
778 panel.select_prev();
780 assert_eq!(panel.selected_option(), BatchPermissionOption::Deny);
781 }
782
783 #[test]
784 fn test_grant_once_response() {
785 let mut panel = BatchPermissionPanel::new();
786 let batch = create_read_batch();
787 panel.activate(1, batch, None);
788
789 let response = panel.build_response(BatchPermissionOption::GrantOnce);
790 assert_eq!(response.batch_id, "batch-1");
791 assert_eq!(response.approved_grants.len(), 3); assert!(response.denied_requests.is_empty());
793 }
794
795 #[test]
796 fn test_grant_session_response() {
797 let mut panel = BatchPermissionPanel::new();
798 let batch = create_read_batch();
799 panel.activate(1, batch, None);
800
801 let response = panel.build_response(BatchPermissionOption::GrantSession);
802 assert_eq!(response.batch_id, "batch-1");
803 assert!(!response.approved_grants.is_empty()); assert!(response.denied_requests.is_empty());
805 }
806
807 #[test]
808 fn test_grant_all_similar_response() {
809 let mut panel = BatchPermissionPanel::new();
810 let batch = create_read_batch();
811 panel.activate(1, batch, None);
812
813 let response = panel.build_response(BatchPermissionOption::GrantAllSimilar);
814 assert_eq!(response.batch_id, "batch-1");
815 assert_eq!(response.approved_grants.len(), 1); assert!(response.denied_requests.is_empty());
817
818 let grant = &response.approved_grants[0];
820 assert_eq!(grant.level, PermissionLevel::Read);
821 if let GrantTarget::Path { path, recursive } = &grant.target {
822 assert_eq!(path.to_str().unwrap(), "/");
823 assert!(*recursive);
824 } else {
825 panic!("Expected path target");
826 }
827 }
828
829 #[test]
830 fn test_deny_response() {
831 let mut panel = BatchPermissionPanel::new();
832 let batch = create_read_batch();
833 panel.activate(1, batch, None);
834
835 let response = panel.build_response(BatchPermissionOption::Deny);
836 assert_eq!(response.batch_id, "batch-1");
837 assert!(response.approved_grants.is_empty());
838 assert_eq!(response.denied_requests.len(), 3);
839 }
840
841 #[test]
842 fn test_option_descriptions() {
843 let option = BatchPermissionOption::GrantOnce;
844 assert_eq!(
845 option.description(3, Some(PermissionLevel::Read)),
846 "Allow only these 3 requests"
847 );
848
849 let option = BatchPermissionOption::GrantSession;
850 assert_eq!(
851 option.description(3, Some(PermissionLevel::Read)),
852 "Allow reading these 3 files"
853 );
854 assert_eq!(
855 option.description(2, Some(PermissionLevel::Write)),
856 "Allow writing these 2 files"
857 );
858 assert_eq!(option.description(5, None), "Allow these for the session");
859
860 let option = BatchPermissionOption::GrantAllSimilar;
861 assert_eq!(
862 option.description(3, Some(PermissionLevel::Read)),
863 "Allow reading any file"
864 );
865 assert_eq!(
866 option.description(2, Some(PermissionLevel::Write)),
867 "Allow writing any file"
868 );
869
870 let option = BatchPermissionOption::Deny;
871 assert_eq!(option.description(3, None), "Deny all requests");
872 }
873}