1use 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
17pub struct TuiRenderer {
19 pub tool_history: crate::tool_history::ToolOutputHistory,
21 pub verbose: bool,
23 pub last_turn_stats: Option<TurnStats>,
25 pub model: String,
27 text_buf: String,
29 think_buf: String,
31 pub preview_shown: bool,
33 has_emitted_text: bool,
35 response_started: bool,
37 md: crate::md_render::MarkdownRenderer,
39 pending_tool_args: HashMap<String, (String, String)>,
42 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 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 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 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 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 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 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 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 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 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 }
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 }
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 #[allow(dead_code)]
314 pub fn stop_spinner(&mut self) {}
315}
316
317fn 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
359fn 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 let collapsed = collapse_blank_lines(output);
385 let output = &collapsed;
386
387 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 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 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
479fn 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
504fn 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 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
547fn render_grep_line(buffer: &mut ScrollBuffer, line: &str) {
552 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 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}