@oh-my-pi/pi-tui 18.3.5 → 18.4.1

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 (43) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +9 -2
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/rewind-selector.d.ts +3 -3
  7. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  8. package/dist/types/prompt/composer-cache.d.ts +34 -16
  9. package/dist/types/prompt/composer.d.ts +15 -18
  10. package/dist/types/prompt/welcome.d.ts +5 -2
  11. package/dist/types/status-line/component.d.ts +13 -4
  12. package/dist/types/status-line/metrics.d.ts +0 -1
  13. package/dist/types/status-line/startup.d.ts +38 -0
  14. package/dist/types/status-line/types.d.ts +0 -2
  15. package/dist/types/terminal-capabilities.d.ts +10 -6
  16. package/dist/types/tools/output-meta.d.ts +2 -1
  17. package/dist/types/tools/vibe.d.ts +4 -0
  18. package/package.json +9 -9
  19. package/src/chat/assistant-message.ts +27 -21
  20. package/src/chat/image-loading.ts +81 -0
  21. package/src/chat/tool-execution.ts +38 -14
  22. package/src/chat/transcript-entry.ts +16 -0
  23. package/src/chrome/transcript-container.ts +208 -74
  24. package/src/components/image.ts +10 -2
  25. package/src/overlays/agents-hub.ts +7 -18
  26. package/src/overlays/copy-selector.ts +3 -31
  27. package/src/overlays/model-hub.ts +49 -16
  28. package/src/overlays/rewind-selector.ts +51 -12
  29. package/src/overlays/usage-dashboard.ts +47 -6
  30. package/src/prompt/composer-cache.ts +217 -196
  31. package/src/prompt/composer.ts +40 -70
  32. package/src/prompt/welcome.ts +31 -28
  33. package/src/render/render-utils.ts +43 -22
  34. package/src/status-line/component.ts +73 -50
  35. package/src/status-line/metrics.ts +3 -21
  36. package/src/status-line/segments.ts +34 -50
  37. package/src/status-line/startup.ts +181 -0
  38. package/src/status-line/types.ts +0 -2
  39. package/src/terminal-capabilities.ts +12 -6
  40. package/src/terminal.ts +38 -9
  41. package/src/tools/output-meta.ts +11 -3
  42. package/src/tools/vibe.ts +15 -2
  43. package/src/tui.ts +66 -42
@@ -110,6 +110,87 @@ export async function convertImageToPng(image: ImageContent): Promise<ImageConte
110
110
  return { ...image, data, mimeType: "image/png" };
111
111
  }
112
112
 
