/** * THE ORDERED DISPLAY DERIVATION (#566) — the canonical projection that turns the typed transcript-event * log into the sequence of display blocks a human transcript renders, top to bottom. * * WHY THIS EXISTS. The raw log is transport-fragmented: a single logical assistant message arrives as * many `message` deltas ("I", "not", "ice I am act", …) because a transport boundary is NOT a message * boundary. {@link deriveView} (the store-level fold) is deliberately faithful to that — it emits one * {@link DerivedMessage} per event and keeps `messages` / `tools` / `permissions` as SEPARATE arrays, * so a consumer that renders those groups loses both the message identity (one card per fragment) AND * the chronological interleaving of text, tool cards and permission prompts. This module is the * DEDICATED display projection that fixes both, WITHOUT changing the raw log, {@link deriveView}, or the * event-count semantics anything else depends on. It is the single source of truth for "what the * transcript looks like"; the cockpit (this repo) and nano-workforce consume it rather than forking a * grammar. * * WHAT IT GUARANTEES. * - COALESCING IS EXACT. Adjacent same-speaker text deltas of the same logical message concatenate with * NO injected space, trim or rewrite — Unicode, paragraphs and code formatting survive byte-for-byte, * so fragments that spell a word reconstruct into exactly that word. * - ORDER IS CHRONOLOGICAL. Text blocks, tool cards and permission prompts appear in offset order, so a * tool call issued mid-message renders between the text before and after it (never hoisted into a * separate group). * - BOUNDARIES ARE HONOURED, NOT GUESSED. A tool call, a permission interaction, a role change, an * explicit message `start`/`final`, a `turn` boundary, a changed `messageId`, or a retention `gap` * all CLOSE the active text block so the next delta starts a fresh one. Absent producer metadata, the * fallback is purely structural (adjacent + same speaker) — never a timing or punctuation heuristic. * - SNAPSHOTS REPLACE, DELTAS APPEND. A `mode:"snapshot"` message REPLACES the block's accumulated text * with its cumulative value; a delta appends. A snapshot is never treated as a delta (which would * double the text). * - REPLAY IS IDEMPOTENT. Offsets are the idempotency key: re-feeding an already-applied offset * (reconnect, pagination overlap, a duplicated chunk) is a no-op, so replayed text never doubles. * - A GAP STAYS VISIBLE. A retention gap is a first-class {@link DisplayGapBlock} in the sequence, so a * reattach that dropped chunks renders a break instead of implying the surrounding text is continuous. * * INCREMENTAL-FRIENDLY. {@link createDisplayProjection} keeps the ordered blocks as mutable state and * returns, from each {@link DisplayProjection.apply}, exactly which block was touched — so a consumer can * update the one active block's DOM node in place instead of re-rendering the whole transcript on every * delta. When opening the first block after a {@link DisplayProjection.noteGap} also anchors that gap's * `beforeOffset`, the same result reports the now-anchored gap as its secondary `anchored` block, so a * consumer that already rendered the gap patches it too. {@link deriveDisplay} is the pure batch * convenience over the same fold. * * BROWSER-SAFE, like {@link ./events.ts}: no Node-only API, no I/O, never touches the engine. NOT pure: * {@link createDisplayProjection} is deliberately stateful (it keeps the ordered blocks as mutable state), * and the non-cloneable `tool.args` fallback freezes the caller's args object in place ({@link freezeArgs}). * {@link deriveDisplay} is the pure batch convenience layered over the stateful fold. */ import type { DerivedPermission, DerivedTool, TranscriptEvent, TranscriptRole } from "./events.ts"; /** A coalesced run of same-speaker, same-message text — the reconstructed logical message block. */ export interface DisplayTextBlock { readonly kind: "text"; /** Stable identity: `text:`. Keyed by the first fragment's offset, which never changes as * the block grows, so a consumer can find-and-update the same DOM node across deltas. */ readonly id: string; readonly role: TranscriptRole; /** The producer's message identity, when one was supplied (else this is a fallback-coalesced block). */ readonly messageId?: string; /** The exactly-concatenated (or snapshot-replaced) text so far. */ readonly text: string; /** Offset of the FIRST fragment folded into this block (its stable identity + top of its offset range). */ readonly startOffset: number; /** Offset of the LAST fragment folded into this block (grows as deltas arrive). */ readonly endOffset: number; /** `true` once an explicit `final`, or any coalescing-breaking boundary, has closed the block. */ readonly complete: boolean; } /** A tool card in the ordered display — the call, paired to its result once it arrives (without hiding * that it is still pending until then). Wraps a deep-frozen snapshot clone of the canonical * {@link DerivedTool} (its nested `result` is cloned + frozen too), so mutating it cannot reach back * into the projection's internal state. */ export interface DisplayToolBlock { readonly kind: "tool"; /** Stable identity `tool:`. */ readonly id: string; readonly tool: DerivedTool; /** The tool-call offset (position of the card in the transcript). */ readonly startOffset: number; /** The result offset once paired, else the call offset (the card's live extent). */ readonly endOffset: number; } /** A permission prompt in the ordered display — the request, paired to its resolution once it arrives. * Wraps a deep-frozen snapshot clone of the canonical {@link DerivedPermission} (its nested `options` * and `resolved` are cloned + frozen too), so mutating it cannot reach back into projection state. */ export interface DisplayPermissionBlock { readonly kind: "permission"; /** Stable identity `permission:`. */ readonly id: string; readonly permission: DerivedPermission; readonly startOffset: number; readonly endOffset: number; } /** * A visible retention gap: chunks the consumer asked for were already evicted (the S6 * {@link TranscriptSlice.gap} signal). It is a real block in the sequence so the transcript renders a * break rather than implying the text on either side is continuous. `beforeOffset` is the offset of the * first block AFTER the gap when known (else `undefined` for a leading gap). */ export interface DisplayGapBlock { readonly kind: "gap"; /** Stable identity `gap:` — a gap has no natural offset, so it is keyed by insertion order. */ readonly id: string; readonly beforeOffset?: number; } /** One block in the ordered, human-facing transcript display. */ export type DisplayBlock = DisplayTextBlock | DisplayToolBlock | DisplayPermissionBlock | DisplayGapBlock; /** What a single {@link DisplayProjection.apply} did — so an incremental consumer can update just the * touched block(s) instead of re-rendering everything. `changed` is the block that was created or mutated * (undefined when the event was a no-op: an ignored kind, or a duplicate/stale offset). `appended` is * `true` when `changed` is a brand-new block at the end (a consumer appends a node) versus an in-place * update of an existing block (a consumer patches that node's content/attributes). `anchored` is a * SECOND, previously-emitted block this same apply also mutated in place, so a consumer patches it too: * currently only the pending retention gap from {@link DisplayProjection.noteGap}, whose `beforeOffset` * is unknown when emitted and becomes known when the first post-gap block opens — the apply that opens * that block reports the now-anchored gap here. `undefined` when nothing secondary changed. */ export interface DisplayApplyResult { readonly changed?: DisplayBlock; readonly appended: boolean; readonly anchored?: DisplayBlock; } /** * A STATEFUL, incremental display projection: feed it offset-ordered transcript events and it maintains * the ordered {@link DisplayBlock} sequence, reporting from each {@link apply} exactly which block was * created or mutated so a consumer can update that one block's DOM in place. Idempotent on offset, so * replay / reconnect / pagination overlap never doubles text. Constructing one per drilled stream gives * each its own display. */ export interface DisplayProjection { /** Fold one event; returns what changed (or a no-op result for an ignored/duplicate event). */ apply(event: TranscriptEvent): DisplayApplyResult; /** Fold many events in order (convenience over {@link apply}). */ applyAll(events: Iterable): void; /** * Record a retention gap at the current tail: the consumer resumed from an offset older than the * oldest retained chunk (the S6 `gap` signal), so the events that follow are NOT continuous with what * precedes. Closes the active text block and appends a visible {@link DisplayGapBlock}. Call it BEFORE * feeding the post-gap events; the gap's `beforeOffset` is filled in from the next block that opens and * surfaced to a consumer as that {@link apply}'s {@link DisplayApplyResult.anchored}. */ noteGap(): DisplayApplyResult; /** A frozen snapshot of the ordered display blocks as they stand now. */ blocks(): readonly DisplayBlock[]; } export declare function createDisplayProjection(): DisplayProjection; /** * The pure batch convenience: fold a whole run of offset-ordered events into the ordered display blocks * in one call, over a fresh {@link createDisplayProjection}. Duplicate offsets in the input are deduped * by the same idempotency gate, so a replayed slice folds to the same result as a gap-free one. */ export declare function deriveDisplay(events: Iterable): readonly DisplayBlock[];