use crate::ansi_parse::parse_ansi_spans;
use crate::scroll_buffer::ScrollBuffer;
use crate::tui_output::{
self, AMBER, BOLD, CYAN, DIM, MAGENTA, ORANGE, READ_CONTENT, RED, TOOL_PREFIX, WRITE_CONTENT,
YELLOW,
};
use crate::widgets::status_bar::TurnStats;
use koda_core::engine::EngineEvent;
use koda_core::tools::{ToolEffect, classify_tool};
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use std::collections::HashMap;
pub struct TuiRenderer {
pub tool_history: crate::tool_history::ToolOutputHistory,
pub verbose: bool,
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)>,
streaming_tool_ids: std::collections::HashSet<String>,
}
impl Default for TuiRenderer {
fn default() -> Self {
Self::new()
}
}
impl TuiRenderer {
pub fn new() -> Self {
Self {
tool_history: crate::tool_history::ToolOutputHistory::new(),
verbose: false,
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(),
streaming_tool_ids: std::collections::HashSet::new(),
}
}
pub fn render_to_buffer(&mut self, event: EngineEvent, buffer: &mut ScrollBuffer) {
match 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 (dot_style, detail) = tool_call_styles(&name, &args);
tui_output::emit_line(
buffer,
Line::from(vec![
Span::raw(indent),
Span::styled("\u{25cf} ", dot_style),
Span::styled(name, BOLD),
Span::raw(" "),
Span::styled(detail, DIM),
]),
);
}
EngineEvent::ToolOutputLine {
id,
line,
is_stderr,
} => {
self.streaming_tool_ids.insert(id.clone());
let tool_name = self
.pending_tool_args
.get(&id)
.map(|(n, _)| n.as_str())
.unwrap_or("");
let (prefix, content_style) = if is_stderr {
(" \u{2502}e ", RED)
} else if matches!(classify_tool(tool_name), ToolEffect::ReadOnly) {
(" \u{2502} ", READ_CONTENT)
} else {
(" \u{2502} ", WRITE_CONTENT)
};
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(prefix, TOOL_PREFIX),
Span::styled(line, content_style),
]),
);
}
EngineEvent::ToolCallResult { id, name, output } => {
let streamed = self.streaming_tool_ids.remove(&id);
let file_ext = self
.pending_tool_args
.remove(&id)
.and_then(|(_, args)| extract_file_extension(&args));
self.tool_history.push(&name, &output);
if streamed {
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 {
render_tool_output(
buffer,
&name,
&output,
self.verbose,
file_ext.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::TurnStart { .. }
| EngineEvent::TurnEnd { .. }
| EngineEvent::LoopCapReached { .. } => {
}
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) {}
}
fn tool_call_styles(name: &str, args: &serde_json::Value) -> (Style, String) {
let dot_style = match name {
"Bash" => ORANGE,
"Read" | "Grep" | "Glob" | "List" => CYAN,
"Write" | "Edit" => AMBER,
"Delete" => RED,
"WebFetch" => Style::new().fg(Color::Blue),
_ => DIM,
};
let detail = match name {
"Bash" => args
.get("command")
.or(args.get("cmd"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Read" | "Write" | "Edit" | "Delete" => args
.get("file_path")
.or(args.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Grep" | "Glob" => args
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"WebFetch" => args
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => String::new(),
};
(dot_style, detail)
}
fn extract_file_extension(args_json: &str) -> Option<String> {
let args: serde_json::Value = serde_json::from_str(args_json).ok()?;
let path = args["path"].as_str()?;
let ext = std::path::Path::new(path).extension()?.to_str()?;
Some(ext.to_string())
}
fn render_tool_output(
buffer: &mut ScrollBuffer,
name: &str,
output: &str,
verbose: bool,
file_ext: Option<&str>,
) {
use koda_core::truncate::{Truncated, truncate_for_display};
if output.is_empty() {
return;
}
let collapsed = collapse_blank_lines(output);
let output = &collapsed;
let use_highlight = name == "Read" && file_ext.is_some();
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 render_line = |buffer: &mut ScrollBuffer,
line: &str,
hl: &mut Option<crate::highlight::CodeHighlighter>| {
if name == "Grep" {
render_grep_line(buffer, line);
} else if name == "List" {
render_list_line(buffer, line);
} else if let Some(h) = hl.as_mut() {
let mut spans = vec![Span::styled(" \u{2502} ", DIM)];
spans.extend(h.highlight_spans(line));
tui_output::emit_line(buffer, Line::from(spans));
} else if is_diff_tool && line.starts_with('+') {
tui_output::emit_line(
buffer,
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('-') {
tui_output::emit_line(
buffer,
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('@') {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line.to_string(), Style::default().fg(Color::Cyan)),
]),
);
} else {
let content_spans = parse_ansi_spans(line);
let mut spans = vec![Span::styled(" \u{2502} ", DIM)];
spans.extend(content_spans);
tui_output::emit_line(buffer, Line::from(spans));
}
};
if verbose {
for line in output.lines() {
render_line(buffer, line, &mut highlighter);
}
return;
}
match truncate_for_display(output) {
Truncated::Full(_) => {
for line in output.lines() {
render_line(buffer, line, &mut highlighter);
}
}
Truncated::Split {
head,
tail,
hidden,
total,
} => {
for line in &head {
render_line(buffer, line, &mut highlighter);
}
tui_output::emit_line(
buffer,
Line::from(vec![Span::styled(
koda_core::truncate::separator(hidden, total),
DIM,
)]),
);
for line in &tail {
render_line(buffer, line, &mut highlighter);
}
}
}
}
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
}
fn render_list_line(buffer: &mut ScrollBuffer, line: &str) {
let is_dir = line.starts_with("d ");
let path_str = if is_dir {
&line[2..]
} else {
line.trim_start()
};
let style = if is_dir {
Style::default().add_modifier(ratatui::style::Modifier::BOLD)
} else {
let ext = std::path::Path::new(path_str)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"rs" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "rb" | "java" | "c" | "cpp"
| "h" | "cs" | "swift" | "kt" => Style::default().fg(Color::Green),
"toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" => {
Style::default().fg(Color::Yellow)
}
"md" | "txt" | "rst" | "adoc" => Style::default().fg(Color::White),
"lock" | "sum" => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Reset),
}
};
let prefix = if is_dir { "\u{1f4c1} " } else { " " };
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::raw(prefix),
Span::styled(path_str.to_string(), style),
]),
);
}
fn render_grep_line(buffer: &mut ScrollBuffer, line: &str) {
if let Some((file_and_line, content)) = line.split_once(':').and_then(|(file, rest)| {
rest.split_once(':')
.map(|(lineno, content)| (format!("{file}:{lineno}"), content))
}) {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(file_and_line, Style::default().fg(Color::Cyan)),
Span::styled(":", DIM),
Span::raw(content.to_string()),
]),
);
} else {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::raw(line.to_string()),
]),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
}