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.
264 lines
15 KiB
Markdown
264 lines
15 KiB
Markdown
# 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 `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
|
|
|
|
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](#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_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.
|
|
|
|
`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](#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](#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.
|