hyperhive/docs/terminal-rendering.md
iris 1c47bd4333 agent-ui: real fixed-width icon column for terminal rows
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.
2026-06-22 00:41:24 +02:00

170 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.