hyperhive/docs/terminal-rendering.md
iris 5eefaa951d Simplify terminal message shape to a uniform TermMsg
Move terminal-row classification server-side into a new
hive-agent/src/term_msg.rs, replacing the old JSON-mutation
enrich()/stamped-field approach in stream_enrich.rs with one
uniform wire shape: {icon?, level: debug|info|warn|error, summary,
body?, body_format?: markdown|diff, coalesce_key?}. No more per-row
`kind` tag or raw claude-JSON passthrough — every row is the same
shape, with structural identity carried by icon + summary text
instead of a CSS class per row kind.

hive-agent/src/web_ui/stream.rs's history + SSE endpoints now both
call term_msg::classify() and serve TermEnvelope{ts, seq?, msgs}
frames; events that classify to zero rows (agent-state changes,
drop-noise) never reach the wire.

Frontend: classifyEvent.ts collapses from a large per-tool dispatch
tree to a thin TermMsg -> StreamRow adapter. streamRow.ts/Row.tsx
drop the now-dead meta/childText fields. terminal.css switches from
a dozen-odd per-row-kind classes to four level-based color rules.
Expand/collapse of a bodied row is now a uniform client-side
decision (the operator's preference), no server-side per-tool
override.

docs/terminal-rendering.md rewritten to match.
2026-08-30 21:11:25 +02:00

15 KiB

Per-agent terminal: row taxonomy (as built)

Snapshot of how the per-agent web UI's live pane renders each event kind today. Every row on the wire is one TermMsg (hive-agent/src/term_msg.rs): {icon?, level: debug|info|warn|error, summary, body?, body_format?: markdown|diff, coalesce_key?} — six fields, the same shape for every row, no kind tag. A raw claude stream-json line (LiveEvent::Stream) can classify into zero, one, or several TermMsgs (an assistant message with both a text block and a tool_use block produces two); other LiveEvent variants (TurnStart/TurnEnd/Note) map straight to one each, and the five agent-state variants (StatusChanged/ModelChanged/EffortChanged/ TokenUsageChanged/TurnStateChanged) always produce zero — they never render as terminal rows, the header/badges read /api/state instead.

Classification happens server-side, once, in hive-agent/src/term_msg.rs (top-level dispatch) + hive-agent/src/stream_enrich.rs (the Stream payload's per-tool/ per-event breakdown — icon, summary text, expandable body). Both the live SSE tail (GET /api/events/stream) and the paginated history replay (GET /api/events/history) call the same classify() and serve identically-shaped TermEnvelope { ts, seq?, msgs: TermMsg[] } frames (hive-agent/src/web_ui/stream.rs) — the sqlite event log still stores the raw, unclassified event, so classification logic can change without a DB migration.

The frontend (frontend/packages/agent/src/lib/classifyEvent.ts) is a thin adapter: TermMsgStreamRow (lib/streamRow.ts), rendered by components/Row.tsx. It does not re-derive icons, summaries, or per-tool formatting — that all happened server-side already. Its only real decision is body layout: an empty-summary + markdown-body row renders as a flat row with just the body (assistant text); every other bodied row is an expandable <details>, opened by default according to the operator's expand-tool-output preference (getExpandDetailsPref(), @hive/shared/prefs.ts) — uniformly, no per-tool server override. frontend/packages/shared/src/terminal/terminal.css supplies the .live .level-* colour rules + shared row layout.

Layout contract

Every row — flat <div class="row …"> and expandable <details class="row …"> alike — shares one prefix column. The mechanism is padding-left + negative text-indent on .live .row: the row's first inline box gets pulled back into the column at ~0.5em, and wrapped continuation lines hang under the body, not under the glyph.

Rows that carry an icon (the per-tool emoji, 🧠/💭 thinking, etc.) set the StreamRow.icon prop, which components/Row.tsx renders into a fixed-width .row-glyph cell (display: inline-block; width: 1.4em) rather than as a bare first character. The constant cell width means every icon's left edge lines up in the column regardless of the glyph's rendered width (emoji differ; some carry a variation selector) — a flat row's 🧠 and a details summary's 🖥️ align. Rows with a plain single-char glyph (◆ · ! ←) still pass it inline; it lands at the same ~0.5em left edge.

<details> summaries inherit those metrics. The icon (when present) sits in the .row-glyph cell; the summary text lives in a .summary-text span and the disclosure caret ( / ) is supplied by CSS .summary-text::before so it leads the text, not the icon — a leading caret on the icon would push it out of the shared column. Icon-less summaries have no .row-glyph, so the caret falls into the prefix column like the old directional glyph. The summary text carries no / ; the row colour (cyan = outbound, muted = inbound) carries the direction.

Child blocks inside a row (the .md markdown wrapper, an inner <details>) get text-indent: 0 so their content lays out from the body column instead of inheriting the parent's negative pull.

Row taxonomy

Every row carries a level (debug/info/warn/error), which is the only thing that drives its colour — frontend/packages/shared/src/ terminal/terminal.css's .live .level-debug/-info/-warn/-error rules. There's no separate per-row-kind class any more (no .turn-start, .tool-use, .tool-result.error, .sys, …) — structural identity (this is a turn boundary, this is a tool call, this is an error) is carried by the row's icon and summary text instead, computed server-side once and read verbatim by the client.

Level Colour Used for
debug muted thinking, thinking-token ticks, plugin-install/status ticks, ambient harness chatter, unrecognised-system-subtype notes
info default fg turn start/ok, assistant text, tool calls + their (non-error) results, operator-initiated notes
warn amber, left rule stderr lines, an unrecognised stream-json shape, API retry backoff
error red, left rule turn failed, a tool result with is_error: true, a hard API error

Representative summaries (icon + text, hive-agent/src/term_msg.rs + stream_enrich.rs):

Row icon level summary body
turn start info TURN ← <from> the wake-prompt text (plain)
turn ok info turn ok
turn failed error turn fail — <note>
assistant text info (empty) the text itself (markdown) — renders as a flat row, no prefix line
thinking 💭 debug the thinking text (or thinking …)
thinking-token tick 🧠 debug thinking… ~N tokens (coalesce_key: "thinking-tok", in-place updates)
tool call per-tool, see salient-arg formatting info <short-name> <args…> some tools: diff (Edit) / markdown (send) / plain (long mcp__bash__run cmd)
tool result, short info the trimmed result text
tool result, long info Nl · headline… the full text (plain)
tool result, recv-correlated info recv ← <summary> the full text (markdown)
tool result, error (short) error the trimmed error text
tool result, error (long) error Nl · headline… the full error text (plain)
subagent task start/notify (dead path — Task not in the agent allow-list) info task <id> started · <desc> [type] / task <id> ✓/✗/◌ <status> · <desc> · → <file>
plugin install / status tick debug ⚙ plugin install · loading…|✓ done / ⚙ status (both coalesce_key-collapsed)
commands changed debug ⚙ commands changed · N available one /name per line (plain)
compact boundary / API retry / API error / unrecognised system subtype debug / warn / error / debug ⚙ compact · … / ⚠ api retry · … / ✗ api error · … / ⚙ <subtype>
harness note debug the note text
stderr line warn stderr: <line>
operator-initiated note info operator: <text>
unrecognised stream-json shape ! warn trimmed raw JSON

Whether a bodied row renders open or collapsed is a client-only decision — the operator's expand-tool-output preference (getExpandDetailsPref()), applied uniformly to every row with a body. There's no server-side per-tool override any more (the old recv/send "always default-open" special case is gone).

Classification pipeline

  1. hive-agent/src/term_msg.rs::classify() is the top-level dispatch, called once per LiveEvent at SSE-emit time (both the live tail and history replay — see web_ui/stream.rs):
    • TurnStart/TurnEnd/Note map straight to one TermMsg each.
    • The five agent-state variants (StatusChanged/…) always produce zero — never rendered as terminal rows.
    • Stream(value) delegates to stream_enrich::classify_stream_value.
  2. classify_stream_value walks one raw claude stream-json line:
    • top-level result / rate_limit_event → dropped (no rows).
    • type: "system"classify_system, which computes (category, summary, body) per subtype (system_fields()) and maps category to a level + optional coalesce_key: "thinking_tok" → debug, coalesced; "details" (currently just commands_changed) → debug, with an expandable body; "note" → level varies by subtype (api_error → error, api_retry → warn, plugin_install/status → debug + coalesced, everything else → debug).
    • subtype: "task_started" | "task_notification" (regardless of top-level type) → classify_task_event.
    • type: "assistant" → walk message.content[]: text → info row, empty summary, markdown body; thinking → debug row with 💭; tool_useclassify_tool_use (records id → name in the connection/page-scoped ClassifyCtx for the next step, computes icon + summary via fmt_tool_use()/tool_icon(), and a body via rich_tool_body() for the fixed set of tools that have one).
    • type: "user" → walk message.content[] for tool_result: classify_tool_result correlates tool_use_id against the ClassifyCtx to spot a recv result (rendered recv ← … with a markdown body), strips the <tool_use_error> wrapper on errors, and otherwise picks short-flat vs. long-with-body by length.
    • Unrecognised shape → warn row, trimmed raw JSON, ! icon.
  3. frontend/packages/agent/src/lib/classifyEvent.ts::classifyEvent maps each TermMsg in the envelope to a StreamRow (lib/streamRow.ts) — levellevel-* CSS class, body_format → which of markdown/diff/plain body prop to set, empty-summary markdown body → the flat "assistant text" shape, everything else with a body → an expandable <details> gated by the operator's preference. components/Row.tsx renders the result.

ClassifyCtx (tool_use id → name correlation, needed for step 2's recv detection) is scoped per SSE connection on the live path and per page on the history path — it does not cross the live/history boundary, so a recv result whose tool_use fell on the other side of a reconnect or page load renders as a plain block instead of default-open markdown. Accepted, documented degradation (hive-agent/src/term_msg.rs's ClassifyCtx doc), not a bug.

Salient-arg formatting

Server-side (fmt_tool_use() and its per-tool-family helpers in hive-agent/src/stream_enrich.rs), computed into the TermMsg's summary field and read verbatim by the client. The short name strips the mcp__hyperhive__ / mcp__bash__ / mcp__matrix__ prefix and appends * (e.g. recv*, run*, send_message*). Unprefixed tools (Read, Write, etc.) keep their name as-is.

Tool Rendered as
Claude built-ins
Read Read <path>
Write flat, same shape as Read: Write <path> — no diff/count (content can be megabytes and is one-sided; open the file to inspect it)
Edit rich diff row Edit <path> · -N +N (just +N for a pure insert, i.e. empty old_string)
Glob Glob <pattern>
Grep Grep <pattern>
Bash Bash [bg] $ <cmd> (dead path — built-in Bash isn't in the agent allow-list either; shell execution goes through mcp__bash__run / run* below instead)
TodoWrite TodoWrite (N items) (dead path — TodoWrite isn't in the agent allow-list; its state lives in claude's in-process session and evaporates on /compact, so agents plan in /state notes instead)
Core hyperhive
send* rich renderer: send* → to · NL (markdown body, expand state now follows the operator's preference like every other bodied row — see Row taxonomy)
recv* recv*() · recv* wait Ns · recv* max N
remind* remind* +Xm "preview" or remind* at HH:MMZ "preview"
set_status* set_status* "text"
get_loose_ends* get_loose_ends*() or get_loose_ends* [agent]
get_agent_meta* get_agent_meta*() or get_agent_meta* name
cancel_loose_end* cancel_loose_end* kind #id
ack_until* ack_until* ≤N
mark_todos_done* mark_todos_done* [id1, id2, …] (first 8 ids, past that)
Lifecycle
kill*/restart*/start*/update* kill* name (etc.)
get_logs* get_logs* name or get_logs* name NL
get_host_journal* get_host_journal*() or with [container] · [/grep/] · NL
Approvals / config
request_init_config* request_init_config* name
request_update_meta_inputs* request_update_meta_inputs* [inp1, …] or all
Scheduling
list_schedules* list_schedules*()
cancel_schedule* cancel_schedule* #id all or #id [t1, t2]
fire_schedule_now* fire_schedule_now* #id
edit_schedule* edit_schedule* #id · body · interval · next · +N tgt · -N tgt (only changed fields shown)
request_schedule_prompt* request_schedule_prompt* → t1, t2 at HH:MMZ (+ +Ns if recurring)
Bash MCP
run* run* [bg] $ cmd (also rich renderer for full cmd body)
status* (bash) status* id:xyz or status* id:xyz · wait Ns
kill* (bash) kill* id or kill* id [force]
Matrix MCP
send_message*/send_dm*/send_reply* send_message* → room: "body" / send_dm* → @user: "body"
send_reaction* send_reaction* room emoji
read_room* read_room* room or read_room* room [N]
mark_read* mark_read* room
join_room*/open_dm* join_room* room / open_dm* @user
invite_user* invite_user* @user → room
download_file* download_file* room
Everything else fmt_args_generic — see Extra-MCP tools

Markdown

frontend/packages/agent/src/lib/markdown.ts's renderMarkdown(text) runs marked.parse(text) through DOMPurify and is rendered into a <div class="md"> by components/Row.tsx's MarkdownBody. CSS in terminal.css scopes paragraph / code / list / blockquote / link styling under .live .row .md so the markdown body doesn't bleed into the row's own text-indent. Applied to any TermMsg with body_format: "markdown" — assistant text, send's body, a recv-correlated tool result.

Extra-MCP tools

fmt_args_generic(name, input) (hive-agent/src/stream_enrich.rs) is the fallback when a tool isn't in the built-in fmt_tool_use switch, computed into the TermMsg's summary field server-side:

  • single string field → name k: "v"
  • single number/bool field → name k: v
  • multi-field → first 4 pairs trimmed to k: "v" / k: [N] / k: {…} with a …+N overflow

This keeps less-frequent tools that don't have a specific fmt_tool_use case from dumping raw JSON. Common matrix and hyperhive tools have their own cases and skip this path.

Dashboard side (not covered here)

The main dashboard's message-flow pane is a different shape: broker messages render as .msgrow grid lines (ts / arrow / from / → / to / body) with their own styling. .live .msgrow explicitly resets text-indent: 0 so the per-agent terminal's hanging-indent metrics don't leak into the flex-grid broker rows.