113
+ /**
114
+ * Byte ceiling for {@link convertImageToPngShared}'s resident conversions.
115
+ * Converted PNGs are larger than the webp/jpeg they came from, so the cache is
116
+ * bounded and evicts least-recently-used entries rather than growing with the
117
+ * session's image history.
118
+ */
119
+ const PNG_CACHE_MAX_BYTES = 32 * 1024 * 1024;
120
+
121
+ /** Least-recently-used first; {@link touchPngCache} re-inserts on hit. */
122
+ const pngCache = new Map<string, ImageContent>();
123
+ let pngCacheBytes = 0;
124
+ const pngConversionsInFlight = new Map<string, Promise<ImageContent>>();
125
+
126
+ /** Cache lookup that also marks `key` as most recently used. */
127
+ function touchPngCache(key: string): ImageContent | undefined {
128
+ const hit = pngCache.get(key);
129
+ if (!hit) return undefined;
130
+ pngCache.delete(key);
131
+ pngCache.set(key, hit);
132
+ return hit;
133
+ }
134
+
135
+ /**
136
+ * Content-addressed identity of an image payload, stable across components and
137
+ * transcript rebuilds. Callers key their own per-image state by this instead of
138
+ * positional ids like `${toolCallId}:${index}`, which go stale when the images
139
+ * behind a position are replaced.
140
+ */
141
+ export function imagePayloadKey(image: ImageContent): string {
142
+ return `${image.mimeType}:${image.data.length}:${Bun.hash(image.data)}`;
143
+ }
144
+
145
+ /**
146
+ * The PNG conversion of `image` when one is still resident, else `undefined`.
147
+ * Synchronous so renderers can use an already-converted image on the spot
148
+ * instead of scheduling another async re-render.
149
+ */
150
+ export function cachedPngConversion(image: ImageContent): ImageContent | undefined {
151
+ return touchPngCache(imagePayloadKey(image));
152
+ }
153
+
154
+ /**
155
+ * Converts `image` to PNG at most once per distinct payload: concurrent callers
156
+ * share one in-flight conversion and later callers hit {@link pngCache}.
157
+ * Kitty-graphics renderers use this because `convertImageToPng` is a full
158
+ * decode plus re-encode, and the same image is delivered repeatedly (read-result
159
+ * replay) and rebuilt from scratch on resume, rewind, and `/tree` navigation.
160
+ *
161
+ * Rejections are not cached — a payload that failed to decode is retried by the
162
+ * next caller, matching {@link convertImageToPng}'s contract.
163
+ */
164
+ export function convertImageToPngShared(image: ImageContent): Promise<ImageContent> {
165
+ const key = imagePayloadKey(image);
166
+ const cached = touchPngCache(key);
167
+ if (cached) return Promise.resolve(cached);
168
+ const running = pngConversionsInFlight.get(key);
169
+ if (running) return running;
170
+ const conversion = convertImageToPng(image)
171
+ .then(({ data }) => {
172
+ pngConversionsInFlight.delete(key);
173
+ // Only the payload is shared; caller-specific fields (`url`,
174
+ // `providerFile`, `detail`) describe the source image, not this PNG.
175
+ const converted: ImageContent = { type: "image", data, mimeType: "image/png" };
176
+ pngCache.set(key, converted);
177
+ pngCacheBytes += data.length;
178
+ for (const [oldest, entry] of pngCache) {
179
+ if (pngCacheBytes <= PNG_CACHE_MAX_BYTES) break;
180
+ if (oldest === key) continue;
181
+ pngCache.delete(oldest);
182
+ pngCacheBytes -= entry.data.length;
183
+ }
184
+ return converted;
185
+ })
186
+ .catch(error => {
187
+ pngConversionsInFlight.delete(key);
188
+ throw error;
189
+ });
190
+ pngConversionsInFlight.set(key, conversion);
191
+ return conversion;
192
+ }
193
+
113
194
  export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
114
195
  if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
115
196
  return image;
@@ -1,3 +1,4 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
1
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import { Box } from "../components/box";
3
4
  import { SPINNER_ADVANCE_MS } from "../components/loader";
@@ -26,7 +27,7 @@ import { isWaitingPollDetails } from "../tools/wait";
26
27
  import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../render/render-utils";
27
28
  import type { XdevMountedState } from "../tools/xdev";
28
29
  import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../render/index";
29
- import { convertImageToPng } from "./image-loading";
30
+ import { cachedPngConversion, convertImageToPngShared, imagePayloadKey } from "./image-loading";
30
31
  import { sanitizeWithOptionalSixelPassthrough } from "../render/sixel";
31
32
  import { renderDiff } from "../chrome/diff";
32
33
  import { type AnimationFrame, trimBlankEdges } from "../chrome/transcript-container";
