@nanobpm/nano-workforce 0.185.1 → 0.186.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,10 +4,14 @@
4
4
  // renders the agentic transcript from ONE source of truth (#660). Regenerate with:
5
5
  // node --experimental-strip-types scripts/build-cockpit-browser.ts
6
6
 
7
- import { deriveViewFromChunks, optionKindAllows, } from "./transcript-events.js";
7
+ import { createDisplayProjection } from "./transcript-display.js";
8
+ import { deriveViewFromChunks, optionKindAllows, parseTranscriptEvent, utf8ByteLength, } from "./transcript-events.js";
8
9
  /**
9
- * Derive the structured view of a fetched transcript page by folding its stored chunks through the ONE
10
- * event parser + fold. Pure: the cockpit reads THIS instead of re-parsing raw frame bytes.
10
+ * Derive the structured (event-fold) view of a fetched transcript page the flat message/tool/permission
11
+ * history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
12
+ * raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
13
+ * display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
14
+ * re-parsing raw frame bytes.
11
15
  */
12
16
  export function deriveTranscript(data) {
13
17
  return deriveViewFromChunks(data.entries);
@@ -116,9 +120,11 @@ function detectDiff(tool) {
116
120
  return { lines: structured, source: "args" };
117
121
  return undefined;
118
122
  }
119
- /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
120
- function renderTool(doc, tool) {
121
- const card = el(doc, "div", "cockpit-transcript-tool");
123
+ /** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
124
+ * the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
125
+ * this one card no sibling block is touched). */
126
+ function applyTool(card, doc, tool) {
127
+ card.replaceChildren();
122
128
  card.setAttribute("data-tool", tool.name);
123
129
  card.setAttribute("data-offset", String(tool.offset));
124
130
  card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
@@ -152,6 +158,11 @@ function renderTool(doc, tool) {
152
158
  resEl.setAttribute("data-tool-result", "true");
153
159
  card.appendChild(resEl);
154
160
  }
161
+ }
162
+ /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
163
+ function renderTool(doc, tool) {
164
+ const card = el(doc, "div", "cockpit-transcript-tool");
165
+ applyTool(card, doc, tool);
155
166
  return card;
156
167
  }
157
168
  /**
@@ -160,8 +171,8 @@ function renderTool(doc, tool) {
160
171
  * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
161
172
  * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
162
173
  */
163
- function renderPermission(doc, perm, options) {
164
- const card = el(doc, "div", "cockpit-transcript-permission");
174
+ function applyPermission(card, doc, perm, options) {
175
+ card.replaceChildren();
165
176
  card.setAttribute("data-permission", "request");
166
177
  card.setAttribute("data-policy", perm.policy);
167
178
  card.setAttribute("data-call-id", perm.callId);
@@ -181,13 +192,13 @@ function renderPermission(doc, perm, options) {
181
192
  if (perm.resolved.by !== undefined)
182
193
  settled.setAttribute("data-by", perm.resolved.by);
183
194
  card.appendChild(settled);
184
- return card;
195
+ return;
185
196
  }
186
197
  if (perm.policy === "yolo") {
187
198
  // Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
188
199
  card.setAttribute("data-status", "auto");
189
200
  card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
190
- return card;
201
+ return;
191
202
  }
192
203
  // Pending escalate: one interactive button per offered option, wired to the resolve seam.
193
204
  card.setAttribute("data-status", "pending");
@@ -206,55 +217,207 @@ function renderPermission(doc, perm, options) {
206
217
  actions.appendChild(button);
207
218
  }
208
219
  card.appendChild(actions);
220
+ }
221
+ /**
222
+ * Render one permission prompt card from a {@link DerivedPermission}:
223
+ * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
224
+ * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
225
+ * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
226
+ */
227
+ function renderPermission(doc, perm, options) {
228
+ const card = el(doc, "div", "cockpit-transcript-permission");
229
+ applyPermission(card, doc, perm, options);
209
230
  return card;
210
231
  }
232
+ /** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
233
+ * node per logical message — a delta patches this node's `textContent` in place (never a new card per
234
+ * fragment), so a split word reconstructs into exactly that word. */
235
+ function applyText(node, block) {
236
+ node.setAttribute("data-role", block.role);
237
+ node.setAttribute("data-offset", String(block.startOffset));
238
+ node.setAttribute("data-end-offset", String(block.endOffset));
239
+ node.setAttribute("data-block-id", block.id);
240
+ if (block.messageId !== undefined)
241
+ node.setAttribute("data-message-id", block.messageId);
242
+ node.setAttribute("data-complete", String(block.complete));
243
+ node.textContent = block.text;
244
+ }
245
+ /** Render one coalesced-message text block (a single growing node). */
246
+ function renderText(doc, block) {
247
+ const node = el(doc, "div", "cockpit-transcript-message");
248
+ applyText(node, block);
249
+ return node;
250
+ }
251
+ /** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
252
+ * chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
253
+ * post-gap block opens. */
254
+ function applyGap(node, block) {
255
+ node.setAttribute("data-gap", "true");
256
+ node.setAttribute("data-block-id", block.id);
257
+ if (block.beforeOffset !== undefined)
258
+ node.setAttribute("data-before-offset", String(block.beforeOffset));
259
+ node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
260
+ }
261
+ /** Render one retention-gap block. */
262
+ function renderGap(doc, block) {
263
+ const node = el(doc, "div", "cockpit-transcript-gap");
264
+ applyGap(node, block);
265
+ return node;
266
+ }
267
+ /** Build a fresh DOM node for any display block kind. */
268
+ function renderBlock(doc, block, options) {
269
+ switch (block.kind) {
270
+ case "text":
271
+ return renderText(doc, block);
272
+ case "tool":
273
+ return renderTool(doc, block.tool);
274
+ case "permission":
275
+ return renderPermission(doc, block.permission, options);
276
+ case "gap":
277
+ return renderGap(doc, block);
278
+ }
279
+ }
280
+ /** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
281
+ function patchBlock(node, doc, block, options) {
282
+ switch (block.kind) {
283
+ case "text":
284
+ applyText(node, block);
285
+ return;
286
+ case "tool":
287
+ applyTool(node, doc, block.tool);
288
+ return;
289
+ case "permission":
290
+ applyPermission(node, doc, block.permission, options);
291
+ return;
292
+ case "gap":
293
+ applyGap(node, block);
294
+ return;
295
+ }
296
+ }
211
297
  /**
212
- * Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
213
- * Draws per-turn sections with their derived messages, rich tool/diff cards and permission prompts, plus
214
- * a raw-fidelity footer (retained bytes/chunks) so the operator sees the byte-replay is preserved
215
- * alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
216
- * of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
217
- * escalate-permission prompt's Allow/Deny buttons.
298
+ * Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
299
+ * Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
300
+ * historical replay renders IDENTICALLY to the final live rendering.
218
301
  */
219
- export function renderDerivedTranscript(host, doc, data, options = {}) {
220
- const view = deriveTranscript(data);
302
+ export function createIncrementalTranscript(host, doc, stream, options = {}) {
221
303
  host.replaceChildren();
304
+ const projection = createDisplayProjection();
305
+ const nodes = new Map();
306
+ const tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
307
+ // A turn opens implicitly before the first structured block even without an explicit `turn` event
308
+ // (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
309
+ let structured = false;
222
310
  const root = el(doc, "div", "cockpit-transcript-derived");
223
- root.setAttribute("data-stream", data.stream);
224
- root.setAttribute("data-lifecycle", view.lifecycle);
225
- root.setAttribute("data-turn-count", String(view.turns.length));
226
- root.setAttribute("data-message-count", String(view.messages.length));
227
- root.setAttribute("data-tool-count", String(view.tools.length));
228
- root.setAttribute("data-permission-count", String(view.permissions.length));
229
- if (view.turns.length === 0) {
230
- const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
231
- empty.setAttribute("data-empty", "true");
232
- root.appendChild(empty);
311
+ root.setAttribute("data-stream", stream);
312
+ const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
313
+ const empty = el(doc, "div", "cockpit-transcript-empty");
314
+ const footer = el(doc, "footer", "cockpit-transcript-raw");
315
+ root.appendChild(blocksHost);
316
+ root.appendChild(empty);
317
+ root.appendChild(footer);
318
+ host.appendChild(root);
319
+ function refreshSummary() {
320
+ const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
321
+ root.setAttribute("data-lifecycle", tallies.lifecycle);
322
+ root.setAttribute("data-turn-count", String(turnCount));
323
+ root.setAttribute("data-message-count", String(tallies.text));
324
+ root.setAttribute("data-tool-count", String(tallies.tool));
325
+ root.setAttribute("data-permission-count", String(tallies.permission));
326
+ root.setAttribute("data-gap-count", String(tallies.gap));
327
+ root.setAttribute("data-block-count", String(nodes.size));
328
+ const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
329
+ // Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
330
+ // without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
331
+ empty.setAttribute("data-empty", String(!hasBlocks));
332
+ empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
333
+ footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
334
+ footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
335
+ footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
336
+ }
337
+ function countAppended(block) {
338
+ structured = structured || block.kind !== "gap";
339
+ if (block.kind === "text")
340
+ tallies.text++;
341
+ else if (block.kind === "tool")
342
+ tallies.tool++;
343
+ else if (block.kind === "permission")
344
+ tallies.permission++;
345
+ else
346
+ tallies.gap++;
233
347
  }
234
- for (const turn of view.turns) {
235
- const section = el(doc, "section", "cockpit-transcript-turn");
236
- section.setAttribute("data-turn", String(turn.index));
237
- section.setAttribute("data-steps", String(turn.steps));
238
- section.appendChild(el(doc, "h3", "cockpit-transcript-turn-title", `Turn ${turn.index}`));
239
- for (const msg of turn.messages) {
240
- const row = el(doc, "div", "cockpit-transcript-message", msg.text);
241
- row.setAttribute("data-role", msg.role);
242
- row.setAttribute("data-offset", String(msg.offset));
243
- section.appendChild(row);
348
+ /** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
349
+ * patch a secondary now-anchored gap. Bounded to the touched block(s) no unaffected node is replaced. */
350
+ function reconcile(changed, appended, anchored) {
351
+ if (changed !== undefined) {
352
+ if (appended) {
353
+ const node = renderBlock(doc, changed, options);
354
+ nodes.set(changed.id, node);
355
+ blocksHost.appendChild(node);
356
+ countAppended(changed);
357
+ }
358
+ else {
359
+ const node = nodes.get(changed.id);
360
+ if (node !== undefined)
361
+ patchBlock(node, doc, changed, options);
362
+ }
244
363
  }
245
- for (const tool of turn.tools) {
246
- section.appendChild(renderTool(doc, tool));
364
+ if (anchored !== undefined) {
365
+ const node = nodes.get(anchored.id);
366
+ if (node !== undefined)
367
+ patchBlock(node, doc, anchored, options);
247
368
  }
248
- for (const perm of turn.permissions) {
249
- section.appendChild(renderPermission(doc, perm, options));
369
+ }
370
+ function applyEvent(event) {
371
+ // Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
372
+ if (event.kind === "stream-chunk") {
373
+ tallies.rawChunks++;
374
+ tallies.rawBytes += utf8ByteLength(event.chunk);
375
+ }
376
+ else if (event.kind === "turn") {
377
+ tallies.turns++;
378
+ }
379
+ else if (event.kind === "lifecycle") {
380
+ tallies.lifecycle = event.phase;
250
381
  }
251
- root.appendChild(section);
382
+ const result = projection.apply(event);
383
+ reconcile(result.changed, result.appended, result.anchored);
252
384
  }
253
- const footer = el(doc, "footer", "cockpit-transcript-raw");
254
- footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
255
- footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
256
- footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
257
- root.appendChild(footer);
258
- host.appendChild(root);
259
- return { root };
385
+ refreshSummary();
386
+ return {
387
+ root,
388
+ applyChunk(chunk) {
389
+ applyEvent(parseTranscriptEvent(chunk));
390
+ refreshSummary();
391
+ },
392
+ noteGap() {
393
+ const result = projection.noteGap();
394
+ reconcile(result.changed, result.appended, result.anchored);
395
+ refreshSummary();
396
+ },
397
+ blocks() {
398
+ return projection.blocks();
399
+ },
400
+ };
401
+ }
402
+ /**
403
+ * Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
404
+ * there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
405
+ * block per logical message, with tool/diff cards and permission prompts interleaved in chronological
406
+ * (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
407
+ * preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
408
+ * call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
409
+ * IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
410
+ * provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
411
+ */
412
+ export function renderDerivedTranscript(host, doc, data, options = {}) {
413
+ const incremental = createIncrementalTranscript(host, doc, data.stream, options);
414
+ // A page-level retention gap precedes the page's first chunk: note it before folding so a leading
415
+ // visible break renders (a reattach that dropped chunks never implies false continuity).
416
+ if (data.gap)
417
+ incremental.noteGap();
418
+ // The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
419
+ // so historical and live rendering are byte-for-byte the same tree.
420
+ for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset))
421
+ incremental.applyChunk(chunk);
422
+ return { root: incremental.root };
260
423
  }
@@ -0,0 +1,316 @@
1
+ // @generated from node_modules/@nanobpm/agentic/dist/transcript/display.js by scripts/build-cockpit-browser.ts — DO NOT EDIT.
2
+ //
3
+ // Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js
4
+ // renders the agentic transcript from ONE source of truth (#660). Regenerate with:
5
+ // node --experimental-strip-types scripts/build-cockpit-browser.ts
6
+
7
+ const NOOP = Object.freeze({ appended: false });
8
+ /** Recursively freeze a value already owned exclusively by the caller (a fresh clone), so no consumer
9
+ * can mutate it at any depth. Idempotent, and a no-op for primitives and already-frozen objects. A
10
+ * `seen` set guards against cyclic references so an arbitrarily-shaped `args` payload with a cycle
11
+ * freezes without recursing forever. */
12
+ function deepFreeze(value, seen = new WeakSet()) {
13
+ if (value === null || typeof value !== "object" || Object.isFrozen(value) || seen.has(value))
14
+ return;
15
+ seen.add(value);
16
+ Object.freeze(value);
17
+ for (const nested of Object.values(value))
18
+ deepFreeze(nested, seen);
19
+ }
20
+ /** Decouple a producer-owned, arbitrarily-shaped `args` value from projection state: deep clone it (so
21
+ * the returned snapshot shares no mutable reference) then deep-freeze the clone. When `structuredClone`
22
+ * is unavailable or `args` is non-cloneable (e.g. it contains functions), fall back to deep-freezing
23
+ * `args` *in place* rather than returning it unfrozen: a shared-by-reference fallback would let a
24
+ * consumer mutate `tool.args` back into the projection's internals, so freezing the shared object is
25
+ * what preserves the "cannot reach back" guarantee even when cloning fails. */
26
+ function freezeArgs(args) {
27
+ if (args === null || typeof args !== "object")
28
+ return args;
29
+ let cloned;
30
+ try {
31
+ cloned = structuredClone(args);
32
+ }
33
+ catch {
34
+ deepFreeze(args);
35
+ return args;
36
+ }
37
+ deepFreeze(cloned);
38
+ return cloned;
39
+ }
40
+ /** Deep-freeze a {@link DerivedTool} into a snapshot decoupled from the projection's mutable internals:
41
+ * a shallow clone whose nested `result` is itself cloned + frozen and whose producer-owned `args` is
42
+ * deep cloned + frozen ({@link freezeArgs}), so a consumer that mutates the returned `tool` (or
43
+ * `tool.result` / `tool.args`) cannot reach back into projection state. */
44
+ function freezeTool(tool) {
45
+ return Object.freeze({
46
+ ...tool,
47
+ ...(tool.args !== undefined ? { args: freezeArgs(tool.args) } : {}),
48
+ ...(tool.result !== undefined ? { result: Object.freeze({ ...tool.result }) } : {}),
49
+ });
50
+ }
51
+ /** Deep-freeze a {@link DerivedPermission} into a snapshot decoupled from the projection's mutable
52
+ * internals: a shallow clone whose nested `options` (and each option) and `resolved` are cloned +
53
+ * frozen, so a consumer cannot mutate projection state through the returned `permission`. */
54
+ function freezePermission(permission) {
55
+ return Object.freeze({
56
+ ...permission,
57
+ options: Object.freeze(permission.options.map((option) => Object.freeze({ ...option }))),
58
+ ...(permission.resolved !== undefined ? { resolved: Object.freeze({ ...permission.resolved }) } : {}),
59
+ });
60
+ }
61
+ function freezeBlock(block) {
62
+ switch (block.kind) {
63
+ case "text":
64
+ return Object.freeze({
65
+ kind: "text",
66
+ id: `text:${block.startOffset}`,
67
+ role: block.role,
68
+ ...(block.messageId !== undefined ? { messageId: block.messageId } : {}),
69
+ text: block.text,
70
+ startOffset: block.startOffset,
71
+ endOffset: block.endOffset,
72
+ complete: block.complete,
73
+ });
74
+ case "tool":
75
+ return Object.freeze({
76
+ kind: "tool",
77
+ id: `tool:${block.startOffset}`,
78
+ tool: freezeTool(block.tool),
79
+ startOffset: block.startOffset,
80
+ endOffset: block.endOffset,
81
+ });
82
+ case "permission":
83
+ return Object.freeze({
84
+ kind: "permission",
85
+ id: `permission:${block.startOffset}`,
86
+ permission: freezePermission(block.permission),
87
+ startOffset: block.startOffset,
88
+ endOffset: block.endOffset,
89
+ });
90
+ case "gap":
91
+ return Object.freeze({
92
+ kind: "gap",
93
+ id: `gap:${block.ordinal}`,
94
+ ...(block.beforeOffset !== undefined ? { beforeOffset: block.beforeOffset } : {}),
95
+ });
96
+ }
97
+ }
98
+ function toolFromCall(event) {
99
+ return {
100
+ name: event.name,
101
+ offset: event.offset,
102
+ ...(event.callId !== undefined ? { callId: event.callId } : {}),
103
+ ...(event.args !== undefined ? { args: event.args } : {}),
104
+ };
105
+ }
106
+ function toolWithResult(tool, result) {
107
+ return {
108
+ ...tool,
109
+ result: {
110
+ ok: result.ok,
111
+ offset: result.offset,
112
+ ...(result.content !== undefined ? { content: result.content } : {}),
113
+ },
114
+ };
115
+ }
116
+ function permissionFromRequest(event) {
117
+ return {
118
+ callId: event.callId,
119
+ policy: event.policy,
120
+ options: event.options,
121
+ offset: event.offset,
122
+ ...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
123
+ ...(event.title !== undefined ? { title: event.title } : {}),
124
+ ...(event.reason !== undefined ? { reason: event.reason } : {}),
125
+ };
126
+ }
127
+ function permissionWithResolution(permission, resolution) {
128
+ return {
129
+ ...permission,
130
+ resolved: {
131
+ allowed: resolution.allowed,
132
+ optionId: resolution.optionId,
133
+ offset: resolution.offset,
134
+ ...(resolution.by !== undefined ? { by: resolution.by } : {}),
135
+ },
136
+ };
137
+ }
138
+ export function createDisplayProjection() {
139
+ const blocks = [];
140
+ const openTools = new Map();
141
+ let anonymousTool;
142
+ const openPermissions = new Map();
143
+ // The append-order idempotency key: the highest offset ever folded. Any event at or below it was
144
+ // already applied (a replayed/duplicated chunk), so it is a no-op — this is what keeps replay,
145
+ // reconnect and pagination overlap from doubling text. Starts at -1 so offset 0 applies.
146
+ let lastOffset = -1;
147
+ // A pending gap awaiting the offset of the next block, so a consumer can anchor the break.
148
+ let pendingGapBlock;
149
+ let gapOrdinal = 0;
150
+ /** The active text block a delta may extend: the LAST block, iff it is an open text block. Any other
151
+ * trailing block (a tool card, a permission, a gap) means there is no open text run to coalesce into. */
152
+ const activeText = () => {
153
+ const tail = blocks[blocks.length - 1];
154
+ return tail !== undefined && tail.kind === "text" && !tail.complete ? tail : undefined;
155
+ };
156
+ const closeActiveText = () => {
157
+ const active = activeText();
158
+ if (active !== undefined)
159
+ active.complete = true;
160
+ };
161
+ /** Anchor a not-yet-anchored gap to the first block that opens after it, returning the now-anchored gap
162
+ * (frozen) so the triggering {@link apply} can surface it as {@link DisplayApplyResult.anchored} — else
163
+ * `undefined` when there is no pending gap. */
164
+ const anchorGap = (offset) => {
165
+ if (pendingGapBlock === undefined)
166
+ return undefined;
167
+ pendingGapBlock.beforeOffset = offset;
168
+ const anchored = freezeBlock(pendingGapBlock);
169
+ pendingGapBlock = undefined;
170
+ return anchored;
171
+ };
172
+ const applyMessage = (event) => {
173
+ const active = activeText();
174
+ // A delta may extend the active block only when it is the SAME speaker AND the SAME logical message
175
+ // AND the producer did not force a new block with `start`. Message identity: if both sides carry a
176
+ // `messageId` they must match; a changed id (or one side having an id the other lacks) is a distinct
177
+ // message. With no ids on either side, the fallback is purely structural — adjacent + same speaker.
178
+ const idsMatch = event.messageId !== undefined || active?.messageId !== undefined
179
+ ? event.messageId === active?.messageId
180
+ : true;
181
+ const canExtend = active !== undefined && event.start !== true && active.role === event.role && idsMatch;
182
+ if (canExtend && active !== undefined) {
183
+ // Snapshot REPLACES the accumulated text; a delta APPENDS exactly (no separator).
184
+ active.text = event.mode === "snapshot" ? event.text : active.text + event.text;
185
+ active.endOffset = event.offset;
186
+ if (event.final === true)
187
+ active.complete = true;
188
+ return { changed: freezeBlock(active), appended: false };
189
+ }
190
+ // Open a fresh block. (A `start`/id-change/role-change also closes any still-open predecessor so the
191
+ // next unrelated delta cannot re-open it.)
192
+ closeActiveText();
193
+ const anchored = anchorGap(event.offset);
194
+ const block = {
195
+ kind: "text",
196
+ role: event.role,
197
+ text: event.text,
198
+ startOffset: event.offset,
199
+ endOffset: event.offset,
200
+ complete: event.final === true,
201
+ ...(event.messageId !== undefined ? { messageId: event.messageId } : {}),
202
+ };
203
+ blocks.push(block);
204
+ return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
205
+ };
206
+ const applyToolCall = (event) => {
207
+ // A tool call interrupts any running text: it becomes the trailing block, so a later delta opens a
208
+ // new text block rather than coalescing across the tool.
209
+ closeActiveText();
210
+ const anchored = anchorGap(event.offset);
211
+ const block = {
212
+ kind: "tool",
213
+ tool: toolFromCall(event),
214
+ startOffset: event.offset,
215
+ endOffset: event.offset,
216
+ };
217
+ blocks.push(block);
218
+ const pending = { block };
219
+ if (event.callId !== undefined)
220
+ openTools.set(event.callId, pending);
221
+ else
222
+ anonymousTool = pending;
223
+ return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
224
+ };
225
+ const applyToolResult = (event) => {
226
+ const pending = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
227
+ if (pending === undefined)
228
+ return NOOP; // A result with no open call — nothing to pair (never invents a card).
229
+ pending.block.tool = toolWithResult(pending.block.tool, event);
230
+ pending.block.endOffset = event.offset;
231
+ if (event.callId !== undefined)
232
+ openTools.delete(event.callId);
233
+ else
234
+ anonymousTool = undefined;
235
+ return { changed: freezeBlock(pending.block), appended: false };
236
+ };
237
+ const applyPermissionRequest = (event) => {
238
+ closeActiveText();
239
+ const anchored = anchorGap(event.offset);
240
+ const block = {
241
+ kind: "permission",
242
+ permission: permissionFromRequest(event),
243
+ startOffset: event.offset,
244
+ endOffset: event.offset,
245
+ };
246
+ blocks.push(block);
247
+ openPermissions.set(event.callId, { block });
248
+ return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
249
+ };
250
+ const applyPermissionResolution = (event) => {
251
+ const pending = openPermissions.get(event.callId);
252
+ if (pending === undefined)
253
+ return NOOP;
254
+ pending.block.permission = permissionWithResolution(pending.block.permission, event);
255
+ pending.block.endOffset = event.offset;
256
+ openPermissions.delete(event.callId);
257
+ return { changed: freezeBlock(pending.block), appended: false };
258
+ };
259
+ const apply = (event) => {
260
+ // Idempotency gate: this projection requires events in strictly increasing `offset` order, so any
261
+ // offset at or below the high-water mark is treated as already folded (replay / reconnect /
262
+ // pagination overlap / a duplicated chunk) and re-applying it must not change anything. A genuinely
263
+ // out-of-order event (offset <= lastOffset arriving late) is likewise dropped here, not merged — a
264
+ // caller seeing a "missing" block must re-feed the stream in order rather than read it as deduped.
265
+ if (event.offset <= lastOffset)
266
+ return NOOP;
267
+ lastOffset = event.offset;
268
+ switch (event.kind) {
269
+ case "message":
270
+ return applyMessage(event);
271
+ case "tool-call":
272
+ return applyToolCall(event);
273
+ case "tool-result":
274
+ return applyToolResult(event);
275
+ case "permission":
276
+ return event.phase === "request" ? applyPermissionRequest(event) : applyPermissionResolution(event);
277
+ case "turn":
278
+ // An explicit turn boundary closes the running message so the next turn's text starts fresh.
279
+ closeActiveText();
280
+ return NOOP;
281
+ // A `step`, a `lifecycle` transition, and a raw `stream-chunk` do not themselves produce a display
282
+ // block and do not break text coalescing (raw bytes render on the separate byte-terminal plane).
283
+ case "step":
284
+ case "lifecycle":
285
+ case "stream-chunk":
286
+ return NOOP;
287
+ }
288
+ };
289
+ return {
290
+ apply,
291
+ applyAll(events) {
292
+ for (const event of events)
293
+ apply(event);
294
+ },
295
+ noteGap() {
296
+ closeActiveText();
297
+ const block = { kind: "gap", ordinal: gapOrdinal++ };
298
+ blocks.push(block);
299
+ pendingGapBlock = block;
300
+ return { changed: freezeBlock(block), appended: true };
301
+ },
302
+ blocks() {
303
+ return blocks.map(freezeBlock);
304
+ },
305
+ };
306
+ }
307
+ /**
308
+ * The pure batch convenience: fold a whole run of offset-ordered events into the ordered display blocks
309
+ * in one call, over a fresh {@link createDisplayProjection}. Duplicate offsets in the input are deduped
310
+ * by the same idempotency gate, so a replayed slice folds to the same result as a gap-free one.
311
+ */
312
+ export function deriveDisplay(events) {
313
+ const projection = createDisplayProjection();
314
+ projection.applyAll(events);
315
+ return projection.blocks();
316
+ }