@oh-my-pi/pi-coding-agent 16.3.5 → 16.3.6

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli.js +3388 -3374
  3. package/dist/types/edit/file-snapshot-store.d.ts +4 -2
  4. package/dist/types/edit/renderer.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  6. package/dist/types/extensibility/shared-events.d.ts +8 -1
  7. package/dist/types/modes/components/assistant-message.d.ts +15 -10
  8. package/dist/types/modes/components/bash-execution.d.ts +6 -0
  9. package/dist/types/modes/components/eval-execution.d.ts +6 -0
  10. package/dist/types/modes/components/tool-execution.d.ts +3 -13
  11. package/dist/types/modes/components/transcript-container.d.ts +26 -28
  12. package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
  13. package/dist/types/session/agent-session.d.ts +2 -0
  14. package/dist/types/tools/bash.d.ts +0 -2
  15. package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
  16. package/dist/types/tools/eval-render.d.ts +0 -2
  17. package/dist/types/tools/renderers.d.ts +0 -20
  18. package/dist/types/tools/ssh.d.ts +0 -2
  19. package/dist/types/tools/write.d.ts +1 -1
  20. package/package.json +12 -12
  21. package/src/edit/file-snapshot-store.ts +5 -2
  22. package/src/edit/renderer.ts +0 -5
  23. package/src/extensibility/custom-tools/types.ts +2 -0
  24. package/src/extensibility/shared-events.ts +9 -1
  25. package/src/modes/components/assistant-message.ts +134 -82
  26. package/src/modes/components/bash-execution.ts +9 -0
  27. package/src/modes/components/chat-transcript-builder.ts +8 -4
  28. package/src/modes/components/eval-execution.ts +9 -0
  29. package/src/modes/components/tool-execution.ts +4 -50
  30. package/src/modes/components/transcript-container.ts +82 -432
  31. package/src/modes/components/tree-selector.ts +9 -3
  32. package/src/modes/controllers/command-controller.ts +0 -3
  33. package/src/modes/controllers/event-controller.ts +74 -14
  34. package/src/modes/controllers/extension-ui-controller.ts +0 -1
  35. package/src/modes/controllers/input-controller.ts +4 -10
  36. package/src/modes/interactive-mode.ts +12 -8
  37. package/src/modes/utils/transcript-render-helpers.ts +40 -13
  38. package/src/modes/utils/ui-helpers.ts +12 -7
  39. package/src/sdk.ts +1 -0
  40. package/src/session/agent-session.ts +148 -1
  41. package/src/session/session-context.ts +7 -0
  42. package/src/tools/bash.ts +0 -4
  43. package/src/tools/browser/tab-supervisor.ts +47 -3
  44. package/src/tools/eval-render.ts +0 -20
  45. package/src/tools/read.ts +63 -20
  46. package/src/tools/renderers.ts +0 -20
  47. package/src/tools/ssh.ts +0 -16
  48. package/src/tools/write.ts +13 -6
@@ -7,45 +7,6 @@ import {
7
7
  type ViewportTailProvider,
8
8
  } from "@oh-my-pi/pi-tui";
9
9
 
10
- const kSnapshot = Symbol("transcript.liveDiffSnapshot");
11
-
12
- /**
13
- * Per-block render cache: the block's previous stripped contribution plus the
14
- * derived append-only state. Still-live blocks use it as input to
15
- * {@link deriveLiveCommitState}; finalized blocks wholly inside already
16
- * committed native scrollback can replay it without calling render().
17
- */
18
- interface LiveDiffSnapshot {
19
- width: number;
20
- lines: readonly string[];
21
- generation: number;
22
- appendOnly: boolean;
23
- /**
24
- * Frames remaining until a block that rewrote an interior row may re-earn
25
- * append-only status. `0` means the block is not under rewrite suspicion.
26
- */
27
- volatileCooldown: number;
28
- /**
29
- * Stable-prefix ratchet (see {@link deriveLiveCommitState}): leading rows
30
- * promoted as commit-safe because they stayed visibly identical for
31
- * {@link STABLE_PREFIX_COMMIT_FRAMES} consecutive frames, plus the in-flight
32
- * candidate run and its age.
33
- */
34
- stablePrefixLength: number;
35
- candidatePrefixLength: number;
36
- candidatePrefixAge: number;
37
- /**
38
- * Topmost row index ever observed rewritten in place (see
39
- * {@link deriveLiveCommitState}): the stable-prefix ratchet never promotes
40
- * rows at/after it. `Infinity` until the first rewrite.
41
- */
42
- rewriteFloor: number;
43
- }
44
-
45
- interface SnapshotCarrier {
46
- [kSnapshot]?: LiveDiffSnapshot;
47
- }
48
-
49
10
  /**
50
11
  * A transcript block that is still mutating (a foreground tool awaiting its
51
12
  * result, an assistant message mid-stream) reports `false` so the container
@@ -65,18 +26,20 @@ interface FinalizableBlock {
65
26
  */
