//! 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 { 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 { 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 `
` with `_summary` /// as the header and `_body` as the expanded text fn system_fields(v: &Value, subtype: &str) -> (&'static str, Option, Option) { 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::>() .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 { 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 { content .iter() .filter(|c| c.get("type").and_then(Value::as_str) == Some("tool_result")) .map(|c| classify_tool_result(c, ctx)) .collect() } /// `` 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("") .and_then(|rest| rest.strip_suffix("")) .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::(), 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::>().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 { 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 `
` 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 = 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::>()
                    .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 = 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).
        // Both halves of that are character counts — `find` returns a byte
        // offset, and spending it as a character budget lets a multi-byte
        // local part buy extra characters from the server half.
        let local = r.split(':').next().unwrap_or(r);
        local.chars().take(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::>().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())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn one(v: &Value) -> TermMsg {
        let mut ctx = ClassifyCtx::default();
        let rows = classify_stream_value(v, &mut ctx);
        assert_eq!(rows.len(), 1, "expected exactly one row for {v}");
        rows.into_iter().next().unwrap()
    }

    // -----------------------------------------------------------------
    // dispatch
    // -----------------------------------------------------------------

    #[test]
    fn terminal_event_types_render_nothing() {
        for t in ["result", "rate_limit_event"] {
            let mut ctx = ClassifyCtx::default();
            assert!(
                classify_stream_value(&json!({ "type": t }), &mut ctx).is_empty(),
                "{t} should be dropped"
            );
        }
    }

    /// The catch-all exists so a claude event shape nobody anticipated stays
    /// *visible* rather than vanishing. Losing it is silent, which is why it
    /// gets a test rather than a comment.
    #[test]
    fn unrecognised_shape_falls_through_loudly() {
        let m = one(&json!({ "type": "something_new", "detail": "x" }));
        assert_eq!(m.level, Level::Warn);
        assert_eq!(m.icon.as_deref(), Some("!"));
        assert!(m.summary.contains("something_new"));
    }

    #[test]
    fn catch_all_truncates_a_huge_payload() {
        let m = one(&json!({ "type": "unknown", "blob": "x".repeat(5_000) }));
        assert_eq!(
            m.summary.chars().count(),
            201,
            "200 chars plus the ellipsis"
        );
        assert!(m.summary.ends_with('…'));
    }

    /// Task events are matched on `subtype` *regardless of `type`* — a
    /// deliberate asymmetry with every other arm, and one a refactor that
    /// "tidies" the dispatch into a single match on `type` would silently
    /// break.
    #[test]
    fn task_events_dispatch_on_subtype_not_type() {
        let m = one(&json!({
            "type": "assistant",
            "subtype": "task_started",
            "task_id": "abcdef1234567890",
            "description": "do a thing",
        }));
        assert!(
            m.summary.starts_with("task abcdef12 started"),
            "{}",
            m.summary
        );
        assert!(
            !m.summary.contains("1234567890"),
            "task id is truncated to 8 chars"
        );
    }

    #[test]
    fn a_failed_task_notification_is_an_error_row() {
        let m = one(&json!({
            "type": "x", "subtype": "task_notification",
            "task_id": "t1", "status": "failed",
        }));
        assert_eq!(m.level, Level::Error);
    }

    // -----------------------------------------------------------------
    // system events
    // -----------------------------------------------------------------

    #[test]
    fn startup_noise_is_dropped_but_an_unknown_subtype_is_not() {
        let mut ctx = ClassifyCtx::default();
        assert!(
            classify_stream_value(&json!({ "type": "system", "subtype": "init" }), &mut ctx)
                .is_empty()
        );
        // The pairing is the point: "init produces nothing" only means
        // something if a *different* subtype produces something.
        let m = one(&json!({ "type": "system", "subtype": "brand_new" }));
        assert_eq!(m.summary, "⚙ brand_new");
    }

    #[test]
    fn system_severity_is_carried_by_subtype() {
        let cases = [
            ("api_error", Level::Error),
            ("api_retry", Level::Warn),
            ("status", Level::Debug),
        ];
        for (subtype, want) in cases {
            let m = one(&json!({ "type": "system", "subtype": subtype }));
            assert_eq!(m.level, want, "{subtype}");
        }
    }

    /// Coalescing is what stops a heartbeat from flooding the terminal: rows
    /// sharing a key collapse into one. A missing key is invisible in every
    /// unit except the rendered stream.
    #[test]
    fn repeating_rows_carry_a_coalesce_key() {
        let tick = one(&json!({ "type": "system", "subtype": "status" }));
        assert_eq!(tick.coalesce_key.as_deref(), Some("status-tick"));

        let think = one(&json!({
            "type": "system", "subtype": "thinking_tokens", "estimated_tokens": 1234,
        }));
        assert_eq!(think.coalesce_key.as_deref(), Some("thinking-tok"));
        assert_eq!(think.summary, "thinking… ~1234 tokens");

        // Same subtype, no count: still coalesces, still says something.
        let bare = one(&json!({ "type": "system", "subtype": "thinking_tokens" }));
        assert_eq!(bare.summary, "thinking…");
        assert_eq!(bare.coalesce_key.as_deref(), Some("thinking-tok"));
    }

    #[test]
    fn commands_changed_expands_into_the_command_list() {
        let m = one(&json!({
            "type": "system", "subtype": "commands_changed",
            "commands": [{ "name": "compact" }, { "name": "loop" }],
        }));
        assert_eq!(m.summary, "⚙ commands changed · 2 available");
        assert_eq!(m.body.as_deref(), Some("/compact\n/loop"));
    }

    #[test]
    fn api_retry_summarises_every_field_it_was_given() {
        let m = one(&json!({
            "type": "system", "subtype": "api_retry",
            "attempt": 2, "max_retries": 5,
            "error_status": 529, "retry_delay_ms": 1500.4,
        }));
        assert_eq!(m.summary, "⚠ api retry · 2/5 · HTTP 529 · 1500ms");
    }

    /// `error` wins over `error_status` — the operator gets the message, not
    /// the number, whenever there is one.
    #[test]
    fn api_error_prefers_the_message_over_the_status() {
        let with_msg = one(&json!({
            "type": "system", "subtype": "api_error",
            "error": "overloaded", "error_status": 529,
        }));
        assert_eq!(with_msg.summary, "✗ api error · overloaded");

        let status_only =
            one(&json!({ "type": "system", "subtype": "api_error", "error_status": 529 }));
        assert_eq!(status_only.summary, "✗ api error · HTTP 529");

        let neither = one(&json!({ "type": "system", "subtype": "api_error" }));
        assert_eq!(neither.summary, "✗ api error · unknown");
    }

    // -----------------------------------------------------------------
    // tool results
    // -----------------------------------------------------------------

    /// The 120-char boundary decides between an inline summary and an
    /// expandable body, and it counts *characters*, so a multi-byte result is
    /// the case that would break a byte-based rewrite.
    #[test]
    fn tool_result_summary_boundary_counts_characters() {
        let at = "a".repeat(120);
        assert_eq!(summarize_tool_result(&at, &at), at);

        let over = "a".repeat(121);
        let s = summarize_tool_result(&over, &over);
        assert!(s.starts_with("1L · "), "{s}");
        assert!(s.ends_with('…'));

        // 120 non-ASCII chars is 240 bytes: byte-length would call this long.
        let wide = "é".repeat(120);
        assert_eq!(summarize_tool_result(&wide, &wide), wide);
    }

    #[test]
    fn an_empty_tool_result_says_so() {
        assert_eq!(summarize_tool_result("", ""), "(empty)");
        assert_eq!(summarize_tool_result("  \n\t ", ""), "(empty)");
    }

    #[test]
    fn a_long_tool_result_reports_its_line_count() {
        let txt = "line\n".repeat(40);
        let trimmed: String = txt.split_whitespace().collect::>().join(" ");
        assert!(summarize_tool_result(&txt, &trimmed).starts_with("40L · "));
    }

    /// Only a *matched* pair is claude's wrapper. A result that merely starts
    /// with the opening tag is real output and must survive intact.
    #[test]
    fn the_error_wrapper_is_stripped_only_when_balanced() {
        assert_eq!(
            strip_tool_use_error_wrapper("boom"),
            "boom"
        );
        assert_eq!(
            strip_tool_use_error_wrapper("   boom   "),
            "boom"
        );
        let unbalanced = "boom";
        assert_eq!(strip_tool_use_error_wrapper(unbalanced), unbalanced);
        assert_eq!(strip_tool_use_error_wrapper("plain"), "plain");
    }

    fn tool_result(text: &str, is_error: bool, id: &str) -> Value {
        json!({
            "type": "user",
            "message": { "content": [{
                "type": "tool_result",
                "tool_use_id": id,
                "is_error": is_error,
                "content": [{ "type": "text", "text": text }],
            }]},
        })
    }

    /// `classify_tool_result` has its **own** `<= 120`, separate from the one
    /// in `summarize_tool_result`, and the two have to agree: one picks the
    /// summary text, the other picks icon-vs-body. A result that summarises as
    /// short but renders with a body is the inconsistency this pins.
    /// Added because mutation testing found the gap — the case below uses 2
    /// and 500 characters, so neither arm goes near the boundary and moving it
    /// to 119 changed nothing.
    #[test]
    fn the_icon_or_body_choice_turns_on_the_same_120_boundary() {
        let at = one(&tool_result(&"a".repeat(120), false, "t1"));
        assert_eq!(at.icon.as_deref(), Some("←"), "120 chars is still short");
        assert!(at.body.is_none());

        let over = one(&tool_result(&"a".repeat(121), false, "t1"));
        assert!(over.icon.is_none(), "121 chars stops being short");
        assert_eq!(over.body.as_deref().map(str::len), Some(121));
    }

    #[test]
    fn a_short_result_gets_an_icon_and_a_long_one_gets_a_body() {
        let short = one(&tool_result("ok", false, "t1"));
        assert_eq!(short.icon.as_deref(), Some("←"));
        assert!(short.body.is_none(), "short results are not expandable");

        let long = one(&tool_result(&"x".repeat(500), false, "t1"));
        assert!(long.icon.is_none());
        assert_eq!(long.body.as_deref().map(str::len), Some(500));
    }

    #[test]
    fn an_error_result_is_an_error_row_with_the_wrapper_gone() {
        let m = one(&tool_result(
            "file not found",
            true,
            "t1",
        ));
        assert_eq!(m.level, Level::Error);
        assert_eq!(m.icon.as_deref(), Some("✗"));
        assert_eq!(m.summary, "file not found");
    }

    /// The one piece of *cross-event* state in this module: the tool name is
    /// learned from the assistant event and read back on the user event that
    /// answers it. Nothing but a test spanning both events can catch it
    /// breaking — and when it does, an inbox message silently renders as
    /// plain text instead of markdown.
    #[test]
    fn a_recv_result_is_markdown_only_because_the_tool_use_was_seen_first() {
        let mut ctx = ClassifyCtx::default();
        let body = "**bold** message from a peer";

        // Without the correlation, it is an ordinary result.
        let cold = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx);
        assert_eq!(cold[0].body_format, None);
        assert!(!cold[0].summary.starts_with("recv ←"));

        // Feed the assistant event that names the tool, then the same result.
        classify_stream_value(
            &json!({
                "type": "assistant",
                "message": { "content": [{
                    "type": "tool_use", "id": "call-1",
                    "name": "mcp__hyperhive__recv", "input": {},
                }]},
            }),
            &mut ctx,
        );
        let warm = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx);
        assert!(
            warm[0].summary.starts_with("recv ← "),
            "{}",
            warm[0].summary
        );
        assert_eq!(warm[0].body_format, Some(BodyFormat::Markdown));
        assert_eq!(warm[0].body.as_deref(), Some(body));
    }

    #[test]
    fn a_recv_result_with_no_content_stays_an_ordinary_row() {
        let mut ctx = ClassifyCtx::default();
        ctx.record_tool_use("call-1", "mcp__hyperhive__recv");
        let m = one_with(&tool_result("   ", false, "call-1"), &mut ctx);
        assert!(!m.summary.starts_with("recv ←"));
        assert_eq!(m.summary, "(empty)");
    }

    fn one_with(v: &Value, ctx: &mut ClassifyCtx) -> TermMsg {
        let rows = classify_stream_value(v, ctx);
        assert_eq!(rows.len(), 1, "expected exactly one row for {v}");
        rows.into_iter().next().unwrap()
    }

    // -----------------------------------------------------------------
    // assistant content
    // -----------------------------------------------------------------

    #[test]
    fn assistant_text_becomes_a_markdown_body_with_no_summary() {
        let m = one(&json!({
            "type": "assistant",
            "message": { "content": [{ "type": "text", "text": "# hi" }]},
        }));
        assert_eq!(m.summary, "");
        assert_eq!(m.body.as_deref(), Some("# hi"));
        assert_eq!(m.body_format, Some(BodyFormat::Markdown));
    }

    #[test]
    fn blank_assistant_text_produces_no_row_at_all() {
        let mut ctx = ClassifyCtx::default();
        let rows = classify_stream_value(
            &json!({
                "type": "assistant",
                "message": { "content": [{ "type": "text", "text": "   \n " }]},
            }),
            &mut ctx,
        );
        assert!(rows.is_empty());
    }

    #[test]
    fn one_assistant_event_can_produce_several_rows() {
        let mut ctx = ClassifyCtx::default();
        let rows = classify_stream_value(
            &json!({
                "type": "assistant",
                "message": { "content": [
                    { "type": "text", "text": "doing it" },
                    { "type": "thinking", "thinking": "hmm" },
                    { "type": "tool_use", "id": "t1", "name": "Bash",
                      "input": { "command": "ls" } },
                    { "type": "something_else" },
                ]},
            }),
            &mut ctx,
        );
        assert_eq!(rows.len(), 3, "the unknown content block is skipped");
        assert_eq!(rows[1].icon.as_deref(), Some("💭"));
        // The tool_use was recorded for later correlation as a side effect.
        assert_eq!(ctx.tool_name("t1"), Some("Bash"));
    }

    // -----------------------------------------------------------------
    // string helpers
    // -----------------------------------------------------------------

    #[test]
    fn token_counts_shorten_at_each_threshold() {
        assert_eq!(fmt_tok(0), "0");
        assert_eq!(fmt_tok(999), "999");
        assert_eq!(fmt_tok(1_000), "1k");
        assert_eq!(fmt_tok(999_999), "999k");
        assert_eq!(fmt_tok(1_000_000), "1.0M");
        assert_eq!(fmt_tok(1_250_000), "1.2M");
    }

    #[test]
    fn trim_str_collapses_whitespace_and_counts_characters() {
        assert_eq!(trim_str("a  b\n\tc", 99), "a b c");
        assert_eq!(trim_str("abcdef", 3), "abc…");
        // Exactly at the limit: no ellipsis.
        assert_eq!(trim_str("abc", 3), "abc");
        // 4 chars / 8 bytes — a byte-based limit would cut this at 2.
        assert_eq!(trim_str("éééé", 4), "éééé");
    }

    #[test]
    fn matrix_ids_shorten_to_something_recognisable() {
        assert_eq!(fmt_user("@mara:pr1ma.darkest.space"), "@mara");
        assert_eq!(fmt_user("@mara"), "@mara");
        assert_eq!(fmt_room("#hive-chat:pr1ma.darkest.space"), "#hive-chat");
        assert_eq!(fmt_room("!abcdefghijkl:server"), "!abcdefgh");
        assert_eq!(fmt_room("plain name"), "plain name");
    }

    /// A short room id keeps its whole local part and stops at the colon.
    /// The non-ASCII case is the one that used to leak: `find(':')` is a
    /// *byte* offset and it was being spent as a *character* budget, so a
    /// multi-byte local part bought extra characters from the server half.
    #[test]
    fn a_room_id_never_shows_part_of_the_server() {
        assert_eq!(fmt_room("!short:server"), "!short");
        assert_eq!(fmt_room("!ÄÖÜ:server"), "!ÄÖÜ");
        assert_eq!(fmt_room("!ÄÖÜ"), "!ÄÖÜ");
    }
}