Replaces the first-character-glyph + negative-text-indent trick (which let a wide emoji or a leading disclosure caret knock the icon out of column) with a genuine icon cell. terminal.js: row() / details() / detailsDiff() take an optional `icon` that goes in a fixed-width `.row-glyph` element (inline-block, 1.4em). Details summaries wrap their text in a `.summary-text` span; the disclosure caret moves to `.summary-text::before` so it leads the text, not the icon — keeping the icon in the shared column. terminal.css carries the cell + caret rules. app.js passes the per-tool emoji as `icon` for the flat tool-use row and every expandable tool summary (Write/Edit/send/ask/answer/bash) plus the 💭 thinking row, instead of string-prefixing it. A details `🖥️` now lines up under a flat row's `🧠` regardless of emoji width. Doc: terminal-rendering.md layout contract updated. Closes #1844.
170 lines
9.6 KiB
Markdown
170 lines
9.6 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-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` | `· <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.
|
||
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.
|