@oh-my-pi/pi-tui 18.4.0 → 18.4.2

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 (65) hide show
  1. package/CHANGELOG.md +42 -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 +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -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
 
@@ -164,6 +174,21 @@ export class TranscriptContainer extends Container {
164
174
  #pinnedFrontier: { index: number; since: number; logged: boolean } | undefined;
165
175
  /** Block spans of the last `renderViewport` output, for click hit-testing. */
166
176
  #lastViewportSpans: TranscriptViewportSpan[] = [];
177
+ /**
178
+ * The composed frame {@link beginFrame} opened; `undefined` outside one.
179
+ * {@link renderViewport} closes it, so it never outlives the synchronous
180
+ * composition that opened it.
181
+ */
182
+ #openFrame: AnimationFrame | undefined;
183
+ /**
184
+ * Full-allocation blank-trimmed renders of the blocks measured during the
185
+ * open frame, keyed by entry at {@link #frameRowsWidth}. A retirement peek
186
+ * and the viewport measure the same live blocks back to back inside one
187
+ * composition, with no block mutation possible in between; replaying the
188
+ * first measurement spares every block its second render per frame.
189
+ */
190
+ #frameRows = new Map<TranscriptEntry, readonly string[]>();
191
+ #frameRowsWidth = 0;
167
192
  override addChild(component: Component): void {
168
193
  if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
169
194
  super.addChild(component);
@@ -290,20 +315,72 @@ export class TranscriptContainer extends Container {
290
315
  this.#replayRequested = false;
291
316
  }
292
317
 
293
- /** Total rows the live, un-emitted tail occupies at `width`. */
294
- liveRowCount(width: number): number {
318
+ /**
319
+ * Open one composed frame: every live-block measurement until this frame's
320
+ * {@link renderViewport} returns renders against `frame` and is taken once,
321
+ * so the retirement peek and the viewport share each block's render.
322
+ *
323
+ * Callers MUST call {@link renderViewport} with the same `frame` in the same
324
+ * synchronous composition, without mutating any transcript block in
325
+ * between: the shared rows are only as fresh as that first measurement.
326
+ */
327
+ beginFrame(frame: AnimationFrame): void {
328
+ this.#lastFrame = frame;
329
+ this.#frameRows.clear();
330
+ this.#openFrame = frame;
331
+ }
332
+
333
+ /**
334
+ * Total rows the live, un-emitted tail occupies at `width`.
335
+ *
336
+ * `limit` stops the walk once the total passes it: measuring a resumed
337
+ * session's whole ledger costs one full render per block, and callers only
338
+ * compare the height against a viewport budget. Past `limit` the result is
339
+ * a lower bound, guaranteed only to be greater than `limit`.
340
+ */
341
+ liveRowCount(width: number, limit = Number.POSITIVE_INFINITY): number {
295
342
  this.#syncEntries();
296
343
  this.#settleFinalized();
297
344
  let total = 0;
298
345
  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));
346
+ const block = this.#liveBlockRows(entry, index, width);
302
347
  if (block.length > 0) total += block.length + (total > 0 ? 1 : 0);
348
+ if (total > limit) break;
303
349
  }
304
350
  return total;
305
351
  }
306
352
 
353
+ /** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
354
+ #liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
355
+ const rows = this.#measuredRows(entry, width);
356
+ const emitted = this.#projectedEmittedRowCount(entry, index, width);
357
+ return emitted === 0 ? rows : rows.slice(emitted);
358
+ }
359
+
360
+ /**
361
+ * One block's blank-trimmed render at its full-height allocation. Inside an
362
+ * open frame the first measurement of each block is replayed to later ones.
363
+ */
364
+ #measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
365
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
366
+ if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
367
+ if (this.#frameRowsWidth !== width) {
368
+ this.#frameRows.clear();
369
+ this.#frameRowsWidth = width;
370
+ }
371
+ let rows = this.#frameRows.get(entry);
372
+ if (rows === undefined) {
373
+ rows = this.#renderEntry(entry, width);
374
+ this.#frameRows.set(entry, rows);
375
+ }
376
+ return rows;
377
+ }
378
+
379
+ #closeFrame(): void {
380
+ this.#openFrame = undefined;
381
+ this.#frameRows.clear();
382
+ }
383
+
307
384
  /** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
308
385
  getLastViewportSpans(): readonly TranscriptViewportSpan[] {
309
386
  return this.#lastViewportSpans;
@@ -327,9 +404,22 @@ export class TranscriptContainer extends Container {
327
404
  this.#lastViewportSpans = spans;
328
405
  }
329
406
 
330
- /** Render the live tail, constrained to the supplied transcript height. */
407
+ /**
408
+ * Render the live tail, constrained to the supplied transcript height.
409
+ * Closes the frame {@link beginFrame} opened; a different `frame` discards
410
+ * its measurements first.
411
+ */
331
412
  renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
