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).
189 lines
11 KiB
Markdown
189 lines
11 KiB
Markdown
# 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:
|
||
|
||
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 · <trigger> · <pre>→<post> tokens · <dur>`. Fields are
|
||
guarded individually — a missing field is silently omitted. Trigger
|
||
is `"manual"` (operator `/compact`) or `"auto"`.
|
||
1e. Other `system/` subtypes → muted note `⚙ <subtype>`.
|
||
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:
|
||
|
||
- 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
|
||
`<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.
|