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 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 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 pub fn end_response(&mut self) {
125 self.state = StreamingState::Idle;
126
127 if !self.current_text.is_empty() && !self.current_text.ends_with('\n') {
129 println!();
130 }
131
132 if !self.tool_calls.is_empty() {
134 ToolCallDisplay::print_summary(&self.tool_calls);
135 }
136
137 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 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 pub fn state(&self) -> StreamingState {
162 self.state
163 }
164
165 pub fn elapsed_secs(&self) -> u64 {
167 self.start_time.map(|t| t.elapsed().as_secs()).unwrap_or(0)
168 }
169
170 pub fn text(&self) -> &str {
172 &self.current_text
173 }
174
175 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
187pub struct SimpleStreamer {
189 started: bool,
190}
191
192impl SimpleStreamer {
193 pub fn new() -> Self {
194 Self { started: false }
195 }
196
197 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 pub fn stream(&mut self, text: &str) {
208 self.start();
209 print!("{}", text);
210 let _ = io::stdout().flush();
211 }
212
213 pub fn end(&mut self) {
215 if self.started {
216 println!();
217 println!();
218 self.started = false;
219 }
220 }
221
222 pub fn tool_call(&self, name: &str, description: &str) {
224 println!();
225 ToolCallDisplay::print_start(name, description);
226 }
227
228 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
241pub async fn show_thinking_with_spinner(message: &str) -> Spinner {
243 Spinner::new(&format!("💠{}", message))
244}
245
246pub 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}