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        if let Some(info) = self.tool_calls.iter_mut().find(|t| t.name == name) {
100            *info = info.clone().error(error);
101            ToolCallDisplay::print_status(info);
102        }
103    }
104
105    /// Show thinking/reasoning indicator
106    pub fn show_thinking(&self, subject: &str) {
107        print!(
108            "{}{} {} {}{}",
109            ansi::CLEAR_LINE,
110            icons::THINKING,
111            "Thinking:".cyan(),
112            subject.dimmed(),
113            ansi::RESET
114        );
115        let _ = io::stdout().flush();
116    }
117
118    /// End the current response
119    pub fn end_response(&mut self) {
120        self.state = StreamingState::Idle;
121
122        // Ensure newline after response
123        if !self.current_text.is_empty() && !self.current_text.ends_with('\n') {
124            println!();
125        }
126
127        // Print summary if there were tool calls
128        if !self.tool_calls.is_empty() {
129            ToolCallDisplay::print_summary(&self.tool_calls);
130        }
131
132        // Print elapsed time if significant
133        if let Some(start) = self.start_time {
134            let elapsed = start.elapsed();
135            if elapsed.as_secs() >= 2 {
136                println!(
137                    "\n{} {:.1}s",
138                    "Response time:".dimmed(),
139                    elapsed.as_secs_f64()
140                );
141            }
142        }
143
144        println!();
145        let _ = io::stdout().flush();
146    }
147
148    /// Handle an error during streaming
149    pub fn handle_error(&mut self, error: &str) {
150        self.state = StreamingState::Idle;
151        println!("\n{} {}", icons::ERROR.red(), error.red());
152        let _ = io::stdout().flush();
153    }
154
155    /// Get the current state
156    pub fn state(&self) -> StreamingState {
157        self.state
158    }
159
160    /// Get elapsed time since start
161    pub fn elapsed_secs(&self) -> u64 {
162        self.start_time
163            .map(|t| t.elapsed().as_secs())
164            .unwrap_or(0)
165    }
166
167    /// Get the accumulated text
168    pub fn text(&self) -> &str {
169        &self.current_text
170    }
171
172    /// Get tool calls
173    pub fn tool_calls(&self) -> &[ToolCallInfo] {
174        &self.tool_calls
175    }
176}
177
178impl Default for StreamingDisplay {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// A simpler streaming helper for basic use cases
185pub struct SimpleStreamer {
186    started: bool,
187}
188
189impl SimpleStreamer {
190    pub fn new() -> Self {
191        Self { started: false }
192    }
193
194    /// Print the AI label (call once at start)
195    pub fn start(&mut self) {
196        if !self.started {
197            print!("\n{} ", "AI:".blue().bold());
198            let _ = io::stdout().flush();
199            self.started = true;
200        }
201    }
202
203    /// Stream a text chunk
204    pub fn stream(&mut self, text: &str) {
205        self.start();
206        print!("{}", text);
207        let _ = io::stdout().flush();
208    }
209
210    /// End the stream
211    pub fn end(&mut self) {
212        if self.started {
213            println!();
214            println!();
215            self.started = false;
216        }
217    }
218
219    /// Print a tool call notification
220    pub fn tool_call(&self, name: &str, description: &str) {
221        println!();
222        ToolCallDisplay::print_start(name, description);
223    }
224
225    /// Print tool call completed
226    pub fn tool_complete(&self, name: &str) {
227        let info = ToolCallInfo::new(name, "").success(None);
228        ToolCallDisplay::print_status(&info);
229    }
230}
231
232impl Default for SimpleStreamer {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238/// Print a "thinking" indicator with optional spinner
239pub async fn show_thinking_with_spinner(message: &str) -> Spinner {
240    Spinner::new(&format!("💭 {}", message))
241}
242
243/// Print a static thinking message
244pub fn print_thinking(subject: &str) {
245    println!(
246        "{} {} {}",
247        icons::THINKING,
248        "Thinking about:".cyan(),
249        subject.white()
250    );
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_streaming_display_state() {
259        let mut display = StreamingDisplay::new();
260        assert_eq!(display.state(), StreamingState::Idle);
261
262        display.start_response();
263        assert_eq!(display.state(), StreamingState::Responding);
264
265        display.tool_call_started("test", "testing");
266        assert_eq!(display.state(), StreamingState::ExecutingTools);
267    }
268
269    #[test]
270    fn test_append_text() {
271        let mut display = StreamingDisplay::new();
272        display.start_response();
273        display.append_text("Hello ");
274        display.append_text("World");
275        assert_eq!(display.text(), "Hello World");
276    }
277}