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.
This commit is contained in:
iris 2026-08-15 12:23:02 +02:00 committed by mara
commit 066cd712ea

View file

@ -1,11 +1,16 @@
# Per-agent terminal: row taxonomy (as built)
Snapshot of how the per-agent web UI's live pane renders each
event kind today. Source of truth lives in
`frontend/packages/agent/src/app.js` (`renderStream`, `fmtToolUse`,
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`, `fmtArgsGeneric`) +
`frontend/packages/shared/src/terminal.css` (the shared
`mdNode`, `detailsOpenMd`) +
`frontend/packages/shared/src/terminal/terminal.css` (the shared
`.live .<class>` styling) + the `marked` npm package (markdown).
## Layout contract
@ -56,8 +61,8 @@ parent's negative pull.
| `.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 `toolIcon(name)`: 📤 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>` | `💾/✏️ Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | 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 |
@ -87,42 +92,65 @@ suffix, so the terminal degrades cleanly against older event shapes.
## Renderer dispatch
`renderStream(v, api)` walks each stream-json line:
`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. Drops `system/init`, `rate_limit_event`, `result` (noise /
handled elsewhere — `result` powers the `cost` badge).
1a. `system/thinking_tokens` (claude streams a running
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. Consecutive ticks reuse the row only
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` → muted note `⚙ plugin install · loading…`
(on `started`) or `⚙ plugin install · ✓ done` (on `completed`).
Emitted in pairs: started fires before the plugin loads, completed
fires when it's ready. The `uuid` links the pair.
1c. `system/commands_changed` → collapsible `.note` details row showing
the new slash-command count (`⚙ commands changed · N available`).
Expanding reveals each `/name` and its aliases. Fires after
`plugin_install` when a plugin registers new commands.
1d. `system/compact_boundary` → muted note showing compaction summary:
`⚙ compact · <trigger> · <pre>→<post> tokens · <dur>`. Fields are
guarded individually — a missing field is silently omitted. Trigger
is `"manual"` (operator `/compact`) or `"auto"`.
1e. Other `system/` subtypes → muted note `⚙ <subtype>`.
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`, try
`renderRichToolUse` (Write/Edit/send/ask/answer get
custom renderings); on miss fall through to a flat
`.tool-use` row with `fmtToolUse → fmtArgsGeneric`.
`fmtToolUse` surfaces the salient arg per built-in tool
(see [`fmtToolUse` patterns](#fmttooluse-patterns) below);
`fmtArgsGeneric` handles everything else.
- `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`
@ -130,27 +158,29 @@ suffix, so the terminal degrades cleanly against older event shapes.
long = collapsed details.
5. Unrecognised shape → `.sys` row (amber, `!` glyph).
### `fmtToolUse` patterns
### Salient-arg formatting
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.
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` | rich diff row `Write <path> · +N` |
| `Edit` | rich diff row `Edit <path> · -N +N` |
| `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>` (also rich renderer for full body) |
| `TodoWrite` | `TodoWrite (N items)` |
| `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` (inline answer form for operator) |
| `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"` |
@ -183,12 +213,12 @@ name as-is.
| `join_room*/open_dm*` | `join_room* room` / `open_dm* @user` |
| `invite_user*` | `invite_user* @user → room` |
| `download_file*` | `download_file* room` |
| **Everything else** | `fmtArgsGeneric` — see [Extra-MCP tools](#extra-mcp-tools) |
| **Everything else** | `fmt_args_generic` — see [Extra-MCP tools](#extra-mcp-tools) |
## Markdown
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
dep, bundled by esbuild into the page's `app.js`) in a `<div
`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
@ -198,8 +228,9 @@ recv message bodies.
## Extra-MCP tools
`fmtArgsGeneric(name, input)` is the fallback when a tool
isn't in the built-in `fmtToolUse` switch:
`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`
@ -207,7 +238,7 @@ isn't in the built-in `fmtToolUse` switch:
`k: [N]` / `k: {…}` with a `…+N` overflow
This keeps less-frequent tools that don't have a specific
`fmtToolUse` case from dumping raw JSON. Common matrix and
`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