Drop classifyEvent.ts, render TermMsg directly
Per review: StreamRow was meant to match what the server sends in TermMsg, not be a separate model needing a translation step. - classifyEvent.ts and streamRow.ts deleted; termMsg.ts holds the wire types (TermMsg/TermEnvelope) plus TermRow, a TermMsg with just the key/fromHistory bookkeeping Preact needs for list rendering. - Row.tsx renders a TermRow directly: level -> CSS class, empty summary + markdown body -> flat row, everything else with a body -> expandable details gated by the operator's preference. No separate classification step. - useLiveStream.ts drops ClassifyCtx (a single incrementing key counter didn't need a whole context object) and maps envelopes to rows inline. - docs/terminal-rendering.md trimmed substantially — was documenting more implementation detail than useful; points at stream_enrich.rs for the per-tool specifics instead of duplicating them in prose.
This commit is contained in:
parent
5eefaa951d
commit
cebf3c6ced
6 changed files with 156 additions and 436 deletions
|
|
@ -1,264 +1,51 @@
|
|||
# Per-agent terminal: row taxonomy (as built)
|
||||
|
||||
Snapshot of how the per-agent web UI's live pane renders each
|
||||
event kind today. Every row on the wire is one **`TermMsg`**
|
||||
The per-agent web UI's live pane renders one row per `TermMsg`
|
||||
(`hive-agent/src/term_msg.rs`): `{icon?, level: debug|info|warn|error,
|
||||
summary, body?, body_format?: markdown|diff, coalesce_key?}` — six
|
||||
fields, the same shape for every row, no `kind` tag. A raw claude
|
||||
`stream-json` line (`LiveEvent::Stream`) can classify into zero, one, or
|
||||
several `TermMsg`s (an assistant message with both a text block and a
|
||||
tool_use block produces two); other `LiveEvent` variants
|
||||
(`TurnStart`/`TurnEnd`/`Note`) map straight to one each, and the five
|
||||
agent-state variants (`StatusChanged`/`ModelChanged`/`EffortChanged`/
|
||||
`TokenUsageChanged`/`TurnStateChanged`) always produce zero — they never
|
||||
render as terminal rows, the header/badges read `/api/state` instead.
|
||||
summary, body?, body_format?: markdown|diff, coalesce_key?}`. Classification
|
||||
(icon, summary text, whether a tool call gets an expandable body) happens
|
||||
server-side, once — `term_msg.rs` + `stream_enrich.rs` — and is served
|
||||
identically by both `GET /api/events/history` and `GET /api/events/stream`
|
||||
as `TermEnvelope { ts, seq?, msgs: TermMsg[] }` frames
|
||||
(`hive-agent/src/web_ui/stream.rs`).
|
||||
|
||||
Classification happens **server-side**, once, in
|
||||
`hive-agent/src/term_msg.rs` (top-level dispatch) +
|
||||
`hive-agent/src/stream_enrich.rs` (the `Stream` payload's per-tool/
|
||||
per-event breakdown — icon, summary text, expandable body). Both the
|
||||
live SSE tail (`GET /api/events/stream`) and the paginated history
|
||||
replay (`GET /api/events/history`) call the same `classify()` and
|
||||
serve identically-shaped `TermEnvelope { ts, seq?, msgs: TermMsg[] }`
|
||||
frames (`hive-agent/src/web_ui/stream.rs`) — the sqlite event log still
|
||||
stores the raw, unclassified event, so classification logic can change
|
||||
without a DB migration.
|
||||
The frontend renders a `TermMsg` close to as-is
|
||||
(`frontend/packages/agent/src/components/Row.tsx`): `level` picks the
|
||||
CSS colour (`terminal.css`'s `.live .level-*`), an empty `summary` +
|
||||
markdown `body` renders as a flat row with just the body (assistant
|
||||
text), and any other bodied row is an expandable `<details>`, opened by
|
||||
default according to the operator's expand-tool-output preference
|
||||
(`getExpandDetailsPref()`) — uniformly, no per-tool override. There's no
|
||||
separate client-side row model or classification step.
|
||||
|
||||
The frontend (`frontend/packages/agent/src/lib/classifyEvent.ts`) is a
|
||||
thin adapter: `TermMsg` → `StreamRow` (`lib/streamRow.ts`), rendered by
|
||||
`components/Row.tsx`. It does **not** re-derive icons, summaries, or
|
||||
per-tool formatting — that all happened server-side already. Its only
|
||||
real decision is body layout: an empty-`summary` + markdown-`body` row
|
||||
renders as a flat row with just the body (assistant text); every other
|
||||
bodied row is an expandable `<details>`, opened by default according to
|
||||
the operator's expand-tool-output preference (`getExpandDetailsPref()`,
|
||||
`@hive/shared/prefs.ts`) — uniformly, no per-tool server override.
|
||||
`frontend/packages/shared/src/terminal/terminal.css` supplies the
|
||||
`.live .level-*` colour rules + shared row layout.
|
||||
## Layout
|
||||
|
||||
## Layout contract
|
||||
Every row shares one prefix column via `padding-left` + negative
|
||||
`text-indent` on `.live .row`; an icon (when set) sits in a fixed-width
|
||||
`.row-glyph` cell so icons of different rendered widths still line up.
|
||||
`<details>` summaries reuse the same metrics, with the disclosure caret
|
||||
leading the summary text rather than the icon.
|
||||
|
||||
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.
|
||||
## Levels
|
||||
|
||||
Rows that carry an icon (the per-tool emoji, `🧠`/`💭`
|
||||
thinking, etc.) set the `StreamRow.icon` prop, which
|
||||
`components/Row.tsx` renders into 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
|
||||
|
||||
Every row carries a **level** (`debug`/`info`/`warn`/`error`), which is
|
||||
the *only* thing that drives its colour — `frontend/packages/shared/src/
|
||||
terminal/terminal.css`'s `.live .level-debug/-info/-warn/-error` rules.
|
||||
There's no separate per-row-kind class any more (no `.turn-start`,
|
||||
`.tool-use`, `.tool-result.error`, `.sys`, …) — structural identity
|
||||
(this is a turn boundary, this is a tool call, this is an error) is
|
||||
carried by the row's **icon** and **summary text** instead, computed
|
||||
server-side once and read verbatim by the client.
|
||||
|
||||
| Level | Colour | Used for |
|
||||
| Level | Colour | Roughly |
|
||||
|---|---|---|
|
||||
| `debug` | muted | thinking, thinking-token ticks, plugin-install/status ticks, ambient harness chatter, unrecognised-system-subtype notes |
|
||||
| `info` | default fg | turn start/ok, assistant text, tool calls + their (non-error) results, operator-initiated notes |
|
||||
| `warn` | amber, left rule | stderr lines, an unrecognised stream-json shape, API retry backoff |
|
||||
| `error` | red, left rule | turn failed, a tool result with `is_error: true`, a hard API error |
|
||||
| `debug` | muted | thinking, coalesced ticks, ambient harness chatter |
|
||||
| `info` | default fg | turn start/ok, assistant text, tool calls + results |
|
||||
| `warn` | amber, left rule | stderr, an unrecognised event shape, API retries |
|
||||
| `error` | red, left rule | turn failed, a tool result with `is_error: true` |
|
||||
|
||||
Representative summaries (icon + text, `hive-agent/src/term_msg.rs` +
|
||||
`stream_enrich.rs`):
|
||||
|
||||
| Row | icon | level | summary | body |
|
||||
|---|---|---|---|---|
|
||||
| turn start | `◆` | info | `TURN ← <from>` | the wake-prompt text (plain) |
|
||||
| turn ok | `✅` | info | `turn ok` | — |
|
||||
| turn failed | `❌` | error | `turn fail — <note>` | — |
|
||||
| assistant text | — | info | *(empty)* | the text itself (markdown) — renders as a flat row, no prefix line |
|
||||
| thinking | `💭` | debug | the thinking text (or `thinking …`) | — |
|
||||
| thinking-token tick | `🧠` | debug | `thinking… ~N tokens` (`coalesce_key: "thinking-tok"`, in-place updates) | — |
|
||||
| tool call | per-tool, see [salient-arg formatting](#salient-arg-formatting) | info | `<short-name> <args…>` | some tools: diff (Edit) / markdown (`send`) / plain (long `mcp__bash__run` cmd) |
|
||||
| tool result, short | `←` | info | the trimmed result text | — |
|
||||
| tool result, long | — | info | `Nl · headline…` | the full text (plain) |
|
||||
| tool result, `recv`-correlated | — | info | `recv ← <summary>` | the full text (markdown) |
|
||||
| tool result, error (short) | `✗` | error | the trimmed error text | — |
|
||||
| tool result, error (long) | — | error | `Nl · headline…` | the full error text (plain) |
|
||||
| subagent task start/notify (dead path — `Task` not in the agent allow-list) | `⌁` | info | `task <id> started · <desc> [type]` / `task <id> ✓/✗/◌ <status> · <desc> · → <file>` | — |
|
||||
| plugin install / status tick | — | debug | `⚙ plugin install · loading…\|✓ done` / `⚙ status` (both `coalesce_key`-collapsed) | — |
|
||||
| commands changed | — | debug | `⚙ commands changed · N available` | one `/name` per line (plain) |
|
||||
| compact boundary / API retry / API error / unrecognised system subtype | — | debug / warn / error / debug | `⚙ compact · …` / `⚠ api retry · …` / `✗ api error · …` / `⚙ <subtype>` | — |
|
||||
| harness note | — | debug | the note text | — |
|
||||
| stderr line | — | warn | `stderr: <line>` | — |
|
||||
| operator-initiated note | — | info | `operator: <text>` | — |
|
||||
| unrecognised stream-json shape | `!` | warn | trimmed raw JSON | — |
|
||||
|
||||
Whether a bodied row renders open or collapsed is a **client-only**
|
||||
decision — the operator's expand-tool-output preference
|
||||
(`getExpandDetailsPref()`), applied uniformly to every row with a body.
|
||||
There's no server-side per-tool override any more (the old `recv`/`send`
|
||||
"always default-open" special case is gone).
|
||||
|
||||
## Classification pipeline
|
||||
|
||||
1. `hive-agent/src/term_msg.rs::classify()` is the top-level dispatch,
|
||||
called once per `LiveEvent` at SSE-emit time (both the live tail and
|
||||
history replay — see `web_ui/stream.rs`):
|
||||
- `TurnStart`/`TurnEnd`/`Note` map straight to one `TermMsg` each.
|
||||
- The five agent-state variants (`StatusChanged`/…) always produce
|
||||
zero — never rendered as terminal rows.
|
||||
- `Stream(value)` delegates to `stream_enrich::classify_stream_value`.
|
||||
2. `classify_stream_value` walks one raw claude `stream-json` line:
|
||||
- top-level `result` / `rate_limit_event` → dropped (no rows).
|
||||
- `type: "system"` → `classify_system`, which computes
|
||||
`(category, summary, body)` per `subtype` (`system_fields()`) and
|
||||
maps `category` to a level + optional `coalesce_key`:
|
||||
`"thinking_tok"` → debug, coalesced; `"details"` (currently just
|
||||
`commands_changed`) → debug, with an expandable body; `"note"` →
|
||||
level varies by subtype (`api_error` → error, `api_retry` → warn,
|
||||
`plugin_install`/`status` → debug + coalesced, everything else →
|
||||
debug).
|
||||
- `subtype: "task_started" | "task_notification"` (regardless of
|
||||
top-level `type`) → `classify_task_event`.
|
||||
- `type: "assistant"` → walk `message.content[]`: `text` → info row,
|
||||
empty summary, markdown body; `thinking` → debug row with `💭`;
|
||||
`tool_use` → `classify_tool_use` (records `id → name` in the
|
||||
connection/page-scoped `ClassifyCtx` for the next step, computes
|
||||
icon + summary via `fmt_tool_use()`/`tool_icon()`, and a body via
|
||||
`rich_tool_body()` for the fixed set of tools that have one).
|
||||
- `type: "user"` → walk `message.content[]` for `tool_result`:
|
||||
`classify_tool_result` correlates `tool_use_id` against the
|
||||
`ClassifyCtx` to spot a `recv` result (rendered `recv ← …` with a
|
||||
markdown body), strips the `<tool_use_error>` wrapper on errors,
|
||||
and otherwise picks short-flat vs. long-with-body by length.
|
||||
- Unrecognised shape → warn row, trimmed raw JSON, `!` icon.
|
||||
3. `frontend/packages/agent/src/lib/classifyEvent.ts::classifyEvent`
|
||||
maps each `TermMsg` in the envelope to a `StreamRow`
|
||||
(`lib/streamRow.ts`) — `level` → `level-*` CSS class, `body_format`
|
||||
→ which of `markdown`/`diff`/`plain` body prop to set, empty-summary
|
||||
markdown body → the flat "assistant text" shape, everything else
|
||||
with a body → an expandable `<details>` gated by the operator's
|
||||
preference. `components/Row.tsx` renders the result.
|
||||
|
||||
`ClassifyCtx` (tool_use `id → name` correlation, needed for step 2's
|
||||
`recv` detection) is scoped per SSE connection on the live path and per
|
||||
page on the history path — it does not cross the live/history boundary,
|
||||
so a `recv` result whose `tool_use` fell on the other side of a
|
||||
reconnect or page load renders as a plain block instead of default-open
|
||||
markdown. Accepted, documented degradation
|
||||
(`hive-agent/src/term_msg.rs`'s `ClassifyCtx` doc), not a bug.
|
||||
|
||||
### Salient-arg formatting
|
||||
|
||||
Server-side (`fmt_tool_use()` and its per-tool-family helpers in
|
||||
`hive-agent/src/stream_enrich.rs`), computed into the `TermMsg`'s
|
||||
`summary` field and read verbatim by the client. The `short` name strips the
|
||||
`mcp__hyperhive__` / `mcp__bash__` / `mcp__matrix__` prefix and
|
||||
appends `*` (e.g. `recv*`, `run*`, `send_message*`). Unprefixed
|
||||
tools (Read, Write, etc.) keep their name as-is.
|
||||
|
||||
| Tool | Rendered as |
|
||||
|------|-------------|
|
||||
| **Claude built-ins** | |
|
||||
| `Read` | `Read <path>` |
|
||||
| `Write` | flat, same shape as Read: `Write <path>` — no diff/count (`content` can be megabytes and is one-sided; open the file to inspect it) |
|
||||
| `Edit` | rich diff row `Edit <path> · -N +N` (just `+N` for a pure insert, i.e. empty `old_string`) |
|
||||
| `Glob` | `Glob <pattern>` |
|
||||
| `Grep` | `Grep <pattern>` |
|
||||
| `Bash` | `Bash [bg] $ <cmd>` (dead path — built-in `Bash` isn't in the agent allow-list either; shell execution goes through `mcp__bash__run` / `run*` below instead) |
|
||||
| `TodoWrite` | `TodoWrite (N items)` (dead path — `TodoWrite` isn't in the agent allow-list; its state lives in claude's in-process session and evaporates on `/compact`, so agents plan in `/state` notes instead) |
|
||||
| **Core hyperhive** | |
|
||||
| `send*` | rich renderer: `send* → to · NL` (markdown body, expand state now follows the operator's preference like every other bodied row — see [Row taxonomy](#row-taxonomy)) |
|
||||
| `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` |
|
||||
| `remind*` | `remind* +Xm "preview"` or `remind* at HH:MMZ "preview"` |
|
||||
| `set_status*` | `set_status* "text"` |
|
||||
| `get_loose_ends*` | `get_loose_ends*()` or `get_loose_ends* [agent]` |
|
||||
| `get_agent_meta*` | `get_agent_meta*()` or `get_agent_meta* name` |
|
||||
| `cancel_loose_end*` | `cancel_loose_end* kind #id` |
|
||||
| `ack_until*` | `ack_until* ≤N` |
|
||||
| `mark_todos_done*` | `mark_todos_done* [id1, id2, …]` (first 8 ids, `…` past that) |
|
||||
| **Lifecycle** | |
|
||||
| `kill*/restart*/start*/update*` | `kill* name` (etc.) |
|
||||
| `get_logs*` | `get_logs* name` or `get_logs* name NL` |
|
||||
| `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` |
|
||||
| **Approvals / config** | |
|
||||
| `request_init_config*` | `request_init_config* name` |
|
||||
| `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` |
|
||||
| **Scheduling** | |
|
||||
| `list_schedules*` | `list_schedules*()` |
|
||||
| `cancel_schedule*` | `cancel_schedule* #id all` or `#id [t1, t2]` |
|
||||
| `fire_schedule_now*` | `fire_schedule_now* #id` |
|
||||
| `edit_schedule*` | `edit_schedule* #id · body · interval · next · +N tgt · -N tgt` (only changed fields shown) |
|
||||
| `request_schedule_prompt*` | `request_schedule_prompt* → t1, t2 at HH:MMZ` (+ `+Ns` if recurring) |
|
||||
| **Bash MCP** | |
|
||||
| `run*` | `run* [bg] $ cmd` (also rich renderer for full cmd body) |
|
||||
| `status*` (bash) | `status* id:xyz` or `status* id:xyz · wait Ns` |
|
||||
| `kill*` (bash) | `kill* id` or `kill* id [force]` |
|
||||
| **Matrix MCP** | |
|
||||
| `send_message*/send_dm*/send_reply*` | `send_message* → room: "body"` / `send_dm* → @user: "body"` |
|
||||
| `send_reaction*` | `send_reaction* room emoji` |
|
||||
| `read_room*` | `read_room* room` or `read_room* room [N]` |
|
||||
| `mark_read*` | `mark_read* room` |
|
||||
| `join_room*/open_dm*` | `join_room* room` / `open_dm* @user` |
|
||||
| `invite_user*` | `invite_user* @user → room` |
|
||||
| `download_file*` | `download_file* room` |
|
||||
| **Everything else** | `fmt_args_generic` — see [Extra-MCP tools](#extra-mcp-tools) |
|
||||
Per-tool icon + summary formatting (what a `Read`/`Edit`/`send` call's row
|
||||
actually says) lives in `stream_enrich.rs`'s `fmt_tool_use()` family —
|
||||
read that when you need the specifics, this doc doesn't duplicate it.
|
||||
|
||||
## Markdown
|
||||
|
||||
`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown(text)`
|
||||
runs `marked.parse(text)` through DOMPurify and is rendered into a
|
||||
`<div class="md">` by `components/Row.tsx`'s `MarkdownBody`. 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. Applied to any `TermMsg` with
|
||||
`body_format: "markdown"` — assistant text, `send`'s body, a
|
||||
`recv`-correlated tool result.
|
||||
|
||||
## Extra-MCP tools
|
||||
|
||||
`fmt_args_generic(name, input)` (`hive-agent/src/stream_enrich.rs`)
|
||||
is the fallback when a tool isn't in the built-in `fmt_tool_use`
|
||||
switch, computed into the `TermMsg`'s `summary` field server-side:
|
||||
|
||||
- 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 less-frequent tools that don't have a specific
|
||||
`fmt_tool_use` case from dumping raw JSON. Common matrix and
|
||||
hyperhive tools have their own cases and skip this path.
|
||||
`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown()` runs
|
||||
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
|
||||
any `TermMsg` with `body_format: "markdown"`.
|
||||
|
||||
## 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.
|
||||
The main dashboard's message-flow pane is a different shape: broker
|
||||
messages render as `.msgrow` grid lines, not agent-terminal rows.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
// Renders one `StreamRow` — flat `<div class="row …">` or expandable
|
||||
// `<details class="row …">`, matching @hive/shared/terminal.css's
|
||||
// existing row-kind classes exactly (see docs/terminal-rendering.md).
|
||||
// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the
|
||||
// taxonomy's visual language isn't what mara asked to change, the
|
||||
// component *model* underneath it is.
|
||||
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
||||
// `<details class="row …">`, driven straight off the wire shape
|
||||
// (`lib/termMsg.ts`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`):
|
||||
// `level` picks the colour class, an empty `summary` + markdown `body`
|
||||
// is a flat row with just the body (assistant text), anything else with
|
||||
// a body is an expandable details row gated by the operator's
|
||||
// expand-tool-output preference. No separate classification step —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { StreamRow } from '../lib/streamRow.js';
|
||||
import type { TermRow } from '../lib/termMsg.js';
|
||||
import { linkifyToNodes } from '../lib/linkify.js';
|
||||
import { renderMarkdown } from '../lib/markdown.js';
|
||||
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
|
||||
|
||||
function MarkdownBody({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -41,25 +44,39 @@ function DiffBody({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function Row({ row }: { row: StreamRow }) {
|
||||
if (row.details) {
|
||||
export function Row({ row }: { row: TermRow }) {
|
||||
const cssClass = 'level-' + row.level;
|
||||
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
|
||||
|
||||
if (row.body == null) {
|
||||
return (
|
||||
<details className={`row ${row.cssClass}`} open={row.defaultOpen || undefined}>
|
||||
<summary>
|
||||
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
|
||||
<span className="summary-text">{row.text}</span>
|
||||
</summary>
|
||||
{row.diffBody != null && <DiffBody text={row.diffBody} />}
|
||||
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
|
||||
{row.plainBody != null && <pre className="tool-body">{linkifyToNodes(row.plainBody)}</pre>}
|
||||
</details>
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
{linkifyToNodes(row.summary)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty summary + markdown body → the body itself is the whole row
|
||||
// (assistant text), no summary prefix line, never collapsible.
|
||||
if (row.body_format === 'markdown' && row.summary === '') {
|
||||
return (
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
<MarkdownBody text={row.body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`row ${row.cssClass}`}>
|
||||
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
|
||||
{row.text != null && linkifyToNodes(row.text)}
|
||||
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
|
||||
</div>
|
||||
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
|
||||
<summary>
|
||||
{icon}
|
||||
<span className="summary-text">{row.summary}</span>
|
||||
</summary>
|
||||
{row.body_format === 'diff' && <DiffBody text={row.body} />}
|
||||
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
|
||||
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
// Backfill + live SSE for the agent's event stream, reduced to a plain
|
||||
// `StreamRow[]` — the Preact-data sibling of
|
||||
// @hive/shared/terminal.js's `create()`. Scroll behaviour is
|
||||
// deliberately NOT this hook's job (see components/LiveStream.tsx):
|
||||
// mara flagged the old page's scroll-while-streaming bug explicitly as
|
||||
// something not to copy 1:1, and keeping "what rows exist" separate
|
||||
// from "where the viewport is" is what makes that fixable — this hook
|
||||
// only ever appends/prepends to `rows`, the DOM-owning component
|
||||
// decides whether that should move the scroll position.
|
||||
// `TermRow[]` — the Preact-data sibling of @hive/shared/terminal.js's
|
||||
// `create()`. Scroll behaviour is deliberately NOT this hook's job (see
|
||||
// components/LiveStream.tsx): mara flagged the old page's scroll-while-
|
||||
// streaming bug explicitly as something not to copy 1:1, and keeping
|
||||
// "what rows exist" separate from "where the viewport is" is what makes
|
||||
// that fixable — this hook only ever appends/prepends to `rows`, the
|
||||
// DOM-owning component decides whether that should move the scroll
|
||||
// position.
|
||||
//
|
||||
// Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as
|
||||
// terminal.js's `start()`: a live envelope landing between EventSource-
|
||||
// open and the history response resolving is buffered, not dropped or
|
||||
// double-counted (`envelope.seq <= history.seq` → already covered by the
|
||||
// initial history page, drop it from the buffer). Post mara's terminal-
|
||||
// message redesign there's no per-row `kind` any more to sanity-check
|
||||
// that against — `seq` alone is the whole dedup signal, see
|
||||
// `TermEnvelope`'s doc in hive-agent's `web_ui/stream.rs`.
|
||||
// initial history page, drop it from the buffer). There's no per-row
|
||||
// `kind` any more to sanity-check that against — `seq` alone is the
|
||||
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
|
||||
// `web_ui/stream.rs`.
|
||||
//
|
||||
// The old `onLiveTurnBoundary` callback (a snappier one-off `/api/state`
|
||||
// refresh right after a live turn_start/turn_end, instead of waiting for
|
||||
|
|
@ -25,8 +25,7 @@
|
|||
// longer has the structure to single one out. `useAgentState`'s 4s poll
|
||||
// is the only refresh path now.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { classifyEvent, createClassifyCtx, type ClassifyCtx, type TermEnvelope } from '../lib/classifyEvent.js';
|
||||
import type { StreamRow } from '../lib/streamRow.js';
|
||||
import type { TermEnvelope, TermRow } from '../lib/termMsg.js';
|
||||
|
||||
export interface UseLiveStreamOptions {
|
||||
historyUrl?: string;
|
||||
|
|
@ -34,7 +33,7 @@ export interface UseLiveStreamOptions {
|
|||
}
|
||||
|
||||
export interface UseLiveStreamResult {
|
||||
rows: StreamRow[];
|
||||
rows: TermRow[];
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
loadMore: () => void;
|
||||
|
|
@ -47,12 +46,12 @@ export interface UseLiveStreamResult {
|
|||
clearLocal: () => void;
|
||||
}
|
||||
|
||||
function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
|
||||
function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
|
||||
const last = rows[rows.length - 1];
|
||||
// Coalesce in place only while the coalescible row is still the last
|
||||
// one in the list — any other row landing in between starts a fresh
|
||||
// one, same rule as terminal.js's makeCoalescer.
|
||||
if (row.coalesceKey && last && last.coalesceKey === row.coalesceKey) {
|
||||
if (row.coalesce_key && last && last.coalesce_key === row.coalesce_key) {
|
||||
const next = rows.slice(0, -1);
|
||||
next.push({ ...row, key: last.key });
|
||||
return next;
|
||||
|
|
@ -60,22 +59,28 @@ function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
|
|||
return [...rows, row];
|
||||
}
|
||||
|
||||
function appendMany(rows: StreamRow[], newRows: StreamRow[]): StreamRow[] {
|
||||
let next = rows;
|
||||
for (const r of newRows) next = appendRow(next, r);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult {
|
||||
const historyUrl = opts.historyUrl ?? 'events/history';
|
||||
const streamUrl = opts.streamUrl ?? 'events/stream';
|
||||
|
||||
const [rows, setRows] = useState<StreamRow[]>([]);
|
||||
const [rows, setRows] = useState<TermRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const ctxRef = useRef<ClassifyCtx | null>(null);
|
||||
if (!ctxRef.current) ctxRef.current = createClassifyCtx();
|
||||
const keySeqRef = useRef(0);
|
||||
function nextKey(): string {
|
||||
keySeqRef.current += 1;
|
||||
return 'r' + keySeqRef.current;
|
||||
}
|
||||
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
|
||||
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
|
||||
}
|
||||
function appendEnvelope(rows: TermRow[], env: TermEnvelope, fromHistory: boolean): TermRow[] {
|
||||
let next = rows;
|
||||
for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
|
||||
return next;
|
||||
}
|
||||
|
||||
const minIdRef = useRef<number | null>(null);
|
||||
const liveRef = useRef(false);
|
||||
const bufferedRef = useRef<TermEnvelope[]>([]);
|
||||
|
|
@ -84,8 +89,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
let cancelled = false;
|
||||
|
||||
function pushLive(env: TermEnvelope) {
|
||||
const newRows = classifyEvent(env, false, ctxRef.current!);
|
||||
if (newRows.length) setRows((prev) => appendMany(prev, newRows));
|
||||
if (env.msgs.length) setRows((prev) => appendEnvelope(prev, env, false));
|
||||
}
|
||||
|
||||
const es = new EventSource(streamUrl);
|
||||
|
|
@ -95,8 +99,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
env = JSON.parse(e.data);
|
||||
} catch {
|
||||
setRows((prev) => appendRow(prev, {
|
||||
key: 'parse-err-' + Date.now(), cssClass: 'level-warn', fromHistory: false,
|
||||
text: '[parse err] ' + e.data,
|
||||
key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -108,8 +111,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
};
|
||||
es.onerror = () => {
|
||||
setRows((prev) => appendRow(prev, {
|
||||
key: 'conn-note', cssClass: 'level-warn', fromHistory: false, coalesceKey: 'conn-status',
|
||||
text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
|
||||
key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status',
|
||||
summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -126,11 +129,11 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
}
|
||||
if (cancelled) return;
|
||||
|
||||
let initial: StreamRow[] = [];
|
||||
for (const env of events) initial = appendMany(initial, classifyEvent(env, true, ctxRef.current!));
|
||||
let initial: TermRow[] = [];
|
||||
for (const env of events) initial = appendEnvelope(initial, env, true);
|
||||
initial = events.length
|
||||
? appendRow(initial, { key: 'live-sep', cssClass: 'level-debug', fromHistory: true, text: '─── live (older above) ───' })
|
||||
: [{ key: 'placeholder', cssClass: 'level-debug', fromHistory: true, text: '(connected — waiting for events)' }];
|
||||
? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' })
|
||||
: [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }];
|
||||
setRows(initial);
|
||||
|
||||
const drained = bufferedRef.current;
|
||||
|
|
@ -171,10 +174,10 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
setHasMore(!!body.has_more);
|
||||
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
|
||||
if (events.length) {
|
||||
let older: StreamRow[] = [];
|
||||
for (const env of events) older = appendMany(older, classifyEvent(env, true, ctxRef.current!));
|
||||
let older: TermRow[] = [];
|
||||
for (const env of events) older = appendEnvelope(older, env, true);
|
||||
older = appendRow(older, {
|
||||
key: 'older-sep-' + minIdRef.current, cssClass: 'level-debug', fromHistory: true, text: '─── older above ───',
|
||||
key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───',
|
||||
});
|
||||
setRows((prev) => [...older, ...prev]);
|
||||
}
|
||||
|
|
@ -185,10 +188,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
}
|
||||
}
|
||||
|
||||
const localKeyRef = useRef(0);
|
||||
function pushLocalNote(text: string) {
|
||||
localKeyRef.current += 1;
|
||||
setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'level-info', fromHistory: false, text }));
|
||||
setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text }));
|
||||
}
|
||||
function clearLocal() {
|
||||
setRows([]);
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
// Thin adapter: turns one server-classified `TermEnvelope` (hive-agent's
|
||||
// `web_ui/stream.rs`) into zero or more `StreamRow`s. Almost all
|
||||
// classification now happens server-side (`hive-agent/src/term_msg.rs` +
|
||||
// `stream_enrich.rs`) — this file used to be a large per-tool/per-event
|
||||
// dispatch tree (see git history pre mara's terminal-message redesign);
|
||||
// now it's just a shape translation.
|
||||
import type { StreamRow } from './streamRow.js';
|
||||
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
|
||||
|
||||
/** One terminal row as served by `GET /api/events/{history,stream}` —
|
||||
* mirrors `hive-agent/src/term_msg.rs::TermMsg` field-for-field. */
|
||||
export interface TermMsg {
|
||||
icon?: string;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
summary: string;
|
||||
body?: string;
|
||||
body_format?: 'markdown' | 'diff';
|
||||
coalesce_key?: string;
|
||||
}
|
||||
|
||||
/** One SSE frame / history array entry — `hive-agent`'s `TermEnvelope`.
|
||||
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
|
||||
* history-replayed envelopes, see useLiveStream.ts's backfill dance. */
|
||||
export interface TermEnvelope {
|
||||
ts: number;
|
||||
seq?: number;
|
||||
msgs: TermMsg[];
|
||||
}
|
||||
|
||||
export interface ClassifyCtx {
|
||||
keySeq: { current: number };
|
||||
}
|
||||
|
||||
export function createClassifyCtx(): ClassifyCtx {
|
||||
return { keySeq: { current: 0 } };
|
||||
}
|
||||
|
||||
function nextKey(ctx: ClassifyCtx): string {
|
||||
ctx.keySeq.current += 1;
|
||||
return 'r' + ctx.keySeq.current;
|
||||
}
|
||||
|
||||
/** `TermEnvelope` → zero or more `StreamRow`s (one per `TermMsg`). */
|
||||
export function classifyEvent(env: TermEnvelope, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] {
|
||||
return env.msgs.map((m) => termMsgToRow(m, fromHistory, ctx));
|
||||
}
|
||||
|
||||
function termMsgToRow(msg: TermMsg, fromHistory: boolean, ctx: ClassifyCtx): StreamRow {
|
||||
const cssClass = 'level-' + msg.level;
|
||||
const base = { key: nextKey(ctx), cssClass, fromHistory, icon: msg.icon, coalesceKey: msg.coalesce_key };
|
||||
|
||||
if (msg.body == null) {
|
||||
return { ...base, text: msg.summary };
|
||||
}
|
||||
|
||||
// Empty summary + markdown body → the old `.text` row: no prefix line,
|
||||
// the body itself is the whole row (assistant text). Every other
|
||||
// bodied row is an expandable details row, gated uniformly by the
|
||||
// operator's expand-tool-output preference — no server-side per-tool
|
||||
// override any more (mara: "client pref covers every message type
|
||||
// uniformly, no server override even for send/ask/answer/recv").
|
||||
if (msg.body_format === 'markdown' && msg.summary === '') {
|
||||
return { ...base, markdownBody: msg.body };
|
||||
}
|
||||
|
||||
const opened = { ...base, text: msg.summary, details: true, defaultOpen: getExpandDetailsPref() };
|
||||
if (msg.body_format === 'diff') return { ...opened, diffBody: msg.body };
|
||||
if (msg.body_format === 'markdown') return { ...opened, markdownBody: msg.body };
|
||||
return { ...opened, plainBody: msg.body };
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// Row model for the live event stream. One `StreamRow` = one rendered
|
||||
// line/panel in the terminal pane; `classifyEvent` (classifyEvent.ts)
|
||||
// turns a server-classified `TermMsg` (hive-agent's `term_msg.rs`) into
|
||||
// one of these, and `<Row>` (components/Row.tsx) renders one. Kept as
|
||||
// plain data (not JSX) so the append/coalesce bookkeeping in
|
||||
// useLiveStream.ts stays pure — see docs/terminal-rendering.md for the
|
||||
// taxonomy this mirrors.
|
||||
//
|
||||
// Post mara's terminal-message redesign, `cssClass` is always exactly
|
||||
// `level-debug|info|warn|error` (derived 1:1 from the wire `level`, see
|
||||
// classifyEvent.ts) rather than a free-text per-row-kind class — the
|
||||
// server no longer tells the client "this is a turn-start" or "this is a
|
||||
// tool call", only "this is icon+level+summary+body". `meta`/`childText`
|
||||
// (turn-time, duration, unread-count spans) are gone with them: the
|
||||
// signal that let the client single out a turn-boundary row to attach
|
||||
// them to (the old `kind` tag) no longer exists on the wire, by design —
|
||||
// see term_msg.rs's module doc.
|
||||
|
||||
export interface StreamRow {
|
||||
/** Stable across re-renders; reused in place when a row is coalesced
|
||||
* (e.g. the thinking-token counter) so Preact updates rather than
|
||||
* remounts it. */
|
||||
key: string;
|
||||
/** Always `level-debug` / `level-info` / `level-warn` / `level-error` —
|
||||
* see @hive/shared/terminal.css's level-colour rules. */
|
||||
cssClass: string;
|
||||
icon?: string;
|
||||
fromHistory: boolean;
|
||||
/** When set, an event that maps to the same coalesceKey and lands
|
||||
* while this row is still the last one in the list replaces it in
|
||||
* place instead of appending a new row (thinking-token ticks, status
|
||||
* ticks, plugin_install started→completed). */
|
||||
coalesceKey?: string;
|
||||
/** `false`/absent → flat `<div class="row">`; `true` → `<details
|
||||
* class="row">` with `.summary-text` + optional body. */
|
||||
details?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
/** Flat-row prefix text, or the details `<summary>` text. Linkified,
|
||||
* never markdown. */
|
||||
text?: string;
|
||||
/** Sanitized-markdown body: appended under a flat row (assistant
|
||||
* text, no `text` set) or inside an open details row (tool bodies). */
|
||||
markdownBody?: string;
|
||||
/** Plain `<pre>` body inside a details row (generic long tool output). */
|
||||
plainBody?: string;
|
||||
/** `+`/`-`/context diff body inside a details row (Edit tool). */
|
||||
diffBody?: string;
|
||||
}
|
||||
33
frontend/packages/agent/src/lib/termMsg.ts
Normal file
33
frontend/packages/agent/src/lib/termMsg.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Wire types for the agent's terminal stream — mirrors hive-agent's
|
||||
// `term_msg.rs`/`web_ui/stream.rs` field-for-field. The frontend renders
|
||||
// a `TermMsg` close to as-is (see components/Row.tsx); there's no
|
||||
// separate client-side row model or classification step any more —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
export type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface TermMsg {
|
||||
icon?: string;
|
||||
level: Level;
|
||||
summary: string;
|
||||
body?: string;
|
||||
body_format?: 'markdown' | 'diff';
|
||||
coalesce_key?: string;
|
||||
}
|
||||
|
||||
/** One SSE frame / history array entry — `hive-agent`'s `TermEnvelope`.
|
||||
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
|
||||
* history-replayed envelopes, see useLiveStream.ts's backfill dance. */
|
||||
export interface TermEnvelope {
|
||||
ts: number;
|
||||
seq?: number;
|
||||
msgs: TermMsg[];
|
||||
}
|
||||
|
||||
/** A `TermMsg` plus the bookkeeping Preact needs to render a list —
|
||||
* stable identity for coalescing/keys, and whether it came from history
|
||||
* replay vs. the live tail. Not a separate model: everything content-wise
|
||||
* is still exactly the wire shape. */
|
||||
export interface TermRow extends TermMsg {
|
||||
key: string;
|
||||
fromHistory: boolean;
|
||||
}
|
||||
Loading…
Reference in a new issue