Skip to main content

koda_cli/
tui_render.rs

1//! TUI renderer: converts EngineEvents to native ratatui `Line`s.
2//!
3//! All output is rendered as `ratatui::text::Line` / `Span` and written
4//! above the viewport via `insert_before()`. No ANSI strings.
5
6use crate::ansi_parse::parse_ansi_spans;
7use crate::scroll_buffer::ScrollBuffer;
8use crate::tui_output::{self, AMBER, BOLD, CYAN, DIM, MAGENTA, ORANGE, RED, YELLOW};
9use crate::widgets::status_bar::TurnStats;
10use koda_core::engine::EngineEvent;
11use ratatui::{
12    style::{Color, Style},
13    text::{Line, Span},
14};
15use std::collections::HashMap;
16
17/// TUI-aware renderer that outputs above the viewport.
18pub struct TuiRenderer {
19    /// Recent tool outputs for `/expand` replay.
20    pub tool_history: crate::tool_history::ToolOutputHistory,
21    /// When true, tool output is never collapsed.
22    pub verbose: bool,
23    /// Last turn stats for status bar display.
24    pub last_turn_stats: Option<TurnStats>,
25    /// Current model name displayed in the status bar.
26    pub model: String,
27    /// Buffer for streaming text deltas (flushed line-by-line).
28    text_buf: String,
29    /// Buffer for streaming thinking deltas.
30    think_buf: String,
31    /// Set when an ApprovalRequest with a preview was shown.
32    pub preview_shown: bool,
33    /// Whether we've emitted any text content for the current response.
34    has_emitted_text: bool,
35    /// Whether we've emitted the response banner for this turn.
36    response_started: bool,
37    /// Streaming markdown renderer.
38    md: crate::md_render::MarkdownRenderer,
39    /// Pending tool call args: maps tool_call_id → (tool_name, args_json).
40    /// Used to extract file paths for syntax highlighting Read/Grep results.
41    pending_tool_args: HashMap<String, (String, String)>,
42    /// Tool IDs that emitted streaming output lines.
43    /// Used to avoid re-rendering the full output in ToolCallResult.
44    streaming_tool_ids: std::collections::HashSet<String>,
45}
46
47impl Default for TuiRenderer {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl TuiRenderer {
54    pub fn new() -> Self {
55        Self {
56            tool_history: crate::tool_history::ToolOutputHistory::new(),
57            verbose: false,
58            last_turn_stats: None,
59            model: String::new(),
60            text_buf: String::new(),
61            think_buf: String::new(),
62            preview_shown: false,
63            has_emitted_text: false,
64            response_started: false,
65            md: crate::md_render::MarkdownRenderer::new(),
66            pending_tool_args: HashMap::new(),
67            streaming_tool_ids: std::collections::HashSet::new(),
68        }
69    }
70
71    /// Render an engine event into the scroll buffer.
72    pub fn render_to_buffer(&mut self, event: EngineEvent, buffer: &mut ScrollBuffer) {
73        match event {
74            EngineEvent::TextDelta { text } => {
75                self.text_buf.push_str(&text);
76                // Flush complete lines (skip leading blank lines)
77                while let Some(pos) = self.text_buf.find('\n') {
78                    let line_text = self.text_buf[..pos].to_string();
79                    self.text_buf = self.text_buf[pos + 1..].to_string();
80                    // Skip empty lines at the very start of a response
81                    if line_text.is_empty() && !self.has_emitted_text {
82                        continue;
83                    }
84                    self.has_emitted_text = true;
85                    tui_output::emit_line(buffer, self.md.render_line(&line_text));
86                }
87            }
88            EngineEvent::TextDone => {
89                // Flush remaining partial line
90                if !self.text_buf.is_empty() {
91                    let remaining = std::mem::take(&mut self.text_buf);
92                    tui_output::emit_line(buffer, self.md.render_line(&remaining));
93                }
94                self.response_started = false;
95                self.has_emitted_text = false;
96                // Reset markdown state for the next response
97                self.md = crate::md_render::MarkdownRenderer::new();
98            }
99            EngineEvent::ThinkingStart => {
100                self.think_buf.clear();
101                tui_output::emit_line(
102                    buffer,
103                    Line::from(vec![
104                        Span::raw("  "),
105                        Span::styled("\u{1f4ad} Thinking...", DIM),
106                    ]),
107                );
108            }
109            EngineEvent::ThinkingDelta { text } => {
110                self.think_buf.push_str(&text);
111                while let Some(pos) = self.think_buf.find('\n') {
112                    let line_text = self.think_buf[..pos].to_string();
113                    self.think_buf = self.think_buf[pos + 1..].to_string();
114                    tui_output::emit_line(
115                        buffer,
116                        Line::from(vec![
117                            Span::styled("  \u{2502} ", DIM),
118                            Span::styled(line_text, DIM),
119                        ]),
120                    );
121                }
122            }
123            EngineEvent::ThinkingDone => {
124                if !self.think_buf.is_empty() {
125                    let remaining = std::mem::take(&mut self.think_buf);
126                    tui_output::emit_line(
127                        buffer,
128                        Line::from(vec![
129                            Span::styled("  \u{2502} ", DIM),
130                            Span::styled(remaining, DIM),
131                        ]),
132                    );
133                }
134            }
135            EngineEvent::ResponseStart => {
136                self.response_started = true;
137                tui_output::emit_line(buffer, Line::styled("  \u{2500}\u{2500}\u{2500}", DIM));
138            }
139            EngineEvent::ToolCallStart {
140                id,
141                name,
142                args,
143                is_sub_agent,
144            } => {
145                // Track args for syntax highlighting in ToolCallResult
146                self.pending_tool_args
147                    .insert(id.clone(), (name.clone(), args.to_string()));
148                let indent = if is_sub_agent { "  " } else { "" };
149                let (dot_style, detail) = tool_call_styles(&name, &args);
150                tui_output::emit_line(
151                    buffer,
152                    Line::from(vec![
153                        Span::raw(indent),
154                        Span::styled("\u{25cf} ", dot_style),
155                        Span::styled(name, BOLD),
156                        Span::raw(" "),
157                        Span::styled(detail, DIM),
158                    ]),
159                );
160            }
161            EngineEvent::ToolOutputLine {
162                id,
163                line,
164                is_stderr,
165            } => {
166                self.streaming_tool_ids.insert(id);
167                let (prefix, style) = if is_stderr {
168                    ("  \u{2502}e ", RED)
169                } else {
170                    ("  \u{2502} ", DIM)
171                };
172                tui_output::emit_line(
173                    buffer,
174                    Line::from(vec![Span::styled(prefix, DIM), Span::styled(line, style)]),
175                );
176            }
177            EngineEvent::ToolCallResult { id, name, output } => {
178                // If we streamed output lines, skip rendering the full result
179                // (the user already saw it in real-time). Just show exit code.
180                let streamed = self.streaming_tool_ids.remove(&id);
181                let file_ext = self
182                    .pending_tool_args
183                    .remove(&id)
184                    .and_then(|(_, args)| extract_file_extension(&args));
185
186                self.tool_history.push(&name, &output);
187                if streamed {
188                    // Already streamed line-by-line — just show exit code summary.
189                    let exit_line = output.lines().next().unwrap_or("");
190                    tui_output::emit_line(
191                        buffer,
192                        Line::from(vec![
193                            Span::styled("  \u{2514} ", DIM),
194                            Span::styled(exit_line.to_string(), DIM),
195                        ]),
196                    );
197                } else {
198                    let is_diff_tool =
199                        matches!(name.as_str(), "Write" | "Edit" | "Delete" | "MemoryWrite");
200                    if self.preview_shown && is_diff_tool {
201                        // Compact: just show line count
202                        let line_count = output.lines().count();
203                        tui_output::emit_line(
204                            buffer,
205                            Line::from(vec![
206                                Span::styled("  \u{2514} ", DIM),
207                                Span::styled(format!("{name}: {line_count} line(s)"), DIM),
208                            ]),
209                        );
210                    } else {
211                        render_tool_output(
212                            buffer,
213                            &name,
214                            &output,
215                            self.verbose,
216                            file_ext.as_deref(),
217                        );
218                    }
219                }
220                self.preview_shown = false;
221            }
222            EngineEvent::SubAgentStart { agent_name } => {
223                tui_output::emit_line(
224                    buffer,
225                    Line::from(vec![
226                        Span::raw("  "),
227                        Span::styled(format!("\u{1f916} Sub-agent: {agent_name}"), MAGENTA),
228                    ]),
229                );
230            }
231            EngineEvent::ApprovalRequest { .. }
232            | EngineEvent::AskUserRequest { .. }
233            | EngineEvent::StatusUpdate { .. }
234            | EngineEvent::ContextUsage { .. }
235            | EngineEvent::TurnStart { .. }
236            | EngineEvent::TurnEnd { .. }
237            | EngineEvent::LoopCapReached { .. } => {
238                // Handled by the event loop, not the renderer.
239            }
240            EngineEvent::ActionBlocked {
241                tool_name: _,
242                detail,
243                preview,
244            } => {
245                tui_output::emit_line(
246                    buffer,
247                    Line::from(vec![
248                        Span::raw("  "),
249                        Span::styled(format!("\u{1f50d} Would execute: {detail}"), YELLOW),
250                    ]),
251                );
252                if let Some(preview) = preview {
253                    let diff_lines = crate::diff_render::render_lines(&preview);
254                    let gutter = crate::diff_render::GUTTER_WIDTH;
255                    for line in diff_lines {
256                        buffer.push_with_gutter(line, gutter);
257                    }
258                }
259            }
260            EngineEvent::Footer {
261                prompt_tokens,
262                completion_tokens,
263                cache_read_tokens,
264                total_chars,
265                elapsed_ms,
266                rate,
267                ..
268            } => {
269                let tokens_out = if completion_tokens > 0 {
270                    completion_tokens
271                } else {
272                    (total_chars / 4) as i64
273                };
274                self.last_turn_stats = Some(TurnStats {
275                    tokens_in: prompt_tokens,
276                    tokens_out,
277                    cache_read: cache_read_tokens,
278                    elapsed_ms,
279                    rate,
280                });
281            }
282            EngineEvent::SpinnerStart { .. } | EngineEvent::SpinnerStop => {
283                // TUI mode: spinner state is in the status bar.
284            }
285            EngineEvent::Info { message } => {
286                tui_output::emit_line(
287                    buffer,
288                    Line::from(vec![Span::raw("  "), Span::styled(message, CYAN)]),
289                );
290            }
291            EngineEvent::Warn { message } => {
292                tui_output::emit_line(
293                    buffer,
294                    Line::from(vec![
295                        Span::raw("  "),
296                        Span::styled(format!("\u{26a0} {message}"), YELLOW),
297                    ]),
298                );
299            }
300            EngineEvent::Error { message } => {
301                tui_output::emit_line(
302                    buffer,
303                    Line::from(vec![
304                        Span::raw("  "),
305                        Span::styled(format!("\u{2717} {message}"), RED),
306                    ]),
307                );
308            }
309        }
310    }
311
312    /// Stop any running spinner (no-op in TUI mode).
313    #[allow(dead_code)]
314    pub fn stop_spinner(&mut self) {}
315}
316
317// ── Helper renderers ─────────────────────────────────────────
318
319/// Get the dot color and detail string for a tool call banner.
320fn tool_call_styles(name: &str, args: &serde_json::Value) -> (Style, String) {
321    let dot_style = match name {
322        "Bash" => ORANGE,
323        "Read" | "Grep" | "Glob" | "List" => CYAN,
324        "Write" | "Edit" => AMBER,
325        "Delete" => RED,
326        "WebFetch" => Style::new().fg(Color::Blue),
327        _ => DIM,
328    };
329
330    let detail = match name {
331        "Bash" => args
332            .get("command")
333            .or(args.get("cmd"))
334            .and_then(|v| v.as_str())
335            .unwrap_or("")
336            .to_string(),
337        "Read" | "Write" | "Edit" | "Delete" => args
338            .get("file_path")
339            .or(args.get("path"))
340            .and_then(|v| v.as_str())
341            .unwrap_or("")
342            .to_string(),
343        "Grep" | "Glob" => args
344            .get("pattern")
345            .and_then(|v| v.as_str())
346            .unwrap_or("")
347            .to_string(),
348        "WebFetch" => args
349            .get("url")
350            .and_then(|v| v.as_str())
351            .unwrap_or("")
352            .to_string(),
353        _ => String::new(),
354    };
355
356    (dot_style, detail)
357}
358
359/// Render tool output with collapsing for long outputs.
360/// Extract file extension from tool call args JSON.
361/// Works for Read ("path") and Grep ("path") tool args.
362fn extract_file_extension(args_json: &str) -> Option<String> {
363    let args: serde_json::Value = serde_json::from_str(args_json).ok()?;
364    let path = args["path"].as_str()?;
365    let ext = std::path::Path::new(path).extension()?.to_str()?;
366    Some(ext.to_string())
367}
368
369fn render_tool_output(
370    buffer: &mut ScrollBuffer,
371    name: &str,
372    output: &str,
373    verbose: bool,
374    file_ext: Option<&str>,
375) {
376    use koda_core::truncate::{Truncated, truncate_for_display};
377
378    if output.is_empty() {
379        return;
380    }
381
382    // Collapse consecutive blank lines (3+ → 1) to reduce visual noise,
383    // especially from WebFetch HTML-to-text conversion.
384    let collapsed = collapse_blank_lines(output);
385    let output = &collapsed;
386
387    // Syntax highlighting for Read tool output
388    let use_highlight = name == "Read" && file_ext.is_some();
389    let is_diff_tool = matches!(name, "Edit" | "Write" | "Delete");
390    let mut highlighter = if use_highlight {
391        Some(crate::highlight::CodeHighlighter::new(file_ext.unwrap()))
392    } else {
393        None
394    };
395
396    let render_line = |buffer: &mut ScrollBuffer,
397                       line: &str,
398                       hl: &mut Option<crate::highlight::CodeHighlighter>| {
399        if name == "Grep" {
400            render_grep_line(buffer, line);
401        } else if name == "List" {
402            render_list_line(buffer, line);
403        } else if let Some(h) = hl.as_mut() {
404            let mut spans = vec![Span::styled("  \u{2502} ", DIM)];
405            spans.extend(h.highlight_spans(line));
406            tui_output::emit_line(buffer, Line::from(spans));
407        } else if is_diff_tool && line.starts_with('+') {
408            tui_output::emit_line(
409                buffer,
410                Line::from(vec![
411                    Span::styled("  \u{2502} ", DIM),
412                    Span::styled(line.to_string(), Style::default().fg(Color::Green)),
413                ]),
414            );
415        } else if is_diff_tool && line.starts_with('-') {
416            tui_output::emit_line(
417                buffer,
418                Line::from(vec![
419                    Span::styled("  \u{2502} ", DIM),
420                    Span::styled(line.to_string(), Style::default().fg(Color::Red)),
421                ]),
422            );
423        } else if is_diff_tool && line.starts_with('@') {
424            tui_output::emit_line(
425                buffer,
426                Line::from(vec![
427                    Span::styled("  \u{2502} ", DIM),
428                    Span::styled(line.to_string(), Style::default().fg(Color::Cyan)),
429                ]),
430            );
431        } else {
432            // Parse ANSI escape codes into native ratatui Spans.
433            // Colored output from tools (cargo, git, pytest, etc.)
434            // renders with proper styles instead of raw escape codes.
435            let content_spans = parse_ansi_spans(line);
436            let mut spans = vec![Span::styled("  \u{2502} ", DIM)];
437            spans.extend(content_spans);
438            tui_output::emit_line(buffer, Line::from(spans));
439        }
440    };
441
442    if verbose {
443        // Show everything in verbose mode
444        for line in output.lines() {
445            render_line(buffer, line, &mut highlighter);
446        }
447        return;
448    }
449
450    match truncate_for_display(output) {
451        Truncated::Full(_) => {
452            for line in output.lines() {
453                render_line(buffer, line, &mut highlighter);
454            }
455        }
456        Truncated::Split {
457            head,
458            tail,
459            hidden,
460            total,
461        } => {
462            for line in &head {
463                render_line(buffer, line, &mut highlighter);
464            }
465            tui_output::emit_line(
466                buffer,
467                Line::from(vec![Span::styled(
468                    koda_core::truncate::separator(hidden, total),
469                    DIM,
470                )]),
471            );
472            for line in &tail {
473                render_line(buffer, line, &mut highlighter);
474            }
475        }
476    }
477}
478
479/// Collapse runs of consecutive blank lines down to at most 1.
480///
481/// WebFetch HTML-to-text conversion often produces dozens of empty lines
482/// from page footers, nav elements, etc. This keeps output scannable
483/// without losing meaningful whitespace (single blank lines are preserved).
484fn collapse_blank_lines(text: &str) -> String {
485    let mut result = String::with_capacity(text.len());
486    let mut consecutive_blanks = 0u32;
487    for line in text.lines() {
488        if line.trim().is_empty() {
489            consecutive_blanks += 1;
490            if consecutive_blanks <= 1 {
491                result.push('\n');
492            }
493        } else {
494            consecutive_blanks = 0;
495            if !result.is_empty() {
496                result.push('\n');
497            }
498            result.push_str(line);
499        }
500    }
501    result
502}
503
504/// Render a single list entry with directory/file coloring.
505///
506/// List output format: `d path/to/dir` (directory) or `  path/to/file` (file).
507/// Directories are shown in bold, files colored by extension.
508fn render_list_line(buffer: &mut ScrollBuffer, line: &str) {
509    let is_dir = line.starts_with("d ");
510    let path_str = if is_dir {
511        &line[2..]
512    } else {
513        line.trim_start()
514    };
515
516    let style = if is_dir {
517        Style::default().add_modifier(ratatui::style::Modifier::BOLD)
518    } else {
519        // Color files by extension category
520        let ext = std::path::Path::new(path_str)
521            .extension()
522            .and_then(|e| e.to_str())
523            .unwrap_or("");
524        match ext {
525            "rs" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "rb" | "java" | "c" | "cpp"
526            | "h" | "cs" | "swift" | "kt" => Style::default().fg(Color::Green),
527            "toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" => {
528                Style::default().fg(Color::Yellow)
529            }
530            "md" | "txt" | "rst" | "adoc" => Style::default().fg(Color::White),
531            "lock" | "sum" => Style::default().fg(Color::DarkGray),
532            _ => Style::default().fg(Color::Reset),
533        }
534    };
535
536    let prefix = if is_dir { "\u{1f4c1} " } else { "   " };
537    tui_output::emit_line(
538        buffer,
539        Line::from(vec![
540            Span::styled("  \u{2502} ", DIM),
541            Span::raw(prefix),
542            Span::styled(path_str.to_string(), style),
543        ]),
544    );
545}
546
547/// Render a single grep result line with the file path highlighted.
548///
549/// Grep output format: `file_path:line_number:content`
550/// We highlight the file path in cyan and the line number in yellow.
551fn render_grep_line(buffer: &mut ScrollBuffer, line: &str) {
552    // Parse file:line:content format
553    if let Some((file_and_line, content)) = line.split_once(':').and_then(|(file, rest)| {
554        rest.split_once(':')
555            .map(|(lineno, content)| (format!("{file}:{lineno}"), content))
556    }) {
557        tui_output::emit_line(
558            buffer,
559            Line::from(vec![
560                Span::styled("  \u{2502} ", DIM),
561                Span::styled(file_and_line, Style::default().fg(Color::Cyan)),
562                Span::styled(":", DIM),
563                Span::raw(content.to_string()),
564            ]),
565        );
566    } else {
567        // Fallback: render as-is
568        tui_output::emit_line(
569            buffer,
570            Line::from(vec![
571                Span::styled("  \u{2502} ", DIM),
572                Span::raw(line.to_string()),
573            ]),
574        );
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn test_collapse_preserves_single_blank() {
584        assert_eq!(collapse_blank_lines("a\n\nb"), "a\n\nb");
585    }
586
587    #[test]
588    fn test_collapse_many_blanks() {
589        assert_eq!(collapse_blank_lines("a\n\n\n\n\nb"), "a\n\nb");
590    }
591
592    #[test]
593    fn test_collapse_no_blanks() {
594        assert_eq!(collapse_blank_lines("a\nb\nc"), "a\nb\nc");
595    }
596
597    #[test]
598    fn test_collapse_all_blank() {
599        assert_eq!(collapse_blank_lines("\n\n\n\n"), "\n");
600    }
601}