@oh-my-pi/pi-tui 18.4.1 → 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 (67) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/chrome/transcript-container.d.ts +15 -1
  4. package/dist/types/components/editor.d.ts +5 -0
  5. package/dist/types/loop-watchdog.d.ts +9 -0
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/model-picker.d.ts +2 -1
  8. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  11. package/dist/types/render/width-aware-text.d.ts +6 -0
  12. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  13. package/dist/types/setup/scenes/types.d.ts +0 -26
  14. package/dist/types/stdin-buffer.d.ts +10 -7
  15. package/dist/types/terminal.d.ts +16 -0
  16. package/dist/types/theme/color.d.ts +1 -0
  17. package/dist/types/theme/session-color.d.ts +3 -3
  18. package/dist/types/theme/shimmer.d.ts +1 -1
  19. package/dist/types/theme/theme-class.d.ts +1 -1
  20. package/dist/types/tools/web-search-types.d.ts +90 -0
  21. package/dist/types/tools/web-search.d.ts +1 -168
  22. package/dist/types/tui.d.ts +3 -2
  23. package/package.json +9 -9
  24. package/src/chat/assistant-message.ts +192 -40
  25. package/src/chat/thinking-display.ts +73 -11
  26. package/src/chat/tool-execution.ts +24 -4
  27. package/src/chrome/transcript-container.ts +178 -31
  28. package/src/components/editor.ts +51 -15
  29. package/src/components/image.ts +15 -7
  30. package/src/components/loader.ts +37 -17
  31. package/src/components/markdown.ts +110 -56
  32. package/src/loop-watchdog.ts +38 -10
  33. package/src/overlays/agent-hub.ts +16 -6
  34. package/src/overlays/copy-selector.ts +5 -0
  35. package/src/overlays/model-browser.ts +106 -25
  36. package/src/overlays/model-picker.ts +9 -6
  37. package/src/overlays/rewind-selector.ts +10 -2
  38. package/src/overlays/settings-selector.ts +2 -10
  39. package/src/prompt/composer-attachments.ts +54 -7
  40. package/src/prompt/composer.ts +5 -2
  41. package/src/prompt/custom-editor.ts +24 -6
  42. package/src/prompt/model-mention-autocomplete.ts +22 -10
  43. package/src/render/width-aware-text.ts +9 -0
  44. package/src/setup/scenes/sign-in.ts +17 -12
  45. package/src/setup/scenes/types.ts +0 -27
  46. package/src/setup/wizard.ts +1 -1
  47. package/src/status-line/component.ts +229 -134
  48. package/src/status-line/segments.ts +34 -29
  49. package/src/stdin-buffer.ts +27 -21
  50. package/src/terminal.ts +60 -20
  51. package/src/theme/color.ts +15 -0
  52. package/src/theme/mermaid-cache.ts +10 -1
  53. package/src/theme/session-color.ts +26 -4
  54. package/src/theme/shimmer.ts +55 -76
  55. package/src/theme/theme-class.ts +29 -12
  56. package/src/theme/tui-adapters.ts +14 -1
  57. package/src/tools/bash.ts +53 -13
  58. package/src/tools/edit.ts +110 -29
  59. package/src/tools/streaming-output.ts +79 -16
  60. package/src/tools/web-search-types.ts +99 -0
  61. package/src/tools/web-search.ts +1 -172
  62. package/src/tui.ts +143 -113
  63. package/src/utils.ts +15 -0
  64. package/dist/types/setup/scenes/providers.d.ts +0 -3
  65. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  66. package/src/setup/scenes/providers.ts +0 -110
  67. package/src/setup/scenes/web-search.ts +0 -165
