syncable_cli/agent/ui/
streaming.rs

1//! Streaming response display for real-time AI output
2//!
3//! Handles streaming tokens from the AI and displaying them in real-time.
4
5use crate::agent::ui::colors::{ansi, icons};
6use crate::agent::ui::spinner::Spinner;
7use crate::agent::ui::tool_display::{ToolCallDisplay, ToolCallInfo, ToolCallStatus};
8use colored::Colorize;
9use std::io::{self, Write};
10use std::time::Instant;
11
12/// State of the streaming response
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum StreamingState {
15    /// Ready for input
16    Idle,
17    /// AI is generating response
18    Responding,
19    /// Waiting for tool confirmation
20    WaitingForConfirmation,
21    /// Tools are executing
22    ExecutingTools,
23}
24
25/// Manages the display of streaming AI responses
26pub struct StreamingDisplay {
27    state: StreamingState,
28    start_time: Option<Instant>,
29    current_text: String,
30    tool_calls: Vec<ToolCallInfo>,
31    chars_displayed: usize,
32}
33
34impl StreamingDisplay {
35    pub fn new() -> Self {
36        Self {
37            state: StreamingState::Idle,
38            start_time: None,
39            current_text: String::new(),
40            tool_calls: Vec::new(),
41            chars_displayed: 0,
42        }
43    }
44
45    /// Start a new response
46    pub fn start_response(&mut self) {
47        self.state = StreamingState::Responding;
48        self.start_time = Some(Instant::now());
49        self.current_text.clear();
50        self.tool_calls.clear();
51        self.chars_displayed = 0;
52
53        // Print AI label
54        print!("\n{} ", "AI:".blue().bold());
55        let _ = io::stdout().flush();
56    }
57
58    /// Append text chunk to the response
59    pub fn append_text(&mut self, text: &str) {
60        self.current_text.push_str(text);
61
62        // Print new text directly (streaming effect)
63        print!("{}", text);
64        let _ = io::stdout().flush();
65        self.chars_displayed += text.len();
66    }
67
68    /// Record a tool call starting
69    pub fn tool_call_started(&mut self, name: &str, description: &str) {
70        self.state = StreamingState::ExecutingTools;
71
72        let info = ToolCallInfo::new(name, description).executing();
73        self.tool_calls.push(info.clone());
74
75        // Print tool call notification
76        ToolCallDisplay::print_start(name, description);
77    }
78
79    /// Record a tool call completed
80    pub fn tool_call_completed(&mut self, name: &str, result: Option<String>) {
81        if let Some(info) = self.tool_calls.iter_mut().find(|t| t.name == name) {
82            *info = info.clone().success(result);
83            ToolCallDisplay::print_status(info);
84        }
85
86        // Check if all tools are done
87        if self.tool_calls.iter().all(|t| {
88            matches!(
89                t.status,
90                ToolCallStatus::Success | ToolCallStatus::Error | ToolCallStatus::Canceled
91            )
92        }) {
93            self.state = StreamingState::Responding;
94        }
95    }
96
97    /// Record a tool call failed
98    pub fn tool_call_failed(&mut self, name: &str, error: String) {
99        // Clean up nested error messages (e.g., "ToolCallError: ToolCallError: actual error")
100        let clean_error = error
101            .replace("Toolset error: ", "")
102            .replace("ToolCallError: ", "");
103
104        if let Some(info) = self.tool_calls.iter_mut().find(|t| t.name == name) {
105            *info = info.clone().error(clean_error);
106            ToolCallDisplay::print_status(info);
107        }
108    }
109
110    /// Show thinking/reasoning indicator
111    pub fn show_thinking(&self, subject: &str) {
112        print!(
113            "{}{} {} {}{}",
114            ansi::CLEAR_LINE,
115            icons::THINKING,
116            "Thinking:".cyan(),
117            subject.dimmed(),
118            ansi::RESET
119        );
120        let _ = io::stdout().flush();
121    }
122
123    /// End the current response
124    pub fn end_response(&mut self) {
125        self.state = StreamingState::Idle;
126
127        // Ensure newline after response
128        if !self.current_text.is_empty() && !self.current_text.ends_with('\n') {
129            println!();
130        }
131
132        // Print summary if there were tool calls
133        if !self.tool_calls.is_empty() {
134            ToolCallDisplay::print_summary(&self.tool_calls);
135        }
136
137        // Print elapsed time if significant
138        if let Some(start) = self.start_time {
139            let elapsed = start.elapsed();
140            if elapsed.as_secs() >= 2 {
141                println!(
142                    "\n{} {:.1}s",
143                    "Response time:".dimmed(),
144                    elapsed.as_secs_f64()
145                );
146            }
147        }
148
149        println!();
150        let _ = io::stdout().flush();
151    }
152
153    /// Handle an error during streaming
154    pub fn handle_error(&mut self, error: &str) {
155        self.state = StreamingState::Idle;
156        println!("\n{} {}", icons::ERROR.red(), error.red());
157        let _ = io::stdout().flush();
158    }
159
160    /// Get the current state
161    pub fn state(&self) -> StreamingState {
162        self.state
163    }
164
165    /// Get elapsed time since start
166    pub fn elapsed_secs(&self) -> u64 {
167        self.start_time.map(|t| t.elapsed().as_secs()).unwrap_or(0)
168    }
169
170    /// Get the accumulated text
171    pub fn text(&self) -> &str {
172        &self.current_text
173    }
174
175    /// Get tool calls
176    pub fn tool_calls(&self) -> &[ToolCallInfo] {
177        &self.tool_calls
178    }
179}
180
181impl Default for StreamingDisplay {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187/// A simpler streaming helper for basic use cases
188pub struct SimpleStreamer {
189    started: bool,
190}
191
192impl SimpleStreamer {
193    pub fn new() -> Self {
194        Self { started: false }
195    }
196
197    /// Print the AI label (call once at start)
198    pub fn start(&mut self) {
199        if !self.started {
200            print!("\n{} ", "AI:".blue().bold());
201            let _ = io::stdout().flush();
202            self.started = true;
203        }
204    }
205
206    /// Stream a text chunk
207    pub fn stream(&mut self, text: &str) {
208        self.start();
209        print!("{}", text);
210        let _ = io::stdout().flush();
211    }
212
213    /// End the stream
214    pub fn end(&mut self) {
215        if self.started {
216            println!();
217            println!();
218            self.started = false;
219        }
220    }
221
222    /// Print a tool call notification
223    pub fn tool_call(&self, name: &str, description: &str) {
224        println!();
225        ToolCallDisplay::print_start(name, description);
226    }
227
228    /// Print tool call completed
229    pub fn tool_complete(&self, name: &str) {
230        let info = ToolCallInfo::new(name, "").success(None);
231        ToolCallDisplay::print_status(&info);
232    }
233}
234
235impl Default for SimpleStreamer {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241/// Print a "thinking" indicator with optional spinner
242pub async fn show_thinking_with_spinner(message: &str) -> Spinner {
243    Spinner::new(&format!("💭 {}", message))
244}
245
246/// Print a static thinking message
247pub fn print_thinking(subject: &str) {
248    println!(
249        "{} {} {}",
250        icons::THINKING,
251        "Thinking about:".cyan(),
252        subject.white()
253    );
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_streaming_display_state() {
262        let mut display = StreamingDisplay::new();
263        assert_eq!(display.state(), StreamingState::Idle);
264
265        display.start_response();
266        assert_eq!(display.state(), StreamingState::Responding);
267
268        display.tool_call_started("test", "testing");
269        assert_eq!(display.state(), StreamingState::ExecutingTools);
270    }
271
272    #[test]
273    fn test_append_text() {
274        let mut display = StreamingDisplay::new();
275        display.start_response();
276        display.append_text("Hello ");
277        display.append_text("World");
278        assert_eq!(display.text(), "Hello World");
279    }
280}