syncable_cli/agent/ui/
confirmation.rs

1//! Interactive confirmation UI for shell commands and file operations
2//!
3//! Provides Claude Code-style confirmation prompts before executing
4//! potentially destructive operations.
5
6use colored::Colorize;
7use inquire::ui::{Color, IndexPrefix, RenderConfig, StyleSheet, Styled};
8use inquire::{InquireError, Select, Text};
9use std::collections::HashSet;
10use std::sync::Mutex;
11
12/// Get custom render config for confirmation prompts
13fn get_confirmation_render_config() -> RenderConfig<'static> {
14    RenderConfig::default()
15        .with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
16        .with_option_index_prefix(IndexPrefix::Simple)
17        .with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
18        .with_scroll_up_prefix(Styled::new("▲ "))
19        .with_scroll_down_prefix(Styled::new("▼ "))
20}
21
22/// Result of a user confirmation prompt
23#[derive(Debug, Clone)]
24pub enum ConfirmationResult {
25    /// User approved, proceed with the operation
26    Proceed,
27    /// User approved and wants to skip future prompts for similar commands
28    ProceedAlways(String), // The command prefix to allow always
29    /// User wants to provide alternative instructions
30    Modify(String),
31    /// User cancelled (Esc or Ctrl+C)
32    Cancel,
33}
34
35/// Session-level tracking of always-allowed commands
36#[derive(Debug)]
37pub struct AllowedCommands {
38    prefixes: Mutex<HashSet<String>>,
39}
40
41impl AllowedCommands {
42    pub fn new() -> Self {
43        Self {
44            prefixes: Mutex::new(HashSet::new()),
45        }
46    }
47
48    /// Check if a command prefix is already allowed
49    pub fn is_allowed(&self, command: &str) -> bool {
50        let prefixes = self.prefixes.lock().unwrap();
51        prefixes.iter().any(|prefix| command.starts_with(prefix))
52    }
53
54    /// Add a command prefix to the allowed list
55    pub fn allow(&self, prefix: String) {
56        let mut prefixes = self.prefixes.lock().unwrap();
57        prefixes.insert(prefix);
58    }
59}
60
61impl Default for AllowedCommands {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Extract the command prefix (first word or first two words for compound commands)
68fn extract_command_prefix(command: &str) -> String {
69    let parts: Vec<&str> = command.split_whitespace().collect();
70    if parts.is_empty() {
71        return command.to_string();
72    }
73
74    // For compound commands like "docker build", "npm run", use first two words
75    let compound_commands = ["docker", "terraform", "helm", "kubectl", "npm", "cargo", "go"];
76    if parts.len() >= 2 && compound_commands.contains(&parts[0]) {
77        format!("{} {}", parts[0], parts[1])
78    } else {
79        parts[0].to_string()
80    }
81}
82
83/// Display a command confirmation box
84fn display_command_box(command: &str, working_dir: &str) {
85    let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
86    let box_width = term_width.min(70);
87    let inner_width = box_width - 4; // Account for borders and padding
88
89    // Top border
90    println!(
91        "{}",
92        format!(
93            "{}{}{}",
94            "┌─ Bash command ".dimmed(),
95            "─".repeat(inner_width.saturating_sub(15)).dimmed(),
96            "┐".dimmed()
97        )
98    );
99
100    // Command content (may wrap)
101    let command_lines = textwrap::wrap(command, inner_width - 2);
102    for line in &command_lines {
103        println!(
104            "{}  {}{}",
105            "│".dimmed(),
106            line.cyan().bold(),
107            " ".repeat(inner_width.saturating_sub(line.len() + 2))
108        );
109    }
110
111    // Working directory
112    let dir_display = format!("in {}", working_dir);
113    println!(
114        "{}  {}{}{}",
115        "│".dimmed(),
116        dir_display.dimmed(),
117        " ".repeat(inner_width.saturating_sub(dir_display.len() + 2)),
118        "│".dimmed()
119    );
120
121    // Bottom border
122    println!(
123        "{}",
124        format!(
125            "{}{}{}",
126            "└".dimmed(),
127            "─".repeat(box_width - 2).dimmed(),
128            "┘".dimmed()
129        )
130    );
131    println!();
132}
133
134/// Confirm shell command execution with the user
135///
136/// Shows the command in a box and presents options:
137/// 1. Yes - proceed once
138/// 2. Yes, and don't ask again for this command type
139/// 3. Type feedback to tell the agent what to do differently
140pub fn confirm_shell_command(
141    command: &str,
142    working_dir: &str,
143) -> ConfirmationResult {
144    display_command_box(command, working_dir);
145
146    let prefix = extract_command_prefix(command);
147    let short_dir = std::path::Path::new(working_dir)
148        .file_name()
149        .map(|n| n.to_string_lossy().to_string())
150        .unwrap_or_else(|| working_dir.to_string());
151
152    let options = vec![
153        format!("Yes"),
154        format!("Yes, and don't ask again for `{}` commands in {}", prefix, short_dir),
155        format!("Type here to tell Syncable Agent what to do differently"),
156    ];
157
158    println!("{}", "Do you want to proceed?".white());
159
160    let selection = Select::new("", options.clone())
161        .with_render_config(get_confirmation_render_config())
162        .with_page_size(3)  // Show all 3 options
163        .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
164        .prompt();
165
166    match selection {
167        Ok(answer) => {
168            if answer == options[0] {
169                ConfirmationResult::Proceed
170            } else if answer == options[1] {
171                ConfirmationResult::ProceedAlways(prefix)
172            } else {
173                // User wants to type feedback
174                println!();
175                match Text::new("What should I do instead?")
176                    .with_help_message("Press Enter to submit, Esc to cancel")
177                    .prompt()
178                {
179                    Ok(feedback) if !feedback.trim().is_empty() => {
180                        ConfirmationResult::Modify(feedback)
181                    }
182                    _ => ConfirmationResult::Cancel,
183                }
184            }
185        }
186        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
187            ConfirmationResult::Cancel
188        }
189        Err(_) => ConfirmationResult::Cancel,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_extract_command_prefix() {
199        assert_eq!(extract_command_prefix("docker build -t test ."), "docker build");
200        assert_eq!(extract_command_prefix("npm run test"), "npm run");
201        assert_eq!(extract_command_prefix("cargo build"), "cargo build");
202        assert_eq!(extract_command_prefix("make"), "make");
203        assert_eq!(extract_command_prefix("hadolint Dockerfile"), "hadolint");
204    }
205
206    #[test]
207    fn test_allowed_commands() {
208        let allowed = AllowedCommands::new();
209        assert!(!allowed.is_allowed("docker build -t test ."));
210
211        allowed.allow("docker build".to_string());
212        assert!(allowed.is_allowed("docker build -t test ."));
213        assert!(allowed.is_allowed("docker build --no-cache ."));
214        assert!(!allowed.is_allowed("docker run test"));
215    }
216}