diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 3b3362c3..7550c321 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1483,6 +1483,33 @@ window.marked = marked; const name = c.name || ''; const input = c.input || {}; 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 // the operator sees the content without an extra click. send / ask // address a target; answer attaches to an existing question id. @@ -1532,15 +1559,11 @@ window.marked = marked; 'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''), a, icon); } - // Generic backend-computed body: the backend stamps `_body` + `_body_type` - // for tools whose expandable content is pre-computable (Edit diff, - // mcp__bash__run command). Dispatch on type — no tool-specific JS needed. - if (c._body != null) { - const summary = c._summary || name || '?'; - if (c._body_type === 'diff') { - return api.detailsDiff('tool-use', summary, c._body, icon); - } - return api.details('tool-use', summary, c._body, icon); + // Generic plain-text body: the backend stamps `_body` for tools whose + // expandable content is plain text (e.g. mcp__bash__run full command). + // Render as a collapsible details row without any tool-specific logic. + if (c._body) { + return api.details('tool-use', c._summary || name || '?', c._body, icon); } return null; } diff --git a/hive-agent/src/stream_enrich.rs b/hive-agent/src/stream_enrich.rs index 942b98a5..c94ab281 100644 --- a/hive-agent/src/stream_enrich.rs +++ b/hive-agent/src/stream_enrich.rs @@ -239,9 +239,8 @@ fn enrich_tool_use_entry(entry: &mut Value) { if 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_type".to_owned(), json!(bt)); } } @@ -256,7 +255,8 @@ fn enrich_tool_use_entry(entry: &mut Value) { fn is_rich_tool(name: &str) -> bool { matches!( name, - "Edit" + "Write" + | "Edit" | "mcp__bash__run" | "mcp__hyperhive__send" | "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 -/// which renderer to use: -/// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines) -/// - `"plain"` → `api.details` (plain `
` block)
-///
-/// 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)> {
+/// Returns `Some(body)` when the tool has a meaningful multi-line body that
+/// the frontend can display in a `
` block without tool-specific JS. +/// `None` for tools whose body is computed client-side (diffs, markdown) or +/// that have no body at all. +fn rich_tool_body(name: &str, input: &Value) -> Option { 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 // `cmd` (prefixed with `$ `) is the body so multi-line scripts are // readable on expand. @@ -314,7 +280,7 @@ fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> { if cmd.is_empty() { None } else { - Some((format!("$ {cmd}"), "plain")) + Some(format!("$ {cmd}")) } } _ => 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. fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String { match name { - "Read" | "Write" => 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}") - } - } + "Read" | "Write" | "Edit" => format!("{short} {}", sv(input, "file_path")), "Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")), "Bash" => { let bg = if input