# 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`, `renderRichToolUse`, `renderToolResult`, `renderTaskEvent`, `mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) + `frontend/packages/shared/src/terminal.css` (the shared `.live .` styling) + the `marked` npm package (markdown). ## Layout contract Every row β€” flat `
` and expandable `
` 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. `
` 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 `
`) 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 ← ` | 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 Β· ` 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` `
` | `πŸ“€ send β†’ to Β· NL`, `❓ ask β†’ to`, `✍️ answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json | | `.tool-use .ask-answer-inline-slot` | (sub-block under `ask β†’ operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | rich renderer | | `.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 | | `.tool-result-block` `
` | `recv ← ` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json | | `.tool-result.error` (flat) | `βœ— ` | red | `tool_result` with `is_error: true` (≀120c); `` wrapper stripped | stream-json | | `.tool-result-block.error` `
` | `Nl Β· headline` | red, body is text | long error `tool_result` (`is_error: true`); wrapper stripped | stream-json | | `.tool-use` | `⌁ task started Β· [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 βœ“/βœ—/β—Œ Β· Β· β†’ ` | 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 Β· Β·
β†’ tokens Β· ` | muted | `system/compact_boundary` (compaction completed; metadata includes pre/post token counts, duration, trigger) | stream-json |
| `.note` | `Β· βš™ ` | muted | other `system` subtypes (context_window_exceeded, etc.) | stream-json catch-all |
| `.note` | `Β· ` | muted | harness chatter | `LiveEvent::Note` |
| `.note.stderr` | `! stderr: ` | amber/orange | stderr lines off claude | `LiveEvent::Note` (`text` starts `stderr:`) |
| `.note.op` | `Β· operator: ` | 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:

1. Drops `system/init`, `rate_limit_event`, `result` (noise /
   handled elsewhere β€” `result` powers the `cost` badge).
1a. `system/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
   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 `βš™ `.
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.
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).

### `fmtToolUse` patterns

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` |
| `Glob` | `Glob ` |
| `Grep` | `Grep ` |
| `Bash` | `Bash [bg] $ ` (also rich renderer for full body) |
| `TodoWrite` | `TodoWrite (N items)` |
| **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) |
| `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_apply_commit*` | `request_apply_commit* agent @ sha12` |
| `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** | `fmtArgsGeneric` β€” 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 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 `fmtArgsGeneric(name, input)` is the fallback when a tool isn't in the built-in `fmtToolUse` switch: - 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 `fmtToolUse` case from dumping raw JSON. Common matrix and hyperhive tools have their own cases and skip this path. ## Inline ask-operator answer When an agent calls `mcp__hyperhive__ask` with `to == "operator"` (default), the rich tool-use renderer mounts an empty `
` inside the row's expanded body, then enqueues a loose-ends refresh. `reconcileAskBinds()` runs on every loose-ends refresh, matches each waiting slot against pending operator-bound questions by question text, and injects an inline `.answer-form` (textarea + send button bound to `/answer-question/` on the host dashboard) into the matching slot. When a question subsequently leaves the pending list (answered, cancelled by asker, or TTL-expired), the same reconciler replaces the form with a struck-through `[resolved]` tag so the scrollback reflects the closed state. The label is neutral because `/api/loose-ends` only carries pending state β€” full resolution detail is visible via the question's history in the side panel. Lets the operator answer mid-flow without context-switching to the loose-ends side panel or the dashboard tab. Side panel + dashboard forms remain β€” they're the same `buildAnswerForm` factory, three mount points for the same POST. ## 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.