Don't state what a function/module doesn't do and where that happens instead — just describe what it does. Cut the "not something this function decides" / "not affected by this" / "not a placeholder for a later commit" asides from the doc comments touched in the last two commits.
1050 lines
39 KiB
Rust
1050 lines
39 KiB
Rust
//! Classify raw claude stream-json values into [`crate::term_msg::TermMsg`]
|
||
//! rows before SSE delivery.
|
||
//!
|
||
//! [`classify_stream_value`] is the entry point — it walks one raw claude
|
||
//! `stream-json` line (the payload of a [`crate::events::LiveEvent::Stream`])
|
||
//! and returns zero or more terminal rows. Applied at SSE-emit time in
|
||
//! `crate::web_ui::stream` so both the live tail (`events/stream`) and the
|
||
//! history replay (`events/history`) endpoints deliver the same classified
|
||
//! shape — the sqlite event log stores the raw, unclassified event, so the
|
||
//! DB never needs migration when classification logic changes.
|
||
//!
|
||
//! The per-tool icon/summary formatting below (`tool_icon`, `fmt_tool_use`
|
||
//! and its per-family helpers, `rich_tool_body`) is reused as-is from
|
||
//! before mara's terminal-message redesign — that logic (what does
|
||
//! `mcp__hyperhive__send`'s row say, which tools get an expandable body)
|
||
//! didn't change; only the shape it gets packed into did.
|
||
|
||
use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg};
|
||
use serde_json::{Value, json};
|
||
|
||
/// Classify one raw claude stream-json line into zero or more terminal rows.
|
||
///
|
||
/// Dispatch order mirrors the old client-side `classifyEvent.ts` (before
|
||
/// this classification moved server-side):
|
||
/// top-level drop-noise types first, then `type="system"`, then task events
|
||
/// (matched on `subtype` regardless of `type`), then `assistant`/`user`
|
||
/// content, with an unrecognised shape falling through to a loud
|
||
/// warn-level catch-all so a silently-dropped event type stays visible.
|
||
pub fn classify_stream_value(v: &Value, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
|
||
let vtype = v.get("type").and_then(Value::as_str).unwrap_or("");
|
||
if matches!(vtype, "result" | "rate_limit_event") {
|
||
return vec![];
|
||
}
|
||
if vtype == "system" {
|
||
return classify_system(v);
|
||
}
|
||
let subtype = v.get("subtype").and_then(Value::as_str);
|
||
if matches!(subtype, Some("task_started" | "task_notification")) {
|
||
return classify_task_event(v).into_iter().collect();
|
||
}
|
||
if vtype == "assistant" {
|
||
return v
|
||
.get("message")
|
||
.and_then(|m| m.get("content"))
|
||
.and_then(Value::as_array)
|
||
.map_or_else(Vec::new, |content| classify_assistant_content(content, ctx));
|
||
}
|
||
if vtype == "user" {
|
||
return v
|
||
.get("message")
|
||
.and_then(|m| m.get("content"))
|
||
.and_then(Value::as_array)
|
||
.map_or_else(Vec::new, |content| classify_user_content(content, ctx));
|
||
}
|
||
vec![TermMsg::new(Level::Warn, trim_str(&v.to_string(), 200)).icon("!")]
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// system events
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn classify_system(v: &Value) -> Vec<TermMsg> {
|
||
let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("");
|
||
let (category, summary, body) = system_fields(v, subtype);
|
||
match category {
|
||
"drop" => vec![],
|
||
"thinking_tok" => vec![
|
||
TermMsg::new(
|
||
Level::Debug,
|
||
summary.unwrap_or_else(|| "thinking…".to_owned()),
|
||
)
|
||
.icon("🧠")
|
||
.coalesce("thinking-tok"),
|
||
],
|
||
"details" => {
|
||
let mut m = TermMsg::new(Level::Debug, summary.unwrap_or_default());
|
||
if let Some(b) = body {
|
||
m = m.body(b, None);
|
||
}
|
||
vec![m]
|
||
}
|
||
// "note" category — ambient harness/system chatter. Level + coalesce
|
||
// key vary by subtype; everything else defaults to a plain debug note.
|
||
_ => {
|
||
let s = summary.unwrap_or_default();
|
||
let m = match subtype {
|
||
"api_error" => TermMsg::new(Level::Error, s),
|
||
"api_retry" => TermMsg::new(Level::Warn, s),
|
||
"plugin_install" => TermMsg::new(Level::Debug, s).coalesce("plugin-install"),
|
||
"status" => TermMsg::new(Level::Debug, s).coalesce("status-tick"),
|
||
_ => TermMsg::new(Level::Debug, s),
|
||
};
|
||
vec![m]
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Compute `(category, summary, body)` for a `type="system"` event.
|
||
///
|
||
/// Categories understood by the frontend:
|
||
/// - `"drop"` — client discards without rendering (noise)
|
||
/// - `"thinking_tok"` — client updates a single in-place counter row
|
||
/// - `"note"` — client renders `_summary` as a terminal note line
|
||
/// - `"details"` — client renders a collapsible `<details>` with `_summary`
|
||
/// as the header and `_body` as the expanded text
|
||
fn system_fields(v: &Value, subtype: &str) -> (&'static str, Option<String>, Option<String>) {
|
||
match subtype {
|
||
// Silent startup / stream-end noise — drop.
|
||
"init" | "result" | "rate_limit_event" => ("drop", None, None),
|
||
|
||
// Live thinking-token counter.
|
||
"thinking_tokens" => {
|
||
let n = v.get("estimated_tokens").and_then(Value::as_u64);
|
||
let s = n.map_or_else(
|
||
|| "thinking…".to_owned(),
|
||
|n| format!("thinking… ~{n} tokens"),
|
||
);
|
||
("thinking_tok", Some(s), None)
|
||
}
|
||
|
||
// API retry back-off.
|
||
"api_retry" => {
|
||
let mut parts = vec!["⚠ api retry".to_owned()];
|
||
if let (Some(a), Some(m)) = (
|
||
v.get("attempt").and_then(Value::as_u64),
|
||
v.get("max_retries").and_then(Value::as_u64),
|
||
) {
|
||
parts.push(format!("{a}/{m}"));
|
||
}
|
||
if let Some(e) = v.get("error").and_then(Value::as_str) {
|
||
parts.push(e.to_owned());
|
||
} else if let Some(s) = v.get("error_status").and_then(Value::as_u64) {
|
||
parts.push(format!("HTTP {s}"));
|
||
}
|
||
if let Some(ms) = v.get("retry_delay_ms").and_then(Value::as_f64) {
|
||
parts.push(format!("{:.0}ms", ms.round()));
|
||
}
|
||
("note", Some(parts.join(" · ")), None)
|
||
}
|
||
|
||
// Hard API error.
|
||
"api_error" => {
|
||
let msg = v
|
||
.get("error")
|
||
.or_else(|| v.get("message"))
|
||
.and_then(Value::as_str)
|
||
.map_or_else(
|
||
|| {
|
||
v.get("error_status")
|
||
.and_then(Value::as_u64)
|
||
.map_or("unknown".to_owned(), |s| format!("HTTP {s}"))
|
||
},
|
||
str::to_owned,
|
||
);
|
||
("note", Some(format!("✗ api error · {msg}")), None)
|
||
}
|
||
|
||
// Plugin (MCP server / slash-command provider) load progress.
|
||
"plugin_install" => {
|
||
let status = v.get("status").and_then(Value::as_str).unwrap_or("?");
|
||
let label = match status {
|
||
"completed" => "✓ done",
|
||
"started" => "loading…",
|
||
other => other,
|
||
};
|
||
("note", Some(format!("⚙ plugin install · {label}")), None)
|
||
}
|
||
|
||
// Available slash-command set changed — expandable list.
|
||
"commands_changed" => {
|
||
let cmds = v.get("commands").and_then(Value::as_array);
|
||
let count = cmds.map_or(0, Vec::len);
|
||
let summary = format!("⚙ commands changed · {count} available");
|
||
let body = cmds.map(|cmds| {
|
||
cmds.iter()
|
||
.filter_map(|c| c.get("name").and_then(Value::as_str))
|
||
.map(|n| format!("/{n}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
});
|
||
("details", Some(summary), body)
|
||
}
|
||
|
||
// Compaction complete — pre/post token counts + duration.
|
||
"compact_boundary" => {
|
||
let m = v
|
||
.get("compact_metadata")
|
||
.cloned()
|
||
.unwrap_or_else(|| json!({}));
|
||
let mut parts = vec!["⚙ compact".to_owned()];
|
||
if let Some(t) = m.get("trigger").and_then(Value::as_str) {
|
||
parts.push(t.to_owned());
|
||
}
|
||
if let (Some(pre), Some(post)) = (
|
||
m.get("pre_tokens").and_then(Value::as_u64),
|
||
m.get("post_tokens").and_then(Value::as_u64),
|
||
) {
|
||
parts.push(format!("{}→{} tokens", fmt_tok(pre), fmt_tok(post)));
|
||
}
|
||
if let Some(ms) = m.get("duration_ms").and_then(Value::as_u64) {
|
||
let dur = if ms < 1000 {
|
||
format!("{ms}ms")
|
||
} else {
|
||
#[allow(clippy::cast_precision_loss)]
|
||
let s_f = ms as f64 / 1_000.0;
|
||
format!("{s_f:.1}s")
|
||
};
|
||
parts.push(dur);
|
||
}
|
||
("note", Some(parts.join(" · ")), None)
|
||
}
|
||
|
||
// Generic "still working" heartbeat tick.
|
||
"status" => ("note", Some("⚙ status".to_owned()), None),
|
||
|
||
// Unknown subtype — render with the subtype label as a muted note.
|
||
other => ("note", Some(format!("⚙ {other}")), None),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// assistant events (claude's own output: text / thinking / tool calls)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn classify_assistant_content(content: &[Value], ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
|
||
let mut rows = Vec::new();
|
||
for c in content {
|
||
match c.get("type").and_then(Value::as_str) {
|
||
Some("text") => {
|
||
let text = c.get("text").and_then(Value::as_str).unwrap_or("");
|
||
if !text.trim().is_empty() {
|
||
// No separate summary line — the markdown body is the
|
||
// whole row, matching the old `.text` row shape.
|
||
rows.push(
|
||
TermMsg::new(Level::Info, String::new())
|
||
.body(text.to_owned(), Some(BodyFormat::Markdown)),
|
||
);
|
||
}
|
||
}
|
||
Some("thinking") => {
|
||
let txt = c
|
||
.get("thinking")
|
||
.or_else(|| c.get("text"))
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("")
|
||
.trim()
|
||
.to_owned();
|
||
let summary = if txt.is_empty() {
|
||
"thinking …".to_owned()
|
||
} else {
|
||
txt
|
||
};
|
||
rows.push(TermMsg::new(Level::Debug, summary).icon("💭"));
|
||
}
|
||
Some("tool_use") => {
|
||
if let (Some(id), Some(name)) = (
|
||
c.get("id").and_then(Value::as_str),
|
||
c.get("name").and_then(Value::as_str),
|
||
) {
|
||
ctx.record_tool_use(id, name);
|
||
}
|
||
rows.push(classify_tool_use(c));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// `_category === 'rich'` tools used to get an expandable row (diff body
|
||
/// for Edit, markdown body for send, plain for everything else with a
|
||
/// body). That's now just "does this row have a body" — `body.is_some()`
|
||
/// on the returned [`TermMsg`] *is* the expandable signal, no separate
|
||
/// flag.
|
||
fn classify_tool_use(c: &Value) -> TermMsg {
|
||
let name = c.get("name").and_then(Value::as_str).unwrap_or("");
|
||
let input = c.get("input").cloned().unwrap_or_else(|| json!({}));
|
||
let mut m = TermMsg::new(Level::Info, fmt_tool_use(name, &input)).icon(tool_icon(name));
|
||
if let Some((body, body_type)) = rich_tool_body(name, &input) {
|
||
let format = match body_type {
|
||
"diff" => Some(BodyFormat::Diff),
|
||
"markdown" => Some(BodyFormat::Markdown),
|
||
_ => None, // "plain"
|
||
};
|
||
m = m.body(body, format);
|
||
}
|
||
m
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// user events (tool_result — claude's own tool calls answered)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn classify_user_content(content: &[Value], ctx: &ClassifyCtx) -> Vec<TermMsg> {
|
||
content
|
||
.iter()
|
||
.filter(|c| c.get("type").and_then(Value::as_str) == Some("tool_result"))
|
||
.map(|c| classify_tool_result(c, ctx))
|
||
.collect()
|
||
}
|
||
|
||
/// `<tool_use_error>…</tool_use_error>` is claude's own wrapper on failed
|
||
/// tool calls — implementation detail, adds nothing for the operator.
|
||
fn strip_tool_use_error_wrapper(s: &str) -> String {
|
||
let trimmed = s.trim();
|
||
trimmed
|
||
.strip_prefix("<tool_use_error>")
|
||
.and_then(|rest| rest.strip_suffix("</tool_use_error>"))
|
||
.map_or_else(|| trimmed.to_owned(), |inner| inner.trim().to_owned())
|
||
}
|
||
|
||
fn classify_tool_result(c: &Value, ctx: &ClassifyCtx) -> TermMsg {
|
||
let raw_txt = match c.get("content") {
|
||
Some(Value::Array(parts)) => parts
|
||
.iter()
|
||
.filter_map(|p| p.get("text").and_then(Value::as_str))
|
||
.collect::<String>(),
|
||
Some(Value::String(s)) => s.clone(),
|
||
_ => String::new(),
|
||
};
|
||
let is_error = c.get("is_error").and_then(Value::as_bool).unwrap_or(false);
|
||
let txt = if is_error {
|
||
strip_tool_use_error_wrapper(&raw_txt)
|
||
} else {
|
||
raw_txt
|
||
};
|
||
|
||
let tool_use_id = c.get("tool_use_id").and_then(Value::as_str);
|
||
let source_name = tool_use_id.and_then(|id| ctx.tool_name(id));
|
||
let is_message_bearing = source_name == Some("mcp__hyperhive__recv");
|
||
|
||
let trimmed: String = txt.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
let summary = summarize_tool_result(&txt, &trimmed);
|
||
let short = txt.trim().is_empty() || txt.chars().count() <= 120;
|
||
|
||
if is_error {
|
||
let m = TermMsg::new(Level::Error, summary);
|
||
return if short {
|
||
m.icon("✗")
|
||
} else {
|
||
m.body(txt, None)
|
||
};
|
||
}
|
||
if is_message_bearing && !txt.trim().is_empty() {
|
||
return TermMsg::new(Level::Info, format!("recv ← {summary}"))
|
||
.body(txt, Some(BodyFormat::Markdown));
|
||
}
|
||
let m = TermMsg::new(Level::Info, summary);
|
||
if short {
|
||
m.icon("←")
|
||
} else {
|
||
m.body(txt, None)
|
||
}
|
||
}
|
||
|
||
/// `(empty)` / a short trimmed line / `"NL · headline…"` for a long one —
|
||
/// matches the old client-side `summaryBody` computation exactly.
|
||
fn summarize_tool_result(txt: &str, trimmed: &str) -> String {
|
||
if trimmed.is_empty() {
|
||
return "(empty)".to_owned();
|
||
}
|
||
if trimmed.chars().count() <= 120 {
|
||
return trimmed.to_owned();
|
||
}
|
||
let lines = txt.lines().filter(|l| !l.is_empty()).count();
|
||
let headline: String = trimmed.chars().take(90).collect();
|
||
format!("{lines}L · {headline}…")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// subagent (claude Task-tool) activity — dead path for agents today (`Task`
|
||
// is omitted from the allow-list) but kept for parity with the old
|
||
// client-side `classifyTaskEvent`.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn classify_task_event(v: &Value) -> Option<TermMsg> {
|
||
let id: String = v
|
||
.get("task_id")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("")
|
||
.chars()
|
||
.take(8)
|
||
.collect();
|
||
let kind = v
|
||
.get("task_type")
|
||
.and_then(Value::as_str)
|
||
.map(|t| format!(" [{t}]"))
|
||
.unwrap_or_default();
|
||
let desc = v
|
||
.get("description")
|
||
.or_else(|| v.get("summary"))
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("(no description)");
|
||
match v.get("subtype").and_then(Value::as_str) {
|
||
Some("task_started") => {
|
||
Some(TermMsg::new(Level::Info, format!("task {id} started · {desc}{kind}")).icon("⌁"))
|
||
}
|
||
Some("task_notification") => {
|
||
let status = v.get("status").and_then(Value::as_str).unwrap_or("unknown");
|
||
let (glyph, level) = match status {
|
||
"completed" => ("✓", Level::Info),
|
||
"failed" => ("✗", Level::Error),
|
||
_ => ("◌", Level::Info),
|
||
};
|
||
let out = v
|
||
.get("output_file")
|
||
.and_then(Value::as_str)
|
||
.map(|f| format!(" · → {f}"))
|
||
.unwrap_or_default();
|
||
Some(TermMsg::new(level, format!("task {id} {glyph} {status} · {desc}{out}")).icon("⌁"))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// tool helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Pre-compute the expandable body for rich tool entries.
|
||
///
|
||
/// Returns `Some((body, body_type))` where `body_type` becomes the
|
||
/// `TermMsg`'s `body_format` and tells the frontend which renderer to use:
|
||
/// - `"diff"` → colour-coded `+`/`-` lines
|
||
/// - `"plain"` (`None` on the wire) → a plain `<pre>` block
|
||
/// - `"markdown"` → rendered via `marked` + `DOMPurify`; used for
|
||
/// message-bearing tools: `send`
|
||
///
|
||
/// Returns `None` for tools 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 {
|
||
// 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.
|
||
"mcp__bash__run" => {
|
||
let cmd = input.get("cmd").and_then(Value::as_str).unwrap_or("");
|
||
if cmd.is_empty() {
|
||
None
|
||
} else {
|
||
Some((format!("$ {cmd}"), "plain"))
|
||
}
|
||
}
|
||
// Message-bearing tool: body is markdown text rendered by the client.
|
||
"mcp__hyperhive__send" => {
|
||
let body = input.get("body").and_then(Value::as_str).unwrap_or("");
|
||
if body.is_empty() {
|
||
None
|
||
} else {
|
||
Some((body.to_owned(), "markdown"))
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn tool_icon(name: &str) -> &'static str {
|
||
// Exact-match table first, then prefix/contains fallbacks.
|
||
match name {
|
||
"mcp__hyperhive__send" => "📤",
|
||
"mcp__hyperhive__recv" => "📥",
|
||
"mcp__hyperhive__remind" => "⏰",
|
||
"mcp__hyperhive__set_status" => "🏷️",
|
||
"mcp__hyperhive__get_loose_ends" => "🪢",
|
||
"mcp__hyperhive__cancel_loose_end" => "✂️",
|
||
"mcp__hyperhive__ack_until" => "✅",
|
||
"mcp__hyperhive__get_agent_meta" => "ℹ️",
|
||
"mcp__hyperhive__restart" => "↻",
|
||
"mcp__hyperhive__kill" => "⏹️",
|
||
"mcp__hyperhive__start" => "▶️",
|
||
"mcp__hyperhive__update" => "🔄",
|
||
"mcp__hyperhive__list_containers"
|
||
| "mcp__matrix__list_rooms"
|
||
| "mcp__matrix__list_room_members"
|
||
| "mcp__matrix__list_invites" => "📋",
|
||
"mcp__hyperhive__get_logs" | "mcp__hyperhive__get_host_journal" => "📜",
|
||
"mcp__matrix__read_room" | "Read" => "📖",
|
||
"mcp__matrix__mark_read" => "👁️",
|
||
"mcp__bash__kill" => "🛑",
|
||
"Write" => "💾",
|
||
"Edit" => "✏️",
|
||
"Glob" | "Grep" => "🔍",
|
||
_ => tool_icon_fallback(name),
|
||
}
|
||
}
|
||
|
||
fn tool_icon_fallback(name: &str) -> &'static str {
|
||
if name.starts_with("mcp__matrix__") {
|
||
"💬"
|
||
} else if name.starts_with("mcp__bash__") {
|
||
"🖥️"
|
||
} else if name.contains("schedule") {
|
||
"⏱️"
|
||
} else if name.starts_with("mcp__hyperhive__request_") {
|
||
"📦"
|
||
} else {
|
||
"🔧"
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// tool_use summary formatter — dispatch to per-family sub-functions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn fmt_tool_use(name: &str, input: &Value) -> String {
|
||
// Short name: strip the MCP server prefix for display.
|
||
let short = if let Some(rest) = name.strip_prefix("mcp__hyperhive__") {
|
||
format!("{rest}*")
|
||
} else if let Some(rest) = name.strip_prefix("mcp__bash__") {
|
||
format!("{rest}*")
|
||
} else if let Some(rest) = name.strip_prefix("mcp__matrix__") {
|
||
format!("{rest}*")
|
||
} else {
|
||
name.to_owned()
|
||
};
|
||
|
||
if name.starts_with("mcp__hyperhive__") {
|
||
fmt_hyperhive_tool(name, &short, input)
|
||
} else if name.starts_with("mcp__bash__") {
|
||
fmt_bash_tool(name, &short, input)
|
||
} else if name.starts_with("mcp__matrix__") {
|
||
fmt_matrix_tool(name, &short, input)
|
||
} else {
|
||
fmt_builtin_tool(name, &short, input)
|
||
}
|
||
}
|
||
|
||
/// Built-in claude tools: Read/Write/Edit/Glob/Grep/Bash/TodoWrite and any
|
||
/// 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}")
|
||
}
|
||
}
|
||
"Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")),
|
||
"Bash" => {
|
||
let bg = if input
|
||
.get("run_in_background")
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false)
|
||
{
|
||
" [bg]"
|
||
} else {
|
||
""
|
||
};
|
||
format!("{short}{bg} $ {}", sv(input, "command"))
|
||
}
|
||
"TodoWrite" => {
|
||
let n = input
|
||
.get("todos")
|
||
.and_then(Value::as_array)
|
||
.map_or(0, Vec::len);
|
||
format!("{short} ({n} items)")
|
||
}
|
||
_ => fmt_args_generic(short, input),
|
||
}
|
||
}
|
||
|
||
/// Summary for `send`, the one message-bearing hyperhive tool.
|
||
///
|
||
/// Format: `"{short} → {recipient}"` with ` · NL` appended when the body
|
||
/// spans multiple lines.
|
||
fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String {
|
||
match name {
|
||
"mcp__hyperhive__send" => {
|
||
let to = sv(input, "to");
|
||
let lines = sv(input, "body").lines().count();
|
||
if lines > 1 {
|
||
format!("{short} → {to} · {lines}L")
|
||
} else {
|
||
format!("{short} → {to}")
|
||
}
|
||
}
|
||
_ => fmt_args_generic(short, input),
|
||
}
|
||
}
|
||
|
||
/// `mcp__hyperhive__*` tools.
|
||
fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
|
||
match name {
|
||
"mcp__hyperhive__send" => fmt_hyperhive_message_tool(name, short, input),
|
||
"mcp__hyperhive__recv" => {
|
||
let mut parts = Vec::new();
|
||
if let Some(w) = input.get("wait_seconds").and_then(Value::as_u64) {
|
||
parts.push(format!("wait {w}s"));
|
||
}
|
||
if let Some(m) = input.get("max").and_then(Value::as_u64) {
|
||
parts.push(format!("max {m}"));
|
||
}
|
||
if parts.is_empty() {
|
||
format!("{short}()")
|
||
} else {
|
||
format!("{short} {}", parts.join(" · "))
|
||
}
|
||
}
|
||
"mcp__hyperhive__kill"
|
||
| "mcp__hyperhive__restart"
|
||
| "mcp__hyperhive__start"
|
||
| "mcp__hyperhive__update"
|
||
| "mcp__hyperhive__request_init_config" => format!("{short} {}", sv(input, "name")),
|
||
"mcp__hyperhive__ack_until" => {
|
||
let up_to = input
|
||
.get("up_to")
|
||
.and_then(Value::as_u64)
|
||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
||
format!("{short} ≤{up_to}")
|
||
}
|
||
"mcp__hyperhive__get_logs" => {
|
||
let lines = input
|
||
.get("lines")
|
||
.and_then(Value::as_u64)
|
||
.map(|n| format!(" · {n}L"))
|
||
.unwrap_or_default();
|
||
format!("{short} {}{lines}", sv(input, "agent"))
|
||
}
|
||
"mcp__hyperhive__get_host_journal" => {
|
||
let mut parts = Vec::new();
|
||
if let Some(c) = input.get("container").and_then(Value::as_str) {
|
||
parts.push(c.to_owned());
|
||
} else if let Some(u) = input.get("unit").and_then(Value::as_str) {
|
||
parts.push(u.to_owned());
|
||
}
|
||
if let Some(g) = input.get("grep").and_then(Value::as_str) {
|
||
parts.push(format!("/{g}/"));
|
||
}
|
||
if let Some(l) = input.get("lines").and_then(Value::as_u64) {
|
||
parts.push(format!("{l}L"));
|
||
}
|
||
if parts.is_empty() {
|
||
format!("{short}()")
|
||
} else {
|
||
format!("{short} {}", parts.join(" · "))
|
||
}
|
||
}
|
||
"mcp__hyperhive__remind" => fmt_hyperhive_remind(short, input),
|
||
"mcp__hyperhive__request_update_meta_inputs"
|
||
| "mcp__hyperhive__list_schedules"
|
||
| "mcp__hyperhive__cancel_schedule"
|
||
| "mcp__hyperhive__fire_schedule_now"
|
||
| "mcp__hyperhive__edit_schedule"
|
||
| "mcp__hyperhive__request_schedule_prompt" => {
|
||
fmt_hyperhive_schedule_tool(name, short, input)
|
||
}
|
||
"mcp__hyperhive__set_status" => {
|
||
format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60))
|
||
}
|
||
"mcp__hyperhive__get_loose_ends" => {
|
||
let agent = input
|
||
.get("agent")
|
||
.and_then(Value::as_str)
|
||
.map_or_else(|| "()".to_owned(), |a| format!(" [{a}]"));
|
||
format!("{short}{agent}")
|
||
}
|
||
"mcp__hyperhive__get_agent_meta" => {
|
||
let name_part = input
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map_or_else(|| "()".to_owned(), |n| format!(" {n}"));
|
||
format!("{short}{name_part}")
|
||
}
|
||
"mcp__hyperhive__cancel_loose_end" => {
|
||
let id = input
|
||
.get("id")
|
||
.and_then(Value::as_u64)
|
||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
||
format!("{short} {} #{id}", sv(input, "kind"))
|
||
}
|
||
"mcp__hyperhive__mark_todos_done" => fmt_hyperhive_mark_todos_done(short, input),
|
||
_ => fmt_args_generic(short, input),
|
||
}
|
||
}
|
||
|
||
/// `mark_todos_done` — renders the acked ids so the terminal shows which
|
||
/// todos a bulk-clear call actually covered, not just a bracketed count
|
||
/// (`fmt_args_generic`'s generic array handling collapses to `ids: [N]`,
|
||
/// the count alone, which isn't useful here).
|
||
fn fmt_hyperhive_mark_todos_done(short: &str, input: &Value) -> String {
|
||
let ids = match input.get("ids").and_then(Value::as_array) {
|
||
Some(arr) if !arr.is_empty() => {
|
||
let nums: Vec<String> = arr
|
||
.iter()
|
||
.filter_map(Value::as_u64)
|
||
.take(8)
|
||
.map(|n| n.to_string())
|
||
.collect();
|
||
let tail = if arr.len() > 8 { ", …" } else { "" };
|
||
format!("[{}{tail}]", nums.join(", "))
|
||
}
|
||
_ => "[]".to_owned(),
|
||
};
|
||
format!("{short} {ids}")
|
||
}
|
||
|
||
fn fmt_hyperhive_remind(short: &str, input: &Value) -> String {
|
||
let when = if let Some(s) = input.get("delay_seconds").and_then(Value::as_u64) {
|
||
if s < 60 {
|
||
format!("+{s}s")
|
||
} else if s < 3_600 {
|
||
format!("+{}m", s / 60)
|
||
} else {
|
||
#[allow(clippy::cast_precision_loss)]
|
||
let h_f = s as f64 / 3_600.0;
|
||
format!("+{h_f:.1}h")
|
||
}
|
||
} else if let Some(ts) = input.get("at_unix_timestamp").and_then(Value::as_u64) {
|
||
let h = (ts % 86_400) / 3_600;
|
||
let m = (ts % 3_600) / 60;
|
||
format!("at {h:02}:{m:02}Z")
|
||
} else {
|
||
String::new()
|
||
};
|
||
let msg_raw = input
|
||
.get("message")
|
||
.or_else(|| input.get("file_path"))
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("");
|
||
let msg = trim_str(&msg_raw.replace(char::is_whitespace, " "), 60);
|
||
let when_part = if when.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(" {when}")
|
||
};
|
||
let msg_part = if msg.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(" \"{msg}\"")
|
||
};
|
||
format!("{short}{when_part}{msg_part}")
|
||
}
|
||
|
||
fn fmt_hyperhive_edit_schedule(short: &str, input: &Value) -> String {
|
||
let id = input
|
||
.get("id")
|
||
.and_then(Value::as_u64)
|
||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
||
let mut parts = vec![format!("#{id}")];
|
||
if input.get("body").is_some() {
|
||
parts.push("body".to_owned());
|
||
}
|
||
if input.get("interval_seconds").is_some() {
|
||
parts.push("interval".to_owned());
|
||
}
|
||
if input.get("next_fire_at_unix").is_some() {
|
||
parts.push("next".to_owned());
|
||
}
|
||
if let Some(a) = input.get("targets_add").and_then(Value::as_array)
|
||
&& !a.is_empty()
|
||
{
|
||
parts.push(format!("+{} tgt", a.len()));
|
||
}
|
||
if let Some(r) = input.get("targets_remove").and_then(Value::as_array)
|
||
&& !r.is_empty()
|
||
{
|
||
parts.push(format!("-{} tgt", r.len()));
|
||
}
|
||
format!("{short} {}", parts.join(" · "))
|
||
}
|
||
|
||
/// Schedule-management hyperhive tools (list/cancel/fire/edit/request).
|
||
fn fmt_hyperhive_schedule_tool(name: &str, short: &str, input: &Value) -> String {
|
||
match name {
|
||
"mcp__hyperhive__request_update_meta_inputs" => {
|
||
let ins = match input.get("inputs").and_then(Value::as_array) {
|
||
Some(arr) if !arr.is_empty() => {
|
||
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).take(4).collect();
|
||
let tail = if arr.len() > 4 { ", …" } else { "" };
|
||
format!("[{}{}]", names.join(", "), tail)
|
||
}
|
||
_ => "all".to_owned(),
|
||
};
|
||
format!("{short} {ins}")
|
||
}
|
||
"mcp__hyperhive__list_schedules" => format!("{short}()"),
|
||
"mcp__hyperhive__cancel_schedule" => {
|
||
let id = input
|
||
.get("id")
|
||
.and_then(Value::as_u64)
|
||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
||
let tgts = match input.get("targets").and_then(Value::as_array) {
|
||
Some(arr) if !arr.is_empty() => {
|
||
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).collect();
|
||
format!(" [{}]", names.join(", "))
|
||
}
|
||
_ => " all".to_owned(),
|
||
};
|
||
format!("{short} #{id}{tgts}")
|
||
}
|
||
"mcp__hyperhive__fire_schedule_now" => {
|
||
let id = input
|
||
.get("id")
|
||
.and_then(Value::as_u64)
|
||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
||
format!("{short} #{id}")
|
||
}
|
||
"mcp__hyperhive__edit_schedule" => fmt_hyperhive_edit_schedule(short, input),
|
||
"mcp__hyperhive__request_schedule_prompt" => {
|
||
let tgts = match input.get("targets").and_then(Value::as_array) {
|
||
Some(arr) => arr
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.collect::<Vec<_>>()
|
||
.join(", "),
|
||
None => "?".to_owned(),
|
||
};
|
||
let when = input
|
||
.get("first_fire_at_unix")
|
||
.and_then(Value::as_u64)
|
||
.map_or_else(
|
||
|| "?".to_owned(),
|
||
|ts| {
|
||
let h = (ts % 86_400) / 3_600;
|
||
let m = (ts % 3_600) / 60;
|
||
format!("{h:02}:{m:02}Z")
|
||
},
|
||
);
|
||
let recur = input
|
||
.get("interval_seconds")
|
||
.and_then(Value::as_u64)
|
||
.map(|s| format!(" +{s}s"))
|
||
.unwrap_or_default();
|
||
format!("{short} → {tgts} at {when}{recur}")
|
||
}
|
||
_ => fmt_args_generic(short, input),
|
||
}
|
||
}
|
||
|
||
/// `mcp__bash__*` tools.
|
||
fn fmt_bash_tool(name: &str, short: &str, input: &Value) -> String {
|
||
match name {
|
||
"mcp__bash__run" => {
|
||
let cmd = sv(input, "cmd");
|
||
let first = cmd.lines().next().unwrap_or("").trim().to_owned();
|
||
format!("{short} $ {}", trim_str(&first, 72))
|
||
}
|
||
"mcp__bash__status" => {
|
||
let wait = input
|
||
.get("wait_seconds")
|
||
.and_then(Value::as_u64)
|
||
.map(|w| format!(" · wait {w}s"))
|
||
.unwrap_or_default();
|
||
format!("{short} id:{}{wait}", sv(input, "id"))
|
||
}
|
||
"mcp__bash__kill" => {
|
||
let force = if input.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||
" [force]"
|
||
} else {
|
||
""
|
||
};
|
||
format!("{short} {}{force}", sv(input, "id"))
|
||
}
|
||
_ => fmt_args_generic(short, input),
|
||
}
|
||
}
|
||
|
||
/// `mcp__matrix__*` tools.
|
||
fn fmt_matrix_tool(name: &str, short: &str, input: &Value) -> String {
|
||
match name {
|
||
"mcp__matrix__read_room" => {
|
||
let limit = input
|
||
.get("limit")
|
||
.and_then(Value::as_u64)
|
||
.map(|l| format!(" [{l}]"))
|
||
.unwrap_or_default();
|
||
format!("{short} {}{limit}", fmt_room(&sv(input, "room")))
|
||
}
|
||
"mcp__matrix__mark_read" | "mcp__matrix__join_room" | "mcp__matrix__download_file" => {
|
||
format!("{short} {}", fmt_room(&sv(input, "room")))
|
||
}
|
||
"mcp__matrix__send_message" | "mcp__matrix__send_reply" => format!(
|
||
"{short} → {}: {}",
|
||
fmt_room(&sv(input, "room")),
|
||
json_str(&trim_str(&sv(input, "body"), 50))
|
||
),
|
||
"mcp__matrix__send_dm" => format!(
|
||
"{short} → {}: {}",
|
||
fmt_user(&sv(input, "user_id")),
|
||
json_str(&trim_str(&sv(input, "body"), 50))
|
||
),
|
||
"mcp__matrix__send_reaction" => format!(
|
||
"{short} {} {}",
|
||
fmt_room(&sv(input, "room")),
|
||
sv(input, "key")
|
||
),
|
||
"mcp__matrix__open_dm" => format!("{short} {}", fmt_user(&sv(input, "user_id"))),
|
||
"mcp__matrix__invite_user" => format!(
|
||
"{short} {} → {}",
|
||
fmt_user(&sv(input, "user_id")),
|
||
fmt_room(&sv(input, "room"))
|
||
),
|
||
_ => fmt_args_generic(short, input),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// generic fallback formatter (port of JS fmtArgsGeneric)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn fmt_args_generic(name: &str, input: &Value) -> String {
|
||
let Some(obj) = input.as_object() else {
|
||
return format!("{name}()");
|
||
};
|
||
if obj.is_empty() {
|
||
return format!("{name}()");
|
||
}
|
||
let keys: Vec<&String> = obj.keys().collect();
|
||
if keys.len() == 1 {
|
||
let k = keys[0];
|
||
let v = &obj[k];
|
||
if let Some(s) = v.as_str() {
|
||
return format!("{name} {k}: {}", json_str(&trim_str(s, 100)));
|
||
}
|
||
if v.is_number() || v.is_boolean() {
|
||
return format!("{name} {k}: {v}");
|
||
}
|
||
}
|
||
let pretty: Vec<String> = keys
|
||
.iter()
|
||
.take(4)
|
||
.map(|k| {
|
||
let v = &obj[*k];
|
||
if v.is_null() {
|
||
format!("{k}: null")
|
||
} else if let Some(s) = v.as_str() {
|
||
format!("{k}: {}", json_str(&trim_str(s, 40)))
|
||
} else if v.is_number() || v.is_boolean() {
|
||
format!("{k}: {v}")
|
||
} else if let Some(arr) = v.as_array() {
|
||
format!("{k}: [{}]", arr.len())
|
||
} else {
|
||
format!("{k}: {{…}}")
|
||
}
|
||
})
|
||
.collect();
|
||
let tail = if keys.len() > 4 {
|
||
format!(" …+{}", keys.len() - 4)
|
||
} else {
|
||
String::new()
|
||
};
|
||
format!("{name} {}{tail}", pretty.join(" · "))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// string utilities
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Format a token count as "1k", "1.2M", etc. (mirrors JS `fmtTok`).
|
||
fn fmt_tok(n: u64) -> String {
|
||
if n >= 1_000_000 {
|
||
// Divide before casting to keep the value in a range where f64
|
||
// has enough precision for one decimal place (≤ ~9×10^12 tokens).
|
||
#[allow(clippy::cast_precision_loss)]
|
||
let m = (n / 1_000) as f64 / 1_000.0;
|
||
format!("{m:.1}M")
|
||
} else if n >= 1_000 {
|
||
format!("{}k", n / 1_000)
|
||
} else {
|
||
n.to_string()
|
||
}
|
||
}
|
||
|
||
/// Shorten a matrix room id/alias for display.
|
||
fn fmt_room(r: &str) -> String {
|
||
if r.starts_with('!') {
|
||
// Room id: keep only the local part before the colon (up to 9 chars).
|
||
// Use chars().take() so we never slice on a non-ASCII byte boundary.
|
||
let colon = r.find(':').unwrap_or(r.len());
|
||
r.chars().take(colon.min(9)).collect()
|
||
} else if r.starts_with('#') {
|
||
// Alias: keep `#name` part before the server.
|
||
r.split(':').next().unwrap_or(r).to_owned()
|
||
} else {
|
||
trim_str(r, 20)
|
||
}
|
||
}
|
||
|
||
/// Shorten `@user:server` → `@user`.
|
||
fn fmt_user(u: &str) -> String {
|
||
let end = u.find(':').unwrap_or(u.len());
|
||
u[..end].to_owned()
|
||
}
|
||
|
||
/// Extract a string field from a JSON object, defaulting to `""`.
|
||
fn sv(v: &Value, key: &str) -> String {
|
||
v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
|
||
}
|
||
|
||
/// Trim to `max` *characters* (not bytes), collapsing whitespace first.
|
||
fn trim_str(s: &str, max: usize) -> String {
|
||
let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
let mut chars = collapsed.chars();
|
||
let trimmed: String = chars.by_ref().take(max).collect();
|
||
if chars.next().is_some() {
|
||
format!("{trimmed}…")
|
||
} else {
|
||
trimmed
|
||
}
|
||
}
|
||
|
||
/// Serialize a string as a JSON string literal (e.g. `"hello"`) for display
|
||
/// in summary lines. Falls back to the raw string if serialization fails.
|
||
fn json_str(s: &str) -> String {
|
||
serde_json::to_string(s).unwrap_or_else(|_| s.to_owned())
|
||
}
|