Skip to main content

tycode_core/formatter/
compact.rs

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        // Find current InProgress task
471        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}