hyperhive/docs/terminal-rendering.md
iris 6b4b0f11c0 docs(web-ui): document turn start/end times + duration on the agent terminal
Follow-up to the turn-time feature: the terminal now stamps a wall-clock
time on the turn-start / turn-end rows and an elapsed duration on
turn-end, sourced from a per-event `ts` (unix seconds) that the backend
serializes as a flattened sibling of the event tag on both the live SSE
frame and each history row.

- terminal-rendering.md: add the `.turn-time` row to the taxonomy table
  + a note on the ts source and the graceful degradation when a frame
  omits ts.
- web-ui/agent.md: note the ts sibling on the emitted events, and the
  time/duration suffixes on the TurnStart / TurnEnd renderings.
2026-06-10 15:38:04 +02:00

148 lines
8.1 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 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 |
| `.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) | `→ 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` |
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).
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.