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.map(|t| t.elapsed().as_secs()).unwrap_or(0)
163 }
164
165 pub fn text(&self) -> &str {
167 &self.current_text
168 }
169
170 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
182pub struct SimpleStreamer {
184 started: bool,
185}
186
187impl SimpleStreamer {
188 pub fn new() -> Self {
189 Self { started: false }
190 }
191
192 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 pub fn stream(&mut self, text: &str) {
203 self.start();
204 print!("{}", text);
205 let _ = io::stdout().flush();
206 }
207
208 pub fn end(&mut self) {
210 if self.started {
211 println!();
212 println!();
213 self.started = false;
214 }
215 }
216
217 pub fn tool_call(&self, name: &str, description: &str) {
219 println!();
220 ToolCallDisplay::print_start(name, description);
221 }
222
223 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
236pub async fn show_thinking_with_spinner(message: &str) -> Spinner {
238 Spinner::new(&format!("💠{}", message))
239}
240
241pub 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}