@oh-my-pi/pi-tui 18.4.1 → 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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/chrome/transcript-container.d.ts +15 -1
  3. package/dist/types/overlays/model-browser.d.ts +15 -0
  4. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  5. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  6. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  7. package/dist/types/render/width-aware-text.d.ts +6 -0
  8. package/dist/types/terminal.d.ts +7 -0
  9. package/dist/types/theme/color.d.ts +1 -0
  10. package/dist/types/theme/session-color.d.ts +3 -3
  11. package/dist/types/theme/shimmer.d.ts +1 -1
  12. package/dist/types/theme/theme-class.d.ts +1 -1
  13. package/dist/types/tui.d.ts +3 -2
  14. package/package.json +9 -9
  15. package/src/chat/tool-execution.ts +24 -4
  16. package/src/chrome/transcript-container.ts +76 -14
  17. package/src/components/editor.ts +28 -6
  18. package/src/components/image.ts +15 -7
  19. package/src/components/loader.ts +37 -17
  20. package/src/overlays/agent-hub.ts +16 -6
  21. package/src/overlays/copy-selector.ts +5 -0
  22. package/src/overlays/model-browser.ts +106 -25
  23. package/src/overlays/rewind-selector.ts +10 -2
  24. package/src/prompt/composer-attachments.ts +54 -7
  25. package/src/prompt/composer.ts +5 -2
  26. package/src/prompt/custom-editor.ts +24 -6
  27. package/src/prompt/model-mention-autocomplete.ts +22 -10
  28. package/src/render/width-aware-text.ts +9 -0
  29. package/src/status-line/component.ts +213 -132
  30. package/src/status-line/segments.ts +34 -29
  31. package/src/terminal.ts +37 -11
  32. package/src/theme/color.ts +15 -0
  33. package/src/theme/session-color.ts +26 -4
  34. package/src/theme/shimmer.ts +55 -76
  35. package/src/theme/theme-class.ts +29 -12
  36. package/src/tui.ts +142 -112
  37. package/src/utils.ts +15 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.2] - 2026-09-28
