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.
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: TermMsg → StreamRow (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
hive-agent/src/term_msg.rs::classify()is the top-level dispatch, called once perLiveEventat SSE-emit time (both the live tail and history replay — seeweb_ui/stream.rs):TurnStart/TurnEnd/Notemap straight to oneTermMsgeach.- The five agent-state variants (
StatusChanged/…) always produce zero — never rendered as terminal rows. Stream(value)delegates tostream_enrich::classify_stream_value.
classify_stream_valuewalks one raw claudestream-jsonline:- top-level
result/rate_limit_event→ dropped (no rows). type: "system"→classify_system, which computes(category, summary, body)persubtype(system_fields()) and mapscategoryto a level + optionalcoalesce_key:"thinking_tok"→ debug, coalesced;"details"(currently justcommands_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-leveltype) →classify_task_event.type: "assistant"→ walkmessage.content[]:text→ info row, empty summary, markdown body;thinking→ debug row with💭;tool_use→classify_tool_use(recordsid → namein the connection/page-scopedClassifyCtxfor the next step, computes icon + summary viafmt_tool_use()/tool_icon(), and a body viarich_tool_body()for the fixed set of tools that have one).type: "user"→ walkmessage.content[]fortool_result:classify_tool_resultcorrelatestool_use_idagainst theClassifyCtxto spot arecvresult (renderedrecv ← …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.
- top-level
frontend/packages/agent/src/lib/classifyEvent.ts::classifyEventmaps eachTermMsgin the envelope to aStreamRow(lib/streamRow.ts) —level→level-*CSS class,body_format→ which ofmarkdown/diff/plainbody 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.tsxrenders 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…+Noverflow
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.