@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
@@ -424,11 +424,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
424
424
  #rawPasteCandidate = "";
425
425
  #rawPasteTimer?: NodeJS.Timeout;
426
426
  // Unbracketed raw-paste classification is a fallback for terminals that do
427
- // not wrap pastes in DECSET 2004 markers. When the terminal confirms mode
428
- // 2004 support, a genuine paste always arrives bracketed, so the heuristic
429
- // can only misfire on keystrokes an event-loop stall batched into one read
430
- // (issue #12540) — Terminal disables it via `setRawPasteClassification`.
431
- #rawPasteClassificationEnabled = true;
427
+ // not wrap pastes in DECSET 2004 markers. Once the terminal confirms mode
428
+ // 2004, Terminal installs this probe: an unmarked multiline burst is then
429
+ // an input-method commit (#13344) unless an event-loop stall batched the
430
+ // keystrokes into one read (#12540). See `setRawPasteStallProbe`.
431
+ #rawPasteStallProbe?: () => boolean;
432
432
  #stringDiscardActive = false;
433
433
  #stringDiscardBytes = 0;
434
434
  #stringDiscardEscHeld = false;
@@ -491,14 +491,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
491
491
  } else {
492
492
  this.#rawPasteCandidate += str;
493
493
  if (isRawMultilineBurst(this.#rawPasteCandidate)) {
494
- this.#emitRawPasteCandidate();
494
+ this.#classifyRawPasteCandidate();
495
495
  }
496
496
  return;
497
497
  }
498
498
  }
499
499
 
