Simplify terminal message shape to a uniform TermMsg

Move terminal-row classification server-side into a new
hive-agent/src/term_msg.rs, replacing the old JSON-mutation
enrich()/stamped-field approach in stream_enrich.rs with one
uniform wire shape: {icon?, level: debug|info|warn|error, summary,
body?, body_format?: markdown|diff, coalesce_key?}. No more per-row
`kind` tag or raw claude-JSON passthrough — every row is the same
shape, with structural identity carried by icon + summary text
instead of a CSS class per row kind.

hive-agent/src/web_ui/stream.rs's history + SSE endpoints now both
call term_msg::classify() and serve TermEnvelope{ts, seq?, msgs}
frames; events that classify to zero rows (agent-state changes,
drop-noise) never reach the wire.

Frontend: classifyEvent.ts collapses from a large per-tool dispatch
tree to a thin TermMsg -> StreamRow adapter. streamRow.ts/Row.tsx
drop the now-dead meta/childText fields. terminal.css switches from
a dozen-odd per-row-kind classes to four level-based color rules.
Expand/collapse of a bodied row is now a uniform client-side
decision (the operator's preference), no server-side per-tool
override.

docs/terminal-rendering.md rewritten to match.
This commit is contained in:
iris 2026-08-30 21:11:22 +02:00
commit 5eefaa951d
13 changed files with 886 additions and 628 deletions

View file

@ -9,25 +9,28 @@
// decides whether that should move the scroll position.
//
// Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as
// terminal.js's `start()`: a live event landing between EventSource-open
// and the history response resolving is buffered, not dropped or
// double-counted (seq <= history.seq AND the event's kind appeared in
// the history replay → already covered, drop it from the buffer).
// 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`.
//
// The old `onLiveTurnBoundary` callback (a snappier one-off `/api/state`
// refresh right after a live turn_start/turn_end, instead of waiting for
// the plain poll interval) is gone with the `kind` tag it relied on to
// spot a turn boundary — per mara's own framing that trigger is an
// agent-state concern, not a terminal-stream one, and this stream no
// 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 } from '../lib/classifyEvent.js';
import { classifyEvent, createClassifyCtx, type ClassifyCtx, type TermEnvelope } from '../lib/classifyEvent.js';
import type { StreamRow } from '../lib/streamRow.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw SSE payload, dynamically shaped
type AnyEvent = any;
export interface UseLiveStreamOptions {
historyUrl?: string;
streamUrl?: string;
/** Fired on every LIVE (not history-replay) turn_start/turn_end, so the
* caller can trigger a snappier `/api/state` refresh than the plain
* poll interval mirrors app.js's turn_end refreshState()/
* refreshTodos(). */
onLiveTurnBoundary?: () => void;
}
export interface UseLiveStreamResult {
@ -75,40 +78,37 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
if (!ctxRef.current) ctxRef.current = createClassifyCtx();
const minIdRef = useRef<number | null>(null);
const liveRef = useRef(false);
const bufferedRef = useRef<AnyEvent[]>([]);
const onBoundaryRef = useRef(opts.onLiveTurnBoundary);
onBoundaryRef.current = opts.onLiveTurnBoundary;
const bufferedRef = useRef<TermEnvelope[]>([]);
useEffect(() => {
let cancelled = false;
function pushLive(ev: AnyEvent) {
const newRows = classifyEvent(ev, false, ctxRef.current!);
function pushLive(env: TermEnvelope) {
const newRows = classifyEvent(env, false, ctxRef.current!);
if (newRows.length) setRows((prev) => appendMany(prev, newRows));
if (ev.kind === 'turn_start' || ev.kind === 'turn_end') onBoundaryRef.current?.();
}
const es = new EventSource(streamUrl);
es.onmessage = (e) => {
let ev: AnyEvent;
let env: TermEnvelope;
try {
ev = JSON.parse(e.data);
env = JSON.parse(e.data);
} catch {
setRows((prev) => appendRow(prev, {
key: 'parse-err-' + Date.now(), cssClass: 'note', fromHistory: false,
key: 'parse-err-' + Date.now(), cssClass: 'level-warn', fromHistory: false,
text: '[parse err] ' + e.data,
}));
return;
}
if (!liveRef.current) {
bufferedRef.current.push(ev);
bufferedRef.current.push(env);
return;
}
pushLive(ev);
pushLive(env);
};
es.onerror = () => {
setRows((prev) => appendRow(prev, {
key: 'conn-note', cssClass: 'note', fromHistory: false, coalesceKey: 'conn-status',
key: 'conn-note', cssClass: 'level-warn', fromHistory: false, coalesceKey: 'conn-status',
text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
}));
};
@ -118,30 +118,29 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const resp = await fetch(historyUrl);
if (!resp.ok) throw new Error('http ' + resp.status);
const body = await resp.json();
const events: AnyEvent[] = Array.isArray(body) ? body : body.events || [];
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null);
if (!Array.isArray(body)) {
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
}
const historyKinds = new Set(events.map((e) => e.kind));
if (cancelled) return;
let initial: StreamRow[] = [];
for (const ev of events) initial = appendMany(initial, classifyEvent(ev, true, ctxRef.current!));
for (const env of events) initial = appendMany(initial, classifyEvent(env, true, ctxRef.current!));
initial = events.length
? appendRow(initial, { key: 'live-sep', cssClass: 'note', fromHistory: true, text: '─── live (older above) ───' })
: [{ key: 'placeholder', cssClass: 'note', fromHistory: true, text: '(connected — waiting for events)' }];
? 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)' }];
setRows(initial);
const drained = bufferedRef.current;
bufferedRef.current = [];
liveRef.current = true;
for (const ev of drained) {
if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq && historyKinds.has(ev.kind)) {
continue;
}
pushLive(ev);
for (const env of drained) {
// Already covered by the initial history page — drop it from
// the buffer rather than rendering it twice.
if (boundarySeq != null && typeof env.seq === 'number' && env.seq <= boundarySeq) continue;
pushLive(env);
}
} catch (err) {
console.warn('history backfill failed', err);
@ -149,7 +148,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const drained = bufferedRef.current;
bufferedRef.current = [];
liveRef.current = true;
for (const ev of drained) pushLive(ev);
for (const env of drained) pushLive(env);
}
}
backfill();
@ -168,14 +167,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current);
if (!resp.ok) return;
const body = await resp.json();
const events: AnyEvent[] = Array.isArray(body) ? body : body.events || [];
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (events.length) {
let older: StreamRow[] = [];
for (const ev of events) older = appendMany(older, classifyEvent(ev, true, ctxRef.current!));
for (const env of events) older = appendMany(older, classifyEvent(env, true, ctxRef.current!));
older = appendRow(older, {
key: 'older-sep-' + minIdRef.current, cssClass: 'note', fromHistory: true, text: '─── older above ───',
key: 'older-sep-' + minIdRef.current, cssClass: 'level-debug', fromHistory: true, text: '─── older above ───',
});
setRows((prev) => [...older, ...prev]);
}
@ -189,7 +188,7 @@ 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: 'note', fromHistory: false, text }));
setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'level-info', fromHistory: false, text }));
}
function clearLocal() {
setRows([]);