66
27
  getTranscriptBlockVersion?(): number;
67
28
  /**
68
- * Whether a still-live block's visually settled leading rows are durable —
69
- * guaranteed to survive the block's remaining transitions (finalize,
70
- * displacement) byte-stable and may therefore be promoted as commit-safe
71
- * by {@link deriveLiveCommitState}. Blocks whose pending render is
72
- * provisional (a tool call's tail-window streaming preview, replaced
73
- * wholesale by the result render) return `false`: committing such rows
74
- * strands a stale copy in immutable terminal history the moment the real
75
- * content re-lays-out the block (the engine audit recommits below it —
76
- * "duplication, never loss"). Absent = `true`, the default for blocks
77
- * whose live rows persist (a streaming assistant message).
29
+ * Leading rows of the block's current render() output that are declared
30
+ * FINAL while the block is still live: byte-stable at the current width
31
+ * until the block finalizes, monotone non-decreasing under streaming
32
+ * growth, re-derived per render (the container reads it right after
33
+ * calling render()). The container extends the native-scrollback commit
34
+ * boundary through these rows so a long streaming reply's scrolled-off
35
+ * head reaches terminal history mid-stream. Declaring a row that later
36
+ * changes strands a stale copy in immutable history (the engine audit
37
+ * repairs by recommitting below — duplication, never loss), so
38
+ * implementers report only rows whose bytes provably cannot change (e.g.
39
+ * rendered output of markdown's frozen token prefix). Absent = 0: nothing
40
+ * commits until the block finalizes.
78
41
  */
79
- isTranscriptBlockCommitStable?(): boolean;
42
+ getTranscriptBlockSettledRows?(): number;
80
43
  }
81
44
 
