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 .<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 character (the prefix glyph) gets pulled back into the column at ~0.5em, and wrapped continuation lines hang under the body, not under the glyph.

<details> summaries inherit those metrics. The disclosure marker ( / ) is supplied by CSS summary::before so it lands in the same column as flat-row glyphs. To make that work the JS-side summary text does not include a directional / — the row's colour (cyan = outbound, muted = inbound) carries the direction, and the prefix column never has to fit two glyphs side-by-side.

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
.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 stream-json
.tool-use <details> Write/Edit <path> · +N (no ) cyan, body is +/- diff renderRichToolUse Write/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-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) ← <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-use ⌁ task <id> started · <desc> [type] cyan claude Task-tool subagent start renderTaskEvent
.turn-end-ok / .turn-end-fail / .tool-result ⌁ task <id> ✓/✗/◌ <status> · <desc> · → <output_file> green / red / muted claude Task-tool result renderTaskEvent
.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

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).
  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 <N>s / max <N> 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 <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

fmtArgsGeneric(name, input) is the fallback when a tool isn't in the built-in fmtToolUse switch:

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 <div class="ask-answer-inline-slot"> 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/<id> 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.