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.map(|t| t.elapsed().as_secs()).unwrap_or(0)
163    }
164
165    /// Get the accumulated text
166    pub fn text(&self) -> &str {
167        &self.current_text
168    }
169
170    /// Get tool calls
171    pub fn tool_calls(&self) -> &[ToolCallInfo] {
172        &self.tool_calls
173    }
174}
175
176impl Default for StreamingDisplay {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182/// A simpler streaming helper for basic use cases
183pub struct SimpleStreamer {
184    started: bool,
185}
186
187impl SimpleStreamer {
188    pub fn new() -> Self {
189        Self { started: false }
190    }
191
192    /// Print the AI label (call once at start)
193    pub fn start(&mut self) {
194        if !self.started {
195            print!("\n{} ", "AI:".blue().bold());
196            let _ = io::stdout().flush();
197            self.started = true;
198        }
199    }
200
201    /// Stream a text chunk
202    pub fn stream(&mut self, text: &str) {
203        self.start();
204        print!("{}", text);
205        let _ = io::stdout().flush();
206    }
207
208    /// End the stream
209    pub fn end(&mut self) {
210        if self.started {
211            println!();
212            println!();
213            self.started = false;
214        }
215    }
216
217    /// Print a tool call notification
218    pub fn tool_call(&self, name: &str, description: &str) {
219        println!();
220        ToolCallDisplay::print_start(name, description);
221    }
222
223    /// Print tool call completed
224    pub fn tool_complete(&self, name: &str) {
225        let info = ToolCallInfo::new(name, "").success(None);
226        ToolCallDisplay::print_status(&info);
227    }
228}
229
230impl Default for SimpleStreamer {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236/// Print a "thinking" indicator with optional spinner
237pub async fn show_thinking_with_spinner(message: &str) -> Spinner {
238    Spinner::new(&format!("💭 {}", message))
239}
240
241/// Print a static thinking message
242pub fn print_thinking(subject: &str) {
243    println!(
244        "{} {} {}",
245        icons::THINKING,
246        "Thinking about:".cyan(),
247        subject.white()
248    );
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_streaming_display_state() {
257        let mut display = StreamingDisplay::new();
258        assert_eq!(display.state(), StreamingState::Idle);
259
260        display.start_response();
261        assert_eq!(display.state(), StreamingState::Responding);
262
263        display.tool_call_started("test", "testing");
264        assert_eq!(display.state(), StreamingState::ExecutingTools);
265    }
266
267    #[test]
268    fn test_append_text() {
269        let mut display = StreamingDisplay::new();
270        display.start_response();
271        display.append_text("Hello ");
272        display.append_text("World");
273        assert_eq!(display.text(), "Hello World");
274    }
275}