Skip to main content

hh_cli/cli/
render.rs

1use crate::core::agent::AgentEvents;
2use serde_json::Value;
3use std::io::{self, Write};
4use std::sync::{Arc, Mutex};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ThinkingMode {
8    Collapsed,
9    Expanded,
10}
11
12#[derive(Debug, Clone)]
13pub struct LiveRender {
14    inner: Arc<Mutex<RenderState>>,
15}
16
17#[derive(Debug)]
18struct RenderState {
19    thinking_mode: ThinkingMode,
20    thinking_placeholder_shown: bool,
21    thinking_line_open: bool,
22    assistant_line_open: bool,
23}
24
25impl LiveRender {
26    pub fn new() -> Self {
27        Self {
28            inner: Arc::new(Mutex::new(RenderState {
29                thinking_mode: ThinkingMode::Collapsed,
30                thinking_placeholder_shown: false,
31                thinking_line_open: false,
32                assistant_line_open: false,
33            })),
34        }
35    }
36
37    pub fn begin_turn(&self) {
38        if let Ok(mut state) = self.inner.lock() {
39            state.thinking_placeholder_shown = false;
40            state.thinking_line_open = false;
41            state.assistant_line_open = false;
42        }
43    }
44
45    pub fn toggle_thinking_mode(&self) -> ThinkingMode {
46        if let Ok(mut state) = self.inner.lock() {
47            state.thinking_mode = match state.thinking_mode {
48                ThinkingMode::Collapsed => ThinkingMode::Expanded,
49                ThinkingMode::Expanded => ThinkingMode::Collapsed,
50            };
51            state.thinking_mode
52        } else {
53            ThinkingMode::Collapsed
54        }
55    }
56
57    pub fn thinking_mode(&self) -> ThinkingMode {
58        self.inner
59            .lock()
60            .map(|s| s.thinking_mode)
61            .unwrap_or(ThinkingMode::Collapsed)
62    }
63}
64
65impl Default for LiveRender {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl AgentEvents for LiveRender {
72    fn on_thinking(&self, text: &str) {
73        let Ok(mut state) = self.inner.lock() else {
74            return;
75        };
76
77        match state.thinking_mode {
78            ThinkingMode::Collapsed => {
79                if !state.thinking_placeholder_shown {
80                    if state.assistant_line_open {
81                        println!();
82                        state.assistant_line_open = false;
83                    }
84                    println!("thinking… (toggle with :thinking)");
85                    state.thinking_placeholder_shown = true;
86                }
87            }
88            ThinkingMode::Expanded => {
89                if state.assistant_line_open {
90                    println!();
91                    state.assistant_line_open = false;
92                }
93                if !state.thinking_line_open {
94                    print!("thinking> ");
95                    state.thinking_line_open = true;
96                }
97                print!("{}", text);
98                let _ = io::stdout().flush();
99            }
100        }
101    }
102
103    fn on_tool_start(&self, name: &str, args: &Value) {
104        let Ok(mut state) = self.inner.lock() else {
105            return;
106        };
107        if state.assistant_line_open || state.thinking_line_open {
108            println!();
109            state.assistant_line_open = false;
110            state.thinking_line_open = false;
111        }
112        println!("tool:{}> start {}", name, format_args_preview(args, 220));
113    }
114
115    fn on_tool_end(&self, name: &str, result: &crate::tool::ToolResult) {
116        let Ok(mut state) = self.inner.lock() else {
117            return;
118        };
119        if state.assistant_line_open || state.thinking_line_open {
120            println!();
121            state.assistant_line_open = false;
122            state.thinking_line_open = false;
123        }
124        let status = if result.is_error { "error" } else { "ok" };
125        println!(
126            "tool:{}> {} {}",
127            name,
128            status,
129            truncate_text(&result.summary, 220)
130        );
131    }
132
133    fn on_assistant_delta(&self, delta: &str) {
134        let Ok(mut state) = self.inner.lock() else {
135            return;
136        };
137        if state.thinking_line_open {
138            println!();
139            state.thinking_line_open = false;
140        }
141        if !state.assistant_line_open {
142            print!("assistant> ");
143            state.assistant_line_open = true;
144        }
145        print!("{}", delta);
146        let _ = io::stdout().flush();
147    }
148
149    fn on_assistant_done(&self) {
150        let Ok(mut state) = self.inner.lock() else {
151            return;
152        };
153        if state.thinking_line_open || state.assistant_line_open {
154            println!();
155            state.thinking_line_open = false;
156            state.assistant_line_open = false;
157        }
158    }
159}
160
161pub fn print_assistant(text: &str) {
162    println!("assistant> {}", text);
163}
164
165pub fn print_tool_log(name: &str, message: &str) {
166    println!("tool:{}> {}", name, message);
167}
168
169pub fn print_error(message: &str) {
170    eprintln!("error: {}", message);
171}
172
173pub fn print_info(message: &str) {
174    println!("info: {}", message);
175}
176
177pub fn prompt_user() -> io::Result<String> {
178    print!("you> ");
179    io::stdout().flush()?;
180    let mut input = String::new();
181    io::stdin().read_line(&mut input)?;
182    Ok(input.trim().to_string())
183}
184
185pub fn confirm(prompt: &str) -> io::Result<bool> {
186    print!("{} [y/N]: ", prompt);
187    io::stdout().flush()?;
188    let mut input = String::new();
189    io::stdin().read_line(&mut input)?;
190    let normalized = input.trim().to_ascii_lowercase();
191    Ok(normalized == "y" || normalized == "yes")
192}
193
194pub fn ask_questions(
195    questions: &[crate::core::QuestionPrompt],
196) -> io::Result<crate::core::QuestionAnswers> {
197    let mut answers = Vec::with_capacity(questions.len());
198
199    for question in questions {
200        println!();
201        println!("{}", question.question);
202        println!();
203
204        for (index, option) in question.options.iter().enumerate() {
205            println!("{}. {}", index + 1, option.label);
206            if !option.description.trim().is_empty() {
207                println!("   {}", option.description);
208            }
209        }
210
211        let custom_index = if question.custom {
212            let index = question.options.len() + 1;
213            println!("{}. Type your own answer", index);
214            Some(index)
215        } else {
216            None
217        };
218
219        if question.multiple {
220            print!("Select option numbers (comma-separated), or press enter to skip: ");
221        } else {
222            print!("Select an option number, or press enter to skip: ");
223        }
224        io::stdout().flush()?;
225
226        let mut input = String::new();
227        io::stdin().read_line(&mut input)?;
228        let trimmed = input.trim();
229
230        if trimmed.is_empty() {
231            answers.push(Vec::new());
232            continue;
233        }
234
235        let mut selected = Vec::new();
236        let tokens = if question.multiple {
237            trimmed
238                .split(',')
239                .map(str::trim)
240                .filter(|token| !token.is_empty())
241                .collect::<Vec<_>>()
242        } else {
243            vec![trimmed]
244        };
245
246        for token in tokens {
247            let Ok(choice) = token.parse::<usize>() else {
248                continue;
249            };
250
251            if let Some(index) = custom_index
252                && choice == index
253            {
254                print!("Type your own answer: ");
255                io::stdout().flush()?;
256                let mut custom = String::new();
257                io::stdin().read_line(&mut custom)?;
258                let custom = custom.trim();
259                if !custom.is_empty() {
260                    selected.push(custom.to_string());
261                }
262                continue;
263            }
264
265            if let Some(option) = question.options.get(choice.saturating_sub(1)) {
266                selected.push(option.label.clone());
267            }
268        }
269
270        selected.sort();
271        selected.dedup();
272        answers.push(selected);
273    }
274
275    Ok(answers)
276}
277
278pub fn format_args_preview(args: &Value, max_len: usize) -> String {
279    let compact = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string());
280    truncate_text(&compact, max_len)
281}
282
283pub fn truncate_text(input: &str, max_len: usize) -> String {
284    if max_len == 0 {
285        return String::new();
286    }
287
288    let mut chars = input.chars();
289    let head: String = chars.by_ref().take(max_len).collect();
290    if chars.next().is_some() {
291        format!("{}…", head)
292    } else {
293        head
294    }
295}