1use super::EventFormatter;
2use crate::ai::model::Model;
3use crate::ai::TokenUsage;
4use crate::chat::events::{ChatMessage, ToolExecutionResult, ToolRequest, ToolRequestType};
5use crate::chat::ModelInfo;
6use crate::modules::task_list::{TaskList, TaskStatus};
7use std::io::Write;
8
9#[derive(Clone, Debug)]
10pub enum MessageType {
11 AI,
12 System,
13}
14
15#[derive(Clone, Debug)]
16pub struct LastMessage {
17 pub content: String,
18 pub message_type: MessageType,
19 pub agent_name: Option<String>,
20 pub token_usage: Option<TokenUsage>,
21}
22
23#[derive(Clone)]
24pub struct CompactFormatter {
25 spinner_state: usize,
26 typing_state: bool,
27 last_message: Option<LastMessage>,
28 thinking_shown: bool,
29 last_tool_request: Option<ToolRequest>,
30 terminal_width: usize,
31 show_full_message: bool,
32}
33
34impl Default for CompactFormatter {
35 fn default() -> Self {
36 Self::new(80)
37 }
38}
39
40impl CompactFormatter {
41 pub fn new(terminal_width: usize) -> Self {
42 Self {
43 spinner_state: 0,
44 typing_state: false,
45 last_message: None,
46 thinking_shown: false,
47 last_tool_request: None,
48 terminal_width,
49 show_full_message: false,
50 }
51 }
52
53 fn get_spinner_char(&mut self) -> char {
54 const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
55 let c = SPINNER_CHARS[self.spinner_state % SPINNER_CHARS.len()];
56 self.spinner_state += 1;
57 c
58 }
59
60 fn format_bytes_compact(bytes: usize) -> String {
61 if bytes < 1024 {
62 format!("{bytes}B")
63 } else if bytes < 1024 * 1024 {
64 format!("{}KB", bytes / 1024)
65 } else {
66 format!("{}MB", bytes / (1024 * 1024))
67 }
68 }
69
70 fn format_token_usage_compact(usage: &TokenUsage) -> String {
71 let input_k = (usage.input_tokens + usage.cache_creation_input_tokens.unwrap_or(0)) / 1000;
72 let output_k = (usage.output_tokens + usage.reasoning_tokens.unwrap_or(0)) / 1000;
73 format!("({input_k}k↑/{output_k}k↓)")
74 }
75
76 fn clear_thinking_if_shown(&mut self) {
77 if self.thinking_shown {
78 print!("\r\x1b[2K");
79 self.thinking_shown = false;
80 }
81 }
82
83 fn print_line(&mut self, line: &str) {
84 self.clear_thinking_if_shown();
85 println!("{line}");
86 }
87
88 fn eprint_line(&mut self, line: &str) {
89 self.clear_thinking_if_shown();
90 eprintln!("{line}");
91 }
92
93 fn print_compact_bullet(&self, text: &str) {
94 print!("\r\x1b[2K • {text}");
95 let _ = std::io::stdout().flush();
96 }
97
98 fn finish_compact_bullet(&mut self, text: &str) {
99 self.print_line(&format!("\r\x1b[2K • {text}"));
100 }
101
102 fn shorten_path(path: &str) -> String {
103 let parts: Vec<&str> = path.split('/').collect();
104 if parts.len() > 3 {
105 format!(".../{}", parts[parts.len() - 1])
106 } else {
107 path.to_string()
108 }
109 }
110
111 fn shorten_command(&self, cmd: &str) -> String {
112 if cmd.len() > self.terminal_width {
113 let truncate_at = self.terminal_width.saturating_sub(3);
114 format!("{}...", &cmd[..truncate_at])
115 } else {
116 cmd.to_string()
117 }
118 }
119
120 fn get_tool_display_text(&self, tool_request: &ToolRequest, spinner: char) -> String {
121 match &tool_request.tool_type {
122 ToolRequestType::ModifyFile { file_path, .. } => {
123 format!(
124 "{} {} (patching...)",
125 spinner,
126 Self::shorten_path(file_path)
127 )
128 }
129 ToolRequestType::RunCommand { command, .. } => {
130 format!("{} {} (running...)", spinner, self.shorten_command(command))
131 }
132 ToolRequestType::ReadFiles { file_paths } => {
133 if file_paths.is_empty() {
134 format!("{} reading files...", spinner)
135 } else if file_paths.len() <= 3 {
136 let paths: Vec<String> =
137 file_paths.iter().map(|p| Self::shorten_path(p)).collect();
138 format!("{} reading {}...", spinner, paths.join(", "))
139 } else {
140 let paths: Vec<String> = file_paths
141 .iter()
142 .take(3)
143 .map(|p| Self::shorten_path(p))
144 .collect();
145 format!(
146 "{} reading {}, +{} more...",
147 spinner,
148 paths.join(", "),
149 file_paths.len() - 3
150 )
151 }
152 }
153 ToolRequestType::Other { .. } => {
154 format!("{} {} (executing...)", spinner, tool_request.tool_name)
155 }
156 ToolRequestType::SearchTypes { type_name, .. } => {
157 format!("{} Searching types: {}...", spinner, type_name)
158 }
159 ToolRequestType::GetTypeDocs { type_path, .. } => {
160 format!("{} Getting docs: {}...", spinner, type_path)
161 }
162 }
163 }
164
165 fn finalize_last_message(&mut self) {
166 if let Some(msg) = self.last_message.take() {
167 let display_text = if self.show_full_message {
168 msg.content.clone()
169 } else {
170 let first_line = msg.content.lines().next().unwrap_or(&msg.content);
171 if first_line.chars().count() > self.terminal_width {
172 let truncate_at = self.terminal_width.saturating_sub(3);
173 let truncated_str: String = first_line.chars().take(truncate_at).collect();
174 format!("{}...", truncated_str)
175 } else {
176 first_line.to_string()
177 }
178 };
179
180 match msg.message_type {
181 MessageType::AI => {
182 let agent = msg.agent_name.as_deref().unwrap_or("AI");
183 self.finish_compact_bullet(&format!("[{}] {}", agent, display_text));
184 }
185 MessageType::System => {
186 self.finish_compact_bullet(&format!("[System] {}", display_text));
187 }
188 }
189 }
190 }
191
192 fn reprint_final_message(&mut self, msg: &LastMessage) {
193 print!("\r\x1b[2K");
194 match msg.message_type {
195 MessageType::AI => {
196 let usage_text = msg
197 .token_usage
198 .as_ref()
199 .map(|usage| Self::format_token_usage_compact(usage))
200 .unwrap_or_default();
201 let agent = msg.agent_name.as_deref().unwrap_or("AI");
202 self.print_line(&format!(
203 "\x1b[32m[{agent}]\x1b[0m \x1b[90m{usage_text}\x1b[0m {}",
204 msg.content
205 ));
206 }
207 MessageType::System => {
208 self.print_line(&format!("\x1b[33m[System]\x1b[0m {}", msg.content));
209 }
210 }
211 }
212}
213
214impl EventFormatter for CompactFormatter {
215 fn print_system(&mut self, msg: &str) {
216 if msg.contains("🔧") && msg.contains("tool call") {
217 return;
218 }
219
220 if self.typing_state {
221 self.finalize_last_message();
222
223 let first_line = msg.lines().next().unwrap_or(msg);
224 let truncated = if first_line.chars().count() > self.terminal_width {
225 let truncate_at = self.terminal_width.saturating_sub(3);
226 let truncated_str: String = first_line.chars().take(truncate_at).collect();
227 format!("{}...", truncated_str)
228 } else {
229 first_line.to_string()
230 };
231
232 print!("\r\x1b[2K");
233 self.print_compact_bullet(&format!("[System] {}", truncated));
234
235 self.last_message = Some(LastMessage {
236 content: msg.to_string(),
237 message_type: MessageType::System,
238 agent_name: None,
239 token_usage: None,
240 });
241 } else {
242 self.print_line(&format!("\x1b[33m[System]\x1b[0m {}", msg));
243 }
244 }
245
246 fn print_ai(
247 &mut self,
248 msg: &str,
249 agent: &str,
250 _model_info: &Option<ModelInfo>,
251 token_usage: &Option<TokenUsage>,
252 ) {
253 if self.typing_state {
254 self.finalize_last_message();
255 self.show_full_message = false;
256
257 let first_line = msg.lines().next().unwrap_or(msg);
258 let truncated = if first_line.chars().count() > self.terminal_width {
259 let truncate_at = self.terminal_width.saturating_sub(3);
260 let truncated_str: String = first_line.chars().take(truncate_at).collect();
261 format!("{}...", truncated_str)
262 } else {
263 first_line.to_string()
264 };
265
266 print!("\r\x1b[2K");
267 self.print_compact_bullet(&format!("[{}] {}", agent, truncated));
268
269 self.last_message = Some(LastMessage {
270 content: msg.to_string(),
271 message_type: MessageType::AI,
272 agent_name: Some(agent.to_string()),
273 token_usage: token_usage.clone(),
274 });
275 } else {
276 let usage_text = token_usage
277 .as_ref()
278 .map(|usage| Self::format_token_usage_compact(usage))
279 .unwrap_or_default();
280
281 self.print_line(&format!(
282 "\x1b[32m[{agent}]\x1b[0m \x1b[90m{usage_text}\x1b[0m {msg}"
283 ));
284 }
285 }
286
287 fn print_warning(&mut self, msg: &str) {
288 self.clear_thinking_if_shown();
289 print!("\r\x1b[2K");
290 let _ = std::io::stdout().flush();
291 self.eprint_line(&format!("\x1b[33m[Warning]\x1b[0m {msg}"));
292 }
293
294 fn print_error(&mut self, msg: &str) {
295 self.clear_thinking_if_shown();
296 print!("\r\x1b[2K");
297 let _ = std::io::stdout().flush();
298 self.eprint_line(&format!("\x1b[31m[Error]\x1b[0m {msg}"));
299 }
300
301 fn print_retry_attempt(&mut self, attempt: u32, max_retries: u32, error: &str) {
302 let max_error_len = (self.terminal_width * 6 / 10).max(20);
303 let error_preview = if error.len() > max_error_len {
304 let truncate_at = max_error_len.saturating_sub(3);
305 format!("{}...", &error[..truncate_at])
306 } else {
307 error.to_string()
308 };
309 self.print_compact_bullet(&format!(
310 "⟳ Retry {}/{}: {}",
311 attempt, max_retries, error_preview
312 ));
313 }
314
315 fn print_tool_request(&mut self, tool_request: &ToolRequest) {
316 self.last_tool_request = Some(tool_request.clone());
317 if tool_request.tool_name == "complete_task"
318 || tool_request.tool_name == "ask_user_question"
319 {
320 self.show_full_message = true;
321 }
322 let spinner = self.get_spinner_char();
323 let text = self.get_tool_display_text(tool_request, spinner);
324 self.print_compact_bullet(&text);
325 }
326
327 fn print_tool_result(
328 &mut self,
329 name: &str,
330 success: bool,
331 result: ToolExecutionResult,
332 _verbose: bool,
333 ) {
334 if name == "complete_task" {
335 self.last_tool_request = None;
336 return;
337 }
338
339 let summary = match result {
340 ToolExecutionResult::RunCommand {
341 exit_code,
342 stdout: _,
343 stderr,
344 } => {
345 let cmd_context = self
346 .last_tool_request
347 .as_ref()
348 .and_then(|req| match &req.tool_type {
349 ToolRequestType::RunCommand { command, .. } => {
350 Some(self.shorten_command(command))
351 }
352 _ => None,
353 })
354 .unwrap_or_else(|| name.to_string());
355
356 if success {
357 format!("{} ✓ exit:{}", cmd_context, exit_code)
358 } else {
359 let error_preview = if !stderr.is_empty() {
360 let first_line = stderr.lines().next().unwrap_or("");
361 let max_error_len = (self.terminal_width / 2).max(20);
362 if first_line.len() > max_error_len {
363 format!(" ({}...)", &first_line[..max_error_len])
364 } else {
365 format!(" ({})", first_line)
366 }
367 } else {
368 String::new()
369 };
370 format!("{} ✗ exit:{}{}", cmd_context, exit_code, error_preview)
371 }
372 }
373 ToolExecutionResult::ReadFiles { ref files } => {
374 let file_context =
375 self.last_tool_request
376 .as_ref()
377 .and_then(|req| match &req.tool_type {
378 ToolRequestType::ReadFiles { file_paths } if !file_paths.is_empty() => {
379 if file_paths.len() == 1 {
380 Some(Self::shorten_path(&file_paths[0]))
381 } else if file_paths.len() <= 3 {
382 let paths: Vec<String> =
383 file_paths.iter().map(|p| Self::shorten_path(p)).collect();
384 Some(paths.join(", "))
385 } else {
386 let paths: Vec<String> = file_paths
387 .iter()
388 .take(2)
389 .map(|p| Self::shorten_path(p))
390 .collect();
391 Some(format!(
392 "{}, +{} more",
393 paths.join(", "),
394 file_paths.len() - 2
395 ))
396 }
397 }
398 _ => None,
399 });
400
401 let total_size = files.iter().map(|f| f.bytes).sum();
402 if let Some(context) = file_context {
403 format!(
404 "{} ✓ {} files ({})",
405 context,
406 files.len(),
407 Self::format_bytes_compact(total_size)
408 )
409 } else {
410 format!(
411 "{} ✓ {} files ({})",
412 name,
413 files.len(),
414 Self::format_bytes_compact(total_size)
415 )
416 }
417 }
418 ToolExecutionResult::ModifyFile {
419 lines_added,
420 lines_removed,
421 } => {
422 let file_context = self
423 .last_tool_request
424 .as_ref()
425 .and_then(|req| match &req.tool_type {
426 ToolRequestType::ModifyFile { file_path, .. } => {
427 Some(Self::shorten_path(file_path))
428 }
429 _ => None,
430 })
431 .unwrap_or_else(|| name.to_string());
432
433 format!("{} ✓ +{} -{}", file_context, lines_added, lines_removed)
434 }
435 ToolExecutionResult::Error { short_message, .. } => {
436 format!("{} ✗ {}", name, short_message)
437 }
438 ToolExecutionResult::SearchTypes { ref types } => {
439 format!("{} ✓ {} types found", name, types.len())
440 }
441 ToolExecutionResult::GetTypeDocs { .. } => {
442 format!("{} ✓ docs retrieved", name)
443 }
444 ToolExecutionResult::Other { .. } => {
445 if success {
446 format!("{} ✓", name)
447 } else {
448 format!("{} ✗", name)
449 }
450 }
451 };
452 self.finish_compact_bullet(&summary);
453 self.last_tool_request = None;
454 }
455
456 fn print_thinking(&mut self) {
457 if self.typing_state {
458 let spinner = self.get_spinner_char();
459 let text = if let Some(ref tool_request) = self.last_tool_request {
460 self.get_tool_display_text(tool_request, spinner)
461 } else {
462 format!("{} Thinking...", spinner)
463 };
464 self.print_compact_bullet(&text);
465 self.thinking_shown = true;
466 }
467 }
468
469 fn print_task_update(&mut self, task_list: &TaskList) {
470 if let Some(current_task) = task_list
472 .tasks
473 .iter()
474 .find(|t| matches!(t.status, TaskStatus::InProgress))
475 {
476 let completed = task_list
477 .tasks
478 .iter()
479 .filter(|t| matches!(t.status, TaskStatus::Completed))
480 .count();
481 let total = task_list.tasks.len();
482 self.finish_compact_bullet(&format!(
483 "Task {}/{}: {}",
484 completed, total, current_task.description
485 ));
486 }
487 }
488
489 fn print_stream_start(&mut self, _message_id: &str, agent: &str, _model: &Model) {
490 self.clear_thinking_if_shown();
491 print!("\r\x1b[2K\x1b[32m[{agent}]\x1b[0m ");
492 let _ = std::io::stdout().flush();
493 }
494
495 fn print_stream_delta(&mut self, _message_id: &str, text: &str) {
496 print!("{text}");
497 let _ = std::io::stdout().flush();
498 }
499
500 fn print_stream_end(&mut self, message: &ChatMessage) {
501 println!();
502 if let Some(ref usage) = message.token_usage {
503 let usage_text = Self::format_token_usage_compact(usage);
504 print!("\x1b[90m {usage_text}\x1b[0m");
505 println!();
506 }
507 }
508
509 fn on_typing_status_changed(&mut self, typing: bool) {
510 self.typing_state = typing;
511
512 if !typing {
513 if let Some(msg) = self.last_message.take() {
514 self.reprint_final_message(&msg);
515 }
516 self.show_full_message = false;
517 }
518 }
519
520 fn clone_box(&self) -> Box<dyn EventFormatter> {
521 Box::new(self.clone())
522 }
523}