6
+
7
+ ### Changed
8
+
9
+ - Optimized activity clock rendering by caching timestamp formatting strings
10
+ - Reduced CPU overhead during animated terminal redraws, transcript updates, inline-image bookkeeping, and status-line layout.
11
+ - Reduced per-keystroke CPU in `^` model-mention autocomplete and per-frame CPU while streaming (status line, live transcript blocks, tool cards, frame writes).
12
+
13
+ ### Fixed
14
+
15
+ - Fixed the `@` completion popup swallowing Tab and cursor-movement keys while a narrowed filter matched nothing: with no candidate to accept they now fall through to their normal completion and cursor roles instead of being trapped ([#13046](https://github.com/can1357/oh-my-pi/pull/13046) by [@jchanghong023](https://github.com/jchanghong023)).
16
+ - Fixed the Esc-Esc rewind and fullscreen `/copy` selectors getting stuck at the oldest turn of a long session's recent tail; stepping past it now loads the earlier history
17
+ - Fixed the composer attachment band showing chip `#1` (and other prefix IDs) as still present when only `#10` remained in the prompt ([#13605](https://github.com/can1357/oh-my-pi/issues/13605))
18
+
5
19
  ## [18.4.1] - 2026-09-28
6
20
 
7
21
  ### Breaking Changes
@@ -96,6 +96,16 @@ export declare class TranscriptContainer extends Container {
96
96
  * quit is pure write volume. An already offered replay batch stays valid.
97
97
  */
98
98
  cancelReplay(): void;
99
+ /**
100
+ * Open one composed frame: every live-block measurement until this frame's
101
+ * {@link renderViewport} returns renders against `frame` and is taken once,
102
+ * so the retirement peek and the viewport share each block's render.
103
+ *
104
+ * Callers MUST call {@link renderViewport} with the same `frame` in the same
105
+ * synchronous composition, without mutating any transcript block in
106
+ * between: the shared rows are only as fresh as that first measurement.
107
+ */
108
+ beginFrame(frame: AnimationFrame): void;
99
109
  /**
100
110
  * Total rows the live, un-emitted tail occupies at `width`.
101
111
  *
@@ -107,7 +117,11 @@ export declare class TranscriptContainer extends Container {
107
117
  liveRowCount(width: number, limit?: number): number;
108
118
  /** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
109
119
  getLastViewportSpans(): readonly TranscriptViewportSpan[];
110
- /** Render the live tail, constrained to the supplied transcript height. */
120
+ /**
121
+ * Render the live tail, constrained to the supplied transcript height.
122
+ * Closes the frame {@link beginFrame} opened; a different `frame` discards
123
+ * its measurements first.
124
+ */
111
125
  renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[];
112
126
  /** Offers stable-head emission or the shortest finalized prefix needed under pressure. */
113
127
  peekFinalizedBatch(width: number, capacity: number): HistoryBatch | undefined;
@@ -37,6 +37,11 @@ export interface ResolvedModelRoleValue {
37
37
  }
38
38
  /** Host-provided preferences and model-role resolution for the browser. */
39
39
  export interface ModelBrowserSource extends ModelRoleLookup {
40
+ /**
41
+ * Changes whenever any preference this source reads or resolves against changes,
42
+ * except the storage-backed `mruOrder` and `modelPerf`. Keys derived-scope caches.
43
+ */
44
+ readonly revision: number;
40
45
  readonly defaultThinkingLevel: string;
41
46
  readonly modelProviderOrder: readonly string[];
42
47
  readonly knownRoleIds: readonly string[];
@@ -104,6 +109,16 @@ export interface SessionModelScope {
104
109
  }
105
110
  /** Build the session picker's current scope without creating an interactive browser. */
106
111
  export declare function buildSessionModelScope(settings: ModelBrowserSource, registry: ModelBrowserRegistry, scopedModels: ReadonlyArray<Model>): SessionModelScope;
112
+ /**
113
+ * {@link buildSessionModelScope} for per-keystroke callers: returns the same
114
+ * scope until the source revision, MRU order, scoped models, or the registry's
115
+ * available models, catalog, or load error change.
116
+ */
117
+ export declare class SessionModelScopeCache {
118
+ #private;
119
+ constructor(settings: ModelBrowserSource, registry: ModelBrowserRegistry);
120
+ get(scopedModels: ReadonlyArray<Model>): SessionModelScope;
121
+ }
107
122
  /** User affinity used to order search matches within one relevance tier. */
108
123
  export interface SearchAffinity {
109
124
  /** `provider/id` (lowercased) → rank; configured-role models first, then MRU. */
@@ -22,7 +22,8 @@
22
22
  * variants at a fork and jump between user turns elsewhere, `f` opens a
23
23
  * filter (typing narrows the current path to matching items; Esc leaves the
24
24
  * filter with the selection kept), Enter rewinds to the outlined item, A loads
25
- * earlier turns without changing selection, Esc cancels.
25
+ * earlier turns without changing selection (stepping above the oldest replayed
26
+ * turn loads them too), Esc cancels.
26
27
  */
27
28
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
28
29
  import { type Component, type TUI } from "../index.js";
@@ -37,6 +37,13 @@ export declare function attachmentSgr(kind: ChipKind, n: number): string;
37
37
  export declare const PLACEHOLDER_REGEX: RegExp;
38
38
  /** Matches an expanded attachment marker, a compact attachment chip, or a skill chip. */
39
39
  export declare const COMPOSER_TOKEN_REGEX: RegExp;
40
+ /**
41
+ * Attachment indices referenced by a composer buffer, per kind. Image/video count compact chips
42
+ * and expanded markers; paste counts compact chips only, since `[Paste #N]` markers number the
43
+ * base editor's separate paste buffer. Registered labels take precedence over theme glyphs so
44
+ * tokens created before a theme switch keep their kind; full numbers prevent `#1` matching `#10`.
45
+ */
46
+ export declare function referencedAttachments(text: string, recorded: ReadonlyMap<string, ChipKind>): Record<ChipKind, Set<number>>;
40
47
  /** Add the registered model-chip labels to the composer placeholder matcher. */
41
48
  export declare function composerTokenRegex(mentionLabels: Iterable<string>): RegExp;
42
49
  /** Offsets image marker indices, including matching `attachment://` references. */
@@ -17,7 +17,10 @@ export declare function applyModelMentionCompletion(lines: string[], cursorLine:
17
17
  cursorLine: number;
18
18
  cursorCol: number;
19
19
  };
20
- /** Create a fresh session-scoped model candidate lookup using picker ordering. */
20
+ /**
21
+ * Create a session-scoped model candidate lookup using picker ordering. The
22
+ * scope and search affinity are reused across queries until their inputs change.
23
+ */
21
24
  export declare function createModelMentionSource(host: {
22
25
  source: ModelBrowserSource;
23
26
  registry: ModelBrowserRegistry;
@@ -19,5 +19,11 @@ export declare class WidthAwareText implements Component {
19
19
  setCustomBgFn(customBgFn?: (text: string) => string): void;
20
20
  setIgnoreTight(ignore: boolean): this;
21
21
  invalidate(): void;
22
+ /**
23
+ * Re-run the formatter on the next render because its inputs changed.
24
+ * Unlike {@link invalidate}, the inner `Text` keeps its wrap cache, so a
25
+ * reformat that yields the same string skips re-wrapping and re-tinting.
26
+ */
27
+ reformat(): void;
22
28
  render(width: number): readonly string[];
23
29
  }
@@ -276,6 +276,13 @@ export declare class ProcessTerminal implements Terminal {
276
276
  stop(): void;
277
277
  write(data: string): void;
278
278
  get columns(): number;
279
+ /**
280
+ * With the output pump, a backlog bound at or below
281
+ * {@link STDOUT_BACKLOG_CLEAR_BYTES} is reported as-is instead of re-read:
282
+ * both consumers (the render gate and the stall watchdog) act only on a
283
+ * backlog above that level, so the bound already decides them and spares a
284
+ * native read on every frame.
285
+ */
279
286
  get pendingOutputBytes(): number;
280
287
  get hostOwnsGridOnResize(): boolean;
281
288
  get rows(): number;
@@ -5,6 +5,7 @@ export declare const FG_RESET = "\u001B[39m";
5
5
  export declare const BG_RESET = "\u001B[49m";
6
6
  /** Resolve theme color depth from the shared terminal capability model. */
7
7
  export declare function detectColorMode(env?: NodeJS.ProcessEnv): ColorMode;
8
+ /** Convert a theme color to foreground SGR at the requested depth; throws for invalid colors. */
8
9
  export declare function colorToAnsi(color: string, mode: ColorMode): string;
9
10
  export declare function fgAnsi(color: string | number, mode: ColorMode): string;
10
11
  export declare function bgAnsi(color: string | number, mode: ColorMode): string;
@@ -1,11 +1,11 @@
1
1
  /** Theme-derived inputs for {@link getSessionAccentHex}; see `Theme.sessionAccentInputs`. */
2
2
  export interface SessionAccentTheme {
3
3
  /** Theme accent hex; the session accent adopts its OKLCH lightness and chroma. */
4
- accentHex: string;
4
+ readonly accentHex: string;
5
5
  /** Major theme color hexes checked for hue collision. */
6
- colorHexes: string[];
6
+ readonly colorHexes: readonly string[];
7
7
  /** WCAG luminance of the status-line surface on light themes; undefined on dark themes. */
8
- surfaceLuminance?: number;
8
+ readonly surfaceLuminance?: number;
9
9
  }
10
10
  /**
11
11
  * Derive a stable CSS hex accent color from a session name and the active theme.
@@ -1,6 +1,6 @@
1
1
  import type { Theme, ThemeColor } from "./theme.js";
2
2
  type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
3
- /** Sweep style for animated shimmer text; `disabled` renders every tier as the low color. */
3
+ /** Sweep style for animated shimmer text; `disabled` renders every tier as the mid color. */
4
4
  export type ShimmerMode = "classic" | "kitt" | "disabled";
5
5
  /** Select the shimmer sweep style. The host pushes its `display.shimmer` preference here. */
6
6
  export declare function setShimmerMode(mode: ShimmerMode): void;
@@ -49,7 +49,7 @@ export declare class Theme {
49
49
  * Theme-derived inputs for `getSessionAccentHex`: the accent hex whose
50
50
  * OKLCH weight session accents adopt, the major colors they must not
51
51
  * hue-collide with, and the light-theme surface luminance to contrast
52
- * against.
52
+ * against. Built once per instance and frozen; callers share the object.
53
53
  */
54
54
  get sessionAccentInputs(): SessionAccentTheme;
55
55
  fg(color: ThemeColor, text: string): string;
@@ -273,8 +273,9 @@ export declare class Container implements Component {
273
273
  /**
274
274
  * Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
275
275
  * CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
276
- * payloads pass through verbatim. Returns the original reference when nothing
277
- * merges, so SGR-light lines incur only a single `indexOf` scan.
276
+ * payloads pass through verbatim. Isolated sequences require no parameter
277
+ * slices or arrays; output is built only at merged boundaries or empty resets
278
+ * that need normalization within an adjacent run.
278
279
  */
279
280
  export declare function coalesceAdjacentSgr(line: string): string;
280
281
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.4.1",
4
+ "version": "18.4.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.4.1",
45
- "@oh-my-pi/pi-agent-core": "18.4.1",
46
- "@oh-my-pi/pi-ai": "18.4.1",
47
- "@oh-my-pi/pi-catalog": "18.4.1",
48
- "@oh-my-pi/pi-natives": "18.4.1",
49
- "@oh-my-pi/pi-utils": "18.4.1",
50
- "@oh-my-pi/pi-wire": "18.4.1",
51
- "@oh-my-pi/snapcompact": "18.4.1"
44
+ "@oh-my-pi/omptype": "18.4.2",
45
+ "@oh-my-pi/pi-agent-core": "18.4.2",
46
+ "@oh-my-pi/pi-ai": "18.4.2",
47
+ "@oh-my-pi/pi-catalog": "18.4.2",
48
+ "@oh-my-pi/pi-natives": "18.4.2",
49
+ "@oh-my-pi/pi-utils": "18.4.2",
50
+ "@oh-my-pi/pi-wire": "18.4.2",
51
+ "@oh-my-pi/snapcompact": "18.4.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -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) {
@@ -174,6 +174,21 @@ export class TranscriptContainer extends Container {
174
174
  #pinnedFrontier: { index: number; since: number; logged: boolean } | undefined;
175
175
  /** Block spans of the last `renderViewport` output, for click hit-testing. */
176
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;
177
192
  override addChild(component: Component): void {
178
193
  if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
179
194
  super.addChild(component);
@@ -300,6 +315,21 @@ export class TranscriptContainer extends Container {
300
315
  this.#replayRequested = false;
301
316
  }
302
317
 
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
+
303
333
  /**
304
334
  * Total rows the live, un-emitted tail occupies at `width`.
305
335
  *
@@ -322,8 +352,33 @@ export class TranscriptContainer extends Container {
322
352
 
323
353
  /** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
324
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[] {
325
365
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
326
- return this.#renderEntry(entry, width).slice(this.#projectedEmittedRowCount(entry, index, width));
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();
327
382
  }
328
383
 
329
384
  /** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
@@ -349,9 +404,22 @@ export class TranscriptContainer extends Container {
349
404
  this.#lastViewportSpans = spans;
350
405
  }
351
406
 
352
- /** 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
+ */
353
412
  renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
413
+ if (frame !== this.#openFrame) this.#closeFrame();
354
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[] {
355
423
  this.#syncEntries();
356
424
  this.#settleFinalized();
357
425
  const live = this.#liveEntries();
@@ -522,7 +590,7 @@ export class TranscriptContainer extends Container {
522
590
  // Only a render publishes a block's stable rows, so the head renders
523
591
  // before its progressive-append eligibility is read.
524
592
  const head = this.#entries[this.#frontier];
525
- if (head !== undefined) this.#liveBlockRows(head, this.#frontier, width);
593
+ if (head !== undefined) this.#measuredRows(head, width);
526
594
  const appendHead =
527
595
  policy === "pressure" &&
528
596
  head?.mode === "appendOnly" &&
@@ -723,15 +791,10 @@ export class TranscriptContainer extends Container {
723
791
  return this.#freezeStableRows(entry, rendered, "stable rows changed within a width epoch");
724
792
  }
725
793
  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
- ) {
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) {
735
798
  entry.renderedStableByWidth.set(width, stableRendered.slice());
736
799
  }
737
800
  let perCount = entry.stableRowCountByWidth.get(width);
@@ -858,8 +921,7 @@ export class TranscriptContainer extends Container {
858
921
  while (this.#frontier < this.#entries.length) {
859
922
  const entry = this.#entries[this.#frontier]!;
860
923
  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);
924
+ const rendered = this.#measuredRows(entry, width);
863
925
  if (entry.emitted !== entry.stableRows.length) return;
864
926
  if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
865
927
  this.#retireEntry(entry);
@@ -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
@@ -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) {