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.
259 lines
16 KiB
Markdown
259 lines
16 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. 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.
|