413
+ if (frame !== this.#openFrame) this.#closeFrame();
332
414
  this.#lastFrame = frame;
415
+ try {
416
+ return this.#composeViewport(width, rows, frame);
417
+ } finally {
418
+ this.#closeFrame();
419
+ }
420
+ }
421
+
422
+ #composeViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
333
423
  this.#syncEntries();
334
424
  this.#settleFinalized();
335
425
  const live = this.#liveEntries();
@@ -339,23 +429,37 @@ export class TranscriptContainer extends Container {
339
429
  return EMPTY_ROWS;
340
430
  }
341
431
 
432
+ // Collect newest-first and stop one block past what the viewport can
433
+ // hold: beyond that the emergency layout is already certain, and every
434
+ // further block would cost a full render to produce rows no frame can
435
+ // show — the whole ledger on a resumed session's first paint (#12933).
342
436
  const shown: Array<{ entry: TranscriptEntry; index: number }> = [];
343
437
  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));
438
+ let unrendered = 0;
439
+ for (let cursor = live.length - 1; cursor >= 0; cursor--) {
440
+ if (shown.length > capacity) {
441
+ unrendered = cursor + 1;
442
+ break;
443
+ }
444
+ const candidate = live[cursor]!;
445
+ const block = this.#liveBlockRows(candidate.entry, candidate.index, width);
349
446
  if (block.length === 0) continue;
350
- total += block.length + (shown.length > 0 ? 1 : 0);
351
447
  shown.push(candidate);
352
448
  blocks.push(block);
353
449
  }
450
+ shown.reverse();
451
+ blocks.reverse();
452
+ let total = 0;
453
+ for (const block of blocks) total += block.length + (total > 0 ? 1 : 0);
354
454
  if (shown.length === 0) {
355
455
  this.#lastViewportSpans = [];
356
456
  return EMPTY_ROWS;
357
457
  }
