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

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 (38) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/components/editor.d.ts +5 -0
  4. package/dist/types/loop-watchdog.d.ts +9 -0
  5. package/dist/types/overlays/model-picker.d.ts +2 -1
  6. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  7. package/dist/types/setup/scenes/types.d.ts +0 -26
  8. package/dist/types/stdin-buffer.d.ts +10 -7
  9. package/dist/types/terminal.d.ts +9 -0
  10. package/dist/types/tools/web-search-types.d.ts +90 -0
  11. package/dist/types/tools/web-search.d.ts +1 -168
  12. package/package.json +9 -9
  13. package/src/chat/assistant-message.ts +192 -40
  14. package/src/chat/thinking-display.ts +73 -11
  15. package/src/chrome/transcript-container.ts +102 -17
  16. package/src/components/editor.ts +23 -9
  17. package/src/components/markdown.ts +110 -56
  18. package/src/loop-watchdog.ts +38 -10
  19. package/src/overlays/model-picker.ts +9 -6
  20. package/src/overlays/settings-selector.ts +2 -10
  21. package/src/setup/scenes/sign-in.ts +17 -12
  22. package/src/setup/scenes/types.ts +0 -27
  23. package/src/setup/wizard.ts +1 -1
  24. package/src/status-line/component.ts +16 -2
  25. package/src/stdin-buffer.ts +27 -21
  26. package/src/terminal.ts +23 -9
  27. package/src/theme/mermaid-cache.ts +10 -1
  28. package/src/theme/tui-adapters.ts +14 -1
  29. package/src/tools/bash.ts +53 -13
  30. package/src/tools/edit.ts +110 -29
  31. package/src/tools/streaming-output.ts +79 -16
  32. package/src/tools/web-search-types.ts +99 -0
  33. package/src/tools/web-search.ts +1 -172
  34. package/src/tui.ts +1 -1
  35. package/dist/types/setup/scenes/providers.d.ts +0 -3
  36. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  37. package/src/setup/scenes/providers.ts +0 -110
  38. package/src/setup/scenes/web-search.ts +0 -165
@@ -33,7 +33,8 @@ const EMPTY_STABLE_RENDER: readonly string[] = [];
33
33
 
34
34
  type ThinkingContentBlock = Extract<AssistantMessage["content"][number], { type: "thinking" }>;
35
35
  type DisplayThinkingContentBlock = ThinkingContentBlock & { rawThinking?: string };
36
- type StablePart = { kind: "thinking" | "text"; text: string } | { kind: "spacer" };
36
+ type StablePartKind = "thinking" | "text";
37
+ type StablePart = { kind: StablePartKind; text: string } | { kind: "spacer" };
37
38
 
