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