358
- if (shown.length > capacity) return this.#renderEmergency(shown, width, capacity, frame);
458
+ if (shown.length > capacity) {
459
+ // Blocks the walk never reached are still transcript state: the
460
+ // emergency layout consults them only where it must.
461
+ return this.#renderEmergency(shown, live.slice(0, unrendered), width, capacity, frame);
462
+ }
359
463
  if (total <= capacity) {
360
464
  const output: string[] = [];
361
465
  const owners: (Component | undefined)[] = [];
@@ -430,7 +534,15 @@ export class TranscriptContainer extends Container {
430
534
  return this.#offered.kind === "replay" ? this.#offered.batch : undefined;
431
535
  }
432
536
  if (!this.#replayPending) return undefined;
433
- const rows = this.#renderReplay(width);
537
+ // The one path that must compose the whole ledger in a single frame; the
538
+ // phase label attributes any watchdog block here instead of "unknown".
539
+ pushLoopPhase("ui.transcript-replay");
540
+ let rows: readonly string[];
541
+ try {
542
+ rows = this.#renderReplay(width);
543
+ } finally {
544
+ popLoopPhase();
545
+ }
434
546
  this.#replayPending = false;
435
547
  if (rows.length === 0) return undefined;
436
548
  const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" };
@@ -455,7 +567,7 @@ export class TranscriptContainer extends Container {
455
567
  const after = this.#renderStablePrefix(entry, offered.emittedEnd, width);
456
568
  rows = after.slice(before.length);
457
569
  } else if (offered.kind === "commit") {
458
- rows = this.#renderRange(this.#frontier, offered.end, width, true);
570
+ rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
459
571
  } else {
460
572
  rows = this.#renderReplay(width);
461
573
  }
@@ -474,58 +586,74 @@ export class TranscriptContainer extends Container {
474
586
  const room = Math.max(0, Math.trunc(capacity));
475
587
  const live = this.#liveEntries();
476
588
  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
589
 
590
+ // Only a render publishes a block's stable rows, so the head renders
591
+ // before its progressive-append eligibility is read.
500
592
  const head = this.#entries[this.#frontier];
501
- if (
593
+ if (head !== undefined) this.#measuredRows(head, width);
594
+ const appendHead =
502
595
  policy === "pressure" &&
503
- total > room &&
504
596
  head?.mode === "appendOnly" &&
505
597
  !head.stableFrozen &&
506
598
  head.state !== "committed" &&
507
599
  head.emitted < head.stableRows.length
508
- ) {
600
+ ? head
601
+ : undefined;
602
+
603
+ // Measure the live tail newest-first and stop at the first block that
604
+ // does not fit: `keep` counts the leading blocks bound for scrollback,
605
+ // and everything behind them stays unmeasured. Measuring the whole live
606
+ // region costs one full render per block, which on a resumed session's
607
+ // first paint is every message it ever had (#12933). The progressive
608
+ // append path below needs the exact live height to size its emission,
609
+ // and only runs while a streaming head pins retirement.
610
+ let tailRows = 0;
611
+ let liveRows = 0;
612
+ let keep = 0;
613
+ let fits = true;
614
+ for (let cursor = live.length - 1; cursor >= 0; cursor--) {
615
+ const candidate = live[cursor]!;
616
+ const height = this.#liveBlockRows(candidate.entry, candidate.index, width).length;
617
+ if (height > 0) liveRows += height + (liveRows > 0 ? 1 : 0);
618
+ if (fits) {
619
+ const next = height > 0 ? tailRows + height + (tailRows > 0 ? 1 : 0) : tailRows;
620
+ if (next > room) {
621
+ fits = false;
622
+ keep = cursor + 1;
623
+ } else {
624
+ tailRows = next;
625
+ }
626
+ }
627
+ if (!fits && appendHead === undefined) break;
628
+ }
629
+ const overflowing = keep > 0 || this.#liveCount() >= MAX_LIVE_BLOCKS;
630
+ if (policy === "pressure" && !overflowing) {
631
+ this.#pinnedFrontier = undefined;
632
+ return undefined;
633
+ }
634
+
635
+ if (appendHead !== undefined && keep > 0) {
509
636
  // Emit as many finished rows as the overflow needs, in one batch. A
510
637
  // fast stream adds finished rows quicker than one per pressure cycle,
511
638
  // and the live region has to fall back under `room` to stay readable:
512
639
  // 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;
640
+ // `liveRows` is exact here: the append path measured every live block.
641
+ const overflow = liveRows - room;
642
+ const before = this.#renderStablePrefix(appendHead, appendHead.emitted, width);
643
+ let emittedEnd = appendHead.emitted;
516
644
  let rows: readonly string[] = EMPTY_ROWS;
517
- while (emittedEnd < head.stableRows.length && rows.length < overflow) {
518
- const after = this.#renderStablePrefix(head, emittedEnd + 1, width);
645
+ while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
646
+ const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
519
647
  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");
648
+ if (emittedEnd === appendHead.emitted) {
649
+ this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
522
650
  }
523
651
  break;
524
652
  }
525
653
  rows = after.slice(before.length);
526
654
  emittedEnd += 1;
527
655
  }
528
- if (emittedEnd > head.emitted) {
656
+ if (emittedEnd > appendHead.emitted) {
529
657
  const batch: HistoryBatch = {
530
658
  id: this.#nextBatchId++,
531
659
  rows,
@@ -537,31 +665,41 @@ export class TranscriptContainer extends Container {
537
665
  }
538
666
  }
539
667
 
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++;
668
+ // Shutdown retires everything eligible; pressure retires exactly the
669
+ // blocks that no longer fit, plus whatever the live-block cap demands.
670
+ let limit = this.#frontier + keep;
671
+ if (this.#liveCount() >= MAX_LIVE_BLOCKS) {
672
+ limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
553
673
  }
674
+ if (policy === "flush") limit = this.#entries.length;
675
+ let end = this.#frontier;
676
+ while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
554
677
  if (end === this.#frontier) {
555
678
  if (policy === "pressure") this.#notePinnedFrontier();
556
679
  return undefined;
557
680
  }
558
681
  this.#pinnedFrontier = undefined;
682
+ pushLoopPhase("ui.transcript-retire");
683
+ let retirement: { rows: readonly string[]; end: number };
684
+ try {
685
+ // Shutdown must hand over the full prefix; a live frame stops at the
686
+ // budget and offers the rest on the next frames.
687
+ retirement = this.#renderRange(
688
+ this.#frontier,
689
+ end,
690
+ width,
691
+ true,
692
+ policy === "flush" ? undefined : RETIREMENT_BUDGET_MS,
693
+ );
694
+ } finally {
695
+ popLoopPhase();
696
+ }
559
697
  const batch: HistoryBatch = {
560
698
  id: this.#nextBatchId++,
561
- rows: this.#renderRange(this.#frontier, end, width, true),
699
+ rows: retirement.rows,
562
700
  kind: "append",
563
701
  };
564
- this.#offered = { batch, end, kind: "commit" };
702
+ this.#offered = { batch, end: retirement.end, kind: "commit" };
565
703
  return batch;
566
704
  }
567
705
 
@@ -578,8 +716,7 @@ export class TranscriptContainer extends Container {
578
716
  entry.emitted = offered.emittedEnd;
579
717
  } else if (offered.kind === "commit") {
580
718
  for (let index = this.#frontier; index < offered.end; index++) {
581
- this.#entries[index]!.state = "committed";
582
- this.#entries[index]!.emitted = 0;
719
+ this.#retireEntry(this.#entries[index]!);
583
720
  }
584
721
  this.#frontier = offered.end;
585
722
  }
@@ -632,7 +769,7 @@ export class TranscriptContainer extends Container {
632
769
 
633
770
  #renderEntry(entry: TranscriptEntry, width: number): readonly string[] {
634
771
  const rendered = trimBlankEdges(entry.component.render(width));
635
- if (entry.mode === "mutable" || entry.stableFrozen) return rendered;
772
+ if (entry.state === "committed" || entry.mode === "mutable" || entry.stableFrozen) return rendered;
636
773
  const appendOnly = entry.component as Component & AppendOnlyTranscriptBlock;
637
774
  const stable = appendOnly.getTranscriptStableRows();
638
775
  if (!isStablePrefix(entry.stableRows, stable)) {
@@ -654,15 +791,10 @@ export class TranscriptContainer extends Container {
654
791
  return this.#freezeStableRows(entry, rendered, "stable rows changed within a width epoch");
655
792
  }
656
793
  entry.stableRows = published;
657
- // Slice only when the rendered rows actually changed: same length
658
- // plus prefix-equality in both directions means byte-identical, so
659
- // the stored array can be reused (callers only slice/read it).
660
- const priorRows = entry.renderedStableByWidth.get(width);
661
- if (
662
- priorRows === undefined ||
663
- priorRows.length !== stableRendered.length ||
664
- !isRowPrefix(priorRows, stableRendered)
665
- ) {
794
+ // The prefix check above proves equal-length rows are byte-identical.
795
+ // Reuse our snapshot then; copy new rows so mutable renderer buffers
796
+ // cannot change the bytes checked on the next frame.
797
+ if (priorRender === undefined || priorRender.length !== stableRendered.length) {
666
798
  entry.renderedStableByWidth.set(width, stableRendered.slice());
667
799
  }
668
800
  let perCount = entry.stableRowCountByWidth.get(width);
@@ -733,8 +865,25 @@ export class TranscriptContainer extends Container {
733
865
  });
734
866
  }
735
867
 
736
- #renderRange(start: number, end: number, width: number, trailingBlank: boolean): readonly string[] {
868
+ /**
869
+ * Compose entries `[start, end)` as one ordered retirement payload.
870
+ *
871
+ * `budgetMs` stops the walk after the first block that crosses it — always
872
+ * at least one block — and reports the index actually reached, so a single
873
+ * frame never renders more of a resumed ledger than it can afford (#12933).
874
+ * Callers that must emit a whole prefix (replay, shutdown flush, recompose
875
+ * of an already offered batch) omit it.
876
+ */
877
+ #renderRange(
878
+ start: number,
879
+ end: number,
880
+ width: number,
881
+ trailingBlank: boolean,
882
+ budgetMs?: number,
883
+ ): { rows: readonly string[]; end: number } {
737
884
  const rows: string[] = [];
885
+ const startedAt = budgetMs === undefined ? 0 : performance.now();
886
+ let reached = start;
738
887
  for (let index = start; index < end; index++) {
739
888
  const entry = this.#entries[index]!;
740
889
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
@@ -746,16 +895,19 @@ export class TranscriptContainer extends Container {
746
895
  index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width));
747
896
  const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
748
897
  const block = rendered.slice(emittedRows);
749
- if (block.length === 0) continue;
750
- if (rows.length > 0) rows.push("");
751
- rows.push(...block);
898
+ reached = index + 1;
899
+ if (block.length > 0) {
900
+ if (rows.length > 0) rows.push("");
901
+ rows.push(...block);
902
+ }
903
+ if (budgetMs !== undefined && performance.now() - startedAt >= budgetMs) break;
752
904
  }
753
905
  if (trailingBlank && rows.length > 0) rows.push("");
754
- return rows;
906
+ return { rows, end: reached };
755
907
  }
756
908
 
757
909
  #renderReplay(width: number): readonly string[] {
758
- const rows = Array.from(this.#renderRange(0, this.#frontier, width, true));
910
+ const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
759
911
  const head = this.#entries[this.#frontier];
760
912
  if (head?.mode === "appendOnly" && head.emitted > 0) {
761
913
  this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
@@ -769,28 +921,64 @@ export class TranscriptContainer extends Container {
769
921
  while (this.#frontier < this.#entries.length) {
770
922
  const entry = this.#entries[this.#frontier]!;
771
923
  if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
772
- this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
773
- const rendered = this.#renderEntry(entry, width);
924
+ const rendered = this.#measuredRows(entry, width);
774
925
  if (entry.emitted !== entry.stableRows.length) return;
775
926
  if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
776
- entry.state = "committed";
777
- entry.emitted = 0;
927
+ this.#retireEntry(entry);
778
928
  this.#frontier++;
779
929
  }
780
930
  }
781
931
 
932
+ #retireEntry(entry: TranscriptEntry): void {
933
+ entry.state = "committed";
934
+ entry.emitted = 0;
935
+ entry.stableRows = EMPTY_STABLE_ROWS;
936
+ entry.renderedStableByWidth = new Map();
937
+ entry.stableRowCountByWidth = new Map();
938
+ }
939
+
782
940
  #startReplay(): void {
783
941
  const head = this.#entries[this.#frontier];
784
942
  this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
785
943
  this.#replayRequested = false;
786
944
  }
787
945
 
946
+ /**
947
+ * One-row-per-block fallback for a live region that cannot fit the viewport.
948
+ * `behind` holds the older live blocks `renderViewport` deliberately left
949
+ * unrendered. Only its active blocks (few) and the newest settled block
950
+ * offering an emergency row are rendered, so the summary count and the
951
+ * surviving emergency row match a full walk without rendering the ledger.
952
+ */
788
953
  #renderEmergency(
789
954
  shown: readonly { entry: TranscriptEntry; index: number }[],
955
+ behind: readonly { entry: TranscriptEntry; index: number }[],
790
956
  width: number,
791
957
  rows: number,
792
958
  frame: AnimationFrame,
793
959
  ): readonly string[] {
960
+ let hiddenBelow = 0;
961
+ for (const candidate of behind) {
962
+ if (candidate.entry.state !== "active") continue;
963
+ if (this.#liveBlockRows(candidate.entry, candidate.index, width).length > 0) hiddenBelow++;
964
+ }
965
+ let behindEmergency: { candidate: { entry: TranscriptEntry; index: number }; row: string } | null | undefined;
966
+ const findBehindEmergency = () => {
967
+ if (behindEmergency !== undefined) return behindEmergency;
968
+ behindEmergency = null;
969
+ for (let index = behind.length - 1; index >= 0; index--) {
970
+ const candidate = behind[index]!;
971
+ if (candidate.entry.state !== "settled") continue;
972
+ const block = candidate.entry.component as Component & FinalizableBlock;
973
+ if (block.renderTranscriptBlockEmergencyRow === undefined) continue;
974
+ if (this.#liveBlockRows(candidate.entry, candidate.index, width).length === 0) continue;
975
+ const row = block.renderTranscriptBlockEmergencyRow(width);
976
+ if (row === undefined) continue;
977
+ behindEmergency = { candidate, row };
978
+ break;
979
+ }
980
+ return behindEmergency;
981
+ };
794
982
  let visibleRows = rows;
795
983
  let visible: { entry: TranscriptEntry; index: number }[] = [];
796
984
  let emergencyCandidate: { entry: TranscriptEntry; index: number } | undefined;
@@ -812,8 +1000,16 @@ export class TranscriptContainer extends Container {
812
1000
  visible = [candidate, ...visible.slice(1)];
813
1001
  break;
814
1002
  }
1003
+ if (emergencyCandidate === undefined) {
1004
+ const found = findBehindEmergency();
1005
+ if (found !== null) {
1006
+ emergencyCandidate = found.candidate;
1007
+ emergencyRow = found.row;
1008
+ visible = [found.candidate, ...visible.slice(1)];
1009
+ }
1010
+ }
815
1011
 
816
- let activeTotal = 0;
1012
+ let activeTotal = hiddenBelow;
817
1013
  for (const candidate of shown) {
818
1014
  if (candidate.entry.state === "active") activeTotal++;
819
1015
  }
@@ -580,6 +580,8 @@ export class Editor implements Component, Focusable {
580
580
  #wrapCache = new Map<string, WrapEntry>();
581
581
  #wrapCacheWidth = -1;
582
582
  #wrapCacheEpoch = -1;
583
+ /** Last `#getPromptGutter` result, keyed by (gutter string, clamped gutter width). */
584
+ #promptGutterCache: { source: string; firstLine: string; continuation: string; width: number } | undefined;
583
585
  #paddingXOverride: number | undefined;
584
586
  #maxHeight?: number;
585
587
  #scrollOffset: number = 0;
@@ -1075,11 +1077,16 @@ export class Editor implements Component, Focusable {
1075
1077
  if (!gutter) return undefined;
1076
1078
  const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
1077
1079
  if (gutterWidth === 0) return undefined;
1078
- return {
1080
+ const cached = this.#promptGutterCache;
1081
+ if (cached !== undefined && cached.source === gutter && cached.width === gutterWidth) return cached;
1082
+ const next = {
1083
+ source: gutter,
1079
1084
  firstLine: sliceByColumn(gutter, 0, gutterWidth, true),
1080
1085
  continuation: padding(gutterWidth),
1081
1086
  width: gutterWidth,
1082
1087
  };
1088
+ this.#promptGutterCache = next;
1089
+ return next;
1083
1090
  }
1084
1091
 
1085
1092
  #getContentWidth(width: number, paddingX: number): number {
@@ -1682,9 +1689,16 @@ export class Editor implements Component, Focusable {
1682
1689
  kb.matchesCanonical(canonical, "tui.select.pageUp") ||
1683
1690
  kb.matchesCanonical(canonical, "tui.select.pageDown")
1684
1691
  ) {
1685
- this.#autocompleteList.handleInput(data);
1686
- this.onAutocompleteUpdate?.();
1687
- return;
1692
+ // An `@` popup whose narrowing filter matched nothing holds no candidate;
1693
+ // let the key fall through instead of swallowing it.
1694
+ if (!this.#autocompleteList.getSelectedItem()) {
1695
+ this.#cancelAutocomplete();
1696
+ this.onAutocompleteUpdate?.();
1697
+ } else {
1698
+ this.#autocompleteList.handleInput(data);
1699
+ this.onAutocompleteUpdate?.();
1700
+ return;
1701
+ }
1688
1702
  }
1689
1703
 
1690
1704
  // If Tab was pressed, always apply the selection
@@ -1699,7 +1713,14 @@ export class Editor implements Component, Focusable {
1699
1713
  this.#cancelAutocomplete();
1700
1714
  return;
1701
1715
  }
1702
- if (selected && this.#autocompleteProvider) {
1716
+ if (!selected) {
1717
+ // An `@` popup whose narrowing filter matched nothing stays open with no
1718
+ // candidate (see #debouncedUpdateAutocomplete). Nothing to accept: cancel the
1719
+ // popup and fall through so Tab keeps its normal completion role and a right
1720
+ // arrow at end of line moves the cursor.
1721
+ this.#cancelAutocomplete();
1722
+ this.onAutocompleteUpdate?.();
1723
+ } else if (this.#autocompleteProvider) {
1703
1724
  const shouldChainAutocomplete =
1704
1725
  this.#isSlashCommandNameAutocompleteSelection() || isDirectoryCompletionValue(selected.value);
1705
1726
  const result = this.#autocompleteProvider.applyCompletion(
@@ -1725,7 +1746,8 @@ export class Editor implements Component, Focusable {
1725
1746
  queueMicrotask(() => void this.#tryTriggerAutocomplete());
1726
1747
  }
1727
1748
  }
1728
- return;
1749
+ // Only an accepted candidate consumes the key; an empty list falls through.
1750
+ if (selected) return;
1729
1751
  }
1730
1752
 
1731
1753
  // If Enter was pressed on a submitted slash command (not an absolute-path