hyperhive/docs/terminal-rendering.md
iris 066cd712ea docs(terminal-rendering): re-attribute dispatch logic to stream_enrich.rs, fix Write/TodoWrite/Bash dead-path claims
Verified against hive-agent/src/stream_enrich.rs and frontend/packages/agent/src/app.js:
- per-tool icon/summary/category classification moved server-side to
  stream_enrich.rs::enrich() a while ago; fmtToolUse/fmtArgsGeneric/toolIcon no
  longer exist in app.js -- the client now just dispatches on the backend-stamped
  _icon/_summary/_category/_body/_body_type fields. Rewrote the 'Renderer dispatch'
  and 'Salient-arg formatting' (was 'fmtToolUse patterns') sections to match, and
  retargeted the per-tool table's 'Everything else' row to fmt_args_generic.
- Write is no longer a rich diff row -- is_rich_tool() doesn't include it, and
  fmt_builtin_tool formats it identically to Read (flat, no count). Only Edit
  still gets the rich diff.
- TodoWrite and built-in Bash aren't in ALLOWED_BUILTIN_TOOLS (mcp_config.rs) any
  more than Task is -- flagged both as dead paths for consistency with the
  existing Task annotation.
- fixed stray 'ask* has an inline answer form' table note that contradicted the
  doc's own later 'Inline ask-operator answer' section (no such form exists).
- terminal.css path: frontend/packages/shared/src/terminal/terminal.css, not
  .../shared/src/terminal.css; marked npm dep is v18.x, not v4.x.
2026-08-15 12:45:22 +02:00

259 lines
16 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).
## 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.) pass it as the `icon` argument to
`row()` / `details()` / `detailsDiff()`, which puts it in 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
| CSS class | Prefix glyph | Color | Triggered by | Source |
|---|---|---|---|---|
| `.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 · ❓ ask · ⏰ 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`, `❓ ask → to`, `✍️ answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | 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` |
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.
## Renderer dispatch
`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. `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).
### 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
`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` (default-open body) |
| `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` |
| `ask*` | rich renderer: `ask* → to` (no inline answer form — see [Inline ask-operator answer](#inline-ask-operator-answer)) |
| `answer*` | rich renderer: `answer* #id` |
| `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` |
| **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
`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 / ask / answer /
recv message bodies.
## 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:
- 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.
## Inline ask-operator answer
An `mcp__hyperhive__ask(to: "operator", ...)` row has no inline
answer form in this terminal — it renders like any other tool call.
The operator answers a pending question from the main dashboard's
own question surfacing (`dashboard/src/swarm.js` + `call.js`, the
Y3R C4LL tab), not from the per-agent page.
## 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.