@underactive/pi-topping-statusline 0.1.0

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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * TokenRateMonitor's event pipeline: message_update/message_end driving the
3
+ * live EMA sampler, independent of the segment's display formatting (see
4
+ * token-rate.test.ts).
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { TokenRateMonitor } from "../token-rate.ts";
10
+
11
+ function createFakePi(): { pi: ExtensionAPI; emit: (event: string, payload?: unknown) => void } {
12
+ const handlers = new Map<string, (payload: unknown) => void>();
13
+ const pi = {
14
+ on: (event: string, handler: (payload: unknown) => void) => {
15
+ handlers.set(event, handler);
16
+ },
17
+ } as unknown as ExtensionAPI;
18
+ return { pi, emit: (event, payload) => handlers.get(event)?.(payload) };
19
+ }
20
+
21
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
22
+ const textDelta = (delta: string) => ({ assistantMessageEvent: { type: "text_delta", delta } });
23
+
24
+ test("idle before any turn starts", () => {
25
+ const monitor = new TokenRateMonitor(() => {});
26
+ monitor.enable();
27
+ assert.equal(monitor.getDisplay().phase, "idle");
28
+ assert.equal(monitor.getDisplay().rate, null);
29
+ });
30
+
31
+ test("streamed text deltas build a live rate once the sampler ticks", async () => {
32
+ const { pi, emit } = createFakePi();
33
+ const monitor = new TokenRateMonitor(() => {});
34
+ monitor.attach(pi);
35
+ monitor.enable();
36
+ emit("agent_start");
37
+ emit("message_start", { message: { role: "assistant" } });
38
+ for (let i = 0; i < 3; i++) {
39
+ emit("message_update", textDelta("one two three four five"));
40
+ await sleep(120);
41
+ }
42
+ const display = monitor.getDisplay();
43
+ assert.equal(display.phase, "active");
44
+ assert.ok(typeof display.rate === "number" && display.rate > 0);
45
+ monitor.disable();
46
+ });
47
+
48
+ test("message_end reconciles against the exact usage.output, then the held rate fades to idle", async () => {
49
+ const { pi, emit } = createFakePi();
50
+ const monitor = new TokenRateMonitor(() => {});
51
+ monitor.attach(pi);
52
+ monitor.enable();
53
+ emit("agent_start");
54
+ emit("message_start", { message: { role: "assistant" } });
55
+ for (let i = 0; i < 3; i++) {
56
+ emit("message_update", textDelta("one two three four five"));
57
+ await sleep(120);
58
+ }
59
+ emit("message_end", { message: { role: "assistant", usage: { output: 42 } } });
60
+ emit("agent_settled");
61
+ assert.equal(monitor.getDisplay().phase, "active");
62
+ await sleep(2200);
63
+ assert.equal(monitor.getDisplay().phase, "idle");
64
+ assert.equal(monitor.getDisplay().rate, null);
65
+ monitor.disable();
66
+ });
67
+
68
+ test("disable stops the sampler and returns to idle immediately", () => {
69
+ const { pi, emit } = createFakePi();
70
+ const monitor = new TokenRateMonitor(() => {});
71
+ monitor.attach(pi);
72
+ monitor.enable();
73
+ emit("agent_start");
74
+ monitor.disable();
75
+ assert.equal(monitor.getDisplay().phase, "idle");
76
+ });
77
+
78
+ test("events are ignored while the monitor is disabled", async () => {
79
+ const { pi, emit } = createFakePi();
80
+ const monitor = new TokenRateMonitor(() => {});
81
+ monitor.attach(pi);
82
+ emit("agent_start");
83
+ emit("message_start", { message: { role: "assistant" } });
84
+ emit("message_update", textDelta("one two three four five"));
85
+ await sleep(150);
86
+ assert.equal(monitor.getDisplay().phase, "idle");
87
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Display contract for the token_rate segment. The badge is always visible
3
+ * once enabled, so its width has to stay constant: the rate is right-aligned
4
+ * to the width of the `---` placeholder, otherwise dropping from 150 to 9
5
+ * tok/s would shift whatever sits beside it on every frame.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import test from "node:test";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { SegmentContextBuilder } from "../context.ts";
11
+ import { SEGMENTS } from "../segments.ts";
12
+ import { resolveEffectiveSettings } from "../settings.ts";
13
+ import { FADE_SHADE_COUNT, theme } from "../theme.ts";
14
+ import type { TokenRateDisplay } from "../types.ts";
15
+
16
+ const fakePi = { exec: async () => undefined, getThinkingLevel: () => "off" } as unknown as ExtensionAPI;
17
+ const fakeCtx = {
18
+ cwd: "/tmp/pi-topping-statusline-tests",
19
+ sessionManager: { getEntries: () => [], getSessionName: () => undefined },
20
+ getContextUsage: () => undefined,
21
+ model: undefined,
22
+ } as unknown as ExtensionContext;
23
+
24
+ const builder = new SegmentContextBuilder(fakePi);
25
+ builder.attach(fakeCtx);
26
+ const BASE = builder.build(
27
+ 80,
28
+ resolveEffectiveSettings({}).segmentOptions,
29
+ { git: false, pr: false, piStats: false, tokenRate: true, feeds: [] },
30
+ undefined,
31
+ );
32
+
33
+ const render = (tokenRate: TokenRateDisplay) => SEGMENTS.token_rate.render({ ...BASE, tokenRate });
34
+ const plain = (tokenRate: TokenRateDisplay) => render(tokenRate).content.replace(/\x1b\[[0-9;]*m/g, "");
35
+ const active = (rate: number | null): TokenRateDisplay => ({ rate, phase: "active", fadeShade: 0 });
36
+
37
+ test("the rate is right-aligned to the placeholder's width", () => {
38
+ assert.equal(plain(active(150)), "150 tok/s");
39
+ assert.equal(plain(active(47)), " 47 tok/s");
40
+ assert.equal(plain(active(5)), " 5 tok/s");
41
+ assert.equal(plain(active(null)), "--- tok/s");
42
+ });
43
+
44
+ test("every state renders the same width, so neighbours never shift", () => {
45
+ const widths = new Set(
46
+ [
47
+ plain(active(150)),
48
+ plain(active(47)),
49
+ plain(active(5)),
50
+ plain(active(0)),
51
+ plain(active(null)),
52
+ plain({ rate: 8, phase: "fading", fadeShade: 2 }),
53
+ plain({ rate: null, phase: "idle", fadeShade: 0 }),
54
+ ].map(text => text.length),
55
+ );
56
+ assert.equal(widths.size, 1, `expected one width, got ${[...widths].join(", ")}`);
57
+ });
58
+
59
+ test("a four-digit rate grows rather than being truncated", () => {
60
+ assert.equal(plain(active(1200)), "1200 tok/s");
61
+ });
62
+
63
+ test("phase drives the colour: accent live, blended fading, dim idle", () => {
64
+ assert.ok(render(active(150)).content.includes(theme.getFgAnsi("accent")));
65
+ assert.ok(render({ rate: null, phase: "idle", fadeShade: 0 }).content.includes(theme.getFgAnsi("dim")));
66
+ // The fade interpolates, so it emits a colour of its own rather than either endpoint.
67
+ const fading = render({ rate: 150, phase: "fading", fadeShade: FADE_SHADE_COUNT - 1 });
68
+ assert.match(fading.content, /\x1b\[38;[25]/);
69
+ assert.equal(plain({ rate: 150, phase: "fading", fadeShade: 2 }), "150 tok/s");
70
+ });
71
+
72
+ test("the segment hides only when the monitor supplies nothing", () => {
73
+ assert.equal(SEGMENTS.token_rate.render({ ...BASE, tokenRate: undefined }).visible, false);
74
+ assert.equal(render(active(null)).visible, true, "idle still shows the placeholder");
75
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Node loader hook: resolve the source's `./foo.js` specifiers to `./foo.ts`.
3
+ *
4
+ * Node strips TypeScript natively, so the only gap when running these files
5
+ * under `node --test` is that the extension imports with the `.js` extensions
6
+ * TypeScript's Node16 resolution expects, and those files exist only as `.ts`.
7
+ * Rewriting the specifier here keeps the test runner dependency-free.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { registerHooks } from "node:module";
11
+
12
+ registerHooks({
13
+ resolve(specifier, context, nextResolve) {
14
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL) {
15
+ const candidate = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
16
+ if (existsSync(candidate)) return nextResolve(candidate.href, context);
17
+ }
18
+ return nextResolve(specifier, context);
19
+ },
20
+ });
package/src/theme.ts ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Statusline theme: symbol sets + palette ported from oh-my-pi.
3
+ *
4
+ * Symbols: packages/coding-agent/src/modes/theme/theme.ts (UNICODE/NERD/ASCII maps)
5
+ * Palette: packages/coding-agent/src/modes/theme/dark.json (statusLine* roles)
6
+ *
7
+ * Pi's Theme has no statusLine* color roles, so the OMP dark palette is
8
+ * embedded here. Values are hex strings (truecolor), 256-palette indices
9
+ * (numbers), or "" for the terminal default color.
10
+ */
11
+
12
+ export type SymbolPreset = "unicode" | "nerd" | "ascii";
13
+
14
+ export type BorderStyle = "rounded" | "heavy" | "double" | "single";
15
+
16
+ export interface BoxGlyphs {
17
+ topLeft: string;
18
+ topRight: string;
19
+ bottomLeft: string;
20
+ bottomRight: string;
21
+ horizontal: string;
22
+ vertical: string;
23
+ }
24
+
25
+ export interface StatusSymbols {
26
+ sep: {
27
+ powerlineLeft: string;
28
+ powerlineRight: string;
29
+ powerlineThinLeft: string;
30
+ powerlineThinRight: string;
31
+ halfCircleLeft: string;
32
+ halfCircleRight: string;
33
+ block: string;
34
+ space: string;
35
+ asciiLeft: string;
36
+ asciiRight: string;
37
+ dot: string;
38
+ slash: string;
39
+ pipe: string;
40
+ };
41
+ icon: {
42
+ pi: string;
43
+ model: string;
44
+ folder: string;
45
+ scratchFolder: string;
46
+ worktree: string;
47
+ git: string;
48
+ branch: string;
49
+ pr: string;
50
+ tokens: string;
51
+ context: string;
52
+ cost: string;
53
+ time: string;
54
+ ghost: string;
55
+ agents: string;
56
+ job: string;
57
+ cache: string;
58
+ input: string;
59
+ output: string;
60
+ throughput: string;
61
+ host: string;
62
+ session: string;
63
+ auto: string;
64
+ fast: string;
65
+ };
66
+ thinking: {
67
+ minimal: string;
68
+ low: string;
69
+ medium: string;
70
+ high: string;
71
+ xhigh: string;
72
+ max: string;
73
+ autoPending: string;
74
+ };
75
+ }
76
+
77
+ const UNICODE_SYMBOLS: StatusSymbols = {
78
+ sep: {
79
+ powerlineLeft: "▶",
80
+ powerlineRight: "◀",
81
+ powerlineThinLeft: ">",
82
+ powerlineThinRight: "<",
83
+ halfCircleLeft: "",
84
+ halfCircleRight: "",
85
+ block: "▌",
86
+ space: " ",
87
+ asciiLeft: ">",
88
+ asciiRight: "<",
89
+ dot: " · ",
90
+ slash: " / ",
91
+ pipe: " │ ",
92
+ },
93
+ icon: {
94
+ pi: "π",
95
+ model: "⬢",
96
+ folder: "📁",
97
+ scratchFolder: "🗑",
98
+ worktree: "🌳",
99
+ git: "⎇",
100
+ branch: "⑂",
101
+ pr: "⤴",
102
+ tokens: "🪙",
103
+ context: "◫",
104
+ cost: "💲",
105
+ time: "⏱",
106
+ ghost: "👻",
107
+ agents: "👥",
108
+ job: "⚙",
109
+ cache: "💾",
110
+ input: "⤵",
111
+ output: "⤴",
112
+ throughput: "⚡",
113
+ host: "🖥",
114
+ session: "🆔",
115
+ auto: "⟲",
116
+ fast: "⚡",
117
+ },
118
+ thinking: {
119
+ minimal: "○ min",
120
+ low: "◔ low",
121
+ medium: "◑ med",
122
+ high: "◒ high",
123
+ xhigh: "◕ xhigh",
124
+ max: "◉ max",
125
+ autoPending: "⟳",
126
+ },
127
+ };
128
+
129
+ const NERD_SYMBOLS: StatusSymbols = {
130
+ sep: {
131
+ powerlineLeft: "\ue0b0",
132
+ powerlineRight: "\ue0b2",
133
+ powerlineThinLeft: "\ue0b1",
134
+ powerlineThinRight: "\ue0b3",
135
+ halfCircleLeft: "\ue0b6",
136
+ halfCircleRight: "\ue0b4",
137
+ block: "█",
138
+ space: " ",
139
+ asciiLeft: ">",
140
+ asciiRight: "<",
141
+ dot: " · ",
142
+ slash: "\ue0bb",
143
+ pipe: "│",
144
+ },
145
+ icon: {
146
+ pi: "\ue22c",
147
+ model: "\uec19",
148
+ folder: "\uf115",
149
+ scratchFolder: "\uf014",
150
+ worktree: "\uf0e8",
151
+ git: "\uf1d3",
152
+ branch: "\uf126",
153
+ pr: "\uea64",
154
+ tokens: "\ue26b",
155
+ context: "\ue70f",
156
+ cost: "\uf155",
157
+ time: "\uf017",
158
+ ghost: "\u{f02a0}",
159
+ agents: "\uf0c0",
160
+ job: "\uf013",
161
+ cache: "\uf1c0",
162
+ input: "\uf090",
163
+ output: "\uf08b",
164
+ throughput: "⚡",
165
+ host: "\uf109",
166
+ session: "\u{f0051}",
167
+ auto: "\u{f0068}",
168
+ fast: "\uf0e7",
169
+ },
170
+ thinking: {
171
+ minimal: "\u{f0a9e} min",
172
+ low: "\u{f0a9f} low",
173
+ medium: "\u{f0aa1} med",
174
+ high: "\u{f0aa3} high",
175
+ xhigh: "\u{f0aa5} xhigh",
176
+ max: "\u{f06d} max",
177
+ autoPending: "\u{f074}",
178
+ },
179
+ };
180
+
181
+ const ASCII_SYMBOLS: StatusSymbols = {
182
+ sep: {
183
+ powerlineLeft: ">",
184
+ powerlineRight: "<",
185
+ powerlineThinLeft: ">",
186
+ powerlineThinRight: "<",
187
+ halfCircleLeft: "",
188
+ halfCircleRight: "",
189
+ block: "#",
190
+ space: " ",
191
+ asciiLeft: ">",
192
+ asciiRight: "<",
193
+ dot: " - ",
194
+ slash: " / ",
195
+ pipe: " | ",
196
+ },
197
+ icon: {
198
+ pi: "pi",
199
+ model: "[M]",
200
+ folder: "[D]",
201
+ scratchFolder: "[T]",
202
+ worktree: "[wt]",
203
+ git: "git:",
204
+ branch: "@",
205
+ pr: "PR",
206
+ tokens: "tok:",
207
+ context: "ctx:",
208
+ cost: "$",
209
+ time: "t:",
210
+ ghost: "@",
211
+ agents: "AG",
212
+ job: "bg",
213
+ cache: "cache",
214
+ input: "in:",
215
+ output: "out:",
216
+ throughput: "tok/s:",
217
+ host: "host",
218
+ session: "id",
219
+ auto: "[A]",
220
+ fast: ">>",
221
+ },
222
+ thinking: {
223
+ minimal: "[min]",
224
+ low: "[low]",
225
+ medium: "[med]",
226
+ high: "[high]",
227
+ xhigh: "[xhigh]",
228
+ max: "[max]",
229
+ autoPending: "[~]",
230
+ },
231
+ };
232
+
233
+ const SYMBOL_PRESETS: Record<SymbolPreset, StatusSymbols> = {
234
+ unicode: UNICODE_SYMBOLS,
235
+ nerd: NERD_SYMBOLS,
236
+ ascii: ASCII_SYMBOLS,
237
+ };
238
+
239
+ const ASCII_BOX: BoxGlyphs = { topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", horizontal: "-", vertical: "|" };
240
+
241
+ /** Same glyphs as pi-topping's User Prompt border style. */
242
+ const BOX_STYLES: Record<BorderStyle, BoxGlyphs> = {
243
+ rounded: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" },
244
+ heavy: { topLeft: "┏", topRight: "┓", bottomLeft: "┗", bottomRight: "┛", horizontal: "━", vertical: "┃" },
245
+ double: { topLeft: "╔", topRight: "╗", bottomLeft: "╚", bottomRight: "╝", horizontal: "═", vertical: "║" },
246
+ single: { topLeft: "┌", topRight: "┐", bottomLeft: "└", bottomRight: "┘", horizontal: "─", vertical: "│" },
247
+ };
248
+
249
+ // ═══════════════════════════════════════════════════════════════════════════
250
+ // Palette (OMP dark.json)
251
+ // ═══════════════════════════════════════════════════════════════════════════
252
+
253
+ type ColorValue = string | number;
254
+
255
+ export type StatusColor =
256
+ | "accent"
257
+ | "success"
258
+ | "error"
259
+ | "warning"
260
+ | "muted"
261
+ | "dim"
262
+ | "text"
263
+ | "thinkingHigh"
264
+ | "statusLineSep"
265
+ | "statusLineModel"
266
+ | "statusLinePath"
267
+ | "statusLineGitClean"
268
+ | "statusLineGitDirty"
269
+ | "statusLineContext"
270
+ | "statusLineContextElevated"
271
+ | "statusLineSpend"
272
+ | "statusLineStaged"
273
+ | "statusLineDirty"
274
+ | "statusLineUntracked"
275
+ | "statusLineOutput"
276
+ | "statusLineCost"
277
+ | "statusLineSubagents";
278
+
279
+ const FG_COLORS: Record<StatusColor, ColorValue> = {
280
+ accent: "#febc38",
281
+ success: "#89d281",
282
+ error: "#fc3a4b",
283
+ warning: "#e4c00f",
284
+ muted: "#777d88",
285
+ dim: "#5f6673",
286
+ text: "",
287
+ thinkingHigh: "#b281d6",
288
+ statusLineSep: 244,
289
+ statusLineModel: "#d787af",
290
+ statusLinePath: "#00afaf",
291
+ statusLineGitClean: "#5faf5f",
292
+ statusLineGitDirty: "#d7af5f",
293
+ statusLineContext: "#8787af",
294
+ statusLineContextElevated: "#e07a1f",
295
+ statusLineSpend: "#5fafaf",
296
+ statusLineStaged: 70,
297
+ statusLineDirty: 178,
298
+ statusLineUntracked: 39,
299
+ statusLineOutput: 205,
300
+ statusLineCost: 205,
301
+ statusLineSubagents: "#febc38",
302
+ };
303
+
304
+ const STATUS_LINE_BG: ColorValue = "#121212";
305
+
306
+ /** Dominant palette hexes the session accent must not collide with. */
307
+ export const MAJOR_COLOR_HEXES = [
308
+ "#febc38",
309
+ "#89d281",
310
+ "#fc3a4b",
311
+ "#e4c00f",
312
+ "#b281d6",
313
+ "#d787af",
314
+ "#00afaf",
315
+ ];
316
+
317
+ export type ColorMode = "truecolor" | "256color";
318
+
319
+ export function detectColorMode(): ColorMode {
320
+ const colorterm = process.env.COLORTERM;
321
+ if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
322
+ if (process.env.WT_SESSION) return "truecolor";
323
+ const term = process.env.TERM || "";
324
+ if (term === "dumb" || term === "" || term === "linux") return "256color";
325
+ return "truecolor";
326
+ }
327
+
328
+ export function parseHex(hex: string): [number, number, number] | undefined {
329
+ const match = /^#?([0-9a-f]{6})$/i.exec(hex);
330
+ if (!match) return undefined;
331
+ const n = Number.parseInt(match[1], 16);
332
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
333
+ }
334
+
335
+ /** Number of discrete, eased shades used for the token-rate fade (pi-topping). */
336
+ export const FADE_SHADE_COUNT = 5;
337
+
338
+ /** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
339
+ function xterm256ToRgb(n: number): [number, number, number] {
340
+ if (n >= 232) {
341
+ const v = 8 + (n - 232) * 10;
342
+ return [v, v, v];
343
+ }
344
+ const i = Math.max(0, n - 16);
345
+ const c = (v: number) => (v === 0 ? 0 : 55 + v * 51);
346
+ return [c(Math.floor(i / 36) % 6), c(Math.floor(i / 6) % 6), c(i % 6)];
347
+ }
348
+
349
+ function colorValueToRgb(color: ColorValue): [number, number, number] {
350
+ if (typeof color === "number") return xterm256ToRgb(color);
351
+ return parseHex(color) ?? [0, 0, 0];
352
+ }
353
+
354
+ function rgbTo256(r: number, g: number, b: number): number {
355
+ if (r === g && g === b) {
356
+ if (r < 8) return 16;
357
+ if (r > 248) return 231;
358
+ return 232 + Math.round(((r - 8) / 247) * 24);
359
+ }
360
+ const scale = (v: number) => Math.round((v / 255) * 5);
361
+ return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
362
+ }
363
+
364
+ export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
365
+ const rgb = parseHex(hex);
366
+ if (!rgb) return "";
367
+ const [r, g, b] = rgb;
368
+ return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
369
+ }
370
+
371
+ function fgAnsi(color: ColorValue, mode: ColorMode): string {
372
+ if (color === "") return "\x1b[39m";
373
+ if (typeof color === "number") return `\x1b[38;5;${color}m`;
374
+ return hexToFgAnsi(color, mode);
375
+ }
376
+
377
+ function bgAnsi(color: ColorValue, mode: ColorMode): string {
378
+ if (color === "") return "\x1b[49m";
379
+ if (typeof color === "number") return `\x1b[48;5;${color}m`;
380
+ return fgAnsi(color, mode).replace("\x1b[38;", "\x1b[48;");
381
+ }
382
+
383
+ // ═══════════════════════════════════════════════════════════════════════════
384
+ // Theme singleton
385
+ // ═══════════════════════════════════════════════════════════════════════════
386
+
387
+ class StatusTheme {
388
+ #mode: ColorMode = detectColorMode();
389
+ #preset: SymbolPreset = "nerd";
390
+ #symbols: StatusSymbols = NERD_SYMBOLS;
391
+ #fgCache = new Map<StatusColor, string>();
392
+ #bgAnsi: string | undefined;
393
+
394
+ setSymbolPreset(preset: SymbolPreset): void {
395
+ this.#preset = preset;
396
+ this.#symbols = SYMBOL_PRESETS[preset];
397
+ }
398
+
399
+ get sep(): StatusSymbols["sep"] {
400
+ return this.#symbols.sep;
401
+ }
402
+
403
+ get icon(): StatusSymbols["icon"] {
404
+ return this.#symbols.icon;
405
+ }
406
+
407
+ get thinking(): StatusSymbols["thinking"] {
408
+ return this.#symbols.thinking;
409
+ }
410
+
411
+ /** The ascii preset overrides every border style with its plain-text box. */
412
+ getBox(style: BorderStyle): BoxGlyphs {
413
+ return this.#preset === "ascii" ? ASCII_BOX : BOX_STYLES[style];
414
+ }
415
+
416
+ getFgAnsi(color: StatusColor): string {
417
+ let ansi = this.#fgCache.get(color);
418
+ if (ansi === undefined) {
419
+ ansi = fgAnsi(FG_COLORS[color], this.#mode);
420
+ this.#fgCache.set(color, ansi);
421
+ }
422
+ return ansi;
423
+ }
424
+
425
+ getBgAnsi(): string {
426
+ if (this.#bgAnsi === undefined) this.#bgAnsi = bgAnsi(STATUS_LINE_BG, this.#mode);
427
+ return this.#bgAnsi;
428
+ }
429
+
430
+ fg(color: StatusColor, text: string): string {
431
+ return `${this.getFgAnsi(color)}${text}\x1b[39m`;
432
+ }
433
+
434
+ /**
435
+ * Render text in one fade shade between two palette roles, blending
436
+ * `from` toward `to` on an eased (raised-cosine) ramp. Ported from
437
+ * pi-topping's fadeWarningString, generalized over StatusColor pairs.
438
+ */
439
+ fadeFg(from: StatusColor, to: StatusColor, shade: number, text: string): string {
440
+ const clamped = Math.max(0, Math.min(FADE_SHADE_COUNT - 1, Math.floor(shade)));
441
+ const progress = (clamped + 1) / FADE_SHADE_COUNT;
442
+ const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
443
+ const a = colorValueToRgb(FG_COLORS[from]);
444
+ const b = colorValueToRgb(FG_COLORS[to]);
445
+ const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
446
+ const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
447
+ const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
448
+ const ansi =
449
+ this.#mode === "truecolor" ? `\x1b[38;2;${r};${g};${bl}m` : `\x1b[38;5;${rgbTo256(r, g, bl)}m`;
450
+ return `${ansi}${text}\x1b[39m`;
451
+ }
452
+ }
453
+
454
+ export const theme = new StatusTheme();