syncable_cli/agent/ui/
streaming.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum StreamingState {
15 Idle,
17 Responding,
19 WaitingForConfirmation,
21 ExecutingTools,
23}
24
25pub 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 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!("\n{} ", "AI:".blue().bold());
55 let _ = io::stdout().flush();
56 }
57
58 pub fn append_text(&mut self, text: &str) {
60 self.current_text.push_str(text);
61
62 print!("{}", text);
64 let _ = io::stdout().flush();
65 self.chars_displayed += text.len();
66 }
67
68 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 ToolCallDisplay::print_start(name, description);
77 }
78
79 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 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 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 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 pub fn end_response(&mut self) {
120 self.state = StreamingState::Idle;
121
122 if !self.current_text.is_empty() && !self.current_text.ends_with('\n') {
124 println!();
125 }
126
127 if !self.tool_calls.is_empty() {
129 ToolCallDisplay::print_summary(&self.tool_calls);
130 }
131
132 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 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 pub fn state(&self) -> StreamingState {
157 self.state
158 }
159
160 pub fn elapsed_secs(&self) -> u64 {
162 self.start_time
163 .map(|t| t.elapsed().as_secs())
164 .unwrap_or(0)
165 }
166
167 pub fn text(&self) -> &str {
169 &self.current_text
170 }
171
172 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
184pub struct SimpleStreamer {
186 started: bool,
187}
188
189impl SimpleStreamer {
190 pub fn new() -> Self {
191 Self { started: false }
192 }
193
194 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 pub fn stream(&mut self, text: &str) {
205 self.start();
206 print!("{}", text);
207 let _ = io::stdout().flush();
208 }
209
210 pub fn end(&mut self) {
212 if self.started {
213 println!();
214 println!();
215 self.started = false;
216 }
217 }
218
219 pub fn tool_call(&self, name: &str, description: &str) {
221 println!();
222 ToolCallDisplay::print_start(name, description);
223 }
224
225 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
238pub async fn show_thinking_with_spinner(message: &str) -> Spinner {
240 Spinner::new(&format!("💠{}", message))
241}
242
243pub 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}