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 · ✂️ 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> |
💾/✏️ 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 (seefmtToolUsepatterns below);fmtArgsGenerichandles everything else.
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).
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 <path> |
Write |
rich diff row Write <path> · +N |
Edit |
rich diff row Edit <path> · -N +N |
Glob |
Glob <pattern> |
Grep |
Grep <pattern> |
Bash |
Bash [bg] $ <cmd> (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_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 |
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 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
<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.