# 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 Β· πŸ–₯️ bash Β· πŸ’¬ matrix Β· πŸ“¦ 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-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` | `Β· ` | 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. 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 β€” e.g. `recv` shows `wait s` / `max ` when set (bare `recv()` otherwise), `Bash` flags `[bg]` for `run_in_background` commands. 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). ## 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 `mcp__matrix__send_message` and similar from dumping raw JSON. ## 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.