hyperhive/docs/terminal-rendering.md
iris 5ca96b8c85 docs: scrub self-ref cookies across 5 files (#719 batch 2)
All five files scrubbed to 0 issue-ref cookies. Most refs were
'(closes #NNN)' style attribution after closed work landed.

- docs/turn-loop.md: 6 → 0 (#598 unified binary, #419 auth-fail,
  #542 mtime resumption, #519 role markers, #442 spawn path,
  #474/#478 partial edit)
- docs/approvals.md: 5 → 0 (#317 flake validation, #250 withdraw,
  #441 rebuild-queue dispatch, #753 manager port hash, #425
  crash-watch grace)
- docs/terminal-rendering.md: 2 → 0 (#666 ask-operator inline)
- docs/conventions.md: 3 → 0 (#753 port hash, #692 parent
  sentinel, #486 reparenting)
- docs/security.md: 4 → 0 (#240 + #658 ×2 + #673/#678 attribution)

Combined with PR #810 (docs/web-ui.md 14→0), this PR closes the
small / medium files. Remaining: agent-hierarchy.md (13),
gotchas.md (13), persistence.md (13). gateway.md is atlas's lane.
2026-05-31 15:39:09 +02:00

7.4 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 character (the prefix glyph) gets pulled back into the column at ~0.5em, and wrapped continuation lines hang under the body, not under the glyph.

<details> summaries inherit those metrics. The disclosure marker ( / ) is supplied by CSS summary::before so it lands in the same column as flat-row glyphs. To make that work the JS-side summary text does not include a directional / — the row's colour (cyan = outbound, muted = inbound) carries the direction, and the prefix column never has to fit two glyphs side-by-side.

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
.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) → Name args… cyan tool_use w/o rich renderer 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 renderTaskEvent
.turn-end-ok / .turn-end-fail / .tool-result ⌁ task <id> ✓/✗/◌ <status> · <desc> · → <output_file> green / red / muted claude Task-tool result 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

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