Compare commits

..
2 changed files with 43 additions and 73 deletions

View file

@ -1483,6 +1483,33 @@ window.marked = marked;
const name = c.name || ''; const name = c.name || '';
const input = c.input || {}; const input = c.input || {};
const icon = c._icon || '🔧'; const icon = c._icon || '🔧';
if (name === 'Write' || name === 'Edit') {
const path = input.file_path || '?';
let body;
let plus = 0;
let minus = 0;
if (name === 'Write') {
const content = String(input.content || '');
const lines = content.split('\n');
plus = lines.length;
body = lines.map(l => '+ ' + l).join('\n');
} else {
const oldLines = String(input.old_string || '').split('\n');
const newLines = String(input.new_string || '').split('\n');
minus = oldLines.length;
plus = newLines.length;
body = oldLines.map(l => '- ' + l).join('\n')
+ '\n'
+ newLines.map(l => '+ ' + l).join('\n');
}
// The tool icon goes in the shared `.row-glyph` cell (4th arg) so it
// lines up with flat-row icons; the summary text carries no
// directional `→` (the row's cyan colour signals "outbound tool" and
// the CSS disclosure caret leads the text).
const summary = name + ' ' + path + ' · '
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
return api.detailsDiff('tool-use', summary, body, icon);
}
// Message-bearing tools render default-open with a markdown body so // Message-bearing tools render default-open with a markdown body so
// the operator sees the content without an extra click. send / ask // the operator sees the content without an extra click. send / ask
// address a target; answer attaches to an existing question id. // address a target; answer attaches to an existing question id.
@ -1532,15 +1559,11 @@ window.marked = marked;
'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''), 'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
a, icon); a, icon);
} }
// Generic backend-computed body: the backend stamps `_body` + `_body_type` // Generic plain-text body: the backend stamps `_body` for tools whose
// for tools whose expandable content is pre-computable (Edit diff, // expandable content is plain text (e.g. mcp__bash__run full command).
// mcp__bash__run command). Dispatch on type — no tool-specific JS needed. // Render as a collapsible details row without any tool-specific logic.
if (c._body != null) { if (c._body) {
const summary = c._summary || name || '?'; return api.details('tool-use', c._summary || name || '?', c._body, icon);
if (c._body_type === 'diff') {
return api.detailsDiff('tool-use', summary, c._body, icon);
}
return api.details('tool-use', summary, c._body, icon);
} }
return null; return null;
} }

View file

@ -239,9 +239,8 @@ fn enrich_tool_use_entry(entry: &mut Value) {
if rich { if rich {
obj.insert("_category".to_owned(), json!("rich")); obj.insert("_category".to_owned(), json!("rich"));
} }
if let Some((b, bt)) = body { if let Some(b) = body {
obj.insert("_body".to_owned(), json!(b)); obj.insert("_body".to_owned(), json!(b));
obj.insert("_body_type".to_owned(), json!(bt));
} }
} }
@ -256,7 +255,8 @@ fn enrich_tool_use_entry(entry: &mut Value) {
fn is_rich_tool(name: &str) -> bool { fn is_rich_tool(name: &str) -> bool {
matches!( matches!(
name, name,
"Edit" "Write"
| "Edit"
| "mcp__bash__run" | "mcp__bash__run"
| "mcp__hyperhive__send" | "mcp__hyperhive__send"
| "mcp__hyperhive__ask" | "mcp__hyperhive__ask"
@ -264,48 +264,14 @@ fn is_rich_tool(name: &str) -> bool {
) )
} }
/// Pre-compute the expandable body for rich tool entries. /// Pre-compute the expandable body text for tools that have one.
/// ///
/// Returns `Some((body, body_type))` where `body_type` tells the frontend /// Returns `Some(body)` when the tool has a meaningful multi-line body that
/// which renderer to use: /// the frontend can display in a `<details>` block without tool-specific JS.
/// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines) /// `None` for tools whose body is computed client-side (diffs, markdown) or
/// - `"plain"` → `api.details` (plain `<pre>` block) /// that have no body at all.
/// fn rich_tool_body(name: &str, input: &Value) -> Option<String> {
/// Returns `None` for tools whose body is inherently client-side (markdown
/// rendering, DOM forms) or that have no body at all.
///
/// **`Write` is intentionally absent**: its `content` field can be megabytes
/// and is one-sided (no `old_string` to diff against), making it too large to
/// inline in every SSE event. The flat `"Write /path"` summary row is
/// sufficient — the operator can open the file directly if they need to inspect
/// the written content.
fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
match name { match name {
// Edit diff: old lines prefixed `- `, new lines prefixed `+ `.
// The summary already carries `-N +M` counts (from `fmt_builtin_tool`),
// so the body is the full colour-coded diff.
"Edit" => {
let old = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("");
let mut body = String::new();
for line in old.lines() {
body.push_str("- ");
body.push_str(line);
body.push('\n');
}
for line in new.lines() {
body.push_str("+ ");
body.push_str(line);
body.push('\n');
}
Some((body, "diff"))
}
// Full bash command — first line is already in `_summary`; the full // Full bash command — first line is already in `_summary`; the full
// `cmd` (prefixed with `$ `) is the body so multi-line scripts are // `cmd` (prefixed with `$ `) is the body so multi-line scripts are
// readable on expand. // readable on expand.
@ -314,7 +280,7 @@ fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
if cmd.is_empty() { if cmd.is_empty() {
None None
} else { } else {
Some((format!("$ {cmd}"), "plain")) Some(format!("$ {cmd}"))
} }
} }
_ => None, _ => None,
@ -399,26 +365,7 @@ fn fmt_tool_use(name: &str, input: &Value) -> String {
/// unknown tool that doesn't carry a known MCP server prefix. /// unknown tool that doesn't carry a known MCP server prefix.
fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String { fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String {
match name { match name {
"Read" | "Write" => format!("{short} {}", sv(input, "file_path")), "Read" | "Write" | "Edit" => format!("{short} {}", sv(input, "file_path")),
"Edit" => {
let path = sv(input, "file_path");
let old = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new_n = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("")
.lines()
.count();
if old.is_empty() {
format!("{short} {path} · +{new_n}")
} else {
let old_n = old.lines().count();
format!("{short} {path} · -{old_n} +{new_n}")
}
}
"Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")), "Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")),
"Bash" => { "Bash" => {
let bg = if input let bg = if input