use crate::ansi_parse::parse_ansi_spans;
use crate::scroll_buffer::Fold;
use crate::scroll_buffer::ScrollBuffer;
use crate::theme;
use crate::tui_output::{self, CYAN, DIM, MAGENTA, RED, TOOL_PREFIX, YELLOW};
use crate::widgets::status_bar::TurnStats;
use koda_core::engine::EngineEvent;
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use std::collections::HashMap;
pub struct TuiRenderer {
pub tool_history: crate::tool_history::ToolOutputHistory,
pub last_turn_stats: Option<TurnStats>,
pub model: String,
text_buf: String,
think_buf: String,
pub preview_shown: bool,
has_emitted_text: bool,
response_started: bool,
md: crate::md_render::MarkdownRenderer,
pending_tool_args: HashMap<String, (String, String)>,
stream_starts: HashMap<String, u64>,
}
impl Default for TuiRenderer {
fn default() -> Self {
Self::new()
}
}
impl TuiRenderer {
pub fn new() -> Self {
Self {
tool_history: crate::tool_history::ToolOutputHistory::new(),
last_turn_stats: None,
model: String::new(),
text_buf: String::new(),
think_buf: String::new(),
preview_shown: false,
has_emitted_text: false,
response_started: false,
md: crate::md_render::MarkdownRenderer::new(),
pending_tool_args: HashMap::new(),
stream_starts: HashMap::new(),
}
}
pub fn render_to_buffer(&mut self, event: EngineEvent, buffer: &mut ScrollBuffer) {
match event.notice().unwrap_or(event) {
EngineEvent::TextDelta { text } => {
self.text_buf.push_str(&text);
while let Some(pos) = self.text_buf.find('\n') {
let line_text = self.text_buf[..pos].to_string();
self.text_buf = self.text_buf[pos + 1..].to_string();
if line_text.is_empty() && !self.has_emitted_text {
continue;
}
self.has_emitted_text = true;
tui_output::emit_line(buffer, self.md.render_line(&line_text));
}
}
EngineEvent::TextDone => {
if !self.text_buf.is_empty() {
let remaining = std::mem::take(&mut self.text_buf);
tui_output::emit_line(buffer, self.md.render_line(&remaining));
}
self.response_started = false;
self.has_emitted_text = false;
self.md = crate::md_render::MarkdownRenderer::new();
}
EngineEvent::ThinkingStart => {
self.think_buf.clear();
tui_output::emit_line(
buffer,
Line::from(vec![
Span::raw(" "),
Span::styled("\u{1f4ad} Thinking...", DIM),
]),
);
}
EngineEvent::ThinkingDelta { text } => {
self.think_buf.push_str(&text);
while let Some(pos) = self.think_buf.find('\n') {
let line_text = self.think_buf[..pos].to_string();
self.think_buf = self.think_buf[pos + 1..].to_string();
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line_text, DIM),
]),
);
}
}
EngineEvent::ThinkingDone if !self.think_buf.is_empty() => {
let remaining = std::mem::take(&mut self.think_buf);
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(remaining, DIM),
]),
);
}
EngineEvent::ResponseStart => {
self.response_started = true;
tui_output::emit_line(buffer, Line::styled(" \u{2500}\u{2500}\u{2500}", DIM));
}
EngineEvent::ToolCallStart {
id,
name,
args,
is_sub_agent,
} => {
self.pending_tool_args
.insert(id.clone(), (name.clone(), args.to_string()));
let indent = if is_sub_agent { " " } else { "" };
let line = crate::tool_header::build_header_line(indent, &name, &args);
tui_output::emit_line(buffer, line);
}
EngineEvent::ToolOutputLine {
id,
line,
is_stderr,
} => {
self.stream_starts
.entry(id.clone())
.or_insert_with(|| buffer.next_seq());
let tool_name = self
.pending_tool_args
.get(&id)
.map(|(n, _)| n.as_str())
.unwrap_or("");
let prefix = if is_stderr {
" \u{2502}e "
} else {
" \u{2502} "
};
let content_style = theme::content_style_for(tool_name, is_stderr);
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(prefix, TOOL_PREFIX),
Span::styled(line, content_style),
]),
);
}
EngineEvent::ToolCallResult {
id,
name,
output,
outcome,
} => {
let failed = outcome == koda_core::engine::event::ToolOutcome::Error;
let stream_start = self.stream_starts.remove(&id);
let pending = self.pending_tool_args.remove(&id);
let file_ext = pending
.as_ref()
.and_then(|(_, args)| extract_file_extension(args));
let grep_pattern = if name == "Grep" {
pending.as_ref().and_then(|(_, args)| {
serde_json::from_str::<serde_json::Value>(args)
.ok()
.and_then(|v| v["pattern"].as_str().map(str::to_string))
})
} else {
None
};
self.tool_history.push(&name, &output);
if let Some(start) = stream_start {
let tail = if failed { FOLD_TAIL } else { 0 };
buffer.fold_since(start, FOLD_HEAD, tail, fold_marker);
let exit_line = output.lines().next().unwrap_or("");
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2514} ", DIM),
Span::styled(exit_line.to_string(), DIM),
]),
);
} else {
let is_diff_tool =
matches!(name.as_str(), "Write" | "Edit" | "Delete" | "MemoryWrite");
if self.preview_shown && is_diff_tool {
let line_count = output.lines().count();
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2514} ", DIM),
Span::styled(format!("{name}: {line_count} line(s)"), DIM),
]),
);
} else {
buffer.push_folded_lines(tool_output_view(
&name,
&output,
failed,
file_ext.as_deref(),
grep_pattern.as_deref(),
));
}
}
self.preview_shown = false;
}
EngineEvent::SubAgentStart { agent_name } => {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::raw(" "),
Span::styled(format!("\u{1f916} Sub-agent: {agent_name}"), MAGENTA),
]),
);
}
EngineEvent::ApprovalRequest { .. }
| EngineEvent::AskUserRequest { .. }
| EngineEvent::StatusUpdate { .. }
| EngineEvent::ContextUsage { .. }
| EngineEvent::ContextWindowChanged { .. }
| EngineEvent::TurnStart { .. }
| EngineEvent::TurnEnd { .. }
| EngineEvent::LoopCapReached { .. }
| EngineEvent::ChildTaskUpdate { .. }
| EngineEvent::TodoUpdate { .. }
| EngineEvent::ChildAgentActivity { .. } => {
}
EngineEvent::ActionBlocked {
tool_name: _,
detail,
preview,
} => {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::raw(" "),
Span::styled(format!("\u{1f50d} Would execute: {detail}"), YELLOW),
]),
);
if let Some(preview) = preview {
let diff_lines = crate::diff_render::render_lines(&preview);
let gutter = crate::diff_render::GUTTER_WIDTH;
for line in diff_lines {
buffer.push_with_gutter(line, gutter);
}
}
}
EngineEvent::Footer {
prompt_tokens,
completion_tokens,
cache_read_tokens,
total_chars,
elapsed_ms,
rate,
..
} => {
let tokens_out = if completion_tokens > 0 {
completion_tokens
} else {
(total_chars / 4) as i64
};
self.last_turn_stats = Some(TurnStats {
tokens_in: prompt_tokens,
tokens_out,
cache_read: cache_read_tokens,
elapsed_ms,
rate,
});
}
EngineEvent::SpinnerStart { .. } | EngineEvent::SpinnerStop => {
}
EngineEvent::Info { message } => {
tui_output::emit_line(
buffer,
Line::from(vec![Span::raw(" "), Span::styled(message, CYAN)]),
);
}
EngineEvent::Warn { message } => {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::raw(" "),
Span::styled(format!("\u{26a0} {message}"), YELLOW),
]),
);
}
EngineEvent::Error { message } => {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::raw(" "),
Span::styled(format!("\u{2717} {message}"), RED),
]),
);
}
_ => {}
}
}
#[allow(dead_code)]
pub fn stop_spinner(&mut self) {}
}
pub(crate) fn extract_file_extension(args_json: &str) -> Option<String> {
let args: serde_json::Value = serde_json::from_str(args_json).ok()?;
let path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(|v| v.as_str())?;
let ext = std::path::Path::new(path).extension()?.to_str()?;
Some(ext.to_string())
}
const FOLD_HEAD: usize = 5;
const FOLD_TAIL: usize = 5;
pub(crate) fn fold_marker(hidden: usize) -> Line<'static> {
let noun = if hidden == 1 { "line" } else { "lines" };
Line::from(vec![
Span::styled(" \u{2502} ", TOOL_PREFIX),
Span::styled(
format!("\u{2026} +{hidden} {noun} (ctrl+o to show all)"),
DIM,
),
])
}
fn read_summary(output: &str) -> Option<String> {
let numbers: Vec<usize> = output
.lines()
.filter_map(|l| l.split_once('\t')?.0.trim().parse().ok())
.collect();
let (&first, &last) = (numbers.first()?, numbers.last()?);
let total = output.lines().find_map(|l| {
let rest = l.split_once("[Showing lines ")?.1;
rest.split_once(" of ")?
.1
.split_once('.')?
.0
.parse::<usize>()
.ok()
});
Some(match total {
Some(total) => format!("lines {first}\u{2013}{last} of {total}"),
None if first == 1 => {
let n = numbers.len();
format!("{n} {}", if n == 1 { "line" } else { "lines" })
}
None => format!("lines {first}\u{2013}{last}"),
})
}
fn split_numbered(line: &str) -> Option<(usize, &str)> {
let (number, content) = line.split_once('\t')?;
Some((number.trim_start().parse().ok()?, content))
}
fn numbered_read_lines(
output: &str,
mut highlighter: Option<&mut crate::highlight::CodeHighlighter>,
body: Style,
) -> Vec<Line<'static>> {
let width = output
.lines()
.filter_map(|l| split_numbered(l).map(|(n, _)| n.to_string().len()))
.max()
.unwrap_or(1);
output
.lines()
.map(|line| {
let mut spans = vec![Span::styled(" \u{2502} ", DIM)];
match split_numbered(line) {
Some((number, content)) => {
spans.push(Span::styled(format!("{number:>width$} "), DIM));
let content = content.replace('\t', " ");
match highlighter.as_deref_mut() {
Some(h) => spans.extend(h.highlight_spans(&content)),
None => spans.push(Span::styled(content, body)),
}
}
None => spans.push(Span::styled(line.to_string(), DIM)),
}
Line::from(spans)
})
.collect()
}
pub(crate) fn tool_output_view(
name: &str,
output: &str,
failed: bool,
file_ext: Option<&str>,
grep_pattern: Option<&str>,
) -> Vec<(Line<'static>, Fold)> {
use koda_core::truncate::{Truncated, truncate_for_display};
if output.is_empty() {
return Vec::new();
}
let collapsed = collapse_blank_lines(output);
let output = &collapsed;
let use_highlight = name == "Read" && file_ext.is_some() && theme::syntax_highlight_enabled();
let is_diff_tool = matches!(name, "Edit" | "Write" | "Delete");
let mut highlighter = if use_highlight {
Some(crate::highlight::CodeHighlighter::new(file_ext.unwrap()))
} else {
None
};
let body = theme::content_style_for(name, false);
if name == "Read"
&& let Some(summary) = read_summary(output)
{
let mut lines = vec![(
Line::from(vec![
Span::styled(" \u{2514} ", TOOL_PREFIX),
Span::styled(summary, DIM),
]),
Fold::Folded,
)];
lines.extend(
numbered_read_lines(output, highlighter.as_mut(), body)
.into_iter()
.map(|l| (l, Fold::Full)),
);
return lines;
}
let mut render_line = |line: &str| -> Line<'static> {
if name == "Grep" {
crate::tool_output_lines::grep_line(line, grep_pattern)
} else if name == "List" {
crate::tool_output_lines::list_line(line)
} else if name == "Glob" {
crate::tool_output_lines::glob_line(line)
} else if let Some(h) = highlighter.as_mut() {
let mut spans = vec![Span::styled(" \u{2502} ", DIM)];
spans.extend(h.highlight_spans(line));
Line::from(spans)
} else if is_diff_tool && line.starts_with('+') {
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line.to_string(), Style::default().fg(Color::Green)),
])
} else if is_diff_tool && line.starts_with('-') {
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line.to_string(), Style::default().fg(Color::Red)),
])
} else if is_diff_tool && line.starts_with('@') {
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line.to_string(), Style::default().fg(Color::Cyan)),
])
} else {
let mut spans = vec![Span::styled(" \u{2502} ", DIM)];
spans.extend(parse_ansi_spans(line).into_iter().map(|mut span| {
span.style = body.patch(span.style);
span
}));
Line::from(spans)
}
};
if is_diff_tool {
let always = |line| (line, Fold::Always);
return match truncate_for_display(output) {
Truncated::Full(_) => output.lines().map(|l| always(render_line(l))).collect(),
Truncated::Split {
head,
tail,
hidden,
total,
} => {
let mut lines: Vec<_> = head.iter().map(|l| always(render_line(l))).collect();
lines.push(always(Line::from(vec![Span::styled(
koda_core::truncate::separator(hidden, total),
DIM,
)])));
lines.extend(tail.iter().map(|l| always(render_line(l))));
lines
}
};
}
let rendered: Vec<Line<'static>> = output.lines().map(&mut render_line).collect();
let tail = if failed { FOLD_TAIL } else { 0 };
if rendered.len() <= FOLD_HEAD + tail + 1 {
return rendered.into_iter().map(|l| (l, Fold::Always)).collect();
}
let hidden = rendered.len() - FOLD_HEAD - tail;
let mut lines = Vec::with_capacity(rendered.len() + 1);
for (i, line) in rendered.into_iter().enumerate() {
if i == FOLD_HEAD {
lines.push((fold_marker(hidden), Fold::Folded));
}
let fold = if i < FOLD_HEAD || i >= FOLD_HEAD + hidden {
Fold::Always
} else {
Fold::Full
};
lines.push((line, fold));
}
lines
}
fn collapse_blank_lines(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut consecutive_blanks = 0u32;
for line in text.lines() {
if line.trim().is_empty() {
consecutive_blanks += 1;
if consecutive_blanks <= 1 {
result.push('\n');
}
} else {
consecutive_blanks = 0;
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use koda_core::engine::event::EngineEvent;
use serde_json::json;
fn view_text(view: &[(Line<'static>, Fold)], hide: Fold) -> Vec<String> {
view.iter()
.filter(|(_, f)| *f != hide)
.map(|(l, _)| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn a_read_folds_to_its_summary() {
let out = " 1\tfn a() {}\n 2\tfn b() {}\n 3\tfn c() {}";
let view = tool_output_view("Read", out, false, Some("rs"), None);
let folded = view_text(&view, Fold::Full);
assert_eq!(folded, vec![" \u{2514} 3 lines"]);
let full = view_text(&view, Fold::Folded);
assert_eq!(full.len(), 3);
assert!(full[1].contains("fn b"), "{full:?}");
}
#[test]
fn a_read_shows_numbers_in_a_column_and_highlights_the_content() {
let out = " 9\t# Title\n 10\tplain words\n 11\t\tindented\n\n... [Showing lines 9-11 of 40. Continue with start_line=12.]";
let view = tool_output_view("Read", out, false, Some("md"), None);
let full: Vec<&Line<'static>> = view
.iter()
.filter(|(_, f)| *f == Fold::Full)
.map(|(l, _)| l)
.collect();
let text =
|l: &Line<'_>| -> String { l.spans.iter().map(|s| s.content.as_ref()).collect() };
assert_eq!(text(full[0]), " \u{2502} 9 # Title");
assert_eq!(text(full[1]), " \u{2502} 10 plain words");
assert_eq!(text(full[2]), " \u{2502} 11 indented");
assert_eq!(full[0].spans[1].content, " 9 ");
assert_eq!(full[0].spans[1].style, DIM);
if theme::syntax_highlight_enabled() {
let heading = full[0].spans[2].style.fg;
let plain = full[1].spans[2].style.fg;
assert!(
heading.is_some() && heading != plain,
"{heading:?} vs {plain:?}"
);
}
let note = full.last().unwrap();
assert!(
text(note).contains("[Showing lines 9-11 of 40"),
"{}",
text(note)
);
assert_eq!(note.spans[1].style, DIM);
}
#[test]
fn a_read_summary_names_a_range_and_a_cut() {
assert_eq!(
read_summary(" 40\ta\n 41\tb").as_deref(),
Some("lines 40\u{2013}41")
);
let cut =
" 1\ta\n 2\tb\n\n... [Showing lines 1-2 of 1340. Continue with start_line=3.]";
assert_eq!(
read_summary(cut).as_deref(),
Some("lines 1\u{2013}2 of 1340")
);
assert_eq!(read_summary(" 1\tonly").as_deref(), Some("1 line"));
assert_eq!(read_summary("(empty file)"), None);
let view = tool_output_view("Read", "(empty file)", false, Some("rs"), None);
assert_eq!(
view_text(&view, Fold::Full),
vec![" \u{2502} (empty file)"]
);
}
#[test]
fn long_output_folds_after_its_first_lines() {
let out: String = (1..=20).map(|i| format!("out {i}\n")).collect();
let view = tool_output_view("Bash", &out, false, None, None);
let folded = view_text(&view, Fold::Full);
assert_eq!(folded.len(), 6, "{folded:?}");
assert!(folded[4].ends_with("out 5"));
assert!(folded[5].contains("+15 lines (ctrl+o to show all)"));
assert_eq!(view_text(&view, Fold::Folded).len(), 20);
let failed = view_text(
&tool_output_view("Bash", &out, true, None, None),
Fold::Full,
);
assert_eq!(failed.len(), 11, "{failed:?}");
assert!(failed[5].contains("+10 lines"));
assert!(failed[10].ends_with("out 20"));
let short: String = (1..=6).map(|i| format!("out {i}\n")).collect();
let view = tool_output_view("Bash", &short, false, None, None);
assert!(view.iter().all(|(_, f)| *f == Fold::Always));
}
#[test]
fn output_takes_its_effect_colour() {
let view = tool_output_view("WebFetch", "plain text", false, None, None);
let body = &view[0].0.spans[1];
assert_eq!(
body.style.fg,
theme::content_style_for("WebFetch", false).fg
);
let view = tool_output_view("Bash", "\x1b[31mred\x1b[0m", false, None, None);
assert_eq!(view[0].0.spans[1].style.fg, Some(Color::Red));
}
#[test]
fn streamed_output_folds_when_the_command_ends() {
let mut renderer = TuiRenderer::new();
let mut buffer = ScrollBuffer::new(256);
renderer.render_to_buffer(
EngineEvent::ToolCallStart {
id: "b1".into(),
name: "Bash".into(),
args: json!({ "command": "seq 20" }),
is_sub_agent: false,
},
&mut buffer,
);
for i in 1..=20 {
renderer.render_to_buffer(
EngineEvent::ToolOutputLine {
id: "b1".into(),
line: format!("{i}"),
is_stderr: false,
},
&mut buffer,
);
}
renderer.render_to_buffer(
EngineEvent::ToolCallResult {
id: "b1".into(),
name: "Bash".into(),
output: "Exit code: 0 | stdout: 20 lines".into(),
outcome: koda_core::engine::ToolOutcome::Success,
},
&mut buffer,
);
let text = |b: &ScrollBuffer| -> Vec<String> {
b.all_lines()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
};
let folded = text(&buffer);
assert_eq!(folded.len(), 8, "{folded:?}");
assert!(folded[6].contains("+15 lines"), "{folded:?}");
assert!(folded[7].contains("Exit code: 0"), "{folded:?}");
buffer.set_full(true);
assert_eq!(text(&buffer).len(), 22);
}
fn render_read(file_path: &str, content: &str) -> Vec<ratatui::text::Line<'static>> {
let mut renderer = TuiRenderer::new();
let mut buffer = ScrollBuffer::new(256);
renderer.render_to_buffer(
EngineEvent::ToolCallStart {
id: "t1".into(),
name: "Read".into(),
args: json!({ "file_path": file_path }),
is_sub_agent: false,
},
&mut buffer,
);
renderer.render_to_buffer(
EngineEvent::ToolCallResult {
id: "t1".into(),
name: "Read".into(),
output: content.to_string(),
outcome: koda_core::engine::ToolOutcome::Success,
},
&mut buffer,
);
buffer.set_full(true);
buffer.all_lines().cloned().collect()
}
fn render_tool(
tool_name: &str,
args: serde_json::Value,
output: &str,
) -> Vec<ratatui::text::Line<'static>> {
let mut renderer = TuiRenderer::new();
let mut buffer = ScrollBuffer::new(256);
renderer.render_to_buffer(
EngineEvent::ToolCallStart {
id: "t1".into(),
name: tool_name.into(),
args,
is_sub_agent: false,
},
&mut buffer,
);
renderer.render_to_buffer(
EngineEvent::ToolCallResult {
id: "t1".into(),
name: tool_name.into(),
output: output.to_string(),
outcome: koda_core::engine::ToolOutcome::Success,
},
&mut buffer,
);
buffer.all_lines().cloned().collect()
}
fn spans_on_lines_containing(
lines: &[ratatui::text::Line<'static>],
needle: &str,
) -> Vec<ratatui::text::Span<'static>> {
lines
.iter()
.filter(|line| {
let joined: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
joined.contains(needle)
})
.flat_map(|line| line.spans.iter().cloned())
.collect()
}
#[test]
fn extract_file_extension_handles_file_path_key() {
let args = r#"{"file_path": "src/main.rs"}"#;
assert_eq!(extract_file_extension(args).as_deref(), Some("rs"));
}
#[test]
fn extract_file_extension_falls_back_to_path() {
let args = r#"{"path": "src/lib.py"}"#;
assert_eq!(extract_file_extension(args).as_deref(), Some("py"));
}
#[test]
fn extract_file_extension_prefers_file_path_when_both_present() {
let args = r#"{"file_path": "a.rs", "path": "b.py"}"#;
assert_eq!(extract_file_extension(args).as_deref(), Some("rs"));
}
#[test]
fn extract_file_extension_returns_none_for_extensionless() {
let args = r#"{"file_path": "Makefile"}"#;
assert_eq!(extract_file_extension(args), None);
}
#[test]
fn read_toml_produces_multiple_colored_spans() {
if !crate::theme::syntax_highlight_enabled() {
return;
}
let lines = render_read(
"Cargo.toml",
"[package]\nname = \"koda\"\nversion = \"0.2.16\"",
);
let spans = spans_on_lines_containing(&lines, "[package]");
assert!(
spans.len() > 2,
"[package] line should have multiple colored spans (got {}); \
TOML syntax highlighting broken — check two-face dep",
spans.len()
);
let colored = spans.iter().any(|s| {
s.style
.fg
.is_some_and(|c| c != ratatui::style::Color::Reset)
});
assert!(
colored,
"no colored spans on [package] line — highlighter is a no-op"
);
}
#[test]
fn read_rust_still_produces_colored_spans() {
if !crate::theme::syntax_highlight_enabled() {
return;
}
let lines = render_read("src/main.rs", "fn main() {}\nprintln!(\"hello\");");
let spans = spans_on_lines_containing(&lines, "fn main");
assert!(
spans.len() > 2,
"`fn main` line should have multiple spans; Rust highlighting broken"
);
}
#[test]
fn read_typescript_produces_colored_spans() {
if !crate::theme::syntax_highlight_enabled() {
return;
}
let lines = render_read(
"index.ts",
"interface Foo { bar: string }\nconst x: number = 42;",
);
let spans = spans_on_lines_containing(&lines, "interface");
assert!(
spans.len() > 2,
"`interface` line should have multiple spans; TypeScript highlighting broken"
);
}
#[test]
fn read_unknown_extension_falls_back_safely() {
let lines = render_read("data.xyz123", "some content here");
let spans = spans_on_lines_containing(&lines, "some content");
assert!(
!spans.is_empty(),
"unknown-extension Read must still emit spans (got none)"
);
}
#[test]
fn read_extensionless_file_renders_safely() {
let lines = render_read("Makefile", ".PHONY: all\nall:\n\techo done");
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
all_text.contains(".PHONY"),
"Makefile content missing from output"
);
}
#[test]
fn list_directory_entries_render_styled() {
let lines = render_tool(
"List",
json!({ "path": "." }),
"d src\nd tests\n Cargo.toml\n README.md",
);
let src_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.as_ref() == "src"));
let src_line = src_line.expect("src directory entry not in output");
let dir_span = src_line
.spans
.iter()
.find(|s| s.content.as_ref() == "src")
.unwrap();
assert!(
dir_span
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD),
"directory entry should be bold; render_list_line dispatch broken"
);
}
#[test]
fn list_file_entries_colored_by_extension() {
let lines = render_tool("List", json!({ "path": "." }), " Cargo.toml\n src.rs");
let toml_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.as_ref() == "Cargo.toml"))
.expect("Cargo.toml entry not in output");
let toml_span = toml_line
.spans
.iter()
.find(|s| s.content.as_ref() == "Cargo.toml")
.unwrap();
assert_eq!(
toml_span.style.fg,
Some(ratatui::style::Color::Yellow),
".toml file should be yellow (config bucket); style_for_extension dispatch broken"
);
}
#[test]
fn glob_paths_colored_by_extension() {
let lines = render_tool(
"Glob",
json!({ "pattern": "**/*.rs" }),
"3 files matched:\nsrc/main.rs\nsrc/lib.rs\ntests/it.rs",
);
let rs_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.as_ref() == "src/main.rs"))
.expect("src/main.rs not in Glob output");
let rs_span = rs_line
.spans
.iter()
.find(|s| s.content.as_ref() == "src/main.rs")
.unwrap();
assert_eq!(
rs_span.style.fg,
Some(ratatui::style::Color::Green),
".rs file should be green (source bucket); render_glob_line dispatch broken"
);
}
#[test]
fn grep_pattern_hits_styled_with_match_hit() {
let lines = render_tool(
"Grep",
json!({ "pattern": "TODO", "path": "." }),
"src/main.rs:42: // TODO: refactor this",
);
let hit_span = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "TODO");
let hit_span = hit_span.expect("TODO match span missing; Grep dispatch broken");
assert!(
hit_span
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD),
"Grep match should be bold (MATCH_HIT); pattern propagation broken"
);
assert_eq!(
hit_span.style.fg,
Some(ratatui::style::Color::Rgb(255, 191, 0)),
"Grep match should be amber (MATCH_HIT)"
);
}
#[test]
fn grep_path_and_lineno_styled_without_pattern() {
let lines = render_tool(
"Grep",
json!({ "path": "." }),
"src/main.rs:42:fn main() {}",
);
let path_span = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "src/main.rs");
assert!(
path_span.is_some(),
"Grep file path span missing — render_grep_line dispatch broken"
);
assert!(
path_span.unwrap().style.fg.is_some(),
"Grep file path should have a foreground color"
);
}
#[test]
fn test_collapse_preserves_single_blank() {
assert_eq!(collapse_blank_lines("a\n\nb"), "a\n\nb");
}
#[test]
fn test_collapse_many_blanks() {
assert_eq!(collapse_blank_lines("a\n\n\n\n\nb"), "a\n\nb");
}
#[test]
fn test_collapse_no_blanks() {
assert_eq!(collapse_blank_lines("a\nb\nc"), "a\nb\nc");
}
#[test]
fn test_collapse_all_blank() {
assert_eq!(collapse_blank_lines("\n\n\n\n"), "\n");
}
#[test]
fn bg_child_activity_does_not_push_lines_to_scroll_buffer() {
use koda_core::engine::event::{ChildAgentActivityKind, EngineEvent};
let mut renderer = TuiRenderer::new();
let mut buffer = ScrollBuffer::new(2500);
for kind in [
ChildAgentActivityKind::ToolStart {
tool_name: "Read".into(),
summary: "src/main.rs".into(),
},
ChildAgentActivityKind::ToolEnd {
tool_name: "Read".into(),
success: true,
},
ChildAgentActivityKind::Info {
message: " \u{1f4cc} note".into(),
},
] {
renderer.render_to_buffer(
EngineEvent::ChildAgentActivity {
task_id: 7,
spawner: None,
is_background: true,
kind,
},
&mut buffer,
);
}
assert_eq!(
buffer.len(),
0,
"ChildAgentActivity must not push to scrollback (#1201 B2); \
post-completion `✅ ... completed` Info line is the durable record"
);
}
}