82
45
  function isBlockFinalized(child: Component): boolean {
@@ -89,9 +52,12 @@ function getBlockVersion(child: Component): number | undefined {
89
52
  return fn ? fn.call(child) : undefined;
90
53
  }
91
54
 
92
- function isBlockCommitStable(child: Component): boolean {
93
- const fn = (child as Component & FinalizableBlock).isTranscriptBlockCommitStable;
94
- return fn ? fn.call(child) : true;
55
+ /** Clamped read of a block's declared settled rows (see {@link FinalizableBlock}). */
56
+ function getBlockSettledRows(child: Component): number {
57
+ const fn = (child as Component & FinalizableBlock).getTranscriptBlockSettledRows;
58
+ if (!fn) return 0;
59
+ const value = fn.call(child);
60
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
95
61
  }
96
62
 
97
63
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
@@ -143,270 +109,22 @@ const EMPTY_SEGMENTS: BlockSegment[] = [];
143
109
  /** Shared empty result for an empty viewport-tail render (no allocation). */
144
110
  const EMPTY_TAIL: readonly string[] = [];
145
111
 
146
- interface LiveCommitState {
147
- appendOnly: boolean;
148
- volatileCooldown: number;
149
- stablePrefixLength: number;
150
- candidatePrefixLength: number;
151
- candidatePrefixAge: number;
152
- rewriteFloor: number;
153
- safeLength: number;
154
- }
155
-
156
- /**
157
- * Render frames a block must stay clean (static or append-shaped) after an
158
- * interior rewrite before its rows become committable again. A one-off
159
- * re-layout (a codespan finalizing across a wrap boundary, a paragraph
160
- * re-parsed as a heading) only suspends commits briefly — the pinned emitter
161
- * appends from the stalled high-water mark, so the gap backfills contiguously
162
- * once the block re-earns append-only. Periodic animations (a spinner rewrites
163
- * its row every few frames) keep resetting the countdown and never re-earn it,
164
- * so genuinely volatile blocks stay deferred. Frames arrive at most at the
165
- * TUI's 30 Hz render cadence, so 30 frames ≈ 1s of clean streaming.
166
- */
167
- const VOLATILE_REARM_FRAMES = 30;
168
-
169
- /**
170
- * Consecutive frames a leading row run must stay visibly identical before it
171
- * is promoted as commit-safe even though the block's tail keeps rewriting.
172
- * Append-only detection alone is all-or-nothing per block: one perpetually
173
- * ticking row (a task tool's progress tree, per-agent cost/tool counters, a
174
- * log line spinner) suspends commits for the WHOLE block forever, so once the
175
- * block outgrows the viewport its static head — e.g. a task's prompt/context
176
- * markdown — is neither committed to native scrollback nor on screen: the
177
- * transcript reads as cut off for the entire (possibly minutes-long) run.
178
- * The ratchet commits the settled head while only the genuinely volatile tail
179
- * stays deferred. If a promoted row is later rewritten (a collapsing
180
- * preview), the engine's committed-prefix audit re-anchors and recommits —
181
- * duplication, never loss — and the ratchet retreats to the divergence.
182
- */
183
- const STABLE_PREFIX_COMMIT_FRAMES = 30;
184
-
185
- /**
186
- * Rows at a live block's tail treated as the volatile streaming edge. Real
187
- * streaming is not strictly append-only at the bottom: the in-flight markdown
188
- * paragraph re-wraps as words arrive (rewriting its last 1-2 visual rows), an
189
- * unclosed token (`**bold`, a half-streamed link) re-renders when its closer
190
- * arrives, and a wrap-shrink moves the last word onto a new row. Divergence
191
- * confined to this zone is clean growth, and the zone itself is held back
192
- * from the offered commit boundary — so a tolerated rewrite can never touch a
193
- * row the engine may have committed. Width 4 covers the observed shapes (≤2
194
- * rows) with margin for wide glyphs and multi-row token spans; the cost is
195
- * only that the last 4 rows of a live block commit at finalization instead of
196
- * mid-stream, which is invisible (they are on screen — the viewport is always
197
- * taller than the holdback).
198
- */
199
- const TAIL_VOLATILITY_ROWS = 4;
200
-
201
- /**
202
- * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
203
- * write framing, not content. A styled line's closing escape moves when the
204
- * line stops being the last of its span (a wrapped thinking paragraph growing
205
- * by one row), and width-padded rows shift their trailing spaces as text
206
- * grows; both leave the on-screen cells identical and must not count as a
207
- * rewrite of a committed-candidate row. Committed scrollback rows are written
208
- * with a full SGR/OSC reset terminator, so escape-placement drift between
209
- * visually identical renders cannot bleed styles across rows.
210
- */
211
- function normalizeRow(line: string): string {
212
- return Bun.stripANSI(line).trimEnd();
213
- }
214
-
215
- function rowsVisiblyEqual(prev: string, cur: string): boolean {
216
- return prev === cur || normalizeRow(prev) === normalizeRow(cur);
217
- }
218
-
219
- /**
220
- * Whether `cur` is `prev` grown in place: the visible content of `prev` is a
221
- * strict-or-equal prefix of `cur`'s (token streaming appending to the cursor
222
- * row). Escape placement and pad drift are ignored, same as rowsVisiblyEqual.
223
- */
224
- function rowVisiblyGrew(prev: string, cur: string): boolean {
225
- return normalizeRow(cur).startsWith(normalizeRow(prev));
226
- }
227
-
228
- function hasValidSnapshot(
229
- snapshot: LiveDiffSnapshot | undefined,
230
- width: number,
231
- generation: number,
232
- ): snapshot is LiveDiffSnapshot {
233
- return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
234
- }
235
-
236
- function commonPrefixLength(prev: readonly string[], cur: readonly string[]): number {
237
- const limit = Math.min(prev.length, cur.length);
238
- let i = 0;
239
- while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
240
- return i;
241
- }
242
-
243
- function commonSuffixLength(prev: readonly string[], cur: readonly string[], prefixLength: number): number {
244
- const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
245
- let i = 0;
246
- while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
247
- return i;
248
- }
249
-
250
- function deriveLiveCommitState(
251
- previous: LiveDiffSnapshot | undefined,
252
- current: readonly string[],
253
- width: number,
254
- generation: number,
255
- ): LiveCommitState {
256
- let appendOnly = false;
257
- let volatileCooldown = 0;
258
- let stablePrefixLength = 0;
259
- let candidatePrefixLength = 0;
260
- let candidatePrefixAge = 0;
261
- let rewriteFloor = Number.POSITIVE_INFINITY;
262
- let trailingRowGrowth = false;
263
- if (hasValidSnapshot(previous, width, generation)) {
264
- appendOnly = previous.appendOnly;
265
- volatileCooldown = previous.volatileCooldown;
266
- stablePrefixLength = previous.stablePrefixLength;
267
- candidatePrefixLength = previous.candidatePrefixLength;
268
- candidatePrefixAge = previous.candidatePrefixAge;
269
- rewriteFloor = previous.rewriteFloor;
270
-
271
- const prefixLength = commonPrefixLength(previous.lines, current);
272
- const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
273
- let cleanFrame = true;
274
- if (!staticRender) {
275
- const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
276
- // Append-only growth never rewrites a row that may already have scrolled
277
- // into native scrollback; it only grows the block at/near its tail. Two
278
- // shapes qualify:
279
- // - a pure insertion that preserves every previous row across a
280
- // matching prefix + suffix (a bottom append, or an insertion above
281
- // stable trailing chrome like a streaming tool's footer/border);
282
- // - a rewrite whose divergence BEGINS inside the trailing
283
- // TAIL_VOLATILITY_ROWS of the previous render — the streaming edge:
284
- // the in-flight paragraph re-wrapping as words arrive (its last 1-2
285
- // visual rows), an unclosed markdown token (`**bold`) re-rendering
286
- // when its closer streams in, a wrap-shrink pushing the last word
287
- // onto an appended row. That zone is held back from `safeLength`
288
- // below, so a tolerated rewrite can never touch a row that was
289
- // offered for commit.
290
- // The anchor matters: the gap must START in the tail zone, not merely
291
- // be small — a one-row ticker mid-block with stable rows beneath it
292
- // would otherwise classify clean, get offered past, and rewrite
293
- // committed rows on every tick. Any deeper divergent row means the
294
- // block re-laid-out committed-candidate content — a rewrite, which
295
- // suspends commits until the block re-earns append-only.
296
- const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
297
- const tailConfined = preservedEveryRow || prefixLength >= previous.lines.length - TAIL_VOLATILITY_ROWS;
298
- if (tailConfined && current.length >= previous.lines.length) {
299
- // Strict trailing-row growth: every previous row except the last
300
- // is visibly unchanged and the last grew in place as a visible
301
- // prefix, with no rows appended — a line accumulating tokens.
302
- // The sole divergent row is the block's physical last row, which
303
- // the engine's window floor never commits while it stays last
304
- // (chunkTo ≤ windowTop ≤ last row index), so the volatile-tail
305
- // holdback below is unnecessary: the whole body is offerable and
306
- // the block's scrolled-off head reaches native scrollback.
307
- trailingRowGrowth =
308
- current.length === previous.lines.length &&
309
- prefixLength === previous.lines.length - 1 &&
310
- rowVisiblyGrew(previous.lines[prefixLength]!, current[prefixLength]!);
311
- if (volatileCooldown === 0) appendOnly = true;
312
- // Clean growth inserts/rewrites rows at the divergence; a floor
313
- // inside the preserved suffix travels down with it, a floor at or
314
- // above the divergent zone stays put (conservative: a stale floor
315
- // index can only point at an earlier row, never a later one).
316
- const delta = current.length - previous.lines.length;
317
- if (delta > 0 && Number.isFinite(rewriteFloor)) {
318
- const suffixStart = Math.max(prefixLength, previous.lines.length - suffixLength);
319
- if (rewriteFloor >= suffixStart) rewriteFloor += delta;
320
- }
321
- } else {
322
- cleanFrame = false;
323
- appendOnly = false;
324
- volatileCooldown = VOLATILE_REARM_FRAMES;
325
- }
326
- }
327
- if (cleanFrame && volatileCooldown > 0) volatileCooldown--;
328
-
329
- // Stable-prefix ratchet, independent of append-only. `prefixLength` is
330
- // this frame's visibly-unchanged leading run; the candidate accumulates
331
- // the MINIMUM prefix across a STABLE_PREFIX_COMMIT_FRAMES window, so
332
- // promotion means every promoted row stayed identical for the whole
333
- // window (row r is inside frame i's common prefix iff r < p_i, so
334
- // r < min(p) holds for every frame of the window). A row settling
335
- // mid-window promotes at most two windows later. The engine audit owns
336
- // any promoted rows that already committed (recommit, never loss).
337
- if (prefixLength < stablePrefixLength) {
338
- // A divergence inside the promoted run is the ratchet's proof of
339
- // over-promotion: this row was visibly stable for a full window,
340
- // got promoted (and likely committed), and then mutated anyway — a
341
- // slow ticker (an agent row's tool/cost counter, a growing progress
342
- // tree), not settling content. It will mutate again, and every
343
- // promote→mutate cycle makes the engine audit recommit, spraying a
344
- // stale snapshot of the block into native scrollback. Floor the
345
- // ratchet at the divergence permanently: rows above it may still
346
- // promote, rows at/below it never re-promote while the block lives.
347
- // One-off re-layouts before any promotion (a call→result frame
348
- // transition, a codespan finalizing) never hit this branch, and the
349
- // append-only re-arm path commits the full block regardless of the
350
- // floor.
351
- rewriteFloor = Math.min(rewriteFloor, prefixLength);
352
- stablePrefixLength = prefixLength;
353
- candidatePrefixLength = prefixLength;
354
- candidatePrefixAge = 0;
355
- } else {
356
- candidatePrefixLength =
357
- candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
358
- candidatePrefixAge++;
359
- if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
360
- // Cap at the volatile-tail holdback: a long static stretch would
361
- // otherwise promote the streaming edge itself (min prefix == full
362
- // length), and the next chunk's tail re-wrap would then rewrite
363
- // offered rows.
364
- stablePrefixLength = Math.min(
365
- candidatePrefixLength,
366
- rewriteFloor,
367
- Math.max(0, current.length - TAIL_VOLATILITY_ROWS),
368
- );
369
- candidatePrefixLength = prefixLength;
370
- candidatePrefixAge = 0;
371
- }
372
- }
373
- }
374
-
375
- return {
376
- appendOnly,
377
- volatileCooldown,
378
- stablePrefixLength,
379
- candidatePrefixLength,
380
- candidatePrefixAge,
381
- rewriteFloor,
382
- // A clean-streaming block's body is committable up to the volatile-tail
383
- // holdback (the streaming edge is never offered, so its tolerated
384
- // rewrites can never touch committed rows); otherwise the settled head
385
- // still is — only the volatile tail stays deferred. Strict in-place
386
- // growth of the trailing row skips the holdback: its only mutable row
387
- // is the block's last, which cannot commit while it remains last.
388
- safeLength: appendOnly
389
- ? trailingRowGrowth
390
- ? current.length
391
- : Math.max(stablePrefixLength, current.length - TAIL_VOLATILITY_ROWS, 0)
392
- : stablePrefixLength,
393
- };
394
- }
395
-
396
112
  /**
397
113
  * Transcript container that renders every block's current content each frame
398
- * and reports the live-region seam (`NativeScrollbackLiveRegion`) that gates
399
- * the engine's append-only scrollback commits.
114
+ * and reports the native-scrollback exactness boundary
115
+ * (`NativeScrollbackLiveRegion`): the frame row below which every rendered
116
+ * row is final. The boundary covers the leading run of finalized blocks plus
117
+ * the first still-live block's declared settled rows
118
+ * ({@link FinalizableBlock.getTranscriptBlockSettledRows}). Rows below it
119
+ * commit to native scrollback as exact, audited content; rows above it that
120
+ * scroll off the window commit as frozen visual snapshots the engine never
121
+ * re-anchors or recommits (the tape records what was on screen).
400
122
  *
401
- * The engine never rewrites committed history: rows above the seam that have
402
- * entered the tape keep whatever bytes they were committed with ("let the
403
- * history be"), while the visible window always repaints from each block's
404
- * latest render — a late tool result, a post-finalize error pin, or an expand
405
- * toggle is always reflected on screen. Blocks that are still mutating (an
406
- * unfinalized tool, a streaming assistant message) stay below the seam so
407
- * their rows do not enter history while they can still change; a streaming
408
- * block whose render grows append-only deepens the seam through its settled
409
- * head so a long reply's scrolled-off rows still reach scrollback mid-stream.
123
+ * The engine never rewrites committed history: rows that have entered the
124
+ * tape keep whatever bytes they were committed with ("let the history be"),
125
+ * while the visible window always repaints from each block's latest render —
126
+ * a late tool result, a post-finalize error pin, or an expand toggle is
127
+ * always reflected on screen while it remains in the window.
410
128
  *
411
129
  * Assembly is incremental: the returned array is persistent and mutated in
412
130
  * place. Each block's render is still called every frame, but a block whose
@@ -420,29 +138,13 @@ export class TranscriptContainer
420
138
  extends Container
421
139
  implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
422
140
  {
423
- // Bumped to retire every block's diff snapshot at once (theme change /
424
- // clear); a snapshot is only honored when its stored generation matches.
141
+ // Bumped to retire every block segment at once (theme change / clear); a
142
+ // segment is only reused when its stored generation matches.
425
143
  #generation = 0;
426
- // Local line index where the current live region begins in the most recent
427
- // render. TUI commits rows to native scrollback only above this seam (or
428
- // the deeper commit-safe end below).
144
+ // Local line index below which every row of the most recent render is
145
+ // final: the leading finalized blocks plus the first live block's declared
146
+ // settled rows. TUI commits rows to native scrollback only above it.
429
147
  #nativeScrollbackLiveRegionStart: number | undefined;
430
- // Local line index up to which the leading run of live blocks is safe to
431
- // commit. Finalized blocks contribute their full body; still-live blocks
432
- // contribute only while their render has been observed growing without
433
- // visibly rewriting a previously rendered interior row (escape placement
434
- // and pad drift are ignored). A rewrite suspends the block's contribution
435
- // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
436
- // the engine then backfills the stalled gap.
437
- #nativeScrollbackCommitSafeEnd: number | undefined;
438
- // Local line index up to which the leading run of live blocks is DURABLE: a
439
- // commit-stable block's full body is permanent content even while its interior
440
- // rows re-lay-out (a streaming markdown table re-aligning columns), so the
441
- // engine must append their scroll-off snapshot rather than drop it. Reported
442
- // separately from the byte-stable commit-safe end because these rows may still
443
- // drift after commit; the engine commits them audit-exempt. Provisional
444
- // (commit-unstable) blocks never extend it.
445
- #nativeScrollbackSnapshotSafeEnd: number | undefined;
446
148
  // Persistent assembled transcript rows. Rows before the stable floor are
447
149
  // byte-identical to the previous render; rows at/after it were re-pushed.
448
150
  #lines: string[] = [];
@@ -483,42 +185,31 @@ export class TranscriptContainer
483
185
  return this.#nativeScrollbackLiveRegionStart;
484
186
  }
485
187
 
486
- getNativeScrollbackCommitSafeEnd(): number | undefined {
487
- return this.#nativeScrollbackCommitSafeEnd;
488
- }
489
-
490
- getNativeScrollbackSnapshotSafeEnd(): number | undefined {
491
- return this.#nativeScrollbackSnapshotSafeEnd;
492
- }
493
-
494
188
  /**
495
- * Whether `component` sits below a still-mutating block i.e. inside the
496
- * live region, where its rows cannot have been committed to native
497
- * scrollback yet (commits are prefix-only and stop at the first
498
- * still-live block). Callers that retract ephemeral blocks (IRC cards)
499
- * must check this: removing a block whose rows may already be in history
500
- * is an interior deletion of the committed prefix, which the engine can
501
- * only repair by recommitting everything below it — duplication.
189
+ * Whether none of `component`'s rows (per the most recent render) have
190
+ * entered native scrollback. Callers that retract ephemeral blocks (IRC
191
+ * cards, displaceable todo/job snapshots) must check this: removing a
192
+ * block whose rows are already on the tape is an interior deletion of
193
+ * committed history the engine cannot express the block must be sealed
194
+ * in place as history instead. A component that has never rendered has no
195
+ * committed rows and is safely removable.
502
196
  */
503
- isWithinLiveRegion(component: Component): boolean {
504
- const index = this.children.indexOf(component);
505
- if (index < 0) return false;
506
- for (let i = 0; i < index; i++) {
507
- if (!isBlockFinalized(this.children[i]!)) return true;
197
+ isBlockUncommitted(component: Component): boolean {
198
+ for (const segment of this.#segments) {
199
+ if (segment.component !== component) continue;
200
+ return segment.rowCount === 0 || segment.startRow >= this.#committedRows;
508
201
  }
509
- return false;
202
+ return true;
510
203
  }
511
204
 
512
205
  /**
513
206
  * Whether `component` is inside the live (repaintable) region exactly as
514
207
  * {@link render} computes it: at/after the first still-mutating block, or
515
- * the transcript tail when every block has finalized. Unlike
516
- * {@link isWithinLiveRegion} (strictly below a still-mutating block, i.e.
517
- * guaranteed-uncommitted), this also counts the trailing block that anchors
518
- * the live region. Self-animating finalized blocks (a detached task's
519
- * shimmering progress rows) poll this to stop animating — and settle on
520
- * static bytes — the moment they sit above the seam, where their rows
521
- * become commit-eligible native-scrollback history.
208
+ * the transcript tail when every block has finalized. Self-animating
209
+ * finalized blocks (a detached task's shimmering progress rows) poll this
210
+ * to stop animating and settle on static bytes — the moment they sit
211
+ * above the seam, where their rows become commit-eligible native-scrollback
212
+ * history.
522
213
  */
523
214
  isBlockInLiveRegion(component: Component): boolean {
524
215
  const children = this.children;
@@ -581,21 +272,21 @@ export class TranscriptContainer
581
272
  override render(width: number): readonly string[] {
582
273
  width = Math.max(1, width);
583
274
  this.#nativeScrollbackLiveRegionStart = undefined;
584
- this.#nativeScrollbackCommitSafeEnd = undefined;
585
- this.#nativeScrollbackSnapshotSafeEnd = undefined;
586
275
 
587
276
  const count = this.children.length;
588
277
 
589
- // The live region spans from the earliest still-mutating block through the
590
- // bottom. A block that has not finalized must stay below the seam: out-of-
591
- // band inserts (TTSR/todo cards) can append a finalized block *below* a
592
- // tool that is still awaiting its result, and committing the tool there
593
- // would strand its history rows on the mid-stream preview the late result
594
- // never reaches.
595
- let liveStartIndex = count - 1;
278
+ // The commit boundary stops at the earliest still-mutating block. A
279
+ // block that has not finalized must gate it: out-of-band inserts
280
+ // (TTSR/todo cards) can append a finalized block *below* a tool that is
281
+ // still awaiting its result, and committing rows there would strand the
282
+ // tool's history rows on a mid-stream preview the late result never
283
+ // reaches.
284
+ let liveStartIndex = -1;
285
+ let hasLiveBlock = false;
596
286
  for (let i = 0; i < count; i++) {
597
287
  if (!isBlockFinalized(this.children[i]!)) {
598
288
  liveStartIndex = i;
289
+ hasLiveBlock = true;
599
290
  break;
600
291
  }
601
292
  }
@@ -621,19 +312,11 @@ export class TranscriptContainer
621
312
  // invariant — otherwise re-pushed rows land after the stale frame.
622
313
  if (!chainStable) lines.length = 0;
623
314
 
624
- // Tracks whether we are still inside the leading run of commit-safe live
625
- // blocks. The first still-live volatile block closes it, but rendering
626
- // continues so lower blocks remain visible.
627
- let commitSafeOpen = true;
628
- // The live-region start is recorded at the first visible row at/after
629
- // liveStartIndex; empty leading blocks (or a separator) must not claim it
630
- // early.
631
- let liveRecorded = false;
632
315
  // Frame row cursor: rows emitted (reused or pushed) so far.
633
316
  let row = 0;
634
317
  let stableRows = 0;
635
318
  for (let i = 0; i < count; i++) {
636
- const child = this.children[i]! as Component & SnapshotCarrier;
319
+ const child = this.children[i]!;
637
320
 
638
321
  // This child's contribution: its current render with plain-blank
639
322
  // top/bottom edges stripped (the container owns inter-block gaps).
@@ -642,7 +325,6 @@ export class TranscriptContainer
642
325
  // immutable terminal history for the current width/generation. Blocks
643
326
  // outside committed history still render normally so late results,
644
327
  // post-finalize re-layouts, and expand toggles remain visible.
645
- const previousSnapshot = child[kSnapshot];
646
328
  const previous = previousSegments[i];
647
329
  const finalized = isBlockFinalized(child);
648
330
  const version = getBlockVersion(child);
@@ -674,33 +356,15 @@ export class TranscriptContainer
674
356
  previous.width === width &&
675
357
  previous.generation === this.#generation);
676
358
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
677
- let liveCommitState: LiveCommitState | undefined;
678
- // Provisional live renders (commit-unstable blocks) never feed the
679
- // promotion machinery: their settled-looking rows are replaced
680
- // wholesale on finalize, so offering them would commit a stale
681
- // preview the result render can only duplicate, never erase.
682
- if (i >= liveStartIndex && !finalized && isBlockCommitStable(child)) {
683
- liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
684
- }
685
- // Cache the latest contribution as the next frame's diff input.
686
- child[kSnapshot] = {
687
- width,
688
- lines: contribution,
689
- generation: this.#generation,
690
- appendOnly: liveCommitState?.appendOnly ?? false,
691
- volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
692
- stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0,
693
- candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0,
694
- candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0,
695
- rewriteFloor: liveCommitState?.rewriteFloor ?? Number.POSITIVE_INFINITY,
696
- };
697
359
 
698
360
  // Empty (or stripped-to-nothing) children contribute nothing and never
699
- // affect spacing or the live-region offsets. An empty still-live child
700
- // still closes the commit-safe run: if it later gains rows, it pushes
361
+ // affect spacing. An empty still-live child still gates the commit
362
+ // boundary at its position: if it later gains rows, it pushes
701
363
  // everything below it.
702
364
  if (contribution.length === 0) {
703
- if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
365
+ if (hasLiveBlock && i === liveStartIndex) {
366
+ this.#nativeScrollbackLiveRegionStart = row;
367
+ }
704
368
  if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
705
369
  chainStable = false;
706
370
  lines.length = row;
@@ -729,11 +393,19 @@ export class TranscriptContainer
729
393
  const sep = row > 0 && !isPlainBlank(lines[row - 1]!) ? 1 : 0;
730
394
 
731
395
  // The separator before the first live block stays in the committed
732
- // prefix (it is deterministic once the prior block's body is settled),
733
- // so the live region begins at the block's first content row.
734
- if (!liveRecorded && i >= liveStartIndex) {
735
- this.#nativeScrollbackLiveRegionStart = row + sep;
736
- liveRecorded = true;
396
+ // prefix (it is deterministic once the prior block's body is
397
+ // settled); the boundary then extends through the live block's
398
+ // declared settled rows, mapped from its raw render into the
399
+ // stripped contribution.
400
+ if (hasLiveBlock && i === liveStartIndex) {
401
+ let settled = 0;
402
+ const settledRaw = getBlockSettledRows(child);
403
+ if (settledRaw > 0) {
404
+ let lead = 0;
405
+ while (lead < raw.length && isPlainBlank(raw[lead]!)) lead++;
406
+ settled = Math.max(0, Math.min(contribution.length, settledRaw - lead));
407
+ }
408
+ this.#nativeScrollbackLiveRegionStart = row + sep + settled;
737
409
  }
738
410
 
739
411
  const rowCount = sep + contribution.length;
@@ -749,28 +421,6 @@ export class TranscriptContainer
749
421
  for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
750
422
  }
751
423
 
752
- const blockStart = row + sep;
753
- if (i >= liveStartIndex && commitSafeOpen) {
754
- const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
755
- if (safeLength > 0) {
756
- this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
757
- }
758
- // Durable snapshot end: a commit-stable block's whole body is durable
759
- // content — its scrolled-off rows are permanent even while interior
760
- // rows re-lay-out (a streaming table re-aligning columns), so the
761
- // engine must commit their snapshot on scroll-off rather than drop it.
762
- // Finalized blocks are wholly durable; provisional (commit-unstable)
763
- // blocks offer nothing beyond their byte-stable safe length.
764
- const snapshotLength = finalized || isBlockCommitStable(child) ? contribution.length : safeLength;
765
- if (snapshotLength > 0) {
766
- this.#nativeScrollbackSnapshotSafeEnd = blockStart + snapshotLength;
767
- }
768
- // A finalized, fully safe block may let the contiguous safe run extend
769
- // into blocks rendered below it. A still-live block keeps pushing lower
770
- // rows around as it grows, so the run closes there.
771
- if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
772
- }
773
-
774
424
  segments[i] = {
775
425
  component: child,
776
426
  rawRef: raw,
@@ -18,6 +18,7 @@ import type { SessionTreeNode } from "../../session/session-entries";
18
18
  import { toPathList } from "../../tools/path-utils";
19
19
  import { shortenPath } from "../../tools/render-utils";
20
20
  import { canonicalizeMessage } from "../../utils/thinking-display";
21
+ import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
21
22
  import { DynamicBorder } from "./dynamic-border";
22
23
  import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers";
23
24
 
@@ -615,15 +616,20 @@ class TreeList implements Component {
615
616
  const content = normalize(this.#extractContent(msgWithContent.content));
616
617
  result = theme.fg("dim", "developer: ") + theme.fg("muted", content);
617
618
  } else if (role === "assistant") {
619
+ const presentation = resolveAssistantErrorPresentation(msg);
620
+ if (presentation.kind === "compact-recovered") {
621
+ result = theme.fg("success", "assistant: ") + theme.fg("dim", presentation.text);
622
+ break;
623
+ }
618
624
  const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
619
625
  const textContent = normalize(this.#extractContent(msgWithContent.content));
620
626
  if (textContent) {
621
627
  result = theme.fg("success", "assistant: ") + textContent;
628
+ } else if (presentation.kind === "full") {
629
+ result =
630
+ theme.fg("success", "assistant: ") + theme.fg("error", normalize(presentation.text).slice(0, 80));
622
631
  } else if (msgWithContent.stopReason === "aborted") {
623
632
  result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
624
- } else if (msgWithContent.errorMessage) {
625
- const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
626
- result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
627
633
  } else {
628
634
  result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
629
635
  }