38
39
  /**
39
40
  * One published prefix of the block's finished content. Later snapshots extend
@@ -47,6 +48,37 @@ interface StableSnapshot {
47
48
  readonly lastTextLength: number;
48
49
  }
49
50
 
51
+ /**
52
+ * Stable-row renders at one width. `rows` renders snapshot `newest`; every
53
+ * count in `ends` renders byte-identically to `rows.slice(0, end)` — checked
54
+ * against `rows` when recorded — so an earlier prefix is a slice of the newest
55
+ * render instead of a re-render, and one row array stays resident per width.
56
+ */
57
+ interface StableRowLedger {
58
+ newest: number;
59
+ rows: readonly string[];
60
+ readonly ends: Map<number, number>;
61
+ /** Rendered rows of parts a snapshot has closed (full text final), by part index. */
62
+ readonly parts: (
63
+ | { readonly kind: StablePartKind; readonly text: string; readonly rows: readonly string[] }
64
+ | undefined
65
+ )[];
66
+ }
67
+
68
+ /** One Markdown instance reused while a stable part's text grows between renders. */
69
+ interface StablePartRenderer {
70
+ readonly index: number;
71
+ readonly kind: StablePartKind;
72
+ readonly md: Markdown;
73
+ }
74
+
75
+ /** Theme inputs cached stable renders were produced with; any change drops them. */
76
+ interface StableRenderInputs {
77
+ readonly prose: MarkdownTheme;
78
+ readonly markdown: MarkdownTheme;
79
+ readonly color: ((text: string) => string) | undefined;
80
+ }
81
+
50
82
  function isSnapshotExtension(previous: readonly StablePart[], current: readonly StablePart[]): boolean {
51
83
  if (previous.length > current.length) return false;
52
84
  for (let index = 0; index < previous.length; index++) {
@@ -260,13 +292,20 @@ export class AssistantMessageComponent extends Container {
260
292
  #nextStableRowId = 0;
261
293
  #transcriptStableRows: TranscriptStableRow[] = [];
262
294
  /**
263
- * Rendered rows per published snapshot index and width. The container asks
264
- * for several different counts in one frame (emitted, offered end,
265
- * projected, pressure-loop +1s); a 2-entry LRU thrashes across those and
266
- * re-renders the whole prefix per miss. Sized generously so the live prefix
267
- * stays cached; cleared on reset/finalize like before.
295
+ * Verified stable-row renders per width ({@link StableRowLedger}). The
296
+ * container asks for several counts per frame (emitted, offered end,
297
+ * projected) and each publication re-checks the previous prefix; the ledger
298
+ * answers all of them from the newest render. A few widths cover resizes.
299
+ * Cleared on reset, finalize, and theme change.
268
300
  */
269
- #stableRenderCache = new LRUCache<string, readonly string[]>({ max: 64 });
301
+ #stableLedgers = new LRUCache<number, StableRowLedger>({ max: 4 });
302
+ /** Prefixes handed out as ledger slices or rendered off-ledger, by `${count}:${width}`. */
303
+ #stableRenderCache = new LRUCache<string, readonly string[]>({ max: 8 });
304
+ /** Reused for the growing final part of each publication candidate. */
305
+ #stableHeadRenderer: StablePartRenderer | undefined;
306
+ /** Reused for final parts of published snapshots rendered off-ledger (reflow, replay). */
307
+ #stableReplayRenderer: StablePartRenderer | undefined;
308
+ #stableRenderInputs: StableRenderInputs | undefined;
270
309
  /** Provider-reported tokens in the live thinking block — reasoning tokens when
271
310
  * the provider streams them, else total output — shown dimmed beside the
272
311
  * speed badge. 0 when no thinking is streaming. */
@@ -626,22 +665,33 @@ export class AssistantMessageComponent extends Container {
626
665
  this.#stableSnapshots = [];
627
666
  this.#stableParts = [];
628
667
  this.#transcriptStableRows = [];
629
- this.#stableRenderCache.clear();
668
+ this.#dropStableRenders();
630
669
  }
631
670
 
632
671
  renderTranscriptStableRows(count: number, width: number): readonly string[] {
633
672
  const index = Math.min(Math.trunc(count), this.#stableSnapshots.length);
634
673
  if (index <= 0) return EMPTY_STABLE_RENDER;
674
+ this.#syncStableRenderInputs();
675
+ const ledger = this.#stableLedgers.get(width);
676
+ if (ledger?.newest === index) return ledger.rows;
635
677
  const key = `${index}:${width}`;
636
678
  const cached = this.#stableRenderCache.get(key);
637
679
  if (cached) return cached;
638
- const snapshot = this.#stableSnapshots[index - 1]!;
639
- const parts = this.#stableParts.slice(0, snapshot.partCount);
640
- const last = parts.at(-1);
641
- if (last && last.kind !== "spacer") {
642
- parts[parts.length - 1] = { kind: last.kind, text: last.text.slice(0, snapshot.lastTextLength) };
680
+ const end = ledger?.ends.get(index);
681
+ let rows: readonly string[];
682
+ if (ledger !== undefined && end !== undefined) {
683
+ rows = ledger.rows.slice(0, end);
684
+ } else {
685
+ const snapshot = this.#stableSnapshots[index - 1]!;
686
+ rows = this.#renderStableParts(
687
+ this.#stableParts,
688
+ snapshot.partCount,
689
+ snapshot.lastTextLength,
690
+ width,
691
+ "replay",
692
+ );
693
+ if (this.#recordStableRows(index, width, rows)) return rows;
643
694
  }
644
- const rows = this.#renderStableSnapshot(parts, width);
645
695
  this.#stableRenderCache.set(key, rows);
646
696
  return rows;
647
697
  }
@@ -662,9 +712,12 @@ export class AssistantMessageComponent extends Container {
662
712
  if (!last || last.kind === "spacer") return;
663
713
  const snapshot = { partCount: parts.length, lastTextLength: last.text.length };
664
714
  const previous = this.#stableSnapshots.at(-1);
665
- if (previous && !isSnapshotExtension(this.#stableParts, parts)) return;
715
+ // An unmoved boundary publishes nothing whether or not it still extends
716
+ // the last snapshot, so most frames skip the whole-document comparison.
666
717
  if (previous?.partCount === snapshot.partCount && previous.lastTextLength === snapshot.lastTextLength) return;
667
- const currentRows = this.#renderStableSnapshot(parts, width);
718
+ if (previous && !isSnapshotExtension(this.#stableParts, parts)) return;
719
+ this.#syncStableRenderInputs();
720
+ const currentRows = this.#renderStableParts(parts, parts.length, last.text.length, width, "head");
668
721
  // The container verifies stable rows against the blank-trimmed render.
669
722
  if (!isRowPrefix(currentRows, trimBlankEdges(rendered))) return;
670
723
  const previousRows = previous
@@ -676,7 +729,7 @@ export class AssistantMessageComponent extends Container {
676
729
  this.#stableParts = parts;
677
730
  this.#stableSnapshots.push(snapshot);
678
731
  this.#transcriptStableRows.push({ key: `thinking:${this.#nextStableRowId++}` });
679
- this.#stableRenderCache.set(`${this.#stableSnapshots.length}:${width}`, currentRows);
732
+ this.#recordStableRows(this.#stableSnapshots.length, width, currentRows);
680
733
  }
681
734
 
682
735
  /**
@@ -726,37 +779,136 @@ export class AssistantMessageComponent extends Container {
726
779
  return parts;
727
780
  }
728
781
 
729
- #renderStableSnapshot(parts: readonly StablePart[], width: number): readonly string[] {
782
+ /**
783
+ * Render the first `partCount` stable parts, the final one cut to
784
+ * `lastLength`. Rows match fresh Markdown renders of each part byte for
785
+ * byte — {@link #createStableMarkdown} mirrors the live children — but a
786
+ * closed part renders once per width, and the growing final part reuses one
787
+ * Markdown instance so its already-frozen blocks are not re-lexed.
788
+ */
789
+ #renderStableParts(
790
+ parts: readonly StablePart[],
791
+ partCount: number,
792
+ lastLength: number,
793
+ width: number,
794
+ role: "head" | "replay",
795
+ ): readonly string[] {
796
+ const ledger = this.#stableLedger(width);
730
797
  const rows: string[] = [];
731
- for (const part of parts) {
798
+ const lastIndex = partCount - 1;
799
+ for (let index = 0; index < partCount; index++) {
800
+ const part = parts[index]!;
732
801
  if (part.kind === "spacer") {
733
802
  rows.push("");
734
803
  continue;
735
804
  }
736
- // Constructor args mirror the live child Markdown exactly so these
737
- // rows are byte-identical to the block render's prefix — including the
738
- // trim the live children apply, which drops the trailing blank line a
739
- // frozen prefix still carries.
740
- const text = part.text.trim();
741
- const markdown =
742
- part.kind === "text"
743
- ? new Markdown(
744
- text,
745
- 1,
746
- 0,
747
- this.#getProseTheme(),
748
- this.#textColorTransform ? { color: this.#textColorTransform } : undefined,
749
- 0,
750
- )
751
- : new Markdown(text, 1, 0, getMarkdownTheme(), {
752
- color: (value: string) => theme.fg("thinkingText", value),
753
- italic: true,
754
- });
755
- rows.push(...markdown.render(width));
805
+ const closed = index < lastIndex;
806
+ const text = closed || lastLength === part.text.length ? part.text : part.text.slice(0, lastLength);
807
+ const cached = ledger.parts[index];
808
+ let partRows: readonly string[];
809
+ if (cached?.kind === part.kind && cached.text === text) {
810
+ partRows = cached.rows;
811
+ } else {
812
+ partRows = this.#renderStablePart(index, part.kind, text, width, closed ? undefined : role);
813
+ if (closed) ledger.parts[index] = { kind: part.kind, text, rows: partRows };
814
+ }
815
+ for (const row of partRows) rows.push(row);
756
816
  }
757
817
  return rows;
758
818
  }
759
819
 
820
+ /**
821
+ * Render one part's Markdown. `role` names the instance reused for a final
822
+ * part as its text grows; a closed part continues whichever instance was
823
+ * already growing it, else renders once. Finalized blocks keep no instance.
824
+ */
825
+ #renderStablePart(
826
+ index: number,
827
+ kind: StablePartKind,
828
+ text: string,
829
+ width: number,
830
+ role: "head" | "replay" | undefined,
831
+ ): readonly string[] {
832
+ // Trim like the live children, dropping the trailing blank line a frozen
833
+ // prefix still carries.
834
+ const trimmed = text.trim();
835
+ if (this.#transcriptBlockFinalized) return this.#createStableMarkdown(kind, trimmed).render(width);
836
+ const head = this.#stableHeadRenderer;
837
+ const replay = this.#stableReplayRenderer;
838
+ const renderer = role === "head" ? head : role === "replay" ? replay : head?.index === index ? head : replay;
839
+ if (renderer?.index === index && renderer.kind === kind) {
840
+ renderer.md.setText(trimmed);
841
+ return renderer.md.render(width);
842
+ }
843
+ const md = this.#createStableMarkdown(kind, trimmed);
844
+ if (role === "head") this.#stableHeadRenderer = { index, kind, md };
845
+ else if (role === "replay") this.#stableReplayRenderer = { index, kind, md };
846
+ return md.render(width);
847
+ }
848
+
849
+ /** Constructor args mirror the live child Markdown so stable rows prefix the block render. */
850
+ #createStableMarkdown(kind: StablePartKind, text: string): Markdown {
851
+ return kind === "text"
852
+ ? new Markdown(
853
+ text,
854
+ 1,
855
+ 0,
856
+ this.#getProseTheme(),
857
+ this.#textColorTransform ? { color: this.#textColorTransform } : undefined,
858
+ 0,
859
+ )
860
+ : new Markdown(text, 1, 0, getMarkdownTheme(), {
861
+ color: (value: string) => theme.fg("thinkingText", value),
862
+ italic: true,
863
+ });
864
+ }
865
+
866
+ #stableLedger(width: number): StableRowLedger {
867
+ let ledger = this.#stableLedgers.get(width);
868
+ if (ledger === undefined) {
869
+ ledger = { newest: 0, rows: EMPTY_STABLE_RENDER, ends: new Map(), parts: [] };
870
+ this.#stableLedgers.set(width, ledger);
871
+ }
872
+ return ledger;
873
+ }
874
+
875
+ /**
876
+ * Remember `rows` as snapshot `index`'s render at `width`. A newer snapshot
877
+ * becomes the ledger's newest render — keeping earlier counts only when it
878
+ * extends their rows; an older one that prefixes the newest keeps just its
879
+ * row count. Returns whether `rows` is now the newest render.
880
+ */
881
+ #recordStableRows(index: number, width: number, rows: readonly string[]): boolean {
882
+ const ledger = this.#stableLedger(width);
883
+ if (index > ledger.newest) {
884
+ if (!isRowPrefix(ledger.rows, rows)) ledger.ends.clear();
885
+ ledger.newest = index;
886
+ ledger.rows = rows;
887
+ ledger.ends.set(index, rows.length);
888
+ return true;
889
+ }
890
+ if (index < ledger.newest && isRowPrefix(rows, ledger.rows)) ledger.ends.set(index, rows.length);
891
+ return false;
892
+ }
893
+
894
+ /** Drop cached stable renders once the themes they were rendered with change. */
895
+ #syncStableRenderInputs(): void {
896
+ const prose = this.#getProseTheme();
897
+ const markdown = getMarkdownTheme();
898
+ const color = this.#textColorTransform;
899
+ const inputs = this.#stableRenderInputs;
900
+ if (inputs?.prose === prose && inputs.markdown === markdown && inputs.color === color) return;
901
+ this.#dropStableRenders();
902
+ this.#stableRenderInputs = { prose, markdown, color };
903
+ }
904
+
905
+ #dropStableRenders(): void {
906
+ this.#stableLedgers.clear();
907
+ this.#stableRenderCache.clear();
908
+ this.#stableHeadRenderer = undefined;
909
+ this.#stableReplayRenderer = undefined;
910
+ }
911
+
760
912
  /** Render completed prose rather than an earlier thinking row under emergency viewport pressure. */
761
913
  renderTranscriptBlockEmergencyRow(width: number): string | undefined {
762
914
  if (!this.#transcriptBlockFinalized) return undefined;
@@ -769,7 +921,7 @@ export class AssistantMessageComponent extends Container {
769
921
 
770
922
  markTranscriptBlockFinalized(): void {
771
923
  this.#transcriptBlockFinalized = true;
772
- this.#stableRenderCache.clear();
924
+ this.#dropStableRenders();
773
925
  this.#stopThinkingAnimation();
774
926
  // If the live pulse was on screen when the block sealed, drop the fast path
775
927
  // and rebuild so the placeholder is removed — finalized blocks never animate.
@@ -1,13 +1,16 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import { FENCE_RE } from "../render/render-utils";
3
3
 
4
- // Single-slot-per-mode memo for formatThinkingForDisplay. During a streaming
4
+ // Small per-mode MRU memo for formatThinkingForDisplay. During a streaming
5
5
  // tick the same growing thinking text is formatted up to three times (reveal
6
6
  // count, reveal slice, component render); this collapses them to one
7
7
  // computation. Prose and raw modes produce different output for the same text,
8
- // so each mode keeps its own slot. One entry per mode is enough for the common
9
- // case of one active thinking block and never regresses (a miss recomputes
10
- // exactly as before).
8
+ // so each mode keeps its own slots. A message routinely holds several thinking
9
+ // blocks (thinking → text → thinking), and every tick formats each of them, so
10
+ // one slot per mode would thrash between blocks and turn every append into a
11
+ // full refold; {@link DISPLAY_CACHE_SLOTS} slots keep one checkpoint per live
12
+ // block. A miss recomputes exactly as before and evicts the least recently
13
+ // used slot.
11
14
  //
12
15
  // Each slot also carries the fold state for incremental extension: when the
13
16
  // incoming text is an append of the previously formatted text (streaming only
@@ -85,8 +88,69 @@ function freshDisplayCache(): DisplayCache {
85
88
  return { text: "", value: "", hadComment: false, startLineByte: 0, state: freshFoldState(), resumable: true };
86
89
  }
87
90
 
88
- const proseCache = freshDisplayCache();
89
- const rawCache = freshDisplayCache();
91
+ /** Slots per mode: enough for the thinking blocks of one streaming message. */
92
+ const DISPLAY_CACHE_SLOTS = 4;
93
+ // Most recently used first.
94
+ const proseSlots: DisplayCache[] = [];
95
+ const rawSlots: DisplayCache[] = [];
96
+
97
+ /** Drop every memo slot so the next call recomputes from scratch. Tests only. */
98
+ export function resetThinkingDisplayCacheForTests(): void {
99
+ proseSlots.length = 0;
100
+ rawSlots.length = 0;
101
+ }
102
+
103
+ /**
104
+ * Pick the slot to format `text` into and move it to the front: an exact memo
105
+ * hit, else the resumable slot holding the longest verbatim prefix of `text`
106
+ * (an append). A miss overwrites a non-resumable slot holding a prefix of
107
+ * `text` — the same stream's retired checkpoint, e.g. the raw identity
108
+ * shortcut — so such a stream never evicts the other blocks' checkpoints;
109
+ * otherwise the least recently used slot (or a new one below the cap).
110
+ * Append detection runs here once so the caller never re-verifies.
111
+ */
112
+ function acquireDisplayCache(
113
+ slots: DisplayCache[],
114
+ text: string,
115
+ ): { cache: DisplayCache; match: "hit" | "append" | "miss" } {
116
+ // Identity pass first: the repeated renders of one streaming tick must not
117
+ // pay any prefix verification against the other blocks' slots.
118
+ let index = slots.findIndex(slot => slot.text === text);
119
+ let match: "hit" | "append" | "miss" = index === -1 ? "miss" : "hit";
120
+ if (index === -1) {
121
+ let appendLength = 0;
122
+ let retired = -1;
123
+ let retiredLength = 0;
124
+ for (let i = 0; i < slots.length; i++) {
125
+ const slot = slots[i]!;
126
+ const length = slot.text.length;
127
+ if (length <= (slot.resumable ? appendLength : retiredLength) || !isAppend(slot, text)) continue;
128
+ if (slot.resumable) {
129
+ index = i;
130
+ match = "append";
131
+ appendLength = length;
132
+ } else {
133
+ retired = i;
134
+ retiredLength = length;
135
+ }
136
+ }
137
+ if (index === -1) index = retired;
138
+ }
139
+ if (index === -1) {
140
+ if (slots.length < DISPLAY_CACHE_SLOTS) {
141
+ const cache = freshDisplayCache();
142
+ slots.unshift(cache);
143
+ return { cache, match };
144
+ }
145
+ index = slots.length - 1;
146
+ }
147
+ const cache = slots[index]!;
148
+ if (index > 0) {
149
+ slots.splice(index, 1);
150
+ slots.unshift(cache);
151
+ }
152
+ return { cache, match };
153
+ }
90
154
 
91
155
  export function canonicalizeMessage(text: string | null | undefined): string {
92
156
  if (!text) return "";
@@ -190,14 +254,12 @@ function renderFold(state: FoldState): string {
190
254
  */
191
255
  export function formatThinkingForDisplay(text: string, proseOnly: boolean): string {
192
256
  if (!text) return text;
193
- const cache = proseOnly ? proseCache : rawCache;
194
- // Identity memo first: the 2nd and 3rd renders of one streaming tick pass
195
- // the same text and must not pay the prefix verification below.
196
- if (text === cache.text) return cache.value;
257
+ const { cache, match } = acquireDisplayCache(proseOnly ? proseSlots : rawSlots, text);
258
+ if (match === "hit") return cache.value;
197
259
  let hasComment: boolean;
198
260
  let fromByte: number;
199
261
  let state: FoldState;
200
- if (cache.resumable && isAppend(cache, text)) {
262
+ if (match === "append") {
201
263
  // Append: a `<!--` introduced by the suffix flips the noise gate, and a
202
264
  // marker straddling the seam can start at most 3 bytes back — identical
203
265
  // to rescanning the full text, at O(delta).
@@ -93,6 +93,12 @@ type Offered =
93
93
  | { batch: HistoryBatch; kind: "commit"; end: number }
94
94
  | { batch: HistoryBatch; kind: "replay" };
95
95
 
96
+ /** Rows a progressive-append retirement offers, and the stable count they bring the head to. */
97
+ interface AppendBatch {
98
+ rows: readonly string[];
99
+ emittedEnd: number;
100
+ }
101
+
96
102
  const MAX_LIVE_BLOCKS = 256;
97
103
  /** Grace before a pressure-blocked frontier is reported; a streaming block may legitimately hold it briefly. */
98
104
  const PINNED_FRONTIER_WARN_MS = 30_000;
@@ -189,6 +195,10 @@ export class TranscriptContainer extends Container {
189
195
  */
190
196
  #frameRows = new Map<TranscriptEntry, readonly string[]>();
191
197
  #frameRowsWidth = 0;
198
+ /** The `children` array `#entries` last mirrored; see {@link #syncEntries}. */
199
+ #syncedChildren: Component[] | undefined;
200
+ /** Forces the next {@link #syncEntries} to compare every entry, not just the live tail. */
201
+ #entriesUnverified = false;
192
202
  override addChild(component: Component): void {
193
203
  if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
194
204
  super.addChild(component);
@@ -202,6 +212,9 @@ export class TranscriptContainer extends Container {
202
212
  emitted: 0,
203
213
  stableFrozen: false,
204
214
  });
215
+ // Callers may splice a just-added block into place (insert after an
216
+ // anchor); re-check the whole list once instead of trusting positions.
217
+ this.#entriesUnverified = true;
205
218
  }
206
219
 
207
220
  override removeChild(component: Component): void {
@@ -639,20 +652,9 @@ export class TranscriptContainer extends Container {
639
652
  // rows left behind here are rows dropped from the top of the viewport.
640
653
  // `liveRows` is exact here: the append path measured every live block.
641
654
  const overflow = liveRows - room;
642
- const before = this.#renderStablePrefix(appendHead, appendHead.emitted, width);
643
- let emittedEnd = appendHead.emitted;
644
- let rows: readonly string[] = EMPTY_ROWS;
645
- while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
646
- const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
647
- if (!isRowPrefix(before, after) || after.length === before.length) {
648
- if (emittedEnd === appendHead.emitted) {
649
- this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
650
- }
651
- break;
652
- }
653
- rows = after.slice(before.length);
654
- emittedEnd += 1;
655
- }
655
+ const { rows, emittedEnd } =
656
+ this.#measuredAppendBatch(appendHead, width, overflow) ??
657
+ this.#renderedAppendBatch(appendHead, width, overflow);
656
658
  if (emittedEnd > appendHead.emitted) {
657
659
  const batch: HistoryBatch = {
658
660
  id: this.#nextBatchId++,
@@ -840,6 +842,64 @@ export class TranscriptContainer extends Container {
840
842
  if (memo !== undefined) return memo;
841
843
  return this.#renderStablePrefix(entry, count, width).length;
842
844
  }
845
+
846
+ /**
847
+ * Size a progressive-append batch from the stable renders `#renderEntry`
848
+ * recorded at this width, without rendering any prefix. Each recorded count
849
+ * was checked to render as a byte prefix of every later one, and
850
+ * `renderedStableByWidth` holds the newest, so each count's rows are a
851
+ * prefix of it and the batch is one slice. Undefined when a count the walk
852
+ * needs was never recorded here (published between renders, or before a
853
+ * resize); the rendered walk then decides.
854
+ */
855
+ #measuredAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch | undefined {
856
+ const counts = entry.stableRowCountByWidth.get(width);
857
+ const newest = entry.renderedStableByWidth.get(width);
858
+ const target = entry.stableRows.length;
859
+ if (counts === undefined || newest === undefined || counts.get(target) !== newest.length) return undefined;
860
+ const start = entry.emitted === 0 ? 0 : counts.get(entry.emitted);
861
+ if (start === undefined) return undefined;
862
+ let emittedEnd = entry.emitted;
863
+ let end = start;
864
+ while (emittedEnd < target && end - start < overflow) {
865
+ const next = counts.get(emittedEnd + 1);
866
+ if (next === undefined) return undefined;
867
+ if (next <= start) {
868
+ if (emittedEnd === entry.emitted) {
869
+ this.#freezeStableRows(entry, EMPTY_ROWS, "semantic row render added no suffix");
870
+ }
871
+ break;
872
+ }
873
+ end = next;
874
+ emittedEnd += 1;
875
+ }
876
+ return { rows: emittedEnd > entry.emitted ? newest.slice(start, end) : EMPTY_ROWS, emittedEnd };
877
+ }
878
+
879
+ /**
880
+ * Size a progressive-append batch by rendering each further stable prefix:
881
+ * extend the emitted prefix until its new rows cover `overflow`, stopping
882
+ * at the first prefix that adds no row or stops extending the emitted one
883
+ * (freezing the block when that is the very next prefix).
884
+ */
885
+ #renderedAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch {
886
+ const before = this.#renderStablePrefix(entry, entry.emitted, width);
887
+ let emittedEnd = entry.emitted;
888
+ let after = before;
889
+ while (emittedEnd < entry.stableRows.length && after.length - before.length < overflow) {
890
+ const next = this.#renderStablePrefix(entry, emittedEnd + 1, width);
891
+ if (!isRowPrefix(before, next) || next.length === before.length) {
892
+ if (emittedEnd === entry.emitted) {
893
+ this.#freezeStableRows(entry, EMPTY_ROWS, "semantic row render added no suffix");
894
+ }
895
+ break;
896
+ }
897
+ after = next;
898
+ emittedEnd += 1;
899
+ }
900
+ return { rows: emittedEnd > entry.emitted ? after.slice(before.length) : EMPTY_ROWS, emittedEnd };
901
+ }
902
+
843
903
  /**
844
904
  * Record that pressure retirement is blocked behind a not-yet-settled
845
905
  * frontier block, and log its identity once the episode outlives the grace
@@ -1069,12 +1129,27 @@ export class TranscriptContainer extends Container {
1069
1129
  return this.#entries.length - this.#frontier;
1070
1130
  }
1071
1131
 
1132
+ /**
1133
+ * Mirror `children` into `#entries`. The container's own add/remove/clear
1134
+ * keep the two aligned, so the per-frame check stays off the committed
1135
+ * ledger: external edits to the public `children` array are caught by array
1136
+ * identity (replacement), length (push, removing splices), and an identity
1137
+ * scan of the live tail (in-place reorders and index writes). Committed
1138
+ * blocks are immutable history nothing reorders, and the scan skipping
1139
+ * them keeps this proportional to the live tail rather than the session.
1140
+ */
1072
1141
  #syncEntries(): void {
1142
+ const children = this.children;
1073
1143
  if (
1074
- this.#entries.length === this.children.length &&
1075
- this.#entries.every((entry, index) => entry.component === this.children[index])
1076
- )
1144
+ !this.#entriesUnverified &&
1145
+ children === this.#syncedChildren &&
1146
+ this.#entriesMatch(children, this.#frontier)
1147
+ ) {
1077
1148
  return;
1149
+ }
1150
+ this.#entriesUnverified = false;
1151
+ this.#syncedChildren = children;
1152
+ if (this.#entriesMatch(children, 0)) return;
1078
1153
  const existing = new Map(this.#entries.map(entry => [entry.component, entry]));
1079
1154
  this.#entries = this.children.map(
1080
1155
  component =>
@@ -1092,6 +1167,16 @@ export class TranscriptContainer extends Container {
1092
1167
  this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
1093
1168
  if (this.#frontier < 0) this.#frontier = this.#entries.length;
1094
1169
  }
1170
+
1171
+ /** Whether `#entries` has `children`'s length and components from `start` on. */
1172
+ #entriesMatch(children: readonly Component[], start: number): boolean {
1173
+ const entries = this.#entries;
1174
+ if (entries.length !== children.length) return false;
1175
+ for (let index = start; index < entries.length; index++) {
1176
+ if (entries[index]!.component !== children[index]) return false;
1177
+ }
1178
+ return true;
1179
+ }
1095
1180
  }
1096
1181
 
1097
1182
  /** Groups sibling rows into one conservative mutable semantic transcript block. */
@@ -63,12 +63,12 @@ const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
63
63
  /**
64
64
  * `@` file lists are narrowed in place (`setFilter(liveToken)`) while a fresh
65
65
  * search runs, so a slow walk never leaves entries that contradict the typed
66
- * token on screen. An emptied list means the refresh is still pending.
66
+ * token on screen. An emptied list means the refresh is still pending; the
67
+ * popup stays open but renders nothing until results arrive.
67
68
  */
68
69
  const AT_FILE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
69
70
  ...AUTOCOMPLETE_SELECT_LIST_LAYOUT,
70
71
  filterItems: (items, token) => items.filter(item => atCompletionMatches(token, item.value)),
71
- noMatchText: "Searching…",
72
72
  };
73
73
 
74
74
  const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
@@ -1539,15 +1539,15 @@ export class Editor implements Component, Focusable {
1539
1539
  if (bottomRow !== undefined) result.push(bottomRow);
1540
1540
 
1541
1541
  // Add autocomplete list if active
1542
- if (this.#autocompleteState && this.#autocompleteList) {
1542
+ const autocompleteList = this.#visibleAutocompleteList();
1543
+ if (autocompleteList) {
1543
1544
  // Clamp the dropdown to the terminal viewport: the editor rows already
1544
1545
  // rendered above plus a small reserve must stay visible.
1545
1546
  const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24;
1546
- this.#autocompleteList.setMaxVisible(
1547
+ autocompleteList.setMaxVisible(
1547
1548
  Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)),
1548
1549
  );
1549
- const autocompleteResult = this.#autocompleteList.render(width);
1550
- result.push(...autocompleteResult);
1550
+ result.push(...autocompleteList.render(width));
1551
1551
  }
1552
1552
 
1553
1553
  return result;
@@ -1651,10 +1651,13 @@ export class Editor implements Component, Focusable {
1651
1651
 
1652
1652
  // Handle autocomplete special keys first (but don't block other input)
1653
1653
  if (this.#autocompleteState && this.#autocompleteList) {
1654
- // Escape - cancel autocomplete
1654
+ // Escape - cancel autocomplete. A hidden popup (empty narrowed `@` list) is
1655
+ // dropped too, so its pending refresh cannot pop up afterward, but the key
1656
+ // falls through: the user never saw anything to dismiss.
1655
1657
  if (kb.matchesCanonical(canonical, "tui.select.cancel")) {
1658
+ const visible = this.isShowingAutocomplete();
1656
1659
  this.#cancelAutocomplete(true);
1657
- return;
1660
+ if (visible) return;
1658
1661
  }
1659
1662
  // Right arrow at end of line accepts the selection like Tab (fish-style).
1660
1663
  // Mid-line, right arrow keeps its cursor-movement role and falls through.
@@ -4246,8 +4249,19 @@ export class Editor implements Component, Focusable {
4246
4249
  }
4247
4250
  }
4248
4251
 
4252
+ /**
4253
+ * Whether an autocomplete popup is on screen. An `@` list narrowed to no match
4254
+ * while its refresh is pending stays open internally but is hidden, so it does
4255
+ * not claim keys (Escape, Vim mode switches) meant for the editor or app.
4256
+ */
4249
4257
  isShowingAutocomplete(): boolean {
4250
- return this.#autocompleteState !== null;
4258
+ return this.#visibleAutocompleteList() !== undefined;
4259
+ }
4260
+
4261
+ /** The open autocomplete list, unless it has no candidate to show. */
4262
+ #visibleAutocompleteList(): SelectList | undefined {
4263
+ if (this.#autocompleteState === null) return undefined;
4264
+ return this.#autocompleteList?.getSelectedItem() ? this.#autocompleteList : undefined;
4251
4265
  }
4252
4266
 
4253
4267
  async #updateAutocomplete(): Promise<void> {