use std::io::{BufRead, Write};
use std::process::ExitCode;
use serde_json::Value;
pub fn handle_agent_stream() -> ExitCode {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut out = stdout.lock();
for line in stdin.lock().lines() {
let Ok(line) = line else { continue };
if line.is_empty() {
continue;
}
let trimmed = line.trim();
if !trimmed.starts_with('{') {
let _ = writeln!(out, "{line}");
let _ = out.flush();
continue;
}
let Ok(val) = serde_json::from_str::<Value>(trimmed) else {
let _ = writeln!(out, "{line}");
let _ = out.flush();
continue;
};
render_event(&val, &mut out);
let _ = out.flush();
}
ExitCode::SUCCESS
}
const TOOL_MARKER: &str = "⏺";
const RESULT_PREFIX: &str = " ⎿ ";
const INDENT_CONTENT: &str = " ";
fn render_event<W: Write>(val: &Value, out: &mut W) {
let event_type = val.get("type").and_then(Value::as_str).unwrap_or("");
match event_type {
"system" => render_system(val, out),
"assistant" => render_assistant(val, out),
"user" => render_user_tool_result(val, out),
"result" => render_result(val, out),
"stream_event" => render_stream_event(val, out),
_ => {
}
}
}
fn render_system<W: Write>(val: &Value, out: &mut W) {
if val.get("subtype").and_then(Value::as_str) == Some("init") {
let model = val
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown-model");
let _ = writeln!(out, "{TOOL_MARKER} session started · model: {model}");
}
}
fn render_assistant<W: Write>(val: &Value, out: &mut W) {
let Some(content) = val
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
else {
return;
};
for block in content {
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(Value::as_str) {
if !text.is_empty() {
write_text_with_diff_coloring(text, out);
}
}
}
"tool_use" => {
render_tool_use_block(block, out);
}
"thinking" => {
let _ = writeln!(out, "{TOOL_MARKER} (thinking…)");
}
_ => {}
}
}
}
fn render_tool_use_block<W: Write>(block: &Value, out: &mut W) {
let name = block.get("name").and_then(Value::as_str).unwrap_or("?");
let input = block.get("input");
match name {
"Edit" => render_file_edit(input, out),
"MultiEdit" => render_multi_edit(input, out),
"Write" => render_file_write(input, out),
"Read" => render_single_path(input, "Read", out),
"NotebookEdit" => render_single_path(input, "Edit Notebook", out),
"Bash" => render_bash(input, out),
"Glob" | "Grep" => render_search(input, out),
"WebFetch" => render_single_arg(input, "Fetch", "url", out),
"WebSearch" => render_single_arg(input, "Web Search", "query", out),
"LSPTool" | "LSP" => render_single_arg(input, "LSP", "operation", out),
"Skill" => render_skill(input, out),
"Task" | "Agent" => render_task_agent(input, out),
"TodoWrite" => render_todowrite(input, out),
"TaskCreate" => render_task_create(input, out),
"TaskUpdate" => render_task_update(input, out),
"TaskGet" | "TaskStop" | "TaskOutput" => render_single_arg(input, name, "taskId", out),
"TaskList" => {
let _ = writeln!(out, "{TOOL_MARKER} {name}");
}
other => {
let summary = summarize_tool_input(other, input);
let _ = writeln!(out, "{TOOL_MARKER} {other}{summary}");
}
}
}
fn write_header_path<W: Write>(verb: &str, path: &str, out: &mut W) {
if path.is_empty() {
let _ = writeln!(out, "{TOOL_MARKER} {verb}");
} else {
let _ = writeln!(out, "{TOOL_MARKER} {verb}({path})");
}
}
fn render_single_path<W: Write>(input: Option<&Value>, verb: &str, out: &mut W) {
let path = input
.and_then(|i| i.get("file_path"))
.and_then(Value::as_str)
.map(to_display_path)
.unwrap_or_default();
write_header_path(verb, &path, out);
}
fn render_single_arg<W: Write>(input: Option<&Value>, verb: &str, field: &str, out: &mut W) {
let arg = input
.and_then(|i| i.get(field))
.and_then(Value::as_str)
.map(truncate_one_line)
.unwrap_or_default();
write_header_path(verb, &arg, out);
}
fn render_bash<W: Write>(input: Option<&Value>, out: &mut W) {
let cmd = input
.and_then(|i| i.get("command"))
.and_then(Value::as_str)
.unwrap_or("");
let preview = truncate_bash_command(cmd);
write_header_path("Bash", &preview, out);
}
fn render_task_create<W: Write>(input: Option<&Value>, out: &mut W) {
let subject = input
.and_then(|i| i.get("subject"))
.and_then(Value::as_str)
.map(truncate_one_line)
.unwrap_or_default();
write_header_path("TaskCreate", &subject, out);
}
fn render_task_update<W: Write>(input: Option<&Value>, out: &mut W) {
let Some(input) = input else {
let _ = writeln!(out, "{TOOL_MARKER} TaskUpdate");
return;
};
let task_id = input.get("taskId").and_then(Value::as_str).unwrap_or("");
let status = input.get("status").and_then(Value::as_str);
let owner = input.get("owner").and_then(Value::as_str);
let subject = input.get("subject").and_then(Value::as_str);
let detail = match (status, owner, subject) {
(Some(s), _, _) if !s.is_empty() => format!("{task_id} → {s}"),
(_, Some(o), _) if !o.is_empty() => format!("{task_id} owner={o}"),
(_, _, Some(sub)) if !sub.is_empty() => format!("{task_id}: {sub}"),
_ => task_id.to_string(),
};
write_header_path("TaskUpdate", &truncate_one_line(detail), out);
}
fn render_search<W: Write>(input: Option<&Value>, out: &mut W) {
let pattern = input
.and_then(|i| i.get("pattern"))
.and_then(Value::as_str)
.map(truncate_one_line)
.unwrap_or_default();
write_header_path("Search", &pattern, out);
}
fn render_skill<W: Write>(input: Option<&Value>, out: &mut W) {
let name = input
.and_then(|i| i.get("skill"))
.and_then(Value::as_str)
.unwrap_or("");
let args = input
.and_then(|i| i.get("args"))
.and_then(Value::as_str)
.unwrap_or("");
let label = match (name.is_empty(), args.is_empty()) {
(true, _) => String::new(),
(false, true) => name.to_string(),
(false, false) => truncate_one_line(format!("{name} {args}")),
};
write_header_path("Skill", &label, out);
}
fn render_task_agent<W: Write>(input: Option<&Value>, out: &mut W) {
let desc = input
.and_then(|i| i.get("description"))
.and_then(Value::as_str)
.map(truncate_one_line)
.unwrap_or_default();
let subtype = input
.and_then(|i| i.get("subagent_type"))
.and_then(Value::as_str)
.unwrap_or("Agent");
if desc.is_empty() {
let _ = writeln!(out, "{TOOL_MARKER} {subtype}");
} else {
let _ = writeln!(out, "{TOOL_MARKER} {subtype} · {desc}");
}
}
fn render_todowrite<W: Write>(input: Option<&Value>, out: &mut W) {
let first = input
.and_then(|i| i.get("todos"))
.and_then(Value::as_array)
.and_then(|v| v.first())
.and_then(|t| t.get("content"))
.and_then(Value::as_str)
.map(truncate_one_line)
.unwrap_or_default();
if first.is_empty() {
let _ = writeln!(out, "{TOOL_MARKER} TodoWrite");
} else {
let _ = writeln!(out, "{TOOL_MARKER} TodoWrite · {first}");
}
}
fn render_file_edit<W: Write>(input: Option<&Value>, out: &mut W) {
let Some(input) = input else {
let _ = writeln!(out, "{TOOL_MARKER} Update");
return;
};
let path = input
.get("file_path")
.and_then(Value::as_str)
.map(to_display_path)
.unwrap_or_default();
let old_s = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new_s = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("");
let verb = if old_s.is_empty() { "Create" } else { "Update" };
let added = line_count(new_s);
let removed = line_count(old_s);
write_header_path(verb, &path, out);
write_diff_stats_line(added, removed, out);
let shown = write_colored_diff(old_s, new_s, MAX_PREVIEW_LINES, out);
let total = added + removed;
if total > shown {
let remaining = total - shown;
let word = if remaining == 1 { "line" } else { "lines" };
let _ = writeln!(
out,
"{INDENT_CONTENT}{ANSI_DIM}… +{remaining} {word}{ANSI_RESET}"
);
}
}
fn render_multi_edit<W: Write>(input: Option<&Value>, out: &mut W) {
let Some(input) = input else {
let _ = writeln!(out, "{TOOL_MARKER} Update");
return;
};
let path = input
.get("file_path")
.and_then(Value::as_str)
.map(to_display_path)
.unwrap_or_default();
let edits = input.get("edits").and_then(Value::as_array);
let mut added = 0usize;
let mut removed = 0usize;
let mut all_creates = true;
if let Some(edits) = edits {
for edit in edits {
let old_s = edit.get("old_string").and_then(Value::as_str).unwrap_or("");
let new_s = edit.get("new_string").and_then(Value::as_str).unwrap_or("");
if !old_s.is_empty() {
all_creates = false;
}
added += line_count(new_s);
removed += line_count(old_s);
}
}
let verb = if all_creates { "Create" } else { "Update" };
write_header_path(verb, &path, out);
write_diff_stats_line(added, removed, out);
if let Some(edits) = edits {
let mut budget = MAX_PREVIEW_LINES;
for edit in edits {
if budget == 0 {
break;
}
let old_s = edit.get("old_string").and_then(Value::as_str).unwrap_or("");
let new_s = edit.get("new_string").and_then(Value::as_str).unwrap_or("");
let used = write_colored_diff(old_s, new_s, budget, out);
budget = budget.saturating_sub(used);
}
let total = added + removed;
let shown = MAX_PREVIEW_LINES - budget;
if total > shown {
let remaining = total - shown;
let word = if remaining == 1 { "line" } else { "lines" };
let _ = writeln!(
out,
"{INDENT_CONTENT}{ANSI_DIM}… +{remaining} {word}{ANSI_RESET}"
);
}
}
}
fn render_file_write<W: Write>(input: Option<&Value>, out: &mut W) {
let Some(input) = input else {
let _ = writeln!(out, "{TOOL_MARKER} Write");
return;
};
let path = input
.get("file_path")
.and_then(Value::as_str)
.map(to_display_path)
.unwrap_or_default();
let content = input.get("content").and_then(Value::as_str).unwrap_or("");
let total = line_count(content);
write_header_path("Write", &path, out);
if total > 0 {
let word = if total == 1 { "line" } else { "lines" };
let _ = writeln!(out, "{INDENT_CONTENT}Wrote {total} {word} to {path}");
}
write_content_preview(content, MAX_PREVIEW_LINES, out);
}
const ANSI_CYAN: &str = "\x1b[36m";
const ANSI_BOLD: &str = "\x1b[1m";
const MAX_PREVIEW_LINES: usize = 10;
fn write_text_with_diff_coloring<W: Write>(text: &str, out: &mut W) {
let has_diff = text.contains("diff --git") || text.contains("@@") || text.contains("```diff");
if !has_diff {
let _ = writeln!(out, "{text}");
return;
}
let mut in_diff_block = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == "```diff" {
in_diff_block = true;
let _ = writeln!(out, "{line}");
continue;
}
if trimmed == "```" && in_diff_block {
in_diff_block = false;
let _ = writeln!(out, "{line}");
continue;
}
if line.starts_with("diff --git") {
in_diff_block = true;
}
if in_diff_block {
write_colored_diff_line(line, out);
} else {
let _ = writeln!(out, "{line}");
}
}
}
fn write_colored_diff_line<W: Write>(line: &str, out: &mut W) {
if line.starts_with("diff --git")
|| line.starts_with("index ")
|| line.starts_with("--- ")
|| line.starts_with("+++ ")
{
let _ = writeln!(out, "{ANSI_BOLD}{line}{ANSI_RESET}");
} else if line.starts_with("@@") {
let _ = writeln!(out, "{ANSI_CYAN}{line}{ANSI_RESET}");
} else if line.starts_with('+') {
let _ = writeln!(out, "{ANSI_GREEN}{line}{ANSI_RESET}");
} else if line.starts_with('-') {
let _ = writeln!(out, "{ANSI_RED}{line}{ANSI_RESET}");
} else {
let _ = writeln!(out, "{line}");
}
}
const ANSI_GREEN: &str = "\x1b[32m";
const ANSI_RED: &str = "\x1b[31m";
const ANSI_DIM: &str = "\x1b[2m";
const ANSI_RESET: &str = "\x1b[0m";
fn write_diff_stats_line<W: Write>(added: usize, removed: usize, out: &mut W) {
let pluralize = |n: usize| if n == 1 { "line" } else { "lines" };
match (added, removed) {
(0, 0) => {}
(a, 0) => {
let _ = writeln!(out, "{INDENT_CONTENT}Added {a} {}", pluralize(a));
}
(0, r) => {
let _ = writeln!(out, "{INDENT_CONTENT}Removed {r} {}", pluralize(r));
}
(a, r) => {
let _ = writeln!(
out,
"{INDENT_CONTENT}Added {a} {}, removed {r} {}",
pluralize(a),
pluralize(r)
);
}
}
}
fn write_content_preview<W: Write>(text: &str, max_lines: usize, out: &mut W) {
let lines: Vec<&str> = text.lines().collect();
let total = lines.len();
let visible = total.min(max_lines);
for line in &lines[..visible] {
let display = truncate_char_width(line.trim_end(), 120);
let _ = writeln!(out, "{INDENT_CONTENT}{display}");
}
if total > visible {
let remaining = total - visible;
let word = if remaining == 1 { "line" } else { "lines" };
let _ = writeln!(
out,
"{INDENT_CONTENT}{ANSI_DIM}… +{remaining} {word}{ANSI_RESET}"
);
}
}
fn write_colored_diff<W: Write>(old: &str, new: &str, max_lines: usize, out: &mut W) -> usize {
let old_lines: Vec<&str> = if old.is_empty() {
Vec::new()
} else {
old.lines().collect()
};
let new_lines: Vec<&str> = if new.is_empty() {
Vec::new()
} else {
new.lines().collect()
};
let total_diff = old_lines.len() + new_lines.len();
if total_diff == 0 {
return 0;
}
let max_lineno = old_lines.len().max(new_lines.len());
let gutter_width = if max_lineno >= 100 {
3
} else if max_lineno >= 10 {
2
} else {
1
};
let mut shown = 0;
for (i, line) in old_lines.iter().enumerate() {
if shown >= max_lines {
break;
}
let lineno = i + 1;
let display = truncate_char_width(line.trim_end(), 120);
let _ = writeln!(
out,
"{INDENT_CONTENT}{ANSI_DIM}{lineno:>gutter_width$}{ANSI_RESET} {ANSI_RED}- {display}{ANSI_RESET}",
gutter_width = gutter_width
);
shown += 1;
}
for (i, line) in new_lines.iter().enumerate() {
if shown >= max_lines {
break;
}
let lineno = i + 1;
let display = truncate_char_width(line.trim_end(), 120);
let _ = writeln!(
out,
"{INDENT_CONTENT}{ANSI_DIM}{lineno:>gutter_width$}{ANSI_RESET} {ANSI_GREEN}+ {display}{ANSI_RESET}",
gutter_width = gutter_width
);
shown += 1;
}
shown
}
fn truncate_char_width(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let cut: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{cut}…")
}
fn truncate_one_line<S: AsRef<str>>(s: S) -> String {
let first: String = s.as_ref().lines().next().unwrap_or("").trim().to_string();
truncate_char_width(&first, 160)
}
fn truncate_bash_command(cmd: &str) -> String {
let max_chars = 100;
let first = cmd.lines().next().unwrap_or("").trim();
if first.chars().count() > max_chars {
let cut: String = first.chars().take(max_chars.saturating_sub(1)).collect();
format!("{cut}…")
} else if cmd.lines().count() > 1 {
format!("{first}…")
} else {
first.to_string()
}
}
fn render_user_tool_result<W: Write>(val: &Value, out: &mut W) {
let Some(content) = val
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
else {
return;
};
for block in content {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let is_error = block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
if is_error {
let first = block
.get("content")
.and_then(Value::as_str)
.or_else(|| {
block
.get("content")
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|v| v.get("text"))
.and_then(Value::as_str)
})
.map(truncate_one_line)
.unwrap_or_default();
if first.is_empty() {
let _ = writeln!(out, "{RESULT_PREFIX}tool error");
} else {
let _ = writeln!(out, "{RESULT_PREFIX}tool error: {first}");
}
}
}
}
fn render_result<W: Write>(val: &Value, out: &mut W) {
let subtype = val.get("subtype").and_then(Value::as_str).unwrap_or("");
if subtype == "error_max_turns"
|| subtype == "error_during_execution"
|| val.get("is_error").and_then(Value::as_bool) == Some(true)
{
let _ = writeln!(out, "{RESULT_PREFIX}run ended with error ({subtype})");
}
}
fn render_stream_event<W: Write>(val: &Value, out: &mut W) {
let Some(event) = val.get("event") else {
return;
};
let ev_type = event.get("type").and_then(Value::as_str).unwrap_or("");
match ev_type {
"content_block_delta" => {
let delta = event.get("delta");
if let Some(delta) = delta {
if delta.get("type").and_then(Value::as_str) == Some("text_delta") {
if let Some(text) = delta.get("text").and_then(Value::as_str) {
let _ = write!(out, "{text}");
}
}
}
}
"content_block_start" => {
if let Some(block) = event.get("content_block") {
if block.get("type").and_then(Value::as_str) == Some("tool_use") {
let name = block.get("name").and_then(Value::as_str).unwrap_or("?");
let _ = writeln!(out, "\n{TOOL_MARKER} {name}");
}
}
}
"content_block_stop" | "message_stop" => {
let _ = writeln!(out);
}
_ => {}
}
}
fn to_display_path(path: &str) -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Some(cwd_str) = cwd.to_str() {
let prefix = format!("{cwd_str}/");
if let Some(rest) = path.strip_prefix(&prefix) {
return rest.to_string();
}
}
}
if let Ok(home) = std::env::var("HOME") {
let prefix = format!("{home}/");
if let Some(rest) = path.strip_prefix(&prefix) {
return format!("~/{rest}");
}
}
path.to_string()
}
fn line_count(s: &str) -> usize {
s.lines().count()
}
fn summarize_tool_input(_tool: &str, _input: Option<&Value>) -> String {
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn render_to_string(line: &str) -> String {
let val: Value = serde_json::from_str(line).unwrap();
let mut buf = Vec::new();
render_event(&val, &mut buf);
String::from_utf8(buf).unwrap()
}
#[test]
fn renders_system_init_with_model() {
let line = r#"{"type":"system","subtype":"init","model":"claude-opus-4-6"}"#;
let out = render_to_string(line);
assert!(out.contains("claude-opus-4-6"), "got: {out}");
assert!(out.contains("session started"), "got: {out}");
}
#[test]
fn renders_assistant_text_block() {
let line =
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"#;
assert!(render_to_string(line).contains("Hello world"));
}
#[test]
fn renders_read_tool_as_read_with_path() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"/tmp/foo.rs"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains(TOOL_MARKER), "missing ⏺ marker; got: {out}");
assert!(out.contains("Read(/tmp/foo.rs)"), "got: {out}");
}
#[test]
fn notebook_edit_uses_edit_notebook_verb() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"NotebookEdit","input":{"file_path":"/tmp/nb.ipynb"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Edit Notebook(/tmp/nb.ipynb)"), "got: {out}");
}
#[test]
fn edit_shows_update_header_with_native_stats_and_preview() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/tmp/foo.rs","old_string":"fn bar() {}","new_string":"fn bar() {\n println!(\"hi\");\n}"}}]}}"#;
let out = render_to_string(line);
assert!(
out.contains("Update(/tmp/foo.rs)"),
"header missing; got: {out}"
);
assert!(
out.contains("Added 3 lines, removed 1 line"),
"native stats missing; got: {out}"
);
assert!(out.contains("fn bar()"), "preview missing; got: {out}");
}
#[test]
fn edit_with_empty_old_string_shows_create() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/tmp/new.rs","old_string":"","new_string":"fn main() {}"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Create(/tmp/new.rs)"), "got: {out}");
assert!(out.contains("Added 1 line"), "got: {out}");
assert!(
!out.contains("removed"),
"no removed for create; got: {out}"
);
}
#[test]
fn multi_edit_aggregates_stats_and_uses_update_header() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"MultiEdit","input":{"file_path":"/tmp/x.rs","edits":[{"old_string":"a","new_string":"a\nb"},{"old_string":"c","new_string":"c\nd\ne"}]}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Update(/tmp/x.rs)"), "got: {out}");
assert!(out.contains("Added 5 lines, removed 2 lines"), "got: {out}");
}
#[test]
fn write_uses_write_header_and_shows_line_count() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"/tmp/new.rs","content":"line 1\nline 2\nline 3\n"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Write(/tmp/new.rs)"), "got: {out}");
assert!(out.contains("Wrote 3 lines"), "got: {out}");
assert!(out.contains("line 1"), "preview missing; got: {out}");
}
#[test]
fn singular_vs_plural_line_phrasing() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/tmp/x.rs","old_string":"a","new_string":"a\nb"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Added 2 lines, removed 1 line"), "got: {out}");
}
#[test]
fn preview_truncates_long_lines() {
let long_line = "a".repeat(200);
let body = format!("fn f() {{\n {long_line}\n}}");
let input = serde_json::json!({
"type": "assistant",
"message": {
"content": [{
"type": "tool_use",
"name": "Edit",
"input": {
"file_path": "/tmp/x.rs",
"old_string": "fn f() {}",
"new_string": body,
}
}]
}
});
let out = render_to_string(&input.to_string());
assert!(out.contains("…"), "long line must be truncated; got: {out}");
assert!(
!out.contains(&"a".repeat(150)),
"truncation must cap well below 200 chars; got: {out}"
);
}
#[test]
fn to_display_path_converts_when_under_cwd() {
if let Ok(cwd) = std::env::current_dir() {
let absolute = cwd.join("sub/path.rs");
let rel = super::to_display_path(absolute.to_str().unwrap());
assert_eq!(rel, "sub/path.rs");
}
}
#[test]
fn to_display_path_uses_tilde_for_home() {
if let Ok(home) = std::env::var("HOME") {
let path = format!("{home}/Documents/project");
assert_eq!(super::to_display_path(&path), "~/Documents/project");
}
}
#[test]
fn to_display_path_passes_through_when_outside_cwd_and_home() {
let out = super::to_display_path("/tmp/unrelated/foo.rs");
assert!(
out == "/tmp/unrelated/foo.rs" || out.starts_with('~'),
"got: {out}"
);
}
#[test]
fn renders_bash_with_bash_parens_prefix() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains(TOOL_MARKER), "got: {out}");
assert!(out.contains("Bash(cargo test)"), "got: {out}");
}
#[test]
fn renders_task_create_with_subject() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TaskCreate","input":{"subject":"Fix login bug","description":"..."}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("TaskCreate(Fix login bug)"), "got: {out}");
}
#[test]
fn renders_task_update_with_status() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TaskUpdate","input":{"taskId":"42","status":"completed"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("TaskUpdate(42 → completed)"), "got: {out}");
}
#[test]
fn renders_task_update_owner_when_no_status() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TaskUpdate","input":{"taskId":"7","owner":"reviewer"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("TaskUpdate(7 owner=reviewer)"), "got: {out}");
}
#[test]
fn renders_task_list_header_only() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TaskList","input":{}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("TaskList"), "got: {out}");
assert!(
!out.contains("TaskList("),
"no args for TaskList; got: {out}"
);
}
#[test]
fn renders_task_stop_with_id() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TaskStop","input":{"taskId":"bg123"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("TaskStop(bg123)"), "got: {out}");
}
#[test]
fn renders_glob_and_grep_as_search() {
let glob = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Glob","input":{"pattern":"**/*.rs"}}]}}"#;
assert!(render_to_string(glob).contains("Search(**/*.rs)"));
let grep = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"TODO"}}]}}"#;
assert!(render_to_string(grep).contains("Search(TODO)"));
}
#[test]
fn renders_webfetch_as_fetch() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"WebFetch","input":{"url":"https://example.com"}}]}}"#;
assert!(render_to_string(line).contains("Fetch(https://example.com)"));
}
#[test]
fn renders_skill_tool_with_name() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"lt.review"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Skill(lt.review)"), "got: {out}");
}
#[test]
fn renders_skill_tool_with_name_and_args() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"pdf","args":"report.pdf"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("Skill(pdf report.pdf)"), "got: {out}");
}
#[test]
fn renders_task_agent_with_description_and_subtype() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Task","input":{"description":"Audit auth module","subagent_type":"security-reviewer"}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("security-reviewer"), "got: {out}");
assert!(out.contains("Audit auth module"), "got: {out}");
}
#[test]
fn renders_todowrite_first_item() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"content":"Fix bug in auth handler","status":"pending"}]}}]}}"#;
let out = render_to_string(line);
assert!(out.contains("TodoWrite"), "got: {out}");
assert!(out.contains("Fix bug in auth handler"), "got: {out}");
}
#[test]
fn truncates_long_bash_command() {
let long = "a".repeat(200);
let line = format!(
r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","name":"Bash","input":{{"command":"{long}"}}}}]}}}}"#
);
let out = render_to_string(&line);
assert!(out.contains("…"), "got: {out}");
assert!(!out.contains(&"a".repeat(170)), "got: {out}");
}
#[test]
fn tool_result_silent_on_success_shows_reason_on_error() {
let ok = r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"file saved"}]}}"#;
assert_eq!(render_to_string(ok), "");
let err = r#"{"type":"user","message":{"content":[{"type":"tool_result","is_error":true,"content":"permission denied"}]}}"#;
let out = render_to_string(err);
assert!(out.contains("⎿"), "must use ⎿ marker; got: {out}");
assert!(out.contains("permission denied"), "got: {out}");
}
#[test]
fn thinking_block_shows_marker_not_content() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"secret internal reasoning ..."}]}}"#;
let out = render_to_string(line);
assert!(out.contains("(thinking…)"), "got: {out}");
assert!(!out.contains("secret internal reasoning"), "got: {out}");
}
#[test]
fn stream_event_text_delta_is_appended_inline() {
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}"#;
let out = render_to_string(line);
assert_eq!(out, "Hel");
}
#[test]
fn result_error_event_shows_failure() {
let line = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
let out = render_to_string(line);
assert!(out.contains("error"), "got: {out}");
assert!(out.contains("max_turns"), "got: {out}");
}
#[test]
fn successful_result_is_silent() {
let line = r#"{"type":"result","subtype":"success","result":"done"}"#;
assert_eq!(render_to_string(line), "");
}
}