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 = [
76        "docker",
77        "terraform",
78        "helm",
79        "kubectl",
80        "npm",
81        "cargo",
82        "go",
83    ];
84    if parts.len() >= 2 && compound_commands.contains(&parts[0]) {
85        format!("{} {}", parts[0], parts[1])
86    } else {
87        parts[0].to_string()
88    }
89}
90
91/// Display a command confirmation box
92fn display_command_box(command: &str, working_dir: &str) {
93    let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
94    let box_width = term_width.min(70);
95    let inner_width = box_width - 4; // Account for borders and padding
96
97    // Top border
98    println!(
99        "{}",
100        format!(
101            "{}{}{}",
102            "┌─ Bash command ".dimmed(),
103            "─".repeat(inner_width.saturating_sub(15)).dimmed(),
104            "┐".dimmed()
105        )
106    );
107
108    // Command content (may wrap)
109    let command_lines = textwrap::wrap(command, inner_width - 2);
110    for line in &command_lines {
111        println!(
112            "{}  {}{}",
113            "│".dimmed(),
114            line.cyan().bold(),
115            " ".repeat(inner_width.saturating_sub(line.len() + 2))
116        );
117    }
118
119    // Working directory
120    let dir_display = format!("in {}", working_dir);
121    println!(
122        "{}  {}{}{}",
123        "│".dimmed(),
124        dir_display.dimmed(),
125        " ".repeat(inner_width.saturating_sub(dir_display.len() + 2)),
126        "│".dimmed()
127    );
128
129    // Bottom border
130    println!(
131        "{}",
132        format!(
133            "{}{}{}",
134            "└".dimmed(),
135            "─".repeat(box_width - 2).dimmed(),
136            "┘".dimmed()
137        )
138    );
139    println!();
140}
141
142/// Confirm shell command execution with the user
143///
144/// Shows the command in a box and presents options:
145/// 1. Yes - proceed once
146/// 2. Yes, and don't ask again for this command type
147/// 3. Type feedback to tell the agent what to do differently
148pub fn confirm_shell_command(command: &str, working_dir: &str) -> ConfirmationResult {
149    display_command_box(command, working_dir);
150
151    let prefix = extract_command_prefix(command);
152    let short_dir = std::path::Path::new(working_dir)
153        .file_name()
154        .map(|n| n.to_string_lossy().to_string())
155        .unwrap_or_else(|| working_dir.to_string());
156
157    let options = vec![
158        format!("Yes"),
159        format!(
160            "Yes, and don't ask again for `{}` commands in {}",
161            prefix, short_dir
162        ),
163        format!("Type here to tell Syncable Agent what to do differently"),
164    ];
165
166    println!("{}", "Do you want to proceed?".white());
167
168    let selection = Select::new("", options.clone())
169        .with_render_config(get_confirmation_render_config())
170        .with_page_size(3)  // Show all 3 options
171        .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
172        .prompt();
173
174    match selection {
175        Ok(answer) => {
176            if answer == options[0] {
177                ConfirmationResult::Proceed
178            } else if answer == options[1] {
179                ConfirmationResult::ProceedAlways(prefix)
180            } else {
181                // User wants to type feedback
182                println!();
183                match Text::new("What should I do instead?")
184                    .with_help_message("Press Enter to submit, Esc to cancel")
185                    .prompt()
186                {
187                    Ok(feedback) if !feedback.trim().is_empty() => {
188                        ConfirmationResult::Modify(feedback)
189                    }
190                    _ => ConfirmationResult::Cancel,
191                }
192            }
193        }
194        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
195            ConfirmationResult::Cancel
196        }
197        Err(_) => ConfirmationResult::Cancel,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_extract_command_prefix() {
207        assert_eq!(
208            extract_command_prefix("docker build -t test ."),
209            "docker build"
210        );
211        assert_eq!(extract_command_prefix("npm run test"), "npm run");
212        assert_eq!(extract_command_prefix("cargo build"), "cargo build");
213        assert_eq!(extract_command_prefix("make"), "make");
214        assert_eq!(extract_command_prefix("hadolint Dockerfile"), "hadolint");
215    }
216
217    #[test]
218    fn test_allowed_commands() {
219        let allowed = AllowedCommands::new();
220        assert!(!allowed.is_allowed("docker build -t test ."));
221
222        allowed.allow("docker build".to_string());
223        assert!(allowed.is_allowed("docker build -t test ."));
224        assert!(allowed.is_allowed("docker build --no-cache ."));
225        assert!(!allowed.is_allowed("docker run test"));
226    }
227}