diff --git a/src/ui/chat/tool_renderers.rs b/src/ui/chat/tool_renderers.rs new file mode 100644 index 0000000..97f9236 --- /dev/null +++ b/src/ui/chat/tool_renderers.rs @@ -0,0 +1,789 @@ +//! Per-tool-type rendering for tool cards. +//! +//! Pure functions dispatched by tool name so compact args, result previews, +//! and full card bodies are semantically meaningful rather than generic +//! key:value dumps. + +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use serde_json::Value; + +use super::ChatPalette; + +// ─── Helpers ────────────────────────────────────────────────────────── + +pub(crate) fn clip(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out +} + +fn json_str<'a>(map: &'a serde_json::Map, key: &str) -> Option<&'a str> { + map.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) +} + +fn json_bool(map: &serde_json::Map, key: &str) -> Option { + map.get(key).and_then(|v| v.as_bool()) +} + +fn json_u64(map: &serde_json::Map, key: &str) -> Option { + map.get(key).and_then(|v| v.as_u64()) +} + +// ─── Tier 1: Per-tool argument summarization ────────────────────────── + +/// Format tool arguments into a short human-readable string for compact cards. +pub fn summarize_tool_args(name: &str, arguments: &str) -> String { + let parsed: Result = serde_json::from_str(arguments); + let map = match parsed { + Ok(Value::Object(m)) => m, + Ok(other) => return clip(&other.to_string(), 120), + Err(_) => return clip(arguments, 120), + }; + match name { + "bash" => summarize_bash(&map), + "read" => summarize_read(&map), + "write" => summarize_write(&map), + "edit" => summarize_edit(&map), + "grep" => summarize_grep(&map), + "glob" => summarize_glob(&map), + "list_dir" => summarize_list_dir(&map), + "todo" => summarize_todo(&map), + "subagent" => summarize_subagent(&map), + "atmosphere" => summarize_atmosphere(&map), + "outfit" => summarize_outfit(&map), + "itinerary" => summarize_itinerary(&map), + "schedule" => summarize_schedule(&map), + "memory" => summarize_memory(&map), + "nickname" => summarize_nickname(&map), + "reach" | "consult" => summarize_summon(&map), + _ => summarize_generic(&map), + } +} + +fn summarize_bash(map: &serde_json::Map) -> String { + match json_str(map, "command") { + Some(cmd) => { + let cmd = clip(cmd, 80); + let extra = if json_bool(map, "run_in_background").unwrap_or(false) { + " [bg]" + } else { + "" + }; + format!("$ {}{}", cmd, extra) + } + None => summarize_generic(map), + } +} + +fn summarize_read(map: &serde_json::Map) -> String { + match json_str(map, "path") { + Some(p) => { + let p = clip(p, 60); + let extra = if json_bool(map, "force").unwrap_or(false) { + " [force]" + } else { + "" + }; + format!("{}{}", p, extra) + } + None => summarize_generic(map), + } +} + +fn summarize_write(map: &serde_json::Map) -> String { + match json_str(map, "path") { + Some(p) => { + let p = clip(p, 50); + let mode = json_str(map, "mode").unwrap_or("write"); + // Estimate content length from the content field + let content_len = map + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.len()); + let label = if mode == "append" { "append" } else { "write" }; + match content_len { + Some(n) if n > 0 => format!("{} → {} ({}B)", label, p, n), + _ => format!("{} → {}", label, p), + } + } + None => summarize_generic(map), + } +} + +fn summarize_edit(map: &serde_json::Map) -> String { + let path = json_str(map, "path").map(|p| clip(p, 40)).unwrap_or_default(); + let old = json_str(map, "old_string").map(|s| clip(s, 30)).unwrap_or_default(); + let all = json_bool(map, "replace_all").unwrap_or(false); + if path.is_empty() { + return summarize_generic(map); + } + if !old.is_empty() { + if all { + format!("{}: replace_all \"{}\"", path, old) + } else { + format!("{}: \"{}\"", path, old) + } + } else { + path + } +} + +fn summarize_grep(map: &serde_json::Map) -> String { + let pattern = json_str(map, "pattern").map(|p| clip(p, 40)).unwrap_or_default(); + let path = json_str(map, "path").filter(|p| *p != "."); + let context = json_u64(map, "context").unwrap_or(0); + let mut out = if pattern.is_empty() { + return summarize_generic(map); + } else { + format!("\"{}\"", pattern) + }; + if let Some(p) = path { + out.push_str(&format!(" {}", clip(p, 30))); + } + if context > 0 { + out.push_str(&format!(" -C {}", context)); + } + out +} + +fn summarize_glob(map: &serde_json::Map) -> String { + let pattern = json_str(map, "pattern").map(|p| clip(p, 50)).unwrap_or_default(); + if pattern.is_empty() { + return summarize_generic(map); + } + let base = json_str(map, "base"); + match base { + Some(b) if b != "." => format!("{} (in {})", pattern, clip(b, 30)), + _ => pattern, + } +} + +fn summarize_list_dir(map: &serde_json::Map) -> String { + json_str(map, "path") + .map(|p| clip(p, 50)) + .unwrap_or_else(|| ".".to_string()) +} + +fn summarize_todo(map: &serde_json::Map) -> String { + let action = json_str(map, "action").unwrap_or("list"); + if action == "list" { + return "list".to_string(); + } + let text = json_str(map, "text") + .or_else(|| json_str(map, "id")) + .map(|s| clip(s, 50)); + match text { + Some(t) => format!("{}: {}", action, t), + None => action.to_string(), + } +} + +fn summarize_subagent(map: &serde_json::Map) -> String { + let prompt = json_str(map, "prompt").map(|s| clip(s, 50)).unwrap_or_default(); + let sub_type = json_str(map, "subagent_type").filter(|t| *t != "general-purpose"); + let bg = json_bool(map, "run_in_background").unwrap_or(false); + let mut out = String::new(); + if bg { + out.push_str("[bg] "); + } + if let Some(t) = sub_type { + out.push_str(&format!("[{}] ", t)); + } + if !prompt.is_empty() { + out.push_str(&prompt); + } + if out.is_empty() { + return summarize_generic(map); + } + out +} + +fn summarize_atmosphere(map: &serde_json::Map) -> String { + json_str(map, "name") + .map(|n| n.to_string()) + .unwrap_or_else(|| summarize_generic(map)) +} + +fn summarize_outfit(map: &serde_json::Map) -> String { + json_str(map, "name") + .map(|n| n.to_string()) + .unwrap_or_else(|| summarize_generic(map)) +} + +fn summarize_itinerary(map: &serde_json::Map) -> String { + let action = json_str(map, "action").unwrap_or("describe"); + let title = json_str(map, "title") + .or_else(|| { + // Try first stop from `stops` array + map.get("stops") + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + }) + .map(|s| clip(s, 40)); + match title { + Some(t) => format!("{}: {}", action, t), + None => action.to_string(), + } +} + +fn summarize_schedule(map: &serde_json::Map) -> String { + let action = json_str(map, "action").unwrap_or("list"); + let name = json_str(map, "name").map(|n| clip(n, 30)); + match name { + Some(n) => format!("{}: {}", action, n), + None => action.to_string(), + } +} + +fn summarize_memory(map: &serde_json::Map) -> String { + let cmd = json_str(map, "command").unwrap_or("list"); + let path = json_str(map, "path").map(|p| clip(p, 40)); + match path { + Some(p) => format!("{} {}", cmd, p), + None => cmd.to_string(), + } +} + +fn summarize_nickname(map: &serde_json::Map) -> String { + let action = json_str(map, "action").unwrap_or("get"); + let name = json_str(map, "name").map(|n| clip(n, 30)); + match name { + Some(n) => format!("{}: {}", action, n), + None => action.to_string(), + } +} + +fn summarize_summon(map: &serde_json::Map) -> String { + let target = json_str(map, "target").map(|t| clip(t, 40)).unwrap_or_default(); + if target.is_empty() { + return summarize_generic(map); + } + target +} + +fn summarize_generic(map: &serde_json::Map) -> String { + let parts: Vec = map + .iter() + .map(|(k, v)| { + let s = match v { + Value::String(s) => clip(s, 60), + other => clip(&other.to_string(), 60), + }; + format!("{}: {}", k, s) + }) + .collect(); + parts.join(" · ") +} + +// ─── Tier 2: Compact result details ─────────────────────────────────── + +/// Return an optional second-line detail for the compact card. +/// For completed tools this can show a one-line output preview (e.g. the +/// last line of a bash run, the match count for grep). For errors it is +/// always the first error line. `None` means no detail line. +pub fn compact_result_details(name: &str, output: &str, is_error: bool) -> Option { + if is_error { + return output.lines().next().map(|l| clip(l.trim(), 80)); + } + match name { + "bash" => { + // Last non-empty line of output + output + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .map(|l| clip(l.trim(), 80)) + } + "grep" => { + // First line often says "N matches for pattern:" + output.lines().next().map(|l| clip(l.trim(), 80)) + } + "glob" => { + output.lines().next().map(|l| clip(l.trim(), 80)) + } + "edit" => { + // Edit output is a prose summary + output.lines().next().map(|l| clip(l.trim(), 80)) + } + _ => None, + } +} + +// ─── Tier 3: Full card body rendering ───────────────────────────────── + +/// Render the body of a full tool card. +/// +/// Returns `(args_lines, body_lines)` — the argument summary and the +/// output rendering, both ready to be inserted into the bubble layout. +pub fn render_card_body( + name: &str, + arguments: &str, + output: Option<&str>, + is_error: bool, + inner_width: usize, + palette: &ChatPalette, +) -> (Vec>, Vec>) { + let dim_color = if is_error { + palette.compaction + } else { + palette.tool_dim + }; + let fg_color = if is_error { + palette.compaction + } else { + palette.agent_primary + }; + let mdpal = crate::ui::markdown::MarkdownPalette::from_chat_palette(palette); + + // Argument line(s) + let args_summary = summarize_tool_args(name, arguments); + let args_line = Line::from(vec![Span::styled(args_summary, Style::default().fg(dim_color))]); + let args_lines = crate::ui::markdown::wrap_line(args_line, inner_width); + + // Body — per-tool output rendering + let body_lines = match (name, output) { + ("bash", Some(out)) if !out.is_empty() => render_bash_body(out, inner_width, palette), + ("read", Some(out)) if !out.is_empty() => render_read_body(out, arguments, inner_width, palette, &mdpal), + ("grep", Some(out)) if !out.is_empty() => render_grep_body(out, inner_width, palette, &mdpal), + ("glob", Some(out)) if !out.is_empty() => render_glob_body(out, inner_width, palette, &mdpal), + ("write", Some(out)) if !out.is_empty() => render_write_body(out, arguments, inner_width, palette), + ("edit", Some(out)) if !out.is_empty() => render_edit_body(out, arguments, inner_width, palette), + ("memory", Some(out)) => render_memory_body(out, inner_width, &mdpal, palette), + ("list_dir", Some(out)) if !out.is_empty() => render_list_dir_body(out, inner_width, palette), + _ => { + // Fallback: markdown-rendered preview (existing behavior) + if let Some(out) = output { + let preview = preview_lines(out, 12); + let rendered = crate::ui::markdown::render_with_width( + &preview, + fg_color, + Some(inner_width), + &mdpal, + ); + rendered + } else { + Vec::new() + } + } + }; + + (args_lines, body_lines) +} + +fn render_bash_body( + output: &str, + inner_width: usize, + palette: &ChatPalette, +) -> Vec> { + let dim = palette.tool_dim; + let mut lines: Vec> = Vec::new(); + + // ┌─ header + let top = format!("┌─ bash output "); + let top_dashes = inner_width.saturating_sub(top.chars().count()).max(0); + let top_line = Line::from(Span::styled( + format!("{}{}", top, "─".repeat(top_dashes)), + Style::default().fg(dim).add_modifier(Modifier::DIM), + )); + lines.push(top_line); + + // Content — up to 30 lines, monospace-style (plain spans) + let max_lines = 30usize; + let displayed: Vec<&str> = output.lines().take(max_lines).collect(); + for line_text in &displayed { + let clipped = clip(line_text, inner_width); + lines.push(Line::from(Span::styled( + format!(" {} ", clipped), + Style::default().fg(palette.tool_accent), + ))); + } + let total = output.lines().count(); + if total > max_lines { + lines.push(Line::from(Span::styled( + format!(" … ({} more lines)", total - max_lines), + Style::default().fg(dim).add_modifier(Modifier::ITALIC), + ))); + } + + // └─ footer + let bottom = format!("└{}", "─".repeat(inner_width.saturating_sub(1))); + lines.push(Line::from(Span::styled(bottom, Style::default().fg(dim).add_modifier(Modifier::DIM)))); + + lines +} + +fn render_read_body( + output: &str, + arguments: &str, + _inner_width: usize, + _palette: &ChatPalette, + mdpal: &crate::ui::markdown::MarkdownPalette, +) -> Vec> { + // For .md files, render as markdown. For everything else, render as + // a code block with guessed language from arguments. + let path = arguments_to_path(arguments); + let ext = path.rsplit('.').next().map(|s| s.to_lowercase()); + let is_markdown = matches!(ext.as_deref(), Some("md" | "mdx")); + let lang_label = match ext.as_deref() { + Some("rs") => "rust", + Some("py") => "python", + Some("js") | Some("ts") | Some("tsx") => ext.as_deref().unwrap_or("code"), + Some("toml") => "toml", + Some("json") => "json", + Some("yaml") | Some("yml") => "yaml", + Some("md") | Some("mdx") => "markdown", + Some(other) => other, + None => "code", + }; + + if is_markdown { + // Full markdown render for prose files + let fg = mdpal.code_fg; + let rendered = crate::ui::markdown::render_with_width(output, fg, None, mdpal); + return rendered; + } + + // Code block with language label + let fg = mdpal.code_fg; + let bg = mdpal.code_bg; + let code_style = Style::default().fg(fg).bg(bg); + let dim_style = Style::default().fg(mdpal.code_fg).add_modifier(Modifier::DIM); + + // This is a simplified code-block render that doesn't depend on the + // markdown renderer's internal ┌─ / └─ borders, since we want a + // monospace look in the tool card. + let mut lines: Vec> = Vec::new(); + let header = format!("┌─ {} ", lang_label); + let dashes = _inner_width.saturating_sub(header.chars().count()).max(0); + lines.push(Line::from(Span::styled( + format!("{}{}", header, "─".repeat(dashes)), + dim_style, + ))); + + let max_lines = 30usize; + let displayed: Vec<&str> = output.lines().take(max_lines).collect(); + for line_text in &displayed { + let clipped = clip(line_text, _inner_width.saturating_sub(2).max(10)); + lines.push(Line::from(Span::styled( + format!(" {}", clipped), + code_style, + ))); + } + let total = output.lines().count(); + if total > max_lines { + lines.push(Line::from(Span::styled( + format!(" … ({} more lines)", total - max_lines), + dim_style.add_modifier(Modifier::ITALIC), + ))); + } + lines.push(Line::from(Span::styled( + format!("└{}", "─".repeat(_inner_width.saturating_sub(1))), + dim_style, + ))); + + lines +} + +fn render_grep_body( + output: &str, + inner_width: usize, + palette: &ChatPalette, + mdpal: &crate::ui::markdown::MarkdownPalette, +) -> Vec> { + let dim = palette.tool_dim; + let mut lines: Vec> = Vec::new(); + + // First line is the match count header + if let Some(first) = output.lines().next() { + lines.push(Line::from(Span::styled( + clip(first.trim(), inner_width), + Style::default().fg(palette.tool_accent).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + "─".repeat(inner_width.min(40)), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + } + + // Match lines — cap at 20 + let max_lines = 20usize; + for line_text in output.lines().skip(1).take(max_lines) { + let clipped = clip(line_text.trim(), inner_width); + lines.push(Line::from(Span::styled( + clipped, + Style::default().fg(dim), + ))); + } + let total = output.lines().count().saturating_sub(1); + if total > max_lines { + lines.push(Line::from(Span::styled( + format!(" … ({} more matches)", total - max_lines), + Style::default().fg(dim).add_modifier(Modifier::ITALIC), + ))); + } + + lines +} + +fn render_glob_body( + output: &str, + inner_width: usize, + palette: &ChatPalette, + _mdpal: &crate::ui::markdown::MarkdownPalette, +) -> Vec> { + let dim = palette.tool_dim; + let mut lines: Vec> = Vec::new(); + + if let Some(first) = output.lines().next() { + lines.push(Line::from(Span::styled( + clip(first.trim(), inner_width), + Style::default().fg(palette.tool_accent).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + "─".repeat(inner_width.min(40)), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + } + + let max_lines = 30usize; + for line_text in output.lines().skip(1).take(max_lines) { + let clipped = clip(line_text.trim(), inner_width); + lines.push(Line::from(Span::styled( + clipped, + Style::default().fg(dim), + ))); + } + let total = output.lines().count().saturating_sub(1); + if total > max_lines { + lines.push(Line::from(Span::styled( + format!(" … ({} more files)", total - max_lines), + Style::default().fg(dim).add_modifier(Modifier::ITALIC), + ))); + } + + lines +} + +fn render_list_dir_body( + output: &str, + inner_width: usize, + palette: &ChatPalette, +) -> Vec> { + let dim = palette.tool_dim; + let mut lines: Vec> = Vec::new(); + + let max_lines = 30usize; + for line_text in output.lines().take(max_lines) { + let clipped = clip(line_text.trim(), inner_width); + lines.push(Line::from(Span::styled( + clipped, + Style::default().fg(dim), + ))); + } + let total = output.lines().count(); + if total > max_lines { + lines.push(Line::from(Span::styled( + format!(" … ({} more entries)", total - max_lines), + Style::default().fg(dim).add_modifier(Modifier::ITALIC), + ))); + } + + lines +} + +fn render_write_body( + output: &str, + arguments: &str, + inner_width: usize, + palette: &ChatPalette, +) -> Vec> { + let dim = palette.tool_dim; + let fg = palette.tool_accent; + let path = arguments_to_path(arguments); + + let mut lines: Vec> = Vec::new(); + // Header with path and output summary + let header = format!("┌─ {} ", if path.is_empty() { "write" } else { &path }); + let dashes = inner_width.saturating_sub(header.chars().count()).max(0); + lines.push(Line::from(Span::styled( + format!("{}{}", header, "─".repeat(dashes)), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + // Output summary line — write returns prose like "Appended N chars to path" + let clipped = clip(output.trim(), inner_width.saturating_sub(2).max(10)); + lines.push(Line::from(Span::styled( + format!(" {}", clipped), + Style::default().fg(fg), + ))); + // Footer + lines.push(Line::from(Span::styled( + format!("└{}", "─".repeat(inner_width.saturating_sub(1))), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + lines +} + +fn render_edit_body( + output: &str, + arguments: &str, + inner_width: usize, + palette: &ChatPalette, +) -> Vec> { + let dim = palette.tool_dim; + let fg = palette.tool_accent; + let path = arguments_to_path(arguments); + + let mut lines: Vec> = Vec::new(); + // Header with path + let header = format!("┌─ edit{} ", if path.is_empty() { "" } else { ":" }); + let label = format!("{}{}", header, &path); + let dashes = inner_width.saturating_sub(label.chars().count()).max(0); + lines.push(Line::from(Span::styled( + format!("{}{}", label, "─".repeat(dashes)), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + // Edit returns prose like "Edited file.rs. +42 chars, 10 -> 12 lines" + if let Some(first) = output.lines().next() { + let clipped = clip(first.trim(), inner_width.saturating_sub(2).max(10)); + lines.push(Line::from(Span::styled( + format!(" {}", clipped), + Style::default().fg(fg), + ))); + } + // Footer + lines.push(Line::from(Span::styled( + format!("└{}", "─".repeat(inner_width.saturating_sub(1))), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + lines +} + +fn render_memory_body( + output: &str, + inner_width: usize, + mdpal: &crate::ui::markdown::MarkdownPalette, + palette: &ChatPalette, +) -> Vec> { + let dim = palette.tool_dim; + let mut lines: Vec> = Vec::new(); + + // Header + let header = format!("┌─ memory "); + let dashes = inner_width.saturating_sub(header.chars().count()).max(0); + lines.push(Line::from(Span::styled( + format!("{}{}", header, "─".repeat(dashes)), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + + if output.is_empty() { + lines.push(Line::from(Span::styled( + " (empty)", + Style::default().fg(dim).add_modifier(Modifier::ITALIC), + ))); + lines.push(Line::from(Span::styled( + format!("└{}", "─".repeat(inner_width.saturating_sub(1))), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + return lines; + } + + // Try to split frontmatter from body — frontmatter lives between --- lines + let body = if output.starts_with("---") { + if let Some(end) = output[3..].find("\n---") { + let frontmatter = &output[..end + 6]; // include closing --- + let body = output[end + 6..].trim(); + // Render frontmatter as folded single-line summary + let fm_summary = summarize_frontmatter(frontmatter); + lines.push(Line::from(Span::styled( + format!(" {} ", fm_summary), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + body + } else { + output + } + } else { + output + }; + + // Render body as markdown (up to 30 lines) + if !body.is_empty() { + let max_lines = 30usize; + let preview: String = body.lines().take(max_lines).collect::>().join("\n"); + let rendered = crate::ui::markdown::render_with_width( + &preview, + mdpal.code_fg, + Some(inner_width.saturating_sub(2).max(8)), + mdpal, + ); + lines.extend(rendered); + + let total = body.lines().count(); + if total > max_lines { + lines.push(Line::from(Span::styled( + format!(" … ({} more lines)", total - max_lines), + Style::default().fg(dim).add_modifier(Modifier::ITALIC), + ))); + } + } + + // Footer + lines.push(Line::from(Span::styled( + format!("└{}", "─".repeat(inner_width.saturating_sub(1))), + Style::default().fg(dim).add_modifier(Modifier::DIM), + ))); + lines +} + +/// Extract a one-line summary from YAML frontmatter: `description · tags · limit` +fn summarize_frontmatter(frontmatter: &str) -> String { + let mut desc = String::new(); + let mut tags = String::new(); + let mut limit = String::new(); + + for line in frontmatter.lines() { + let line = line.trim(); + if let Some(val) = line.strip_prefix("description:") { + desc = val.trim().to_string(); + } else if let Some(val) = line.strip_prefix("tags:") { + tags = val.trim().to_string(); + } else if let Some(val) = line.strip_prefix("limit:") { + limit = val.trim().to_string(); + } + } + + let mut parts: Vec<&str> = Vec::new(); + if !desc.is_empty() { parts.push(&desc); } + if !tags.is_empty() { parts.push(&tags); } + if !limit.is_empty() { parts.push(&limit); } + if parts.is_empty() { + return "📄 frontmatter".to_string(); + } + parts.join(" · ") +} + +// ─── Internal helpers ────────────────────────────────────────────────── + +/// Extract just the path from an arguments JSON for read-body heuristics. +fn arguments_to_path(arguments: &str) -> String { + let parsed: Result = serde_json::from_str(arguments); + match parsed { + Ok(Value::Object(map)) => { + json_str(&map, "path").map(|s| s.to_string()).unwrap_or_default() + } + _ => String::new(), + } +} + +/// Take the first N lines of a string. +fn preview_lines(s: &str, n: usize) -> String { + let lines: Vec<&str> = s.lines().take(n).collect(); + lines.join("\n") +}