The renderer dispatch section was missing entries for three system subtypes that now have specific renderers (added in the preceding commits on this branch). Update the row taxonomy table and the numbered dispatch description to cover all four system-subtype handlers (thinking_tokens was already documented; 1b–1e are new).
11 KiB
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 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 toolIcon(name) (📤 send · 📥 recv · ❓ ask · ⏰ remind · 🏷️ set_status · 🪢 loose-ends · 🖥️ bash · 💬 matrix · 📦 request_* · ⏱️ schedule · 🔧 default) |
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-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:
- Drops
system/init,rate_limit_event,result(noise / handled elsewhere —resultpowers thecostbadge). 1a.system/thinking_tokens(claude streams a runningestimated_tokenscounter while thinking — many per turn) → collapses into a single🧠 thinking … ~N tokens.noterow 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…(onstarted) or⚙ plugin install · ✓ done(oncompleted). Emitted in pairs: started fires before the plugin loads, completed fires when it's ready. Theuuidlinks the pair. 1c.system/commands_changed→ collapsible.notedetails row showing the new slash-command count (⚙ commands changed · N available). Expanding reveals each/nameand its aliases. Fires afterplugin_installwhen a plugin registers new commands. 1d.system/compact_boundary→ muted note showing compaction summary:⚙ compact · <trigger> · <pre>→<post> tokens · <dur>. Fields are guarded individually — a missing field is silently omitted. Trigger is"manual"(operator/compact) or"auto". 1e. Othersystem/subtypes → muted note⚙ <subtype>. subtype == "task_started" | "task_notification"→renderTaskEvent(subagent activity gets the⌁glyph).type == "assistant"→ walkmessage.content[]:text→.textrow with a markdown body viamdNode.thinking→.thinkingrow.tool_use→ recordid → nameintoolNameById, tryrenderRichToolUse(Write/Edit/send/ask/answer get custom renderings); on miss fall through to a flat.tool-userow withfmtToolUse → fmtArgsGeneric.fmtToolUsesurfaces the salient arg per built-in tool — e.g.recvshowswait <N>s/max <N>when set (barerecv()otherwise),Bashflags[bg]forrun_in_backgroundcommands.
type == "user"→ walkmessage.content[]fortool_result;renderToolResultcorrelates viatool_use_id → toolNameByIdto default-openrecvresults with a markdown body, else short = flat / long = collapsed details.- Unrecognised shape →
.sysrow (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:
- 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…+Noverflow
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.