@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.
- package/CHANGELOG.md +14 -0
- package/dist/types/chrome/transcript-container.d.ts +15 -1
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/rewind-selector.d.ts +2 -1
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/render/width-aware-text.d.ts +6 -0
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/tool-execution.ts +24 -4
- package/src/chrome/transcript-container.ts +76 -14
- package/src/components/editor.ts +28 -6
- package/src/components/image.ts +15 -7
- package/src/components/loader.ts +37 -17
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/copy-selector.ts +5 -0
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/rewind-selector.ts +10 -2
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer.ts +5 -2
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/render/width-aware-text.ts +9 -0
- package/src/status-line/component.ts +213 -132
- package/src/status-line/segments.ts +34 -29
- package/src/terminal.ts +37 -11
- package/src/theme/color.ts +15 -0
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/tui.ts +142 -112
- package/src/utils.ts +15 -0
package/src/terminal.ts
CHANGED
|
@@ -772,6 +772,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
772
772
|
// enqueues frames and performs the blocking write(2) on its own thread;
|
|
773
773
|
// `pendingOutputBytes` exposes the backlog for render-side frame skipping.
|
|
774
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;
|
|
775
779
|
|
|
776
780
|
#windowsVTInputRestore?: () => void;
|
|
777
781
|
#xtermScrollToBottomRestoreModes = new Set<number>();
|
|
@@ -940,6 +944,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
940
944
|
if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
|
|
941
945
|
try {
|
|
942
946
|
this.#outputPump = new TtyWriter(1);
|
|
947
|
+
this.#pumpBacklogBound = 0;
|
|
943
948
|
} catch (err) {
|
|
944
949
|
logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
|
|
945
950
|
}
|
|
@@ -2135,19 +2140,27 @@ export class ProcessTerminal implements Terminal {
|
|
|
2135
2140
|
this.#trackCursorVisibility(data);
|
|
2136
2141
|
const pump = this.#outputPump;
|
|
2137
2142
|
if (pump) {
|
|
2138
|
-
|
|
2139
|
-
this.#markTerminalDisconnected("stdout failed; output pump died");
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2143
|
+
let pending: number;
|
|
2142
2144
|
try {
|
|
2143
|
-
|
|
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));
|
|
2145
|
+
pending = pump.write(data);
|
|
2148
2146
|
} catch (err) {
|
|
2149
2147
|
this.#markTerminalDisconnected("stdout failed", err);
|
|
2148
|
+
return;
|
|
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;
|
|
2150
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);
|
|
2151
2164
|
return;
|
|
2152
2165
|
}
|
|
2153
2166
|
// A console-sharing child process may have flipped the console codepage
|
|
@@ -2190,8 +2203,19 @@ export class ProcessTerminal implements Terminal {
|
|
|
2190
2203
|
if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
|
|
2191
2204
|
return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
|
|
2192
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
|
+
*/
|
|
2193
2213
|
get pendingOutputBytes(): number {
|
|
2194
|
-
|
|
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
|
+
}
|
|
2195
2219
|
// Stream fallback: bytes queued past the high-water mark by refused writes.
|
|
2196
2220
|
return process.stdout.writableLength ?? 0;
|
|
2197
2221
|
}
|
|
@@ -2296,7 +2320,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
2296
2320
|
if (final === 0x68 /* h */ || final === 0x6c /* l */) break;
|
|
2297
2321
|
idx = idx === 0 ? -1 : data.lastIndexOf("\x1b[?25", idx - 1);
|
|
2298
2322
|
}
|
|
2299
|
-
|
|
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) {
|
|
2300
2326
|
this.#cursorVisible = undefined;
|
|
2301
2327
|
return;
|
|
2302
2328
|
}
|
package/src/theme/color.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/theme/shimmer.ts
CHANGED
|
@@ -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
|
|
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(
|
|
118
|
-
const
|
|
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(
|
|
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
|
-
//
|
|
185
|
-
//
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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
|
|
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 =
|
|
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;
|
package/src/theme/theme-class.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
301
|
-
if (
|
|
302
|
-
|
|
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
|
|
352
|
+
return boldStyler(text);
|
|
336
353
|
}
|
|
337
354
|
|
|
338
355
|
italic(text: string): string {
|
|
339
|
-
return
|
|
356
|
+
return italicStyler(text);
|
|
340
357
|
}
|
|
341
358
|
|
|
342
359
|
underline(text: string): string {
|
|
343
|
-
return
|
|
360
|
+
return underlineStyler(text);
|
|
344
361
|
}
|
|
345
362
|
|
|
346
363
|
strikethrough(text: string): string {
|
|
347
|
-
return
|
|
364
|
+
return strikethroughStyler(text);
|
|
348
365
|
}
|
|
349
366
|
|
|
350
367
|
inverse(text: string): string {
|
|
351
|
-
return
|
|
368
|
+
return inverseStyler(text);
|
|
352
369
|
}
|
|
353
370
|
|
|
354
371
|
getFgAnsi(color: ThemeColor): string {
|