500
500
  if (
501
- this.#rawPasteClassificationEnabled &&
502
501
  this.#buffer.length === 0 &&
503
502
  str.indexOf(ESC) === -1 &&
504
503
  (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
@@ -508,7 +507,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
508
507
  // Enter is replayed unchanged when the fixed window expires.
509
508
  this.#rawPasteCandidate = str;
510
509
  if (isRawMultilineBurst(str)) {
511
- this.#emitRawPasteCandidate();
510
+ this.#classifyRawPasteCandidate();
512
511
  } else {
513
512
  this.#armRawPasteTimer();
514
513
  }
@@ -649,18 +648,18 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
649
648
  }
650
649
 
651
650
  /**
652
- * Enable or disable unbracketed raw-paste classification. Terminal disables
653
- * it once DECRQM confirms bracketed-paste (mode 2004) support: a genuine
654
- * paste then always arrives wrapped, so the heuristic can only misfire on
655
- * keystrokes an event-loop stall batched into one read (issue #12540).
656
- * Disabling flushes any candidate already held by the classification window
657
- * as ordinary key events so no buffered input is lost.
651
+ * Gate unbracketed raw-paste classification on event-loop responsiveness.
652
+ * Terminal installs a probe once DECRQM confirms bracketed-paste (mode
653
+ * 2004) support: a genuine paste then always arrives wrapped, so an
654
+ * unmarked multiline burst is either an input-method commit — IME,
655
+ * dictation, and emoji commits are typed input, never bracketed (#13344) —
656
+ * or keystrokes an event-loop stall batched into one read (#12540). A burst
657
+ * classified while `stalled()` reports a stall is replayed as the original
658
+ * keys so every Enter submits; otherwise it is delivered as one paste.
659
+ * `undefined` restores unconditional classification.
658
660
  */
659
- setRawPasteClassification(enabled: boolean): void {
660
- this.#rawPasteClassificationEnabled = enabled;
661
- if (!enabled && this.#rawPasteCandidate.length > 0) {
662
- this.#flushRawPasteCandidate();
663
- }
661
+ setRawPasteStallProbe(stalled: (() => boolean) | undefined): void {
662
+ this.#rawPasteStallProbe = stalled;
664
663
  }
665
664
 
666
665
  /** Start one fixed window from the first break-bearing raw read. */
@@ -686,8 +685,15 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
686
685
  return content;
687
686
  }
688
687
 
689
- /** Emit a classified raw multiline burst through the paste channel. */
690
- #emitRawPasteCandidate(): void {
688
+ /**
689
+ * Deliver a classified raw multiline burst: one paste, or the original
690
+ * keys when the stall probe says a blocked loop batched typed input.
691
+ */
692
+ #classifyRawPasteCandidate(): void {
693
+ if (this.#rawPasteStallProbe?.()) {
694
+ this.#flushRawPasteCandidate();
695
+ return;
696
+ }
691
697
  const content = this.#takeRawPasteCandidate();
692
698
  this.#pendingKittyPrintableCodepoint = undefined;
693
699
  this.emit("paste", content);
package/src/terminal.ts CHANGED
@@ -460,6 +460,15 @@ export interface TerminalStartOptions {
460
460
  * echoes even while module loading blocks the event loop.
461
461
  */
462
462
  deferInput?: boolean;
463
+ /**
464
+ * Reports whether the event loop is stalled or just recovered from a
465
+ * stall. Once the terminal confirms bracketed paste, an unmarked multiline
466
+ * burst is an input-method commit (IME, dictation) delivered as one paste
467
+ * on a responsive loop, but keystrokes a stall batched into one read —
468
+ * replayed as keys so Enter submits — while this returns true. Without a
469
+ * probe every such burst is treated as an input-method commit.
470
+ */
471
+ isLoopStalled?: () => boolean;
463
472
  }
464
473
  /** Identity of an accepted explicit terminal appearance refresh request. */
465
474
  export type TerminalAppearanceRequestToken = number;
@@ -717,6 +726,7 @@ export class ProcessTerminal implements Terminal {
717
726
  #resizeHandler?: () => void;
718
727
  /** True between a `deferInput` start() and enableInput(). */
719
728
  #inputDeferred = false;
729
+ #isLoopStalled?: () => boolean;
720
730
  #stdoutResizeListener?: () => void;
721
731
  #kittyProtocolActive = false;
722
732
  #kittyEnableSeq: string | null = null;
@@ -772,6 +782,10 @@ export class ProcessTerminal implements Terminal {
772
782
  // enqueues frames and performs the blocking write(2) on its own thread;
773
783
  // `pendingOutputBytes` exposes the backlog for render-side frame skipping.
774
784
  #outputPump?: TtyWriter;
785
+ // Upper bound on the pump's backlog: the count its last enqueue or read
786
+ // reported. Only #safeWrite enqueues and the pump thread only drains, so
787
+ // the live backlog cannot exceed this until the next enqueue refreshes it.
788
+ #pumpBacklogBound = 0;
775
789
 
776
790
  #windowsVTInputRestore?: () => void;
777
791
  #xtermScrollToBottomRestoreModes = new Set<number>();
@@ -919,6 +933,7 @@ export class ProcessTerminal implements Terminal {
919
933
  this.#inputHandler = onInput;
920
934
  this.#resizeHandler = onResize;
921
935
  this.#disconnectHandler = onDisconnect;
936
+ this.#isLoopStalled = options?.isLoopStalled;
922
937
  // The host terminal's cursor visibility is unknown until we write it.
923
938
  this.#cursorVisible = undefined;
924
939
 
@@ -940,6 +955,7 @@ export class ProcessTerminal implements Terminal {
940
955
  if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
941
956
  try {
942
957
  this.#outputPump = new TtyWriter(1);
958
+ this.#pumpBacklogBound = 0;
943
959
  } catch (err) {
944
960
  logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
945
961
  }
@@ -1076,9 +1092,10 @@ export class ProcessTerminal implements Terminal {
1076
1092
  this.#queryPrivateMode(2048);
1077
1093
  this.#queryPrivateMode(2031);
1078
1094
  // 2004 (bracketed paste) is queried only to confirm the terminal brackets
1079
- // pastes; once confirmed, the unbracketed raw-paste heuristic in
1080
- // StdinBuffer is disabled so keystrokes an event-loop stall batches into
1081
- // one read are never misclassified as a paste (#12540).
1095
+ // pastes; once confirmed, StdinBuffer's unbracketed raw-paste heuristic
1096
+ // consults the host's stall probe so keystrokes an event-loop stall
1097
+ // batches into one read stay keys (#12540) while input-method commits
1098
+ // still land as one paste (#13344).
1082
1099
  this.#queryPrivateMode(2004);
1083
1100
  for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
1084
1101
  this.#queryPrivateMode(mode);
@@ -1769,15 +1786,17 @@ export class ProcessTerminal implements Terminal {
1769
1786
  }
1770
1787
  if (mode === 2048 && supported) this.#enableInBandResize();
1771
1788
  if (mode === 2031) this.#syncWindowsTerminalAppearancePolling(supported);
1772
- // Confirmed bracketed-paste support makes the unbracketed raw-paste
1773
- // heuristic pure downside — turn it off so stall-batched keystrokes are
1774
- // not misread as a paste (#12540). `supported` is only true here after an
1775
- // explicit DECRPM reply (the DA1-sentinel fallback resolves unsupported).
1789
+ // Confirmed bracketed-paste support means a genuine paste arrives
1790
+ // wrapped, so an unmarked multiline burst is an input-method commit
1791
+ // (#13344) unless an event-loop stall batched typed keys into one read
1792
+ // (#12540). Let the host's stall probe decide per burst. `supported` is
1793
+ // only true here after an explicit DECRPM reply (the DA1-sentinel
1794
+ // fallback resolves unsupported).
1776
1795
  if (mode === 2004 && supported) {
1777
- this.#stdinBuffer?.setRawPasteClassification(false);
1796
+ this.#stdinBuffer?.setRawPasteStallProbe(this.#isLoopStalled);
1778
1797
  // A terminal can reset this mode after the initial probe (for example,
1779
1798
  // iTerm2's Terminal State toggle). Keep the mode asserted while we own
1780
- // the TTY, since the raw fallback is disabled after confirmation.
1799
+ // the TTY, since a stalled loop now replays unmarked bursts as keys.
1781
1800
  this.#bracketedPasteRefreshTimer ??= setInterval(() => {
1782
1801
  if (this.#active && !this.#dead) this.#safeWrite("\x1b[?2004h");
1783
1802
  }, 1000);
@@ -2135,19 +2154,27 @@ export class ProcessTerminal implements Terminal {
2135
2154
  this.#trackCursorVisibility(data);
2136
2155
  const pump = this.#outputPump;
2137
2156
  if (pump) {
2138
- if (pump.dead) {
2139
- this.#markTerminalDisconnected("stdout failed; output pump died");
2140
- return;
2141
- }
2157
+ let pending: number;
2142
2158
  try {
2143
- // Feed the live backlog to the stall watchdog rather than tripping on
2144
- // the instantaneous byte count: a single large-but-draining frame (a
2145
- // resume repaint of many inline images) must open normally, while a
2146
- // never-draining reader is still torn down (#6854, #10430).
2147
- this.#trackStdoutBacklog(pump.write(data));
2159
+ pending = pump.write(data);
2148
2160
  } catch (err) {
2149
2161
  this.#markTerminalDisconnected("stdout failed", err);
2162
+ return;
2163
+ }
2164
+ // A live enqueue reports at least this chunk's UTF-8 size, never below
2165
+ // its UTF-16 length; a dead pump enqueues nothing and reports only the
2166
+ // remainder it is dropping (soon zero). Only a report that small can
2167
+ // come from a dead pump, so the native `dead` read is skipped otherwise.
2168
+ if ((pending < data.length || data.length === 0) && pump.dead) {
2169
+ this.#markTerminalDisconnected("stdout failed; output pump died");
2170
+ return;
2150
2171
  }
2172
+ this.#pumpBacklogBound = pending;
2173
+ // Feed the live backlog to the stall watchdog rather than tripping on
2174
+ // the instantaneous byte count: a single large-but-draining frame (a
2175
+ // resume repaint of many inline images) must open normally, while a
2176
+ // never-draining reader is still torn down (#6854, #10430).
2177
+ this.#trackStdoutBacklog(pending);
2151
2178
  return;
2152
2179
  }
2153
2180
  // A console-sharing child process may have flipped the console codepage
@@ -2190,8 +2217,19 @@ export class ProcessTerminal implements Terminal {
2190
2217
  if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
2191
2218
  return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
2192
2219
  }
2220
+ /**
2221
+ * With the output pump, a backlog bound at or below
2222
+ * {@link STDOUT_BACKLOG_CLEAR_BYTES} is reported as-is instead of re-read:
2223
+ * both consumers (the render gate and the stall watchdog) act only on a
2224
+ * backlog above that level, so the bound already decides them and spares a
2225
+ * native read on every frame.
2226
+ */
2193
2227
  get pendingOutputBytes(): number {
2194
- if (this.#outputPump) return this.#outputPump.pending();
2228
+ const pump = this.#outputPump;
2229
+ if (pump) {
2230
+ if (this.#pumpBacklogBound > STDOUT_BACKLOG_CLEAR_BYTES) this.#pumpBacklogBound = pump.pending();
2231
+ return this.#pumpBacklogBound;
2232
+ }
2195
2233
  // Stream fallback: bytes queued past the high-water mark by refused writes.
2196
2234
  return process.stdout.writableLength ?? 0;
2197
2235
  }
@@ -2296,7 +2334,9 @@ export class ProcessTerminal implements Terminal {
2296
2334
  if (final === 0x68 /* h */ || final === 0x6c /* l */) break;
2297
2335
  idx = idx === 0 ? -1 : data.lastIndexOf("\x1b[?25", idx - 1);
2298
2336
  }
2299
- if (data.lastIndexOf("\x1b[?1049") > idx) {
2337
+ // Only a switch after the last cursor sequence matters: search that tail
2338
+ // rather than the whole frame.
2339
+ if (data.indexOf("\x1b[?1049", idx + 1) !== -1) {
2300
2340
  this.#cursorVisible = undefined;
2301
2341
  return;
2302
2342
  }
@@ -18,12 +18,27 @@ export function detectColorMode(env: NodeJS.ProcessEnv = Bun.env): ColorMode {
18
18
  return terminal.trueColor ? "truecolor" : "256color";
19
19
  }
20
20
 
21
+ const ANSI_COLOR_CACHE_LIMIT = 256;
22
+ const ANSI_COLOR_CACHE_MAX_LENGTH = 128;
23
+ const ansiColorCaches: Record<ColorMode, Map<string, string>> = {
24
+ truecolor: new Map(),
25
+ "256color": new Map(),
26
+ };
27
+
28
+ /** Convert a theme color to foreground SGR at the requested depth; throws for invalid colors. */
21
29
  export function colorToAnsi(color: string, mode: ColorMode): string {
30
+ const cache = color.length <= ANSI_COLOR_CACHE_MAX_LENGTH ? ansiColorCaches[mode] : undefined;
31
+ const cached = cache?.get(color);
32
+ if (cached !== undefined) return cached;
22
33
  const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
23
34
  const ansi = Bun.color(color, format);
24
35
  if (ansi === null) {
25
36
  throw new Error(`Invalid color value: ${color}`);
26
37
  }
38
+ if (cache) {
39
+ if (cache.size >= ANSI_COLOR_CACHE_LIMIT) cache.clear();
40
+ cache.set(color, ansi);
41
+ }
27
42
  return ansi;
28
43
  }
29
44
 
@@ -1,4 +1,5 @@
1
1
  import type { MermaidRenderOptions } from "@oh-my-pi/pi-natives";
2
+ import { LRUCache } from "@oh-my-pi/pi-utils/lru";
2
3
  import * as mermaidAscii from "@oh-my-pi/pi-utils/mermaid-ascii";
3
4
 
4
5
  /**
@@ -19,7 +20,15 @@ export interface MermaidResolveOptions extends MermaidRenderOptions {
19
20
  // Memoizes rendered ASCII (and failures) keyed on the render options + the
20
21
  // layout-direction variant + source. Width selection happens per call against
21
22
  // the cached renders, so a terminal resize re-decides without re-rendering.
22
- const cache = new Map<string, string | null>();
23
+ // Bounded by entry count and by UTF-16 length of key (which embeds the whole
24
+ // source) plus rendered ASCII, so long sessions cannot pin every diagram.
25
+ const MERMAID_CACHE_MAX = 256;
26
+ const MERMAID_CACHE_MAX_SIZE = 4 * 1024 * 1024;
27
+ const cache = new LRUCache<string, string | null>({
28
+ max: MERMAID_CACHE_MAX,
29
+ maxSize: MERMAID_CACHE_MAX_SIZE,
30
+ sizeCalculation: (ascii, key) => key.length + (ascii?.length ?? 0),
31
+ });
23
32
 
24
33
  /** Display columns, ignoring ANSI so themed diagrams are measured as drawn. */
25
34
  const DISPLAY_WIDTH = { countAnsiEscapeCodes: false } as const;
@@ -145,11 +145,11 @@ const LIGHT_HUE_INTERVALS: readonly HueInterval[] = [[195, 330]];
145
145
  /** Theme-derived inputs for {@link getSessionAccentHex}; see `Theme.sessionAccentInputs`. */
146
146
  export interface SessionAccentTheme {
147
147
  /** Theme accent hex; the session accent adopts its OKLCH lightness and chroma. */
148
- accentHex: string;
148
+ readonly accentHex: string;
149
149
  /** Major theme color hexes checked for hue collision. */
150
- colorHexes: string[];
150
+ readonly colorHexes: readonly string[];
151
151
  /** WCAG luminance of the status-line surface on light themes; undefined on dark themes. */
152
- surfaceLuminance?: number;
152
+ readonly surfaceLuminance?: number;
153
153
  }
154
154
 
155
155
  /**
@@ -180,6 +180,22 @@ export interface SessionAccentTheme {
180
180
  * @param theme — accent hex, collision colors, and surface luminance of the active theme.
181
181
  */
182
182
  export function getSessionAccentHex(name: string, theme: SessionAccentTheme): string {
183
+ // Pure function of its inputs; keyed by value so structurally equal inputs
184
+ // share an entry. Bounded: sessions × themes stays tiny in practice.
185
+ const key = `${name}\0${theme.accentHex}\0${theme.surfaceLuminance}\0${theme.colorHexes.join(",")}`;
186
+ const cached = accentHexCache.get(key);
187
+ if (cached !== undefined) return cached;
188
+ const hex = computeSessionAccentHex(name, theme);
189
+ if (accentHexCache.size >= ACCENT_CACHE_LIMIT) accentHexCache.clear();
190
+ accentHexCache.set(key, hex);
191
+ return hex;
192
+ }
193
+
194
+ const ACCENT_CACHE_LIMIT = 256;
195
+ const accentHexCache = new Map<string, string>();
196
+ const accentAnsiCache = new Map<string, string | undefined>();
197
+
198
+ function computeSessionAccentHex(name: string, theme: SessionAccentTheme): string {
183
199
  // 1. Pick hue range based on theme mode
184
200
  const isDark = theme.surfaceLuminance === undefined;
185
201
  const intervals = isDark ? DARK_HUE_INTERVALS : LIGHT_HUE_INTERVALS;
@@ -249,5 +265,11 @@ export function getSessionAccentHex(name: string, theme: SessionAccentTheme): st
249
265
  */
250
266
  export function getSessionAccentAnsi(hex: string | undefined): string | undefined {
251
267
  if (!hex) return undefined;
252
- return Bun.color(hex, TERMINAL.trueColor ? "ansi-16m" : "ansi-256") ?? undefined;
268
+ const trueColor = TERMINAL.trueColor;
269
+ const key = trueColor ? hex : `256:${hex}`;
270
+ if (accentAnsiCache.has(key)) return accentAnsiCache.get(key);
271
+ const ansi = Bun.color(hex, trueColor ? "ansi-16m" : "ansi-256") ?? undefined;
272
+ if (accentAnsiCache.size >= ACCENT_CACHE_LIMIT) accentAnsiCache.clear();
273
+ accentAnsiCache.set(key, ansi);
274
+ return ansi;
253
275
  }
@@ -27,7 +27,7 @@ const BOLD_OPEN = "\x1b[1m";
27
27
  const BOLD_CLOSE = "\x1b[22m";
28
28
 
29
29
  type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
30
- /** Sweep style for animated shimmer text; `disabled` renders every tier as the low color. */
30
+ /** Sweep style for animated shimmer text; `disabled` renders every tier as the mid color. */
31
31
  export type ShimmerMode = "classic" | "kitt" | "disabled";
32
32
 
33
33
  let activeMode: ShimmerMode = "classic";
@@ -114,13 +114,8 @@ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette
114
114
 
115
115
  // ─── Intensity profiles ──────────────────────────────────────────────────────
116
116
  /** Smooth cosine bump sweeping left → right with edge padding. */
117
- function classicIntensity(time: number, index: number, length: number): number {
118
- const period = length + CLASSIC_PADDING * 2;
119
- // Fixed-velocity, un-floored band position: advancing at a constant
120
- // cells/second (not period / fixed-sweep) keeps the per-frame step ≤1 cell at
121
- // the default cadence for any length, so long messages are no steppier.
122
- const pos = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % period;
123
- const dist = Math.abs(index + CLASSIC_PADDING - pos);
117
+ function classicIntensity(index: number, position: number): number {
118
+ const dist = Math.abs(index + CLASSIC_PADDING - position);
124
119
  if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0;
125
120
  return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH));
126
121
  }
@@ -130,16 +125,7 @@ function classicIntensity(time: number, index: number, length: number): number {
130
125
  * bar with a quadratic-decay trail behind it. No leading glow — LEDs don't
131
126
  * predict the future.
132
127
  */
133
- function kittIntensity(time: number, index: number, length: number): number {
134
- const range = length - 1;
135
- if (range <= 0) return 1;
136
- // Fixed head velocity: a triangle ping-pong over a 2*range round trip at a
137
- // constant cells/second, so the bright head advances ≤1 cell per frame at the
138
- // default cadence regardless of bar length. Round-trip duration scales with length.
139
- const cycleCells = 2 * range;
140
- const sweep = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % cycleCells;
141
- const goingRight = sweep < range;
142
- const head = goingRight ? sweep : cycleCells - sweep;
128
+ function kittIntensity(index: number, head: number, goingRight: boolean): number {
143
129
  const delta = index - head;
144
130
  const abs = delta < 0 ? -delta : delta;
145
131
  if (abs <= KITT_HEAD_HALF) return 1;
@@ -181,46 +167,66 @@ export function shimmerEnabled(): boolean {
181
167
  export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string {
182
168
  const mode = activeMode;
183
169
 
184
- // Pre-scan: total code-point count (positions the band) and resolved palette.
185
- // The per-segment string is kept verbatim — iterating UTF-16 units with a
186
- // surrogate-pair guard produces the same code points as `Array.from(text)`
187
- // at zero per-frame allocation (previously the #1 hotspot at ~10% of profiled
188
- // CPU during streaming — the working message is shimmered every animation
189
- // frame at 30fps and `Array.from` reallocated the code-point array each tick).
190
- let total = 0;
191
- const perSeg: { text: string; palette: ShimmerPalette }[] = [];
192
- for (const seg of segments) {
193
- total += countCodePoints(seg.text);
194
- perSeg.push({ text: seg.text, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
195
- }
196
- if (total === 0) return "";
197
-
198
- // Disabled: no animation, no per-char work. Paint each segment in its mid
199
- // tier so the working line stays legible without movement.
170
+ // Disabled: no animation or code-point scan. Preserve the all-empty result,
171
+ // but include empty segments' ANSI pairs when any segment has text.
200
172
  if (mode === "disabled") {
173
+ let hasText = false;
174
+ for (const { text } of segments) {
175
+ if (text.length > 0) {
176
+ hasText = true;
177
+ break;
178
+ }
179
+ }
180
+ if (!hasText) return "";
201
181
  let out = "";
202
- for (const { text, palette } of perSeg) {
203
- const seq = compile(theme, palette).mid;
182
+ for (const { text, palette } of segments) {
183
+ const seq = compile(theme, palette ?? DEFAULT_SHIMMER_PALETTE).mid;
204
184
  out += `${seq.open}${text}${seq.close}`;
205
185
  }
206
186
  return out;
207
187
  }
208
188
 
209
- const time = Date.now();
210
- const intensityFn = mode === "kitt" ? kittIntensity : classicIntensity;
189
+ // Position the band in code points without copying the segments or splitting
190
+ // their strings. Surrogate pairs are counted independently in each segment.
191
+ let total = 0;
192
+ for (const { text } of segments) total += countCodePoints(text);
193
+ if (total === 0) return "";
194
+
195
+ // Fixed velocity keeps the per-frame step independent of message length.
196
+ // Share the frame's phase/head between the window and every active character.
197
+ const travel = (Date.now() / 1000) * SHIMMER_SPEED_CELLS_PER_S;
198
+ const isKitt = mode === "kitt";
199
+ let position: number;
200
+ let goingRight = true;
201
+ let bandLo: number;
202
+ let bandHi: number;
203
+ if (isKitt) {
204
+ const range = total - 1;
205
+ if (range <= 0) {
206
+ position = 0;
207
+ } else {
208
+ const cycleCells = 2 * range;
209
+ const sweep = travel % cycleCells;
210
+ goingRight = sweep < range;
211
+ position = goingRight ? sweep : cycleCells - sweep;
212
+ }
213
+ // Only the head and its direction-dependent trailing cells can light up.
214
+ bandLo = goingRight ? position - KITT_HEAD_HALF - KITT_TRAIL_LEN : position - KITT_HEAD_HALF;
215
+ bandHi = goingRight ? position + KITT_HEAD_HALF : position + KITT_HEAD_HALF + KITT_TRAIL_LEN;
216
+ } else {
217
+ position = travel % (total + CLASSIC_PADDING * 2);
218
+ bandLo = position - CLASSIC_PADDING - CLASSIC_BAND_HALF_WIDTH;
219
+ bandHi = position - CLASSIC_PADDING + CLASSIC_BAND_HALF_WIDTH;
220
+ }
211
221
 
212
- // Fast-path window: outside `[bandLo, bandHi]` the intensity is guaranteed
213
- // zero (tier "low"), so we can skip `intensityFn` + `tierFor` entirely for
214
- // the prefix/suffix of every segment. On the typical ~60-char working
215
- // message the classic band spans ~12 cells, so ~80% of the per-char loop
216
- // disappears — the intensity call and the tier compare were the residual
217
- // per-frame cost after #4353 removed the allocation hotspot (issue #4377).
218
- const { lo: bandLo, hi: bandHi } = activeBand(mode, time, total);
222
+ // Outside this window intensity is zero, so skip the intensity and tier
223
+ // calculations for the low-tier prefix/suffix of every segment.
224
+ const intensityFn = isKitt ? kittIntensity : classicIntensity;
219
225
 
220
226
  let out = "";
221
227
  let index = 0;
222
- for (const { text, palette } of perSeg) {
223
- const compiled = compile(theme, palette);
228
+ for (const { text, palette } of segments) {
229
+ const compiled = compile(theme, palette ?? DEFAULT_SHIMMER_PALETTE);
224
230
  let runTier: Tier | null = null;
225
231
  let runStart = 0;
226
232
  let runEnd = 0;
@@ -234,7 +240,8 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
234
240
  const c2 = text.charCodeAt(i + 1);
235
241
  if (c2 >= 0xdc00 && c2 <= 0xdfff) step = 2;
236
242
  }
237
- const tier: Tier = index < bandLo || index > bandHi ? "low" : tierFor(intensityFn(time, index, total));
243
+ const tier: Tier =
244
+ index < bandLo || index > bandHi ? "low" : tierFor(intensityFn(index, position, goingRight));
238
245
  if (tier !== runTier) {
239
246
  if (runTier !== null && runEnd > runStart) {
240
247
  const seq = compiled[runTier];
@@ -255,34 +262,6 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
255
262
  return out;
256
263
  }
257
264
 
258
- /**
259
- * Sweep window (code-point indices) outside which the intensity is guaranteed
260
- * zero for `mode` at `time` over `total` cells. Widening the window is safe —
261
- * the per-char intensity call still runs inside the window and reports 0 for
262
- * off-band code points — but narrower windows skip more of the per-char loop.
263
- */
264
- function activeBand(mode: "classic" | "kitt", time: number, total: number): { lo: number; hi: number } {
265
- if (mode === "classic") {
266
- const period = total + CLASSIC_PADDING * 2;
267
- const pos = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % period;
268
- return {
269
- lo: pos - CLASSIC_PADDING - CLASSIC_BAND_HALF_WIDTH,
270
- hi: pos - CLASSIC_PADDING + CLASSIC_BAND_HALF_WIDTH,
271
- };
272
- }
273
- const range = total - 1;
274
- if (range <= 0) return { lo: 0, hi: total };
275
- const cycleCells = 2 * range;
276
- const sweep = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % cycleCells;
277
- const goingRight = sweep < range;
278
- const head = goingRight ? sweep : cycleCells - sweep;
279
- // The trail always lies behind the head for the current direction — chars
280
- // ahead of the head are dark. See {@link kittIntensity} for the exact rule.
281
- return goingRight
282
- ? { lo: head - KITT_HEAD_HALF - KITT_TRAIL_LEN, hi: head + KITT_HEAD_HALF }
283
- : { lo: head - KITT_HEAD_HALF, hi: head + KITT_HEAD_HALF + KITT_TRAIL_LEN };
284
- }
285
-
286
265
  function countCodePoints(text: string): number {
287
266
  let n = 0;
288
267
  let i = 0;
@@ -132,6 +132,14 @@ const LANG_BRAND_COLORS: Partial<Record<SymbolKey, string>> = {
132
132
  const BACKGROUND_RESET_PATTERN = /\x1b\[(?:0|49)m/g;
133
133
  const FOREGROUND_RESET_PATTERN = /\x1b\[(?:0|39)m/g;
134
134
 
135
+ // Prebuilt stylers: each `chalk.<style>` access builds a fresh builder. Builders share the
136
+ // root chalk context, so later `chalk.level` changes still apply.
137
+ const boldStyler = chalk.bold;
138
+ const italicStyler = chalk.italic;
139
+ const underlineStyler = chalk.underline;
140
+ const strikethroughStyler = chalk.strikethrough;
141
+ const inverseStyler = chalk.inverse;
142
+
135
143
  export class Theme {
136
144
  #fgColors: Record<ThemeColor, string>;
137
145
  #bgColors: Record<ThemeBg, string>;
@@ -139,6 +147,10 @@ export class Theme {
139
147
  readonly #hexFgColors: Record<ThemeColor, string>;
140
148
  /** Resolved hex strings for background colors — populated at construction. */
141
149
  readonly #hexBgColors: Record<ThemeBg, string>;
150
+ /** Lazily resolved `fgResolved` ANSI per color; colors and mode are fixed per instance. */
151
+ readonly #resolvedFgColors = new Map<ThemeColor, string>();
152
+ /** Lazily built, frozen `sessionAccentInputs`; every input it reads is fixed per instance. */
153
+ #sessionAccentInputs: SessionAccentTheme | undefined;
142
154
  #symbols: SymbolMap;
143
155
  #spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
144
156
  /**
@@ -279,14 +291,15 @@ export class Theme {
279
291
  * Theme-derived inputs for `getSessionAccentHex`: the accent hex whose
280
292
  * OKLCH weight session accents adopt, the major colors they must not
281
293
  * hue-collide with, and the light-theme surface luminance to contrast
282
- * against.
294
+ * against. Built once per instance and frozen; callers share the object.
283
295
  */
284
296
  get sessionAccentInputs(): SessionAccentTheme {
285
- return {
297
+ this.#sessionAccentInputs ??= Object.freeze({
286
298
  accentHex: this.getAccentColorHex(),
287
- colorHexes: this.getMajorThemeColorHexes(),
299
+ colorHexes: Object.freeze(this.getMajorThemeColorHexes()),
288
300
  surfaceLuminance: this.accentSurfaceLuminance,
289
- };
301
+ });
302
+ return this.#sessionAccentInputs;
290
303
  }
291
304
 
292
305
  fg(color: ThemeColor, text: string): string {
@@ -297,9 +310,13 @@ export class Theme {
297
310
 
298
311
  /** Apply a foreground, replacing terminal-default tokens with the theme's contrast-safe fallback. */
299
312
  fgResolved(color: ThemeColor, text: string): string {
300
- const ansi = this.#fgColors[color];
301
- if (!ansi) throw new Error(`Unknown theme color: ${color}`);
302
- const resolved = ansi === "\x1b[39m" ? colorToAnsi(this.getColorHex(color), this.mode) : ansi;
313
+ let resolved = this.#resolvedFgColors.get(color);
314
+ if (resolved === undefined) {
315
+ const ansi = this.#fgColors[color];
316
+ if (!ansi) throw new Error(`Unknown theme color: ${color}`);
317
+ resolved = ansi === "\x1b[39m" ? colorToAnsi(this.getColorHex(color), this.mode) : ansi;
318
+ this.#resolvedFgColors.set(color, resolved);
319
+ }
303
320
  return `${resolved}${text.replace(FOREGROUND_RESET_PATTERN, `$&${resolved}`)}\x1b[39m`;
304
321
  }
305
322
 
@@ -332,23 +349,23 @@ export class Theme {
332
349
  }
333
350
 
334
351
  bold(text: string): string {
335
- return chalk.bold(text);
352
+ return boldStyler(text);
336
353
  }
337
354
 
338
355
  italic(text: string): string {
339
- return chalk.italic(text);
356
+ return italicStyler(text);
340
357
  }
341
358
 
342
359
  underline(text: string): string {
343
- return chalk.underline(text);
360
+ return underlineStyler(text);
344
361
  }
345
362
 
346
363
  strikethrough(text: string): string {
347
- return chalk.strikethrough(text);
364
+ return strikethroughStyler(text);
348
365
  }
349
366
 
350
367
  inverse(text: string): string {
351
- return chalk.inverse(text);
368
+ return inverseStyler(text);
352
369
  }
353
370
 
354
371
  getFgAnsi(color: ThemeColor): string {
@@ -59,7 +59,20 @@ function getHighlightColors(t: Theme): NativeHighlightColors {
59
59
  * the 33ms frame budget and starving the spinner/render timers (the "TUI freeze").
60
60
  */
61
61
  const HIGHLIGHT_CACHE_MAX = 256;
62
- const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
62
+ // Size-bound like markdown's render cache: a streaming block (bash command,
63
+ // eval cell) inserts one full `code + highlighted` pair per growth frame, so a
64
+ // count cap alone would retain 256 near-copies of a large payload. Sizes are
65
+ // UTF-16 code units of key + value (ANSI output runs several times the source
66
+ // length). The per-entry cap stays generous so a large but settled code body
67
+ // repainting every frame still hits; only payloads past it re-highlight.
68
+ const HIGHLIGHT_CACHE_MAX_SIZE = 8 * 1024 * 1024;
69
+ const HIGHLIGHT_CACHE_MAX_ENTRY_SIZE = 1024 * 1024;
70
+ const highlightCache = new LRUCache<string, string>({
71
+ max: HIGHLIGHT_CACHE_MAX,
72
+ maxSize: HIGHLIGHT_CACHE_MAX_SIZE,
73
+ maxEntrySize: HIGHLIGHT_CACHE_MAX_ENTRY_SIZE,
74
+ sizeCalculation: (value, key) => Math.max(1, key.length + value.length),
75
+ });
63
76
  let highlightCacheTheme: Theme | undefined;
64
77
 
65
78
  function highlightCached(code: string, validLang: string | undefined, highlightTheme: Theme): string | null {