@@ -285,6 +285,14 @@ export class ToolExecutionComponent extends Container {
285
285
  // so a terminal resize re-shapes image-bearing results to rescale them without
286
286
  // forcing the common image-free result to re-shape on every resize tick.
287
287
  #renderedImageCount = 0;
288
+ // `stateBgKey|themeEpoch` of the tint last handed to #contentText. Re-tinting
289
+ // drops its wrap cache, so a rebuild whose tint is unchanged skips it and the
290
+ // inner Text re-wraps only when the reformatted card text actually differs.
291
+ #contentTextBgKey: string | undefined;
292
+ // Memoized #getTextOutput(), keyed by every input it reads: the result
293
+ // (versioned by #resultVersion), #showImages, and the image protocol.
294
+ #textOutput = "";
295
+ #textOutputKey: string | undefined;
288
296
  #tool?: AgentTool;
289
297
  #renderer?: ToolRenderer;
290
298
  #ui: ToolExecutionUi;
@@ -1200,8 +1208,7 @@ export class ToolExecutionComponent extends Container {
1200
1208
  // Generic fallback (no custom/built-in renderer). WidthAwareText
1201
1209
  // reformats at render time so output fills the actual terminal width
1202
1210
  // instead of a fixed column cap.
1203
- this.#contentText.setCustomBgFn(stateBgFn);
1204
- this.#contentText.invalidate();
1211
+ this.#refreshContentText(stateBgKey, stateBgFn);
1205
1212
  }
1206
1213
 
1207
1214
  // Handle images (same for both custom and built-in)
@@ -1339,6 +1346,8 @@ export class ToolExecutionComponent extends Container {
1339
1346
 
1340
1347
  #getTextOutput(): string {
1341
1348
  if (!this.#result) return "";
1349
+ const key = `${this.#resultVersion}|${this.#showImages}|${TERMINAL.imageProtocol ?? "-"}`;
1350
+ if (key === this.#textOutputKey) return this.#textOutput;
1342
1351
 
1343
1352
  const textBlocks = this.#result.content.filter(c => c.type === "text");
1344
1353
  const imageBlocks = this.#getAllImageBlocks();
@@ -1360,9 +1369,21 @@ export class ToolExecutionComponent extends Container {
1360
1369
  output = output ? `${output}\n${imageIndicators}` : imageIndicators;
1361
1370
  }
1362
1371
 
1372
+ this.#textOutputKey = key;
1373
+ this.#textOutput = output;
1363
1374
  return output;
1364
1375
  }
1365
1376
 
1377
+ /** Re-tint (only when the tint changed) and reformat the generic #contentText card. */
1378
+ #refreshContentText(stateBgKey: string, stateBgFn: (text: string) => string): void {
1379
+ const bgKey = `${stateBgKey}|${getThemeEpoch()}`;
1380
+ if (bgKey !== this.#contentTextBgKey) {
1381
+ this.#contentTextBgKey = bgKey;
1382
+ this.#contentText.setCustomBgFn(stateBgFn);
1383
+ }
1384
+ this.#contentText.reformat();
1385
+ }
1386
+
1366
1387
  /**
1367
1388
  * Format the generic call/result card at `contentWidth`. Shared by the
1368
1389
  * #contentText fallback and the benign-skip path so both render identically.
@@ -1406,8 +1427,7 @@ export class ToolExecutionComponent extends Container {
1406
1427
  */
1407
1428
  #renderBenignSkipCard(stateBgFn: (text: string) => string): void {
1408
1429
  if (!this.#usesContentBox) {
1409
- this.#contentText.setCustomBgFn(stateBgFn);
1410
- this.#contentText.invalidate();
1430
+ this.#refreshContentText("toolPendingBg", stateBgFn);
1411
1431
  return;
1412
1432
  }
1413
1433
  for (const box of this.#multiFileBoxes) {
@@ -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;
@@ -174,6 +180,25 @@ export class TranscriptContainer extends Container {
174
180
  #pinnedFrontier: { index: number; since: number; logged: boolean } | undefined;
175
181
  /** Block spans of the last `renderViewport` output, for click hit-testing. */
176
182
  #lastViewportSpans: TranscriptViewportSpan[] = [];
183
+ /**
184
+ * The composed frame {@link beginFrame} opened; `undefined` outside one.
185
+ * {@link renderViewport} closes it, so it never outlives the synchronous
186
+ * composition that opened it.
187
+ */
188
+ #openFrame: AnimationFrame | undefined;
189
+ /**
190
+ * Full-allocation blank-trimmed renders of the blocks measured during the
191
+ * open frame, keyed by entry at {@link #frameRowsWidth}. A retirement peek
192
+ * and the viewport measure the same live blocks back to back inside one
193
+ * composition, with no block mutation possible in between; replaying the
194
+ * first measurement spares every block its second render per frame.
195
+ */
196
+ #frameRows = new Map<TranscriptEntry, readonly string[]>();
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;
177
202
  override addChild(component: Component): void {
178
203
  if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
179
204
  super.addChild(component);
@@ -187,6 +212,9 @@ export class TranscriptContainer extends Container {
187
212
  emitted: 0,
188
213
  stableFrozen: false,
189
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;
190
218
  }
191
219
 
192
220
  override removeChild(component: Component): void {
@@ -300,6 +328,21 @@ export class TranscriptContainer extends Container {
300
328
  this.#replayRequested = false;
301
329
  }
302
330
 
331
+ /**
332
+ * Open one composed frame: every live-block measurement until this frame's
333
+ * {@link renderViewport} returns renders against `frame` and is taken once,
334
+ * so the retirement peek and the viewport share each block's render.
335
+ *
336
+ * Callers MUST call {@link renderViewport} with the same `frame` in the same
337
+ * synchronous composition, without mutating any transcript block in
338
+ * between: the shared rows are only as fresh as that first measurement.
339
+ */
340
+ beginFrame(frame: AnimationFrame): void {
341
+ this.#lastFrame = frame;
342
+ this.#frameRows.clear();
343
+ this.#openFrame = frame;
344
+ }
345
+
303
346
  /**
304
347
  * Total rows the live, un-emitted tail occupies at `width`.
305
348
  *
@@ -322,8 +365,33 @@ export class TranscriptContainer extends Container {
322
365
 
323
366
  /** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
324
367
  #liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
368
+ const rows = this.#measuredRows(entry, width);
369
+ const emitted = this.#projectedEmittedRowCount(entry, index, width);
370
+ return emitted === 0 ? rows : rows.slice(emitted);
371
+ }
372
+
373
+ /**
374
+ * One block's blank-trimmed render at its full-height allocation. Inside an
375
+ * open frame the first measurement of each block is replayed to later ones.
376
+ */
377
+ #measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
325
378
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
326
- return this.#renderEntry(entry, width).slice(this.#projectedEmittedRowCount(entry, index, width));
379
+ if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
380
+ if (this.#frameRowsWidth !== width) {
381
+ this.#frameRows.clear();
382
+ this.#frameRowsWidth = width;
383
+ }
384
+ let rows = this.#frameRows.get(entry);
385
+ if (rows === undefined) {
386
+ rows = this.#renderEntry(entry, width);
387
+ this.#frameRows.set(entry, rows);
388
+ }
389
+ return rows;
390
+ }
391
+
392
+ #closeFrame(): void {
393
+ this.#openFrame = undefined;
394
+ this.#frameRows.clear();
327
395
  }
328
396
 
329
397
  /** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
@@ -349,9 +417,22 @@ export class TranscriptContainer extends Container {
349
417
  this.#lastViewportSpans = spans;
350
418
  }
351
419
 
352
- /** Render the live tail, constrained to the supplied transcript height. */
420
+ /**
421
+ * Render the live tail, constrained to the supplied transcript height.
422
+ * Closes the frame {@link beginFrame} opened; a different `frame` discards
423
+ * its measurements first.
424
+ */
353
425
  renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
426
+ if (frame !== this.#openFrame) this.#closeFrame();
354
427
  this.#lastFrame = frame;
428
+ try {
429
+ return this.#composeViewport(width, rows, frame);
430
+ } finally {
431
+ this.#closeFrame();
432
+ }
433
+ }
434
+
435
+ #composeViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
355
436
  this.#syncEntries();
356
437
  this.#settleFinalized();
357
438
  const live = this.#liveEntries();
@@ -522,7 +603,7 @@ export class TranscriptContainer extends Container {
522
603
  // Only a render publishes a block's stable rows, so the head renders
523
604
  // before its progressive-append eligibility is read.
524
605
  const head = this.#entries[this.#frontier];
525
- if (head !== undefined) this.#liveBlockRows(head, this.#frontier, width);
606
+ if (head !== undefined) this.#measuredRows(head, width);
526
607
  const appendHead =
527
608
  policy === "pressure" &&
528
609
  head?.mode === "appendOnly" &&
@@ -571,20 +652,9 @@ export class TranscriptContainer extends Container {
571
652
  // rows left behind here are rows dropped from the top of the viewport.
572
653
  // `liveRows` is exact here: the append path measured every live block.
573
654
  const overflow = liveRows - room;
574
- const before = this.#renderStablePrefix(appendHead, appendHead.emitted, width);
575
- let emittedEnd = appendHead.emitted;
576
- let rows: readonly string[] = EMPTY_ROWS;
577
- while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
578
- const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
579
- if (!isRowPrefix(before, after) || after.length === before.length) {
580
- if (emittedEnd === appendHead.emitted) {
581
- this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
582
- }
583
- break;
584
- }
585
- rows = after.slice(before.length);
586
- emittedEnd += 1;
587
- }
655
+ const { rows, emittedEnd } =
656
+ this.#measuredAppendBatch(appendHead, width, overflow) ??
657
+ this.#renderedAppendBatch(appendHead, width, overflow);
588
658
  if (emittedEnd > appendHead.emitted) {
589
659
  const batch: HistoryBatch = {
590
660
  id: this.#nextBatchId++,
@@ -723,15 +793,10 @@ export class TranscriptContainer extends Container {
723
793
  return this.#freezeStableRows(entry, rendered, "stable rows changed within a width epoch");
724
794
  }
725
795
  entry.stableRows = published;
726
- // Slice only when the rendered rows actually changed: same length
727
- // plus prefix-equality in both directions means byte-identical, so
728
- // the stored array can be reused (callers only slice/read it).
729
- const priorRows = entry.renderedStableByWidth.get(width);
730
- if (
731
- priorRows === undefined ||
732
- priorRows.length !== stableRendered.length ||
733
- !isRowPrefix(priorRows, stableRendered)
734
- ) {
796
+ // The prefix check above proves equal-length rows are byte-identical.
797
+ // Reuse our snapshot then; copy new rows so mutable renderer buffers
798
+ // cannot change the bytes checked on the next frame.
799
+ if (priorRender === undefined || priorRender.length !== stableRendered.length) {
735
800
  entry.renderedStableByWidth.set(width, stableRendered.slice());
736
801
  }
737
802
  let perCount = entry.stableRowCountByWidth.get(width);
@@ -777,6 +842,64 @@ export class TranscriptContainer extends Container {
777
842
  if (memo !== undefined) return memo;
778
843
  return this.#renderStablePrefix(entry, count, width).length;
779
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
+
780
903
  /**
781
904
  * Record that pressure retirement is blocked behind a not-yet-settled
782
905
  * frontier block, and log its identity once the episode outlives the grace
@@ -858,8 +981,7 @@ export class TranscriptContainer extends Container {
858
981
  while (this.#frontier < this.#entries.length) {
859
982
  const entry = this.#entries[this.#frontier]!;
860
983
  if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
861
- this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
862
- const rendered = this.#renderEntry(entry, width);
984
+ const rendered = this.#measuredRows(entry, width);
863
985
  if (entry.emitted !== entry.stableRows.length) return;
864
986
  if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
865
987
  this.#retireEntry(entry);
@@ -1007,12 +1129,27 @@ export class TranscriptContainer extends Container {
1007
1129
  return this.#entries.length - this.#frontier;
1008
1130
  }
1009
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
+ */
1010
1141
  #syncEntries(): void {
1142
+ const children = this.children;
1011
1143
  if (
1012
- this.#entries.length === this.children.length &&
1013
- this.#entries.every((entry, index) => entry.component === this.children[index])
1014
- )
1144
+ !this.#entriesUnverified &&
1145
+ children === this.#syncedChildren &&
1146
+ this.#entriesMatch(children, this.#frontier)
1147
+ ) {
1015
1148
  return;
1149
+ }
1150
+ this.#entriesUnverified = false;
1151
+ this.#syncedChildren = children;
1152
+ if (this.#entriesMatch(children, 0)) return;
1016
1153
  const existing = new Map(this.#entries.map(entry => [entry.component, entry]));
1017
1154
  this.#entries = this.children.map(
1018
1155
  component =>
@@ -1030,6 +1167,16 @@ export class TranscriptContainer extends Container {
1030
1167
  this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
1031
1168
  if (this.#frontier < 0) this.#frontier = this.#entries.length;
1032
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
+ }
1033
1180
  }
1034
1181
 
1035
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 = {
@@ -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 {
@@ -1532,15 +1539,15 @@ export class Editor implements Component, Focusable {
1532
1539
  if (bottomRow !== undefined) result.push(bottomRow);
1533
1540
 
1534
1541
  // Add autocomplete list if active
1535
- if (this.#autocompleteState && this.#autocompleteList) {
1542
+ const autocompleteList = this.#visibleAutocompleteList();
1543
+ if (autocompleteList) {
1536
1544
  // Clamp the dropdown to the terminal viewport: the editor rows already
1537
1545
  // rendered above plus a small reserve must stay visible.
1538
1546
  const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24;
1539
- this.#autocompleteList.setMaxVisible(
1547
+ autocompleteList.setMaxVisible(
1540
1548
  Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)),
1541
1549
  );
1542
- const autocompleteResult = this.#autocompleteList.render(width);
1543
- result.push(...autocompleteResult);
1550
+ result.push(...autocompleteList.render(width));
1544
1551
  }
1545
1552
 
1546
1553
  return result;
@@ -1644,10 +1651,13 @@ export class Editor implements Component, Focusable {
1644
1651
 
1645
1652
  // Handle autocomplete special keys first (but don't block other input)
1646
1653
  if (this.#autocompleteState && this.#autocompleteList) {
1647
- // 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.
1648
1657
  if (kb.matchesCanonical(canonical, "tui.select.cancel")) {
1658
+ const visible = this.isShowingAutocomplete();
1649
1659
  this.#cancelAutocomplete(true);
1650
- return;
1660
+ if (visible) return;
1651
1661
  }
1652
1662
  // Right arrow at end of line accepts the selection like Tab (fish-style).
1653
1663
  // Mid-line, right arrow keeps its cursor-movement role and falls through.
@@ -1682,9 +1692,16 @@ export class Editor implements Component, Focusable {
1682
1692
  kb.matchesCanonical(canonical, "tui.select.pageUp") ||
1683
1693
  kb.matchesCanonical(canonical, "tui.select.pageDown")
1684
1694
  ) {
1685
- this.#autocompleteList.handleInput(data);
1686
- this.onAutocompleteUpdate?.();
1687
- return;
1695
+ // An `@` popup whose narrowing filter matched nothing holds no candidate;
1696
+ // let the key fall through instead of swallowing it.
1697
+ if (!this.#autocompleteList.getSelectedItem()) {
1698
+ this.#cancelAutocomplete();
1699
+ this.onAutocompleteUpdate?.();
1700
+ } else {
1701
+ this.#autocompleteList.handleInput(data);
1702
+ this.onAutocompleteUpdate?.();
1703
+ return;
1704
+ }
1688
1705
  }
1689
1706
 
1690
1707
  // If Tab was pressed, always apply the selection
@@ -1699,7 +1716,14 @@ export class Editor implements Component, Focusable {
1699
1716
  this.#cancelAutocomplete();
1700
1717
  return;
1701
1718
  }
1702
- if (selected && this.#autocompleteProvider) {
1719
+ if (!selected) {
1720
+ // An `@` popup whose narrowing filter matched nothing stays open with no
1721
+ // candidate (see #debouncedUpdateAutocomplete). Nothing to accept: cancel the
1722
+ // popup and fall through so Tab keeps its normal completion role and a right
1723
+ // arrow at end of line moves the cursor.
1724
+ this.#cancelAutocomplete();
1725
+ this.onAutocompleteUpdate?.();
1726
+ } else if (this.#autocompleteProvider) {
1703
1727
  const shouldChainAutocomplete =
1704
1728
  this.#isSlashCommandNameAutocompleteSelection() || isDirectoryCompletionValue(selected.value);
1705
1729
  const result = this.#autocompleteProvider.applyCompletion(
@@ -1725,7 +1749,8 @@ export class Editor implements Component, Focusable {
1725
1749
  queueMicrotask(() => void this.#tryTriggerAutocomplete());
1726
1750
  }
1727
1751
  }
1728
- return;
1752
+ // Only an accepted candidate consumes the key; an empty list falls through.
1753
+ if (selected) return;
1729
1754
  }
1730
1755
 
1731
1756
  // If Enter was pressed on a submitted slash command (not an absolute-path
@@ -4224,8 +4249,19 @@ export class Editor implements Component, Focusable {
4224
4249
  }
4225
4250
  }
4226
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
+ */
4227
4257
  isShowingAutocomplete(): boolean {
4228
- 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;
4229
4265
  }
4230
4266
 
4231
4267
  async #updateAutocomplete(): Promise<void> {
@@ -85,7 +85,7 @@ interface SurfaceSplit {
85
85
  * id so a partial pass reproduces the on-screen live/text split without a
86
86
  * full, correctly-ordered walk.
87
87
  */
88
- suppressedIds: Set<number>;
88
+ readonly suppressedIds: Set<number>;
89
89
  }
90
90
 
91
91
  function newSurfaceSplit(): SurfaceSplit {
@@ -97,7 +97,7 @@ function resetSurfaceSplit(split: SurfaceSplit): void {
97
97
  split.onTerminal = 0;
98
98
  split.planned = 0;
99
99
  split.lastTotal = 0;
100
- split.suppressedIds = new Set();
100
+ if (split.suppressedIds.size > 0) split.suppressedIds.clear();
101
101
  }
102
102
 
103
103
  let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
@@ -284,8 +284,8 @@ export class ImageBudget {
284
284
  */
285
285
  beginPass(stable = false, altScreen = false): void {
286
286
  this.#passIds.length = 0;
287
- this.#passSuppression.clear();
288
- this.#passIndex.clear();
287
+ if (this.#passSuppression.size > 0) this.#passSuppression.clear();
288
+ if (this.#passIndex.size > 0) this.#passIndex.clear();
289
289
  this.#stablePass = stable;
290
290
  this.#surface = altScreen ? "alt" : "screen";
291
291
  this.#split = altScreen ? this.#altSplit : this.#screenSplit;
@@ -296,7 +296,7 @@ export class ImageBudget {
296
296
  // first. Note that leaving alt mode is not the same as unstacking a
297
297
  // fullscreen overlay: the flush must exclude one that is still stacked
298
298
  // from the pass itself, which is that caller's job, not this line's.
299
- if (!altScreen) this.#liveIds.alt.clear();
299
+ if (!altScreen && this.#liveIds.alt.size > 0) this.#liveIds.alt.clear();
300
300
  this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
301
301
  }
302
302
 
@@ -349,7 +349,10 @@ export class ImageBudget {
349
349
  // [0, onTerminal) is what this surface currently shows as text. Partial
350
350
  // passes replay this per id (see #stablePass) instead of re-deriving it
351
351
  // from a reversed, tail-only walk.
352
- split.suppressedIds = new Set(this.#passIds.slice(0, split.onTerminal));
352
+ const suppressedIds = split.suppressedIds;
353
+ if (suppressedIds.size > 0) suppressedIds.clear();
354
+ const suppressedCount = Math.min(total, split.onTerminal);
355
+ for (let i = 0; i < suppressedCount; i++) suppressedIds.add(this.#passIds[i]);
353
356
  return retry;
354
357
  }
355
358
 
@@ -362,7 +365,12 @@ export class ImageBudget {
362
365
  * the next pass on the *other* surface knows what it may not destroy.
363
366
  */
364
367
  limitResidentImages(): void {
365
- this.#liveIds[this.#surface] = new Set(this.#passIds.filter(id => this.#passShowsLive(id)));
368
+ const liveIds = this.#liveIds[this.#surface];
369
+ if (liveIds.size > 0) liveIds.clear();
370
+ for (let i = 0; i < this.#passIds.length; i++) {
371
+ const id = this.#passIds[i];
372
+ if (this.#passShowsLive(id)) liveIds.add(id);
373
+ }
366
374
  const transmitted = this.#transmitted[this.#surface];
367
375
  if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
368
376
  for (const id of transmitted) {
@@ -26,7 +26,14 @@ export class Loader extends Text {
26
26
  #ui: TUI | null = null;
27
27
  #lastSpinnerTick = 0;
28
28
  #layoutSource?: readonly string[];
29
- #layout?: readonly { leading: string; content: string; trailing: string }[];
29
+ #layout?: readonly {
30
+ leading: string;
31
+ message: string;
32
+ trailing: string;
33
+ spinner: boolean;
34
+ separator: string;
35
+ bodyWidth?: number;
36
+ }[];
30
37
  #layoutFrames: readonly string[];
31
38
  #layoutFrame: string;
32
39
  #trailer?: () => string | undefined;
@@ -72,14 +79,28 @@ export class Loader extends Text {
72
79
  if (source !== this.#layoutSource) {
73
80
  const paddingX = getPaddingX(1);
74
81
  this.#layoutSource = source;
75
- this.#layout = source.map(line => {
82
+ this.#layout = source.map((line, i) => {
76
83
  const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
77
84
  const body = clamped.slice(paddingX);
78
85
  const content = body.trimEnd();
86
+ const leading = clamped.slice(0, paddingX);
87
+ const spinner = i === 0 && content.startsWith(this.#layoutFrame);
88
+ const remainder = spinner ? content.slice(this.#layoutFrame.length) : content;
89
+ const separator = spinner && remainder.startsWith(" ") ? " " : "";
90
+ const message = remainder.slice(separator.length);
91
+ const plainMessage = i === 0 ? Bun.stripANSI(message) : "";
92
+ // A visible non-whitespace ending makes trimEnd independent of
93
+ // colorizer ANSI placement. The separator isolates spinner width
94
+ // from message graphemes; other cases retain whole-body measuring.
95
+ const stableBodyWidth =
96
+ plainMessage.length > 0 && plainMessage.trimEnd() === plainMessage && (!spinner || separator === " ");
79
97
  return {
80
- leading: clamped.slice(0, paddingX),
81
- content,
98
+ leading,
99
+ message,
82
100
  trailing: body.slice(content.length),
101
+ spinner,
102
+ separator,
103
+ bodyWidth: stableBodyWidth ? visibleWidth(leading + separator + message) : undefined,
83
104
  };
84
105
  });
85
106
  }
@@ -88,28 +109,27 @@ export class Loader extends Text {
88
109
  // The wrapped text carries one stable representative per frame width.
89
110
  // Same-width frames swap only the visible glyph here; crossing widths
90
111
  // rewraps against the representative selected by #syncText.
91
- const sentinel = this.#layoutFrame;
92
112
  const lines = [""];
93
113
  const layout = this.#layout ?? [];
94
- for (let i = 0; i < layout.length; i++) {
95
- const { leading, content, trailing } = layout[i];
96
- if (i === 0 && content.startsWith(sentinel)) {
97
- const remainder = content.slice(sentinel.length);
98
- const separator = remainder.startsWith(" ") ? " " : "";
99
- const message = remainder.slice(separator.length);
100
- lines.push(
101
- `${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
102
- );
103
- } else {
104
- lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
114
+ let coloredSpinner = "";
115
+ for (const { leading, message, trailing, spinner, separator } of layout) {
116
+ if (spinner) {
117
+ coloredSpinner = this.spinnerColorFn(frame);
105
118
  }
119
+ lines.push(
120
+ `${leading}${spinner ? coloredSpinner : ""}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
121
+ );
106
122
  }
107
123
  if (this.#trailer && lines.length > 1) {
108
124
  const trailer = this.#trailer();
109
125
  if (trailer) {
110
126
  // Text pads rows to full width; drop that pad before docking right.
111
127
  const body = lines[1].trimEnd();
112
- const gap = width - visibleWidth(body) - visibleWidth(trailer);
128
+ const bodyWidth =
129
+ layout[0].bodyWidth === undefined
130
+ ? visibleWidth(body)
131
+ : layout[0].bodyWidth + visibleWidth(coloredSpinner);
132
+ const gap = width - bodyWidth - visibleWidth(trailer);
113
133
  if (gap >= 2) lines[1] = body + padding(gap) + trailer;
114
134
  }
115
135
  }