Skip to main content

stynx_code_tools/infrastructure/
ask_user_tool.rs

1use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
2
3use stynx_code_errors::{AppError, AppResult};
4use stynx_code_types::{PermissionLevel, Tool};
5use serde_json::{Value, json};
6
7use super::question_bridge::{OptionalQuestionBridge, QuestionBridge, SharedQuestionBridge};
8
9pub struct AskUserTool {
10    paused: Arc<AtomicBool>,
11    bridge: SharedQuestionBridge,
12}
13
14impl AskUserTool {
15    pub fn new(paused: Arc<AtomicBool>) -> Self {
16        Self { paused, bridge: Arc::new(OptionalQuestionBridge::new()) }
17    }
18
19    pub fn bridge_handle(&self) -> SharedQuestionBridge {
20        self.bridge.clone()
21    }
22
23    pub fn install_bridge(&self, b: QuestionBridge) {
24        self.bridge.set(b);
25    }
26}
27
28#[async_trait::async_trait]
29impl Tool for AskUserTool {
30    fn name(&self) -> &str {
31        "ask_user_question"
32    }
33
34    fn description(&self) -> &str {
35        "Ask the user a question and wait for their response. Use this when you need clarification or input from the user."
36    }
37
38    fn input_schema(&self) -> Value {
39        json!({
40            "type": "object",
41            "properties": {
42                "question": {
43                    "type": "string",
44                    "description": "The question to ask the user"
45                }
46            },
47            "required": ["question"]
48        })
49    }
50
51    fn permission_level(&self) -> PermissionLevel {
52        PermissionLevel::ReadOnly
53    }
54
55    fn is_read_only(&self, _input: &Value) -> bool { true }
56    fn requires_user_interaction(&self) -> bool { true }
57
58    async fn execute(&self, input: Value) -> AppResult<String> {
59        let question = input
60            .get("question")
61            .and_then(|q| q.as_str())
62            .ok_or_else(|| AppError::Tool("missing 'question' field".into()))?
63            .to_string();
64
65        if let Some(bridge) = self.bridge.get() {
66            return match bridge.ask(question).await {
67                Some(answer) => Ok(answer),
68                None => Err(AppError::Interrupted),
69            };
70        }
71
72        let paused = self.paused.clone();
73
74        let answer = tokio::task::spawn_blocking(move || {
75            paused.store(true, Ordering::Relaxed);
76            let result = prompt_interactive(&question);
77            paused.store(false, Ordering::Relaxed);
78            result
79        })
80        .await
81        .map_err(|e| AppError::Tool(format!("ask user task failed: {e}")))??;
82
83        Ok(answer)
84    }
85}
86
87fn prompt_interactive(question: &str) -> AppResult<String> {
88    use std::io::Write;
89    use crossterm::terminal;
90
91    let w = terminal::size().map(|(w, _)| w as usize).unwrap_or(80);
92    let outer = w.saturating_sub(4).max(20);
93    let inner = outer.saturating_sub(2);
94    let avail = inner.saturating_sub(4);
95
96    let top_label = "─ Question ";
97    let top_fill = inner.saturating_sub(top_label.len());
98
99    let q_lines = wrap_text(question, avail);
100
101    let mut out = std::io::stdout();
102    let _ = writeln!(out);
103    let _ = writeln!(out, "  \x1b[36m\x1b[1m╭{top_label}{}\x1b[0m", "─".repeat(top_fill));
104    for line in &q_lines {
105        let pad = avail.saturating_sub(line.len());
106        let _ = writeln!(out, "  \x1b[36m│\x1b[0m  {line}{}  \x1b[36m│\x1b[0m", " ".repeat(pad));
107    }
108    let sep_fill = "─".repeat(inner);
109    let _ = writeln!(out, "  \x1b[36m\x1b[2m├{sep_fill}┤\x1b[0m");
110    let input_pad = " ".repeat(avail);
111    let _ = writeln!(out, "  \x1b[36m│\x1b[0m  \x1b[1m\x1b[36m❯\x1b[0m {input_pad}  \x1b[36m│\x1b[0m");
112    let _ = writeln!(out, "  \x1b[36m\x1b[1m╰{}\x1b[0m", "─".repeat(inner));
113    let input_row = q_lines.len() + 3;
114    let _ = write!(out, "\x1b[{input_row}A\r\x1b[6C");
115    let _ = out.flush();
116
117    terminal::enable_raw_mode().map_err(|e| AppError::Tool(e.to_string()))?;
118    let result = read_answer(avail);
119    terminal::disable_raw_mode().ok();
120
121    let lines_to_clear = q_lines.len() + 4;
122    let _ = write!(out, "\x1b[{}B\r\x1b[J", lines_to_clear.saturating_sub(1));
123    let _ = out.flush();
124
125    result
126}
127
128fn read_answer(max_len: usize) -> AppResult<String> {
129    use std::io::Write;
130    use crossterm::event::{self, KeyCode, KeyModifiers};
131
132    let mut buf = String::new();
133    let mut out = std::io::stdout();
134
135    loop {
136        if !event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) {
137            continue;
138        }
139        if let event::Event::Key(k) = event::read().map_err(|e| AppError::Tool(e.to_string()))? { match (k.code, k.modifiers) {
140            (KeyCode::Enter, _) => break,
141            (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
142                return Err(AppError::Interrupted);
143            }
144            (KeyCode::Backspace, _) => {
145                if buf.pop().is_some() {
146                    let visible = buf.chars().take(max_len).collect::<String>();
147                    let pad = max_len.saturating_sub(visible.len());
148                    let _ = write!(out, "\r\x1b[6C{visible}{} \r\x1b[{}C",
149                        " ".repeat(pad), 6 + visible.len());
150                    let _ = out.flush();
151                }
152            }
153            (KeyCode::Char(c), _) if buf.len() < max_len => {
154                buf.push(c);
155                let _ = write!(out, "{c}");
156                let _ = out.flush();
157            }
158            _ => {}
159        } }
160    }
161    Ok(buf)
162}
163
164fn wrap_text(text: &str, width: usize) -> Vec<String> {
165    if width == 0 { return vec![text.to_string()]; }
166    let mut lines = Vec::new();
167    for paragraph in text.split('\n') {
168        if paragraph.is_empty() { lines.push(String::new()); continue; }
169        let mut current = String::new();
170        for word in paragraph.split_whitespace() {
171            if current.is_empty() {
172                current = word.to_string();
173            } else if current.len() + 1 + word.len() <= width {
174                current.push(' ');
175                current.push_str(word);
176            } else {
177                lines.push(current);
178                current = word.to_string();
179            }
180        }
181        if !current.is_empty() { lines.push(current); }
182    }
183    if lines.is_empty() { lines.push(String::new()); }
184    lines
185}