@@ -296,8 +297,13 @@ export class ToolExecutionComponent extends Container {
296
297
  #editMode?: EditMode;
297
298
  #editDiffPreview?: PerFileDiffPreview[];
298
299
  #previewReady?: PromiseWithResolvers<void>;
299
- // Cached converted images for Kitty protocol (which requires PNG), keyed by index
300
- #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
300
+ // Payload keys whose Kitty PNG conversion is already awaited; the converted
301
+ // images themselves live in the process-wide cache behind
302
+ // `convertImageToPngShared`, so rebuilt components reuse them.
303
+ #kittyConversionsAwaited = new Set<string>();
304
+ // Conversions this component displays, held so a later re-render still finds
305
+ // them after the bounded shared cache evicts them.
306
+ #kittyConverted = new Map<string, ImageContent>();
301
307
  // Spinner animation for partial task results
302
308
  #spinnerFrame?: number;
303
309
  #spinnerActive = false;
@@ -545,26 +551,32 @@ export class ToolExecutionComponent extends Container {
545
551
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
546
552
  if (!this.#result) return;
547
553
 
548
- const imageBlocks = this.#getAllImageBlocks();
549
-
550
- for (let i = 0; i < imageBlocks.length; i++) {
551
- const img = imageBlocks[i];
554
+ for (const img of this.#getAllImageBlocks()) {
552
555
  if (!img.data || !img.mimeType) continue;
553
- // Skip if already PNG or already converted
556
+ // Skip if already PNG or already converted anywhere in this process
554
557
  if (img.mimeType === "image/png") continue;
555
- if (this.#convertedImages.has(i)) continue;
558
+ const image: ImageContent = { type: "image", data: img.data, mimeType: img.mimeType };
559
+ const key = imagePayloadKey(image);
560
+ if (this.#kittyConverted.has(key)) continue;
561
+ const cached = cachedPngConversion(image);
562
+ if (cached) {
563
+ this.#kittyConverted.set(key, cached);
564
+ continue;
565
+ }
566
+ if (this.#kittyConversionsAwaited.has(key)) continue;
567
+ this.#kittyConversionsAwaited.add(key);
556
568
 
557
569
  // Convert async - catch errors from processing
558
- const index = i;
559
- convertImageToPng({ type: "image", data: img.data, mimeType: img.mimeType })
570
+ convertImageToPngShared(image)
560
571
  .then(converted => {
561
- this.#convertedImages.set(index, converted);
572
+ this.#kittyConverted.set(key, converted);
562
573
  this.#displayInputVersion++;
563
574
  this.#updateDisplay();
564
575
  this.#ui.requestRender();
565
576
  })
566
577
  .catch(() => {
567
578
  // Ignore conversion failures - display will use original image format
579
+ this.#kittyConversionsAwaited.delete(key);
568
580
  });
569
581
  }
570
582
  }
@@ -840,7 +852,9 @@ export class ToolExecutionComponent extends Container {
840
852
  }
841
853
 
842
854
  override render(width: number): readonly string[] {
843
- if (!this.#toolActivityVisible || this.#allocation === 0) return [];
855
+ if (!this.#toolActivityVisible || this.#allocation === 0 || (this.#toolName === "wait" && this.#isBenignSkip())) {
856
+ return [];
857
+ }
844
858
  let lines = super.render(width);
845
859
  if (this.#allocation < 3) {
846
860
  // A squeezed allocation degrades only blocks that genuinely overflow it.
@@ -929,6 +943,12 @@ export class ToolExecutionComponent extends Container {
929
943
  this.#renderState.executionStarted = this.#executionStarted;
930
944
  this.#renderState.spinnerFrame = this.#spinnerFrame;
931
945
 
946
+ // Interrupted waits carry only model-facing retry guidance, not user-facing output.
947
+ if (this.#toolName === "wait" && this.#isBenignSkip()) {
948
+ this.#contentBox.clear();
949
+ return;
950
+ }
951
+
932
952
  // Non-self-framing tools (custom/extension renderers and the generic
933
953
  // fallback) get a padded, state-tinted block — built-ins that draw their
934
954
  // own frame opt out below via the framed-component mark. A benign skip
@@ -1201,7 +1221,11 @@ export class ToolExecutionComponent extends Container {
1201
1221
  const img = imageBlocks[i];
1202
1222
  if (TERMINAL.imageProtocol && this.#showImages && img.data && img.mimeType) {
1203
1223
  // Use converted PNG for Kitty protocol if available
1204
- const converted = this.#convertedImages.get(i);
1224
+ const source: ImageContent = { type: "image", data: img.data, mimeType: img.mimeType };
1225
+ const converted =
1226
+ TERMINAL.imageProtocol === ImageProtocol.Kitty && img.mimeType !== "image/png"
1227
+ ? (this.#kittyConverted.get(imagePayloadKey(source)) ?? cachedPngConversion(source))
1228
+ : undefined;
1205
1229
  const imageData = converted?.data ?? img.data;
1206
1230
  const imageMimeType = converted?.mimeType ?? img.mimeType;
1207
1231
 
@@ -68,6 +68,22 @@ export function isUserRequestEntry(entry: TranscriptEntryLike | { type: string }
68
68
  return false;
69
69
  }
70
70
 
71
+ /**
72
+ * Recent transcript tail starting at a user-request boundary, so tool calls
73
+ * and results stay together. ChatTranscriptBuilder drops a tool result whose
74
+ * initiating call was sliced away, so a tail of orphaned results can leave
75
+ * the picker without any target.
76
+ *
77
+ * A whole user turn may exceed `limit`; paginate rendering if one turn grows too large.
78
+ */
79
+ export function recentTranscriptEntries(entries: TranscriptEntryLike[], limit = 600): TranscriptEntryLike[] {
80
+ if (entries.length <= limit) return entries;
81
+ for (let index = entries.length - limit; index > 0; index--) {
82
+ if (isUserRequestEntry(entries[index]!)) return entries.slice(index);
83
+ }
84
+ return entries;
85
+ }
86
+
71
87
  /** Editable user request text, preserving skill invocation syntax. */
72
88
  export function userTurnDraft(entry: TranscriptEntryLike): string | undefined {
73
89
  const message = transcriptEntryMessage(entry);
@@ -1,5 +1,6 @@
1
1
  import { type Component, Container, type HistoryBatch } from "../tui";
2
2
  import * as logger from "@oh-my-pi/pi-utils/logger";
3
+ import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
3
4
  import { isToolActivityComponent } from "./tool-activity";
4
5
 
5
6
  /** Shared animation time supplied by the constrained transcript root. */
@@ -95,6 +96,15 @@ type Offered =
95
96
  const MAX_LIVE_BLOCKS = 256;
96
97
  /** Grace before a pressure-blocked frontier is reported; a streaming block may legitimately hold it briefly. */
97
98
  const PINNED_FRONTIER_WARN_MS = 30_000;
99
+ /**
100
+ * Wall-clock budget for composing one retirement batch. A resumed session
101
+ * hands the container its whole ledger at once, and rendering all of it in the
102
+ * frame that first paints it blocks the loop for as long as that render takes
103
+ * (#12933). Retirement stops after the first block that crosses the budget;
104
+ * the remainder follows on the next frames, which the TUI schedules through
105
+ * timers, so terminal input runs between batches.
106
+ */
107
+ const RETIREMENT_BUDGET_MS = 8;
98
108
  const EMPTY_ROWS: readonly string[] = [];
99
109
  const EMPTY_STABLE_ROWS: readonly TranscriptStableRow[] = [];
100
110
 
@@ -290,20 +300,32 @@ export class TranscriptContainer extends Container {
290
300
  this.#replayRequested = false;
291
301
  }
292
302
 
293
- /** Total rows the live, un-emitted tail occupies at `width`. */
294
- liveRowCount(width: number): number {
303
+ /**
304
+ * Total rows the live, un-emitted tail occupies at `width`.
305
+ *
306
+ * `limit` stops the walk once the total passes it: measuring a resumed
307
+ * session's whole ledger costs one full render per block, and callers only
308
+ * compare the height against a viewport budget. Past `limit` the result is
309
+ * a lower bound, guaranteed only to be greater than `limit`.
310
+ */
311
+ liveRowCount(width: number, limit = Number.POSITIVE_INFINITY): number {
295
312
  this.#syncEntries();
296
313
  this.#settleFinalized();
297
314
  let total = 0;
298
315
  for (const { entry, index } of this.#liveEntries()) {
299
- this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
300
- const rendered = this.#renderEntry(entry, width);
301
- const block = rendered.slice(this.#projectedEmittedRowCount(entry, index, width));
316
+ const block = this.#liveBlockRows(entry, index, width);
302
317
  if (block.length > 0) total += block.length + (total > 0 ? 1 : 0);
318
+ if (total > limit) break;
303
319
  }
304
320
  return total;
305
321
  }
306
322
 
323
+ /** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
324
+ #liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
325
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
326
+ return this.#renderEntry(entry, width).slice(this.#projectedEmittedRowCount(entry, index, width));
327
+ }
328
+
307
329
  /** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
308
330
  getLastViewportSpans(): readonly TranscriptViewportSpan[] {
309
331
  return this.#lastViewportSpans;
@@ -339,23 +361,37 @@ export class TranscriptContainer extends Container {
339
361
  return EMPTY_ROWS;
340
362
  }
341
363
 
364
+ // Collect newest-first and stop one block past what the viewport can
365
+ // hold: beyond that the emergency layout is already certain, and every
366
+ // further block would cost a full render to produce rows no frame can
367
+ // show — the whole ledger on a resumed session's first paint (#12933).
342
368
  const shown: Array<{ entry: TranscriptEntry; index: number }> = [];
343
369
  const blocks: (readonly string[])[] = [];
344
- let total = 0;
345
- for (const candidate of live) {
346
- this.#setAllocation(candidate.entry.component, Number.MAX_SAFE_INTEGER, frame);
347
- const rendered = this.#renderEntry(candidate.entry, width);
348
- const block = rendered.slice(this.#projectedEmittedRowCount(candidate.entry, candidate.index, width));
370
+ let unrendered = 0;
371
+ for (let cursor = live.length - 1; cursor >= 0; cursor--) {
372
+ if (shown.length > capacity) {
373
+ unrendered = cursor + 1;
374
+ break;
375
+ }
376
+ const candidate = live[cursor]!;
377
+ const block = this.#liveBlockRows(candidate.entry, candidate.index, width);
349
378
  if (block.length === 0) continue;
350
- total += block.length + (shown.length > 0 ? 1 : 0);
351
379
  shown.push(candidate);
352
380
  blocks.push(block);
353
381
  }
382
+ shown.reverse();
383
+ blocks.reverse();
384
+ let total = 0;
385
+ for (const block of blocks) total += block.length + (total > 0 ? 1 : 0);
354
386
  if (shown.length === 0) {
355
387
  this.#lastViewportSpans = [];
356
388
  return EMPTY_ROWS;
357
389
  }
358
- if (shown.length > capacity) return this.#renderEmergency(shown, width, capacity, frame);
390
+ if (shown.length > capacity) {
391
+ // Blocks the walk never reached are still transcript state: the
392
+ // emergency layout consults them only where it must.
393
+ return this.#renderEmergency(shown, live.slice(0, unrendered), width, capacity, frame);
394
+ }
359
395
  if (total <= capacity) {
360
396
  const output: string[] = [];
361
397
  const owners: (Component | undefined)[] = [];
@@ -430,7 +466,15 @@ export class TranscriptContainer extends Container {
430
466
  return this.#offered.kind === "replay" ? this.#offered.batch : undefined;
431
467
  }
432
468
  if (!this.#replayPending) return undefined;
433
- const rows = this.#renderReplay(width);
469
+ // The one path that must compose the whole ledger in a single frame; the
470
+ // phase label attributes any watchdog block here instead of "unknown".
471
+ pushLoopPhase("ui.transcript-replay");
472
+ let rows: readonly string[];
473
+ try {
474
+ rows = this.#renderReplay(width);
475
+ } finally {
476
+ popLoopPhase();
477
+ }
434
478
  this.#replayPending = false;
435
479
  if (rows.length === 0) return undefined;
436
480
  const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" };
@@ -455,7 +499,7 @@ export class TranscriptContainer extends Container {
455
499
  const after = this.#renderStablePrefix(entry, offered.emittedEnd, width);
456
500
  rows = after.slice(before.length);
457
501
  } else if (offered.kind === "commit") {
458
- rows = this.#renderRange(this.#frontier, offered.end, width, true);
502
+ rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
459
503
  } else {
460
504
  rows = this.#renderReplay(width);
461
505
  }
@@ -474,58 +518,74 @@ export class TranscriptContainer extends Container {
474
518
  const room = Math.max(0, Math.trunc(capacity));
475
519
  const live = this.#liveEntries();
476
520
  if (live.length === 0) return undefined;
477
- // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
478
- const rendered: (readonly string[])[] = new Array(live.length);
479
- // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
480
- const heights: number[] = new Array(live.length);
481
- let total = 0;
482
- let visible = 0;
483
- for (let index = 0; index < live.length; index++) {
484
- const candidate = live[index]!;
485
- this.#setAllocation(candidate.entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
486
- const renderedEntry = this.#renderEntry(candidate.entry, width);
487
- const rows = renderedEntry.slice(
488
- this.#renderStablePrefix(candidate.entry, candidate.entry.emitted, width).length,
489
- );
490
- rendered[index] = rows;
491
- heights[index] = rows.length;
492
- if (rows.length > 0) total += rows.length + (visible++ > 0 ? 1 : 0);
493
- }
494
- const overflowing = total > room || this.#liveCount() >= MAX_LIVE_BLOCKS;
495
- if (policy === "pressure" && !overflowing) {
496
- this.#pinnedFrontier = undefined;
497
- return undefined;
498
- }
499
521
 
522
+ // Only a render publishes a block's stable rows, so the head renders
523
+ // before its progressive-append eligibility is read.
500
524
  const head = this.#entries[this.#frontier];
501
- if (
525
+ if (head !== undefined) this.#liveBlockRows(head, this.#frontier, width);
526
+ const appendHead =
502
527
  policy === "pressure" &&
503
- total > room &&
504
528
  head?.mode === "appendOnly" &&
505
529
  !head.stableFrozen &&
506
530
  head.state !== "committed" &&
507
531
  head.emitted < head.stableRows.length
508
- ) {
532
+ ? head
533
+ : undefined;
534
+
535
+ // Measure the live tail newest-first and stop at the first block that
536
+ // does not fit: `keep` counts the leading blocks bound for scrollback,
537
+ // and everything behind them stays unmeasured. Measuring the whole live
538
+ // region costs one full render per block, which on a resumed session's
539
+ // first paint is every message it ever had (#12933). The progressive
540
+ // append path below needs the exact live height to size its emission,
541
+ // and only runs while a streaming head pins retirement.
542
+ let tailRows = 0;
543
+ let liveRows = 0;
544
+ let keep = 0;
545
+ let fits = true;
546
+ for (let cursor = live.length - 1; cursor >= 0; cursor--) {
547
+ const candidate = live[cursor]!;
548
+ const height = this.#liveBlockRows(candidate.entry, candidate.index, width).length;
549
+ if (height > 0) liveRows += height + (liveRows > 0 ? 1 : 0);
550
+ if (fits) {
551
+ const next = height > 0 ? tailRows + height + (tailRows > 0 ? 1 : 0) : tailRows;
552
+ if (next > room) {
553
+ fits = false;
554
+ keep = cursor + 1;
555
+ } else {
556
+ tailRows = next;
557
+ }
558
+ }
559
+ if (!fits && appendHead === undefined) break;
560
+ }
561
+ const overflowing = keep > 0 || this.#liveCount() >= MAX_LIVE_BLOCKS;
562
+ if (policy === "pressure" && !overflowing) {
563
+ this.#pinnedFrontier = undefined;
564
+ return undefined;
565
+ }
566
+
567
+ if (appendHead !== undefined && keep > 0) {
509
568
  // Emit as many finished rows as the overflow needs, in one batch. A
510
569
  // fast stream adds finished rows quicker than one per pressure cycle,
511
570
  // and the live region has to fall back under `room` to stay readable:
512
571
  // rows left behind here are rows dropped from the top of the viewport.
513
- const overflow = total - room;
514
- const before = this.#renderStablePrefix(head, head.emitted, width);
515
- let emittedEnd = head.emitted;
572
+ // `liveRows` is exact here: the append path measured every live block.
573
+ const overflow = liveRows - room;
574
+ const before = this.#renderStablePrefix(appendHead, appendHead.emitted, width);
575
+ let emittedEnd = appendHead.emitted;
516
576
  let rows: readonly string[] = EMPTY_ROWS;
517
- while (emittedEnd < head.stableRows.length && rows.length < overflow) {
518
- const after = this.#renderStablePrefix(head, emittedEnd + 1, width);
577
+ while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
578
+ const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
519
579
  if (!isRowPrefix(before, after) || after.length === before.length) {
520
- if (emittedEnd === head.emitted) {
521
- this.#freezeStableRows(head, EMPTY_ROWS, "semantic row render added no suffix");
580
+ if (emittedEnd === appendHead.emitted) {
581
+ this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
522
582
  }
523
583
  break;
524
584
  }
525
585
  rows = after.slice(before.length);
526
586
  emittedEnd += 1;
527
587
  }
528
- if (emittedEnd > head.emitted) {
588
+ if (emittedEnd > appendHead.emitted) {
529
589
  const batch: HistoryBatch = {
530
590
  id: this.#nextBatchId++,
531
591
  rows,
@@ -537,31 +597,41 @@ export class TranscriptContainer extends Container {
537
597
  }
538
598
  }
539
599
 
540
- let end = this.#frontier;
541
- let freed = 0;
542
- let index = 0;
543
- while (end < this.#entries.length && this.#entries[end]!.state === "settled") {
544
- if (
545
- policy === "pressure" &&
546
- total - freed <= room &&
547
- this.#liveCount() - (end - this.#frontier) < MAX_LIVE_BLOCKS
548
- )
549
- break;
550
- freed += heights[index]! > 0 ? heights[index]! + 1 : 0;
551
- end++;
552
- index++;
600
+ // Shutdown retires everything eligible; pressure retires exactly the
601
+ // blocks that no longer fit, plus whatever the live-block cap demands.
602
+ let limit = this.#frontier + keep;
603
+ if (this.#liveCount() >= MAX_LIVE_BLOCKS) {
604
+ limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
553
605
  }
606
+ if (policy === "flush") limit = this.#entries.length;
607
+ let end = this.#frontier;
608
+ while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
554
609
  if (end === this.#frontier) {
555
610
  if (policy === "pressure") this.#notePinnedFrontier();
556
611
  return undefined;
557
612
  }
558
613
  this.#pinnedFrontier = undefined;
614
+ pushLoopPhase("ui.transcript-retire");
615
+ let retirement: { rows: readonly string[]; end: number };
616
+ try {
617
+ // Shutdown must hand over the full prefix; a live frame stops at the
618
+ // budget and offers the rest on the next frames.
619
+ retirement = this.#renderRange(
620
+ this.#frontier,
621
+ end,
622
+ width,
623
+ true,
624
+ policy === "flush" ? undefined : RETIREMENT_BUDGET_MS,
625
+ );
626
+ } finally {
627
+ popLoopPhase();
628
+ }
559
629
  const batch: HistoryBatch = {
560
630
  id: this.#nextBatchId++,
561
- rows: this.#renderRange(this.#frontier, end, width, true),
631
+ rows: retirement.rows,
562
632
  kind: "append",
563
633
  };
564
- this.#offered = { batch, end, kind: "commit" };
634
+ this.#offered = { batch, end: retirement.end, kind: "commit" };
565
635
  return batch;
566
636
  }
567
637
 
@@ -578,8 +648,7 @@ export class TranscriptContainer extends Container {
578
648
  entry.emitted = offered.emittedEnd;
579
649
  } else if (offered.kind === "commit") {
580
650
  for (let index = this.#frontier; index < offered.end; index++) {
581
- this.#entries[index]!.state = "committed";
582
- this.#entries[index]!.emitted = 0;
651
+ this.#retireEntry(this.#entries[index]!);
583
652
  }
584
653
  this.#frontier = offered.end;
585
654
  }
@@ -632,7 +701,7 @@ export class TranscriptContainer extends Container {
632
701
 
633
702
  #renderEntry(entry: TranscriptEntry, width: number): readonly string[] {
634
703
  const rendered = trimBlankEdges(entry.component.render(width));
635
- if (entry.mode === "mutable" || entry.stableFrozen) return rendered;
704
+ if (entry.state === "committed" || entry.mode === "mutable" || entry.stableFrozen) return rendered;
636
705
  const appendOnly = entry.component as Component & AppendOnlyTranscriptBlock;
637
706
  const stable = appendOnly.getTranscriptStableRows();
638
707
  if (!isStablePrefix(entry.stableRows, stable)) {
@@ -733,8 +802,25 @@ export class TranscriptContainer extends Container {
733
802
  });
734
803
  }
735
804
 
736
- #renderRange(start: number, end: number, width: number, trailingBlank: boolean): readonly string[] {
805
+ /**
806
+ * Compose entries `[start, end)` as one ordered retirement payload.
807
+ *
808
+ * `budgetMs` stops the walk after the first block that crosses it — always
809
+ * at least one block — and reports the index actually reached, so a single
810
+ * frame never renders more of a resumed ledger than it can afford (#12933).
811
+ * Callers that must emit a whole prefix (replay, shutdown flush, recompose
812
+ * of an already offered batch) omit it.
813
+ */
814
+ #renderRange(
815
+ start: number,
816
+ end: number,
817
+ width: number,
818
+ trailingBlank: boolean,
819
+ budgetMs?: number,
820
+ ): { rows: readonly string[]; end: number } {
737
821
  const rows: string[] = [];
822
+ const startedAt = budgetMs === undefined ? 0 : performance.now();
823
+ let reached = start;
738
824
  for (let index = start; index < end; index++) {
739
825
  const entry = this.#entries[index]!;
740
826
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
@@ -746,16 +832,19 @@ export class TranscriptContainer extends Container {
746
832
  index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width));
747
833
  const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
748
834
  const block = rendered.slice(emittedRows);
749
- if (block.length === 0) continue;
750
- if (rows.length > 0) rows.push("");
751
- rows.push(...block);
835
+ reached = index + 1;
836
+ if (block.length > 0) {
837
+ if (rows.length > 0) rows.push("");
838
+ rows.push(...block);
839
+ }
840
+ if (budgetMs !== undefined && performance.now() - startedAt >= budgetMs) break;
752
841
  }
753
842
  if (trailingBlank && rows.length > 0) rows.push("");
754
- return rows;
843
+ return { rows, end: reached };
755
844
  }
756
845
 
757
846
  #renderReplay(width: number): readonly string[] {
758
- const rows = Array.from(this.#renderRange(0, this.#frontier, width, true));
847
+ const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
759
848
  const head = this.#entries[this.#frontier];
760
849
  if (head?.mode === "appendOnly" && head.emitted > 0) {
761
850
  this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
@@ -773,24 +862,61 @@ export class TranscriptContainer extends Container {
773
862
  const rendered = this.#renderEntry(entry, width);
774
863
  if (entry.emitted !== entry.stableRows.length) return;
775
864
  if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
776
- entry.state = "committed";
777
- entry.emitted = 0;
865
+ this.#retireEntry(entry);
778
866
  this.#frontier++;
779
867
  }
780
868
  }
781
869
 
870
+ #retireEntry(entry: TranscriptEntry): void {
871
+ entry.state = "committed";
872
+ entry.emitted = 0;
873
+ entry.stableRows = EMPTY_STABLE_ROWS;
874
+ entry.renderedStableByWidth = new Map();
875
+ entry.stableRowCountByWidth = new Map();
876
+ }
877
+
782
878
  #startReplay(): void {
783
879
  const head = this.#entries[this.#frontier];
784
880
  this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
785
881
  this.#replayRequested = false;
786
882
  }
787
883
 
884
+ /**
885
+ * One-row-per-block fallback for a live region that cannot fit the viewport.
886
+ * `behind` holds the older live blocks `renderViewport` deliberately left
887
+ * unrendered. Only its active blocks (few) and the newest settled block
888
+ * offering an emergency row are rendered, so the summary count and the
889
+ * surviving emergency row match a full walk without rendering the ledger.
890
+ */
788
891
  #renderEmergency(
789
892
  shown: readonly { entry: TranscriptEntry; index: number }[],
893
+ behind: readonly { entry: TranscriptEntry; index: number }[],
790
894
  width: number,
791
895
  rows: number,
792
896
  frame: AnimationFrame,
793
897
  ): readonly string[] {
898
+ let hiddenBelow = 0;
899
+ for (const candidate of behind) {
900
+ if (candidate.entry.state !== "active") continue;
901
+ if (this.#liveBlockRows(candidate.entry, candidate.index, width).length > 0) hiddenBelow++;
902
+ }
903
+ let behindEmergency: { candidate: { entry: TranscriptEntry; index: number }; row: string } | null | undefined;
904
+ const findBehindEmergency = () => {
905
+ if (behindEmergency !== undefined) return behindEmergency;
906
+ behindEmergency = null;
907
+ for (let index = behind.length - 1; index >= 0; index--) {
908
+ const candidate = behind[index]!;
909
+ if (candidate.entry.state !== "settled") continue;
910
+ const block = candidate.entry.component as Component & FinalizableBlock;
911
+ if (block.renderTranscriptBlockEmergencyRow === undefined) continue;
912
+ if (this.#liveBlockRows(candidate.entry, candidate.index, width).length === 0) continue;
913
+ const row = block.renderTranscriptBlockEmergencyRow(width);
914
+ if (row === undefined) continue;
915
+ behindEmergency = { candidate, row };
916
+ break;
917
+ }
918
+ return behindEmergency;
919
+ };
794
920
  let visibleRows = rows;
795
921
  let visible: { entry: TranscriptEntry; index: number }[] = [];
796
922
  let emergencyCandidate: { entry: TranscriptEntry; index: number } | undefined;
@@ -812,8 +938,16 @@ export class TranscriptContainer extends Container {
812
938
  visible = [candidate, ...visible.slice(1)];
813
939
  break;
814
940
  }
941
+ if (emergencyCandidate === undefined) {
942
+ const found = findBehindEmergency();
943
+ if (found !== null) {
944
+ emergencyCandidate = found.candidate;
945
+ emergencyRow = found.row;
946
+ visible = [found.candidate, ...visible.slice(1)];
947
+ }
948
+ }
815
949
 
816
- let activeTotal = 0;
950
+ let activeTotal = hiddenBelow;
817
951
  for (const candidate of shown) {
818
952
  if (candidate.entry.state === "active") activeTotal++;
819
953
  }