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.
This commit is contained in:
iris 2026-08-30 21:11:22 +02:00
commit 5eefaa951d
13 changed files with 886 additions and 628 deletions

View file

@ -1,17 +1,40 @@
# Per-agent terminal: row taxonomy (as built)
Snapshot of how the per-agent web UI's live pane renders each
event kind today. The per-tool icon/summary/category (and, for a
few rich tools, the expandable body) are pre-computed server-side by
`hive-agent/src/stream_enrich.rs::enrich` and stamped onto the
stream-json value as `_icon`/`_summary`/`_category`/`_body`/
`_body_type` before SSE delivery, so the frontend just dispatches on
those fields instead of re-deriving them. Frontend source of truth
lives in `frontend/packages/agent/src/app.js` (`renderStream`,
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
`mdNode`, `detailsOpenMd`) +
`frontend/packages/shared/src/terminal/terminal.css` (the shared
`.live .<class>` styling) + the `marked` npm package (markdown).
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 `TermMsg`s (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
@ -23,8 +46,8 @@ 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.) pass it as the `icon` argument to
`row()` / `details()` / `detailsDiff()`, which puts it in a
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
@ -52,117 +75,108 @@ parent's negative pull.
## Row taxonomy
| CSS class | Prefix glyph | Color | Triggered by | Source |
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` | `◆ TURN ← <from>` | amber, left rule | `LiveEvent::TurnStart` | harness wake |
| `.turn-body` | (child div under turn-start) | fg | same | the wake-prompt body |
| `.turn-end-ok` | `✅ turn ok` | green, left rule | `LiveEvent::TurnEnd { ok: true }` | harness |
| `.turn-end-fail` | `❌ turn fail — note` | red, left rule | `LiveEvent::TurnEnd { ok: false }` | harness |
| `.turn-time` | `· HH:MM:SS` on turn-start; `· HH:MM:SS · <dur>` on turn-end (child span) | muted, smaller | per-event `ts` (unix seconds) on the live frame + history row | harness |
| `.text` | (no prefix; markdown body) | fg | claude `assistant.content[].text` | stream-json |
| `.thinking` | `💭 thinking …` | muted, italic | claude `assistant.content[].thinking` | stream-json |
| `.tool-use` (flat) | `<icon> Name args…` | cyan | tool_use w/o rich renderer; `<icon>` from the backend's `tool_icon(name)` (`stream_enrich.rs`): 📤 send · 📥 recv · ⏰ remind · 🏷️ set_status · 🪢 loose-ends · ✂️ cancel_loose_end · get_agent_meta · ✅ ack_until · 📜 get_logs/get_host_journal · ↻ restart · ⏹️ kill · ▶️ start · 🔄 update · 📋 list_containers/list_rooms/list_room_members/list_invites · 📖 read_room/Read · 👁️ mark_read · 🛑 bash kill · 🖥️ bash other · 💬 matrix send/reply/dm · 📦 request_* · ⏱️ schedule · 🔧 default | stream-json |
| `.tool-use` `<details>` | `✏️ Edit <path> · -N +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Edit | stream-json |
| `.tool-use` `<details open>` | `📤 send → to · NL` | cyan, body is markdown | rich renderer for send | stream-json |
| `.tool-result` (flat) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
| `.tool-result-block` `<details open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
| `.tool-result.error` (flat) | `✗ <msg>` | red | `tool_result` with `is_error: true` (≤120c); `<tool_use_error>` wrapper stripped | stream-json |
| `.tool-result-block.error` `<details>` | `Nl · headline` | red, body is text | long error `tool_result` (`is_error: true`); wrapper stripped | stream-json |
| `.tool-use` | `⌁ task <id> started · <desc> [type]` | cyan | claude Task-tool subagent start (dead path — `Task` omitted from agent allow-list) | `renderTaskEvent` |
| `.turn-end-ok` / `.turn-end-fail` / `.tool-result` | `⌁ task <id> ✓/✗/◌ <status> · <desc> · → <output_file>` | green / red / muted | claude Task-tool result (dead path for agents) | `renderTaskEvent` |
| `.note` | `· ⚙ plugin install · loading…` or `✓ done` | muted | `system/plugin_install` (`status` = started/completed) | stream-json |
| `.note` | `· ⚙ commands changed · N available` (expandable list of `/name` entries) | muted | `system/commands_changed` (slash-command set updated, usually post-plugin_install) | stream-json |
| `.note` | `· ⚙ compact · <trigger> · <pre>→<post> tokens · <dur>` | muted | `system/compact_boundary` (compaction completed; metadata includes pre/post token counts, duration, trigger) | stream-json |
| `.note` | `· ⚙ <subtype>` | muted | other `system` subtypes (context_window_exceeded, etc.) | stream-json catch-all |
| `.note` | `· <text>` | muted | harness chatter | `LiveEvent::Note` |
| `.note.stderr` | `! stderr: <line>` | amber/orange | stderr lines off claude | `LiveEvent::Note` (`text` starts `stderr:`) |
| `.note.op` | `· operator: <text>` | mauve italic | operator-initiated notes (/cancel, /compact, /model, new-session) | `LiveEvent::Note` (`text` starts `operator:`) |
| `.sys` | `! {json…}` | amber/orange | catch-all for stream shapes `renderStream` didn't classify | catch-all |
| Banner shimmer | mauve | turn in flight (ref-counted) | — | `setBannerActive` |
| 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](#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 | — |
The `.turn-time` span is appended to the turn-start / turn-end rows from
the event's `ts` (unix seconds), which the backend serializes as a
flattened sibling of `kind` on both the live SSE frame and each history
row — so the same renderer path stamps live tail and replayed scrollback
identically. Turn-end also shows the elapsed duration (end start),
paired against the most recent open turn-start. The read is guarded on a
numeric `ts`: if a frame omits it the rows render without the time
suffix, so the terminal degrades cleanly against older event shapes.
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).
## Renderer dispatch
## Classification pipeline
`renderStream(v, api)` walks each stream-json line. Most of the
per-event classification it used to do itself is now pre-computed
server-side by `hive-agent/src/stream_enrich.rs::enrich` (stamped
onto the value as `_category`/`_summary`/`_icon`/`_body`/
`_body_type` at SSE-emit time, for both the live tail and history
replay) — the client mostly just dispatches on those fields rather
than re-deriving them from raw claude field names:
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_use``classify_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`) — `level``level-*` 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.
1. `v._category === 'drop'` → dropped without rendering. Covers the
top-level `result` / `rate_limit_event` types (`result` powers the
`cost` badge elsewhere) and the `system` subtypes `init` /
`result` / `rate_limit_event`.
1a. `system` events with `_category === 'thinking_tok'`
(`subtype == "thinking_tokens"`; claude streams a running
`estimated_tokens` counter while thinking — many per turn) →
collapses into a **single** `🧠 thinking … ~N tokens` `.note`
row that updates in place, text taken verbatim from the
backend-computed `_summary`. Consecutive ticks reuse the row only
while it's still the last one rendered (`nextElementSibling ==
null`); any other event after it makes the next tick start a
fresh row. Avoids a note-per-tick scrollback flood.
1b. `system/plugin_install` (matched on `subtype`, not `_category`,
so start/complete can coalesce into one row) → muted note
`⚙ plugin install · loading…` (on `started`) or
`⚙ plugin install · ✓ done` (on `completed`), text from
`_summary`. Emitted in pairs: started fires before the plugin
loads, completed fires when it's ready.
1c. `system/status` (matched on `subtype`) → muted note from
`_summary`, except while the harness's local `turn_state` is
`compacting`: the client overrides the text with an elapsed-time
counter (`⚙ compact · <N>s…`) computed client-side from
`stateSince`, since the backend can't know client wall-clock time
at emit time.
1d. `_category === 'details'` (currently just `system/commands_changed`)
→ collapsible `.note` details row: `_summary` as the header
(`⚙ commands changed · N available`), `_body` (one `/name` per
line) as the expandable content.
1e. Other `system/` subtypes (e.g. `compact_boundary`, `api_retry`,
`api_error`, or an unrecognised subtype) → `_category === 'note'`,
rendered as a single muted note from `_summary` — computed by
`system_fields()` in `stream_enrich.rs` (e.g. `compact_boundary`
`⚙ compact · <trigger> · <pre>→<post> tokens · <dur>` with each
field guarded individually; an unrecognised subtype falls back to
`⚙ <subtype>`).
2. `subtype == "task_started" | "task_notification"`
`renderTaskEvent` (subagent activity gets the `⌁` glyph).
3. `type == "assistant"` → walk `message.content[]`:
- `text``.text` row with a markdown body via `mdNode`.
- `thinking``.thinking` row.
- `tool_use` → record `id → name` in `toolNameById`. The backend
stamps every `tool_use` entry with `_icon` + `_summary` (via
`fmt_tool_use()` in `stream_enrich.rs` — see [salient-arg
formatting](#salient-arg-formatting) below) and, for a fixed set
of tools, `_category: "rich"` + `_body`/`_body_type`. When
`_category === "rich"`, `renderRichToolUse` dispatches on
`_body_type` (`"diff"``api.detailsDiff`, `"markdown"`
`detailsOpenMd`, else `api.details`) to build the expandable
row; otherwise it falls through to a flat `.tool-use` row using
`_icon` + `_summary` as-is — no per-tool JS.
4. `type == "user"` → walk `message.content[]` for
`tool_result`; `renderToolResult` correlates via
`tool_use_id → toolNameById` to default-open `recv`
results with a markdown body, else short = flat /
long = collapsed details.
5. Unrecognised shape → `.sys` row (amber, `!` glyph).
`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 `_summary` and
read verbatim by the client. The `short` name strips the
`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.
@ -178,7 +192,7 @@ tools (Read, Write, etc.) keep their name as-is.
| `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` (default-open body) |
| `send*` | rich renderer: `send* → to · NL` (markdown body, expand state now follows the operator's preference like every other bodied row — see [Row taxonomy](#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"` |
@ -216,19 +230,20 @@ tools (Read, Write, etc.) keep their name as-is.
## Markdown
`mdNode(text)` wraps `marked.parse(text)` (the `marked` npm dep,
bundled by esbuild into the page's `app.js`) in a `<div
class="md">`. 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. Falls back to plain text if `marked` didn't
load. Applied to `text` rows and to send / recv message bodies.
`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 `_summary` server-side:
switch, computed into the `TermMsg`'s `summary` field server-side:
- single string field → `name k: "v"`
- single number/bool field → `name k: v`