From 066cd712eace00c75b5aded42bd5e090ceca9c30 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 15 Aug 2026 12:23:02 +0200 Subject: [PATCH] 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. --- docs/terminal-rendering.md | 125 +++++++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index 9e463aad..36b5786d 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -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 .` 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 · ` 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) | ` Name args…` | cyan | tool_use w/o rich renderer; `` 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` `
` | `💾/✏️ Write/Edit · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json | +| `.tool-use` (flat) | ` Name args…` | cyan | tool_use w/o rich renderer; `` 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` `
` | `✏️ Edit · -N +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Edit | stream-json | | `.tool-use` `
` | `📤 send → to · NL`, `❓ ask → to`, `✍️ answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json | | `.tool-result` (flat) | `← ` | muted | short `tool_result` (≤120c, non-recv) | stream-json | | `.tool-result-block` `
` | `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 · ·
 tokens · `. Fields are
-   guarded individually — a missing field is silently omitted. Trigger
-   is `"manual"` (operator `/compact`) or `"auto"`.
-1e. Other `system/` subtypes → muted note `⚙ `.
+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 · 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 ·  · 
 tokens · ` with each
+   field guarded individually; an unrecognised subtype falls back to
+   `⚙ `).
 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 ` |
-| `Write` | rich diff row `Write  · +N` |
-| `Edit` | rich diff row `Edit  · -N +N` |
+| `Write` | flat, same shape as Read: `Write ` — no diff/count (`content` can be megabytes and is one-sided; open the file to inspect it) |
+| `Edit` | rich diff row `Edit  · -N +N` (just `+N` for a pure insert, i.e. empty `old_string`) |
 | `Glob` | `Glob ` |
 | `Grep` | `Grep ` |
-| `Bash` | `Bash [bg] $ ` (also rich renderer for full body) |
-| `TodoWrite` | `TodoWrite (N items)` |
+| `Bash` | `Bash [bg] $ ` (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 `
`. 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