Watch
1
0
Fork
You've already forked souveraine
0

add tool_renderers.rs (missing from cherry-pick)

This commit is contained in:
Fimeg 2026-05-21 09:37:33 -04:00
commit e12a540389

View file

@ -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<String, Value>, 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<String, Value>, key: &str) -> Option<bool> {
map.get(key).and_then(|v| v.as_bool())
}
fn json_u64(map: &serde_json::Map<String, Value>, key: &str) -> Option<u64> {
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<Value, _> = 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, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> String {
json_str(map, "path")
.map(|p| clip(p, 50))
.unwrap_or_else(|| ".".to_string())
}
fn summarize_todo(map: &serde_json::Map<String, Value>) -> 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, Value>) -> 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, Value>) -> String {
json_str(map, "name")
.map(|n| n.to_string())
.unwrap_or_else(|| summarize_generic(map))
}
fn summarize_outfit(map: &serde_json::Map<String, Value>) -> String {
json_str(map, "name")
.map(|n| n.to_string())
.unwrap_or_else(|| summarize_generic(map))
}
fn summarize_itinerary(map: &serde_json::Map<String, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> 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, Value>) -> String {
let parts: Vec<String> = 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<String> {
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<Line<'static>>, Vec<Line<'static>>) {
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<Line<'static>> {
let dim = palette.tool_dim;
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
// 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<Line<'static>> = 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<Line<'static>> {
let dim = palette.tool_dim;
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
let dim = palette.tool_dim;
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
let dim = palette.tool_dim;
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
let dim = palette.tool_dim;
let fg = palette.tool_accent;
let path = arguments_to_path(arguments);
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
let dim = palette.tool_dim;
let fg = palette.tool_accent;
let path = arguments_to_path(arguments);
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
let dim = palette.tool_dim;
let mut lines: Vec<Line<'static>> = 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::<Vec<&str>>().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<Value, _> = 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")
}