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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -514,11 +514,15 @@ function parseTmuxVersionFromEnv(env: NodeJS.ProcessEnv): { major: number; minor
514
514
  * Policy (highest precedence first):
515
515
  * 1. Explicit user override (`PI_NO_HYPERLINKS=1` off, `PI_FORCE_HYPERLINKS=1`
516
516
  * on). Opt-out wins ties.
517
- * 2. Static terminal capability — terminals whose {@link TerminalInfo} marks
517
+ * 2. Herdr pane with no nested screen/tmux: on. Herdr hides the outer
518
+ * terminal (`TERM=xterm-256color`, no `TERM_PROGRAM`), but it renders
519
+ * OSC 8 in its own grid and opens links itself on Ctrl+click, so the
520
+ * outer terminal's support does not matter.
521
+ * 3. Static terminal capability — terminals whose {@link TerminalInfo} marks
518
522
  * `hyperlinks: false` (e.g. `base`) stay off unless the user forced on.
519
- * 3. GNU screen's explicit session marker (`STY`) always off, even if tmux is
523
+ * 4. GNU screen's explicit session marker (`STY`) always off, even if tmux is
520
524
  * also present: a screen layer anywhere in the path cannot forward OSC 8.
521
- * 4. tmux session (`TMUX` set): enabled when tmux self-reports >= 3.4 via
525
+ * 5. tmux session (`TMUX` set): enabled when tmux self-reports >= 3.4 via
522
526
  * `TERM_PROGRAM_VERSION` (tmux 3.4 stores OSC 8 as a cell attribute and
523
527
  * forwards it to outer terminals whose `terminal-features` include
524
528
  * `hyperlinks`). Older or unknown versions stay off; on outer terminals
@@ -526,11 +530,11 @@ function parseTmuxVersionFromEnv(env: NodeJS.ProcessEnv): { major: number; minor
526
530
  * identical to today. Checked before the screen-family TERM heuristic
527
531
  * because tmux's historical `default-terminal` is `screen-256color`, so
528
532
  * `TERM=screen*` inside a tmux session must NOT short-circuit to off.
529
- * 5. screen-family TERM without `TMUX` always off: screen never gained OSC 8
533
+ * 6. screen-family TERM without `TMUX` always off: screen never gained OSC 8
530
534
  * support.
531
- * 6. tmux-family TERM without `TMUX` env — unusual (e.g. inspection scripts);
535
+ * 7. tmux-family TERM without `TMUX` env — unusual (e.g. inspection scripts);
532
536
  * no version available, so off.
533
- * 7. Otherwise honor the static terminal capability.
537
+ * 8. Otherwise honor the static terminal capability.
534
538
  */
535
539
  export function shouldEnableHyperlinksByDefault(
536
540
  env: NodeJS.ProcessEnv = Bun.env,
@@ -539,6 +543,8 @@ export function shouldEnableHyperlinksByDefault(
539
543
  const override = hyperlinksUserOverride(env);
540
544
  if (override !== null) return override;
541
545
 
546
+ if (isInsideHerdr(env) && !env.STY && !env.TMUX) return true;
547
+
542
548
  if (!getTerminalInfo(terminalId).hyperlinks) return false;
543
549
 
544
550
  // STY is GNU screen's explicit session marker. It vetoes tmux enabling when
package/src/terminal.ts CHANGED
@@ -171,10 +171,14 @@ export const STDOUT_BACKLOG_CLEAR_BYTES = 256 * 1024;
171
171
  /**
172
172
  * How long an armed backlog may go without any drain progress before the
173
173
  * consumer is declared gone. A slow-but-alive terminal keeps reaching new
174
- * low-water marks (so it never trips); a wedged one that flushes nothing is
175
- * torn down within this window.
174
+ * low-water marks (so it never trips), but a live one can also stop reading
175
+ * for seconds at a time: a busy tmux server holding a slow client, or a
176
+ * container's attach stream. Waiting costs no memory, because frames are
177
+ * deferred while the backlog is up (`TUI.#deferRenderForOutputBacklog`), so the
178
+ * window is long enough to ride those out; a reader that never comes back is
179
+ * still torn down within it.
176
180
  */
177
- const STDOUT_STALL_TIMEOUT_MS = 2_000;
181
+ const STDOUT_STALL_TIMEOUT_MS = 60_000;
178
182
 
179
183
  /** Cadence at which {@link ProcessTerminal} re-samples the backlog while an episode is armed. */
180
184
  const STDOUT_STALL_POLL_MS = 250;
@@ -768,6 +772,10 @@ export class ProcessTerminal implements Terminal {
768
772
  // enqueues frames and performs the blocking write(2) on its own thread;
769
773
  // `pendingOutputBytes` exposes the backlog for render-side frame skipping.
770
774
  #outputPump?: TtyWriter;
775
+ // Upper bound on the pump's backlog: the count its last enqueue or read
776
+ // reported. Only #safeWrite enqueues and the pump thread only drains, so
777
+ // the live backlog cannot exceed this until the next enqueue refreshes it.
778
+ #pumpBacklogBound = 0;
771
779
 
772
780
  #windowsVTInputRestore?: () => void;
773
781
  #xtermScrollToBottomRestoreModes = new Set<number>();
@@ -808,6 +816,11 @@ export class ProcessTerminal implements Terminal {
808
816
  #reportedRows?: number;
809
817
  #mode2031DebounceTimer?: Timer;
810
818
  #windowsTerminalAppearancePollTimer?: Timer;
819
+ #progressActive = false;
820
+ // Ghostty expires OSC 9;4 state without a heartbeat. Persistent hosts such
821
+ // as Windows Terminal restart their indeterminate animation on every write.
822
+ readonly #keepProgressAlive = TERMINAL.id === "ghostty";
823
+ #bracketedPasteRefreshTimer?: Timer;
811
824
  #progressTimer?: Timer;
812
825
 
813
826
  constructor(options?: ProcessTerminalOptions) {
@@ -931,14 +944,14 @@ export class ProcessTerminal implements Terminal {
931
944
  if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
932
945
  try {
933
946
  this.#outputPump = new TtyWriter(1);
947
+ this.#pumpBacklogBound = 0;
934
948
  } catch (err) {
935
949
  logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
936
950
  }
937
951
  }
938
952
 
939
- // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
940
- // the viewport while we own the terminal; released in stop(). See
941
- // stderr-guard in pi-utils (mirrors openai/codex#24459).
953
+ // Keep unmanaged native fd-2 writes off the viewport while we own the
954
+ // terminal; released in stop(). See stderr-guard in pi-utils.
942
955
  suppressTerminalStderr();
943
956
 
944
957
  // Set up resize handler immediately. The OS refreshes process.stdout
@@ -1765,7 +1778,15 @@ export class ProcessTerminal implements Terminal {
1765
1778
  // heuristic pure downside — turn it off so stall-batched keystrokes are
1766
1779
  // not misread as a paste (#12540). `supported` is only true here after an
1767
1780
  // explicit DECRPM reply (the DA1-sentinel fallback resolves unsupported).
1768
- if (mode === 2004 && supported) this.#stdinBuffer?.setRawPasteClassification(false);
1781
+ if (mode === 2004 && supported) {
1782
+ this.#stdinBuffer?.setRawPasteClassification(false);
1783
+ // A terminal can reset this mode after the initial probe (for example,
1784
+ // iTerm2's Terminal State toggle). Keep the mode asserted while we own
1785
+ // the TTY, since the raw fallback is disabled after confirmation.
1786
+ this.#bracketedPasteRefreshTimer ??= setInterval(() => {
1787
+ if (this.#active && !this.#dead) this.#safeWrite("\x1b[?2004h");
1788
+ }, 1000);
1789
+ }
1769
1790
  }
1770
1791
 
1771
1792
  #syncWindowsTerminalAppearancePolling(mode2031Supported: boolean): void {
@@ -1897,6 +1918,10 @@ export class ProcessTerminal implements Terminal {
1897
1918
  // Suppress observer/timer callbacks before any teardown can yield or throw.
1898
1919
  this.#active = false;
1899
1920
  this.#inputDeferred = false;
1921
+ if (this.#bracketedPasteRefreshTimer) {
1922
+ clearInterval(this.#bracketedPasteRefreshTimer);
1923
+ this.#bracketedPasteRefreshTimer = undefined;
1924
+ }
1900
1925
  if (this.#headless) return;
1901
1926
  // Unregister from emergency cleanup
1902
1927
  if (activeTerminal === this) {
@@ -1908,7 +1933,9 @@ export class ProcessTerminal implements Terminal {
1908
1933
  // step throws.
1909
1934
  restoreTerminalStderr();
1910
1935
 
1911
- if (this.#clearProgressTimer()) {
1936
+ this.#clearProgressTimer();
1937
+ if (this.#progressActive) {
1938
+ this.#progressActive = false;
1912
1939
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1913
1940
  }
1914
1941
 
@@ -2061,6 +2088,10 @@ export class ProcessTerminal implements Terminal {
2061
2088
  #markTerminalDisconnected(reason: string, err?: unknown): void {
2062
2089
  if (this.#dead) return;
2063
2090
  this.#dead = true;
2091
+ if (this.#bracketedPasteRefreshTimer) {
2092
+ clearInterval(this.#bracketedPasteRefreshTimer);
2093
+ this.#bracketedPasteRefreshTimer = undefined;
2094
+ }
2064
2095
  this.#disarmStdoutStallWatchdog();
2065
2096
  logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
2066
2097
 
@@ -2109,19 +2140,27 @@ export class ProcessTerminal implements Terminal {
2109
2140
  this.#trackCursorVisibility(data);
2110
2141
  const pump = this.#outputPump;
2111
2142
  if (pump) {
2112
- if (pump.dead) {
2113
- this.#markTerminalDisconnected("stdout failed; output pump died");
2114
- return;
2115
- }
2143
+ let pending: number;
2116
2144
  try {
2117
- // Feed the live backlog to the stall watchdog rather than tripping on
2118
- // the instantaneous byte count: a single large-but-draining frame (a
2119
- // resume repaint of many inline images) must open normally, while a
2120
- // never-draining reader is still torn down (#6854, #10430).
2121
- this.#trackStdoutBacklog(pump.write(data));
2145
+ pending = pump.write(data);
2122
2146
  } catch (err) {
2123
2147
  this.#markTerminalDisconnected("stdout failed", err);
2148
+ return;
2124
2149
  }
2150
+ // A live enqueue reports at least this chunk's UTF-8 size, never below
2151
+ // its UTF-16 length; a dead pump enqueues nothing and reports only the
2152
+ // remainder it is dropping (soon zero). Only a report that small can
2153
+ // come from a dead pump, so the native `dead` read is skipped otherwise.
2154
+ if ((pending < data.length || data.length === 0) && pump.dead) {
2155
+ this.#markTerminalDisconnected("stdout failed; output pump died");
2156
+ return;
2157
+ }
2158
+ this.#pumpBacklogBound = pending;
2159
+ // Feed the live backlog to the stall watchdog rather than tripping on
2160
+ // the instantaneous byte count: a single large-but-draining frame (a
2161
+ // resume repaint of many inline images) must open normally, while a
2162
+ // never-draining reader is still torn down (#6854, #10430).
2163
+ this.#trackStdoutBacklog(pending);
2125
2164
  return;
2126
2165
  }
2127
2166
  // A console-sharing child process may have flipped the console codepage
@@ -2164,8 +2203,19 @@ export class ProcessTerminal implements Terminal {
2164
2203
  if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
2165
2204
  return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
2166
2205
  }
2206
+ /**
2207
+ * With the output pump, a backlog bound at or below
2208
+ * {@link STDOUT_BACKLOG_CLEAR_BYTES} is reported as-is instead of re-read:
2209
+ * both consumers (the render gate and the stall watchdog) act only on a
2210
+ * backlog above that level, so the bound already decides them and spares a
2211
+ * native read on every frame.
2212
+ */
2167
2213
  get pendingOutputBytes(): number {
2168
- if (this.#outputPump) return this.#outputPump.pending();
2214
+ const pump = this.#outputPump;
2215
+ if (pump) {
2216
+ if (this.#pumpBacklogBound > STDOUT_BACKLOG_CLEAR_BYTES) this.#pumpBacklogBound = pump.pending();
2217
+ return this.#pumpBacklogBound;
2218
+ }
2169
2219
  // Stream fallback: bytes queued past the high-water mark by refused writes.
2170
2220
  return process.stdout.writableLength ?? 0;
2171
2221
  }
@@ -2270,7 +2320,9 @@ export class ProcessTerminal implements Terminal {
2270
2320
  if (final === 0x68 /* h */ || final === 0x6c /* l */) break;
2271
2321
  idx = idx === 0 ? -1 : data.lastIndexOf("\x1b[?25", idx - 1);
2272
2322
  }
2273
- if (data.lastIndexOf("\x1b[?1049") > idx) {
2323
+ // Only a switch after the last cursor sequence matters: search that tail
2324
+ // rather than the whole frame.
2325
+ if (data.indexOf("\x1b[?1049", idx + 1) !== -1) {
2274
2326
  this.#cursorVisible = undefined;
2275
2327
  return;
2276
2328
  }
@@ -2297,14 +2349,17 @@ export class ProcessTerminal implements Terminal {
2297
2349
  setProgress(active: boolean): void {
2298
2350
  if (this.#headless) return;
2299
2351
  if (active) {
2352
+ if (this.#progressActive) return;
2353
+ this.#progressActive = true;
2300
2354
  this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
2301
- if (!this.#progressTimer) {
2355
+ if (this.#keepProgressAlive && !this.#progressTimer) {
2302
2356
  this.#progressTimer = setInterval(() => {
2303
2357
  this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
2304
2358
  }, TERMINAL_PROGRESS_KEEPALIVE_MS);
2305
2359
  this.#progressTimer.unref?.();
2306
2360
  }
2307
2361
  } else {
2362
+ this.#progressActive = false;
2308
2363
  this.#clearProgressTimer();
2309
2364
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
2310
2365
  }
@@ -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
 
@@ -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 {
@@ -58,7 +58,8 @@ export interface DiagnosticMeta {
58
58
  */
59
59
  export interface LimitsMeta {
60
60
  matchLimit?: { reached: number; suggestion: number };
61
- resultLimit?: { reached: number; suggestion: number };
61
+ /** `suggestion` is omitted when the tool is already at its hard cap, so no larger usable limit exists to advise. */
62
+ resultLimit?: { reached: number; suggestion?: number };
62
63
  headLimit?: { reached: number; suggestion: number };
63
64
  /** `unit` may be absent in sessions persisted before it was recorded. */
64
65
  columnTruncated?: { maxColumn: number; unit?: "bytes" | "chars"; artifactId?: string };
@@ -187,7 +188,7 @@ function isGeneratedOutputNoticeLine(line: string): boolean {
187
188
  return (
188
189
  body.startsWith("Showing ") ||
189
190
  /^\d+ matches limit reached\. Use limit=\d+ for more/u.test(body) ||
190
- /^\d+ results limit reached\. Use limit=\d+ for more/u.test(body) ||
191
+ /^\d+ results limit reached(?:\.|$)/u.test(body) ||
191
192
  body.startsWith("Some lines truncated to ")
192
193
  );
193
194
  }
@@ -300,7 +301,14 @@ export function formatOutputNotice(meta: OutputMeta | undefined): string {
300
301
  }
301
302
  if (meta.limits?.resultLimit) {
302
303
  const l = meta.limits.resultLimit;
303
- parts.push(`${l.reached} results limit reached. Use limit=${l.suggestion} for more`);
304
+ // At the tool's hard cap there is no larger usable limit, so the
305
+ // "Use limit=" advice would name a value that gets clamped right
306
+ // back — emit the bare reached notice instead (#13263).
307
+ parts.push(
308
+ l.suggestion === undefined
309
+ ? `${l.reached} results limit reached`
310
+ : `${l.reached} results limit reached. Use limit=${l.suggestion} for more`,
311
+ );
304
312
  }
305
313
  if (meta.limits?.headLimit) {
306
314
  const l = meta.limits.headLimit;