@underactive/pi-topping-statusline 0.2.7 → 0.3.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,179 @@
1
+ /**
2
+ * Host-level smoke for /topping-statusline-settings: the command mounts the
3
+ * shared menu from @underactive/pi-topping-primitives through ctx.ui.custom,
4
+ * applying persists the draft and cancelling leaves everything alone. The menu's
5
+ * own behaviour is covered by the package's menu-*.test.ts suites; only the
6
+ * component's public surface (handleInput, render, snapshot) is used here.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import test from "node:test";
13
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
15
+ import { MenuComponent } from "@underactive/pi-topping-primitives/menu";
16
+ import { SegmentContextBuilder } from "../context.ts";
17
+ import { stripAnsi } from "../footer.ts";
18
+ import { registerSettingsCommand } from "../settings-menu.ts";
19
+ import { createSettingsState, DEFAULT_FEEDS, settingsPath } from "../settings.ts";
20
+
21
+ const KEY = { up: "\x1b[A", down: "\x1b[B", right: "\x1b[C", space: " ", enter: "\r", escape: "\x1b" } as const;
22
+
23
+ const theme = {
24
+ fg: (_color: string, text: string) => text,
25
+ bg: (_color: string, text: string) => text,
26
+ bold: (text: string) => text,
27
+ getBgAnsi: () => "",
28
+ } as unknown as Theme;
29
+
30
+ type CommandHandler = (args: string, ctx: ExtensionCommandContext) => Promise<void> | void;
31
+ type OverlayFactory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: unknown) => void) => Component;
32
+
33
+ interface Host {
34
+ handler: CommandHandler;
35
+ ctx: ExtensionCommandContext;
36
+ /** The component the command mounted, once it has. */
37
+ mounted: () => MenuComponent;
38
+ overlayOptions: () => unknown;
39
+ notices: string[];
40
+ changes: () => number;
41
+ settings: () => ReturnType<typeof createSettingsState>["settings"];
42
+ dispose: () => void;
43
+ }
44
+
45
+ /** Register the real command against fakes and expose what the command mounts. */
46
+ function host(): Host {
47
+ const commands = new Map<string, CommandHandler>();
48
+ const fakePi = {
49
+ registerCommand: (name: string, options: { handler: CommandHandler }) => commands.set(name, options.handler),
50
+ exec: async () => undefined,
51
+ getThinkingLevel: () => "off",
52
+ } as unknown as ExtensionAPI;
53
+ const state = createSettingsState();
54
+ const builder = new SegmentContextBuilder(fakePi);
55
+ let changes = 0;
56
+ registerSettingsCommand(fakePi, state, builder, () => changes++);
57
+ const handler = commands.get("topping-statusline-settings");
58
+ assert.ok(handler, "the command must be registered");
59
+
60
+ let mounted: Component | undefined;
61
+ let overlayOptions: unknown;
62
+ const notices: string[] = [];
63
+ const tui = { terminal: { rows: 40, columns: 100 }, requestRender: () => {} } as unknown as TUI;
64
+ const ctx = {
65
+ mode: "tui",
66
+ ui: {
67
+ theme,
68
+ notify: (message: string) => notices.push(message),
69
+ custom: (factory: OverlayFactory, options: unknown) =>
70
+ new Promise(resolve => {
71
+ overlayOptions = options;
72
+ mounted = factory(tui, theme, {} as KeybindingsManager, resolve);
73
+ }),
74
+ },
75
+ } as unknown as ExtensionCommandContext;
76
+
77
+ return {
78
+ handler,
79
+ ctx,
80
+ mounted: () => {
81
+ assert.ok(mounted instanceof MenuComponent, "the command must mount the shared MenuComponent");
82
+ return mounted;
83
+ },
84
+ overlayOptions: () => overlayOptions,
85
+ notices,
86
+ changes: () => changes,
87
+ settings: () => state.settings,
88
+ dispose: () => builder.dispose(),
89
+ };
90
+ }
91
+
92
+ function withTempAgentDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
93
+ const dir = mkdtempSync(join(tmpdir(), "statusline-menu-"));
94
+ const previous = process.env.PI_CODING_AGENT_DIR;
95
+ process.env.PI_CODING_AGENT_DIR = dir;
96
+ return run(dir).finally(() => {
97
+ if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
98
+ else process.env.PI_CODING_AGENT_DIR = previous;
99
+ rmSync(dir, { recursive: true, force: true });
100
+ });
101
+ }
102
+
103
+ const plain = (menu: MenuComponent, width = 80) => menu.render(width).map(stripAnsi);
104
+ const typeText = (menu: MenuComponent, text: string) => {
105
+ for (const ch of text) menu.handleInput(ch);
106
+ };
107
+
108
+ test("the settings command mounts the shared menu; adding a feed and applying saves it", () =>
109
+ withTempAgentDir(async () => {
110
+ const h = host();
111
+ try {
112
+ const run = h.handler("", h.ctx);
113
+ const menu = h.mounted();
114
+ assert.deepEqual(h.overlayOptions(), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%" } });
115
+
116
+ const frame = plain(menu);
117
+ assert.match(frame[0] ?? "", /Pi Topping Statusline: Settings/);
118
+ assert.ok(frame.some(line => line.includes("─ Preview ")), "the live preview section is present");
119
+ assert.ok(frame.some(line => /❯ \[■\] Transparent Segments\s+ON/.test(line)), "the selected row carries the ❯ marker");
120
+ assert.ok(!frame.some(line => /^║\s+> /.test(line)), "the old > marker is gone");
121
+ assert.ok(frame.some(line => line.includes("⏎ apply/edit")), "the command's own hints are shown");
122
+
123
+ // Up/down stop at the ends, so overshooting lands on the last row: "+ add feed".
124
+ const rows = menu.snapshot().rowCount;
125
+ for (let i = 0; i < rows; i++) menu.handleInput(KEY.down);
126
+ assert.equal(menu.snapshot().cursorId, "feed.add");
127
+ menu.handleInput(KEY.enter);
128
+ assert.equal(menu.snapshot().rowCount, rows + 5, "one feed is five rows");
129
+ assert.equal(menu.snapshot().cursorId, "feed.add", "the cursor keeps its row across the rebuild");
130
+
131
+ for (let i = 0; i < 5; i++) menu.handleInput(KEY.up);
132
+ assert.equal(menu.snapshot().cursorId, "feed.0.customType");
133
+ menu.handleInput(KEY.enter);
134
+ typeText(menu, "my-ext/tokens");
135
+ menu.handleInput(KEY.enter);
136
+ menu.handleInput(KEY.down);
137
+ assert.equal(menu.snapshot().cursorId, "feed.0.field");
138
+ menu.handleInput(KEY.enter);
139
+ typeText(menu, "count");
140
+ menu.handleInput(KEY.enter);
141
+ menu.handleInput(KEY.down);
142
+ menu.handleInput(KEY.down);
143
+ assert.equal(menu.snapshot().cursorId, "feed.0.format");
144
+ menu.handleInput(KEY.right); // currency -> number
145
+ assert.ok(plain(menu).some(line => line.includes("my-ext/tokens")), "the committed text shows in the row");
146
+
147
+ // Enter on a cycle row applies the whole menu (the menu has no buttons).
148
+ menu.handleInput(KEY.enter);
149
+ await run;
150
+
151
+ const added = { customType: "my-ext/tokens", field: "count", prefix: "", format: "number" };
152
+ assert.equal(h.changes(), 1);
153
+ assert.deepEqual(h.settings().feeds, [...DEFAULT_FEEDS, added]);
154
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath(), "utf8")).feeds, [...DEFAULT_FEEDS, added]);
155
+ assert.deepEqual(h.notices, []);
156
+ } finally {
157
+ h.dispose();
158
+ }
159
+ }));
160
+
161
+ test("escape resolves the command without applying the edited draft", () =>
162
+ withTempAgentDir(async () => {
163
+ const h = host();
164
+ try {
165
+ const run = h.handler("", h.ctx);
166
+ const menu = h.mounted();
167
+ menu.handleInput(KEY.space);
168
+ assert.equal(menu.snapshot().values.transparent, false, "the draft differs from the saved settings");
169
+ menu.handleInput(KEY.escape);
170
+ await run;
171
+
172
+ assert.equal(h.changes(), 0);
173
+ assert.deepEqual(h.settings(), {}, "cancelling leaves the loaded settings untouched");
174
+ assert.equal(existsSync(settingsPath()), false, "nothing is written on cancel");
175
+ assert.deepEqual(h.notices, []);
176
+ } finally {
177
+ h.dispose();
178
+ }
179
+ }));
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { loadSettings, saveSettings, settingsPath } from "../settings.ts";
7
+
8
+ function withAgentDir(run: (dir: string) => void): void {
9
+ const dir = mkdtempSync(join(tmpdir(), "statusline-settings-"));
10
+ const previous = process.env.PI_CODING_AGENT_DIR;
11
+ process.env.PI_CODING_AGENT_DIR = dir;
12
+ try {
13
+ run(dir);
14
+ } finally {
15
+ if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
16
+ else process.env.PI_CODING_AGENT_DIR = previous;
17
+ rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ }
20
+
21
+ test("the settings path follows PI_CODING_AGENT_DIR changes made after import", () => {
22
+ withAgentDir((first) => {
23
+ assert.equal(settingsPath(), join(first, "pi-topping-statusline", "settings.json"));
24
+ withAgentDir((second) => {
25
+ assert.equal(settingsPath(), join(second, "pi-topping-statusline", "settings.json"));
26
+ assert.equal(saveSettings({ transparent: true }).ok, true);
27
+ assert.equal(loadSettings().transparent, true);
28
+ });
29
+ });
30
+ });
31
+
32
+ test("saveSettings writes tab-indented JSON with a trailing newline, mode 0600", () => {
33
+ withAgentDir(() => {
34
+ assert.equal(saveSettings({ transparent: true, separator: "pipe" }).ok, true);
35
+ assert.equal(readFileSync(settingsPath(), "utf8"), '{\n\t"transparent": true,\n\t"separator": "pipe"\n}\n');
36
+ assert.equal(statSync(settingsPath()).mode & 0o777, 0o600);
37
+ });
38
+ });
39
+
40
+ test("saveSettings reports a failure instead of throwing", () => {
41
+ withAgentDir((dir) => {
42
+ writeFileSync(join(dir, "pi-topping-statusline"), "a file where the directory belongs");
43
+ const result = saveSettings({ transparent: true });
44
+ assert.equal(result.ok, false);
45
+ });
46
+ });
47
+
48
+ test("loadSettings ignores a corrupt or non-object file", () => {
49
+ withAgentDir(() => {
50
+ mkdirSync(join(settingsPath(), ".."), { recursive: true });
51
+ writeFileSync(settingsPath(), "[1,2]");
52
+ assert.deepEqual(loadSettings(), {});
53
+ writeFileSync(settingsPath(), "{nope");
54
+ assert.deepEqual(loadSettings(), {});
55
+ });
56
+ });
@@ -27,7 +27,7 @@ builder.attach(fakeCtx);
27
27
  const BASE = builder.build(
28
28
  80,
29
29
  resolveEffectiveSettings({}).segmentOptions,
30
- { git: false, pr: false, piStats: false, tokenRate: true, feeds: [] },
30
+ { git: false, pr: false, piStats: false, tokenRate: true, cacheHit: false, feeds: [] },
31
31
  undefined,
32
32
  );
33
33
 
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { WORKING_STATUS_SLIDE_MS } from "@underactive/pi-topping-primitives/contracts";
3
4
  import {
4
5
  STATUS_HOLD_MS,
5
- STATUS_SLIDE_MS,
6
6
  type StatusIndicatorKind,
7
7
  StatusTransition,
8
8
  WORKING_FADE_MS,
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  /** Show a status early enough that its slide-out has finished by `at`. */
12
12
  function settle(transition: StatusTransition, status: string, kind: StatusIndicatorKind | undefined, at: number): void {
13
- transition.resolve(status, kind, at - STATUS_SLIDE_MS);
13
+ transition.resolve(status, kind, at - WORKING_STATUS_SLIDE_MS);
14
14
  transition.resolve(status, kind, at);
15
15
  }
16
16
 
@@ -24,24 +24,24 @@ test("a status appearing over the user's segments slides out, then settles verba
24
24
  pending: true,
25
25
  });
26
26
  // Spinner frames change the text mid-slide without restarting it.
27
- const early = transition.resolve("⠹ Working", "working", 1_000 + STATUS_SLIDE_MS / 4).reveal ?? -1;
28
- const late = transition.resolve("⠸ Working", "working", 1_000 + (STATUS_SLIDE_MS * 3) / 4).reveal ?? -1;
27
+ const early = transition.resolve("⠹ Working", "working", 1_000 + WORKING_STATUS_SLIDE_MS / 4).reveal ?? -1;
28
+ const late = transition.resolve("⠸ Working", "working", 1_000 + (WORKING_STATUS_SLIDE_MS * 3) / 4).reveal ?? -1;
29
29
  assert.ok(0 < early && early < late && late < 1, "reveal grows monotonically");
30
- assert.equal(transition.pending(1_000 + STATUS_SLIDE_MS - 1), true);
30
+ assert.equal(transition.pending(1_000 + WORKING_STATUS_SLIDE_MS - 1), true);
31
31
 
32
- assert.deepEqual(transition.resolve("⠼ Working", "working", 1_000 + STATUS_SLIDE_MS), {
32
+ assert.deepEqual(transition.resolve("⠼ Working", "working", 1_000 + WORKING_STATUS_SLIDE_MS), {
33
33
  status: "⠼ Working",
34
34
  kind: "working",
35
35
  pending: false,
36
36
  });
37
- assert.equal(transition.pending(1_000 + STATUS_SLIDE_MS), false);
37
+ assert.equal(transition.pending(1_000 + WORKING_STATUS_SLIDE_MS), false);
38
38
  });
39
39
 
40
40
  test("a status cleared mid-slide keeps sliding through its hold", () => {
41
41
  const transition = new StatusTransition();
42
42
  transition.resolve("⠙ Working", "working", 0);
43
43
 
44
- const clearedAt = STATUS_SLIDE_MS / 4;
44
+ const clearedAt = WORKING_STATUS_SLIDE_MS / 4;
45
45
  const cleared = transition.resolve(undefined, undefined, clearedAt);
46
46
  const held = transition.resolve(undefined, undefined, clearedAt + STATUS_HOLD_MS / 2);
47
47
  assert.equal(held.status, "⠙ Working");
@@ -14,7 +14,7 @@ import { buildStatusLine } from "../layout.ts";
14
14
  import { SEGMENTS } from "../segments.ts";
15
15
  import { getSeparator } from "../separators.ts";
16
16
  import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
17
- import { easeFade, theme } from "../theme.ts";
17
+ import { theme } from "../theme.ts";
18
18
  import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
19
19
 
20
20
  const noGap = (s: string) => s;
@@ -207,12 +207,6 @@ test("fadeAnsi leaves text untouched at full opacity and sinks colors into the b
207
207
  );
208
208
  });
209
209
 
210
- test("easeFade is monotonic and pinned at both ends", () => {
211
- assert.equal(easeFade(0), 0);
212
- assert.equal(easeFade(1), 1);
213
- assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
214
- });
215
-
216
210
  test("leftFade recolors the working status but not pi or model details", () => {
217
211
  const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
218
212
  const ctx = ctxWith(status);
package/src/theme.ts CHANGED
@@ -8,20 +8,22 @@
8
8
  * embedded here. Values are hex strings (truecolor), 256-palette indices
9
9
  * (numbers), or "" for the terminal default color.
10
10
  */
11
- import { getTerminalColorMode } from "@earendil-works/pi-tui";
11
+ import { getTerminalColorMode, type TerminalColorMode } from "@earendil-works/pi-tui";
12
+ import { easeFade, FADE_SHADE_COUNT } from "@underactive/pi-topping-primitives/activity";
13
+ import {
14
+ channelsToFgAnsi,
15
+ fadeRgbToward,
16
+ hexToFgAnsi as sharedHexToFgAnsi,
17
+ parseHex,
18
+ xterm256ToRgb,
19
+ } from "@underactive/pi-topping-primitives/color";
20
+ import { ASCII_BOX, BOX_STYLES, type BoxGlyphs, type BoxStyle } from "@underactive/pi-topping-primitives/glyphs";
21
+
22
+ export type { BoxGlyphs };
12
23
 
13
24
  export type SymbolPreset = "unicode" | "nerd" | "ascii";
14
25
 
15
- export type BorderStyle = "rounded" | "heavy" | "double" | "single";
16
-
17
- export interface BoxGlyphs {
18
- topLeft: string;
19
- topRight: string;
20
- bottomLeft: string;
21
- bottomRight: string;
22
- horizontal: string;
23
- vertical: string;
24
- }
26
+ export type BorderStyle = BoxStyle;
25
27
 
26
28
  export interface StatusSymbols {
27
29
  sep: {
@@ -161,16 +163,6 @@ const SYMBOL_PRESETS: Record<SymbolPreset, StatusSymbols> = {
161
163
  ascii: ASCII_SYMBOLS,
162
164
  };
163
165
 
164
- const ASCII_BOX: BoxGlyphs = { topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", horizontal: "-", vertical: "|" };
165
-
166
- /** Same glyphs as pi-topping's User Prompt border style. */
167
- const BOX_STYLES: Record<BorderStyle, BoxGlyphs> = {
168
- rounded: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" },
169
- heavy: { topLeft: "┏", topRight: "┓", bottomLeft: "┗", bottomRight: "┛", horizontal: "━", vertical: "┃" },
170
- double: { topLeft: "╔", topRight: "╗", bottomLeft: "╚", bottomRight: "╝", horizontal: "═", vertical: "║" },
171
- single: { topLeft: "┌", topRight: "┐", bottomLeft: "└", bottomRight: "┘", horizontal: "─", vertical: "│" },
172
- };
173
-
174
166
  // ═══════════════════════════════════════════════════════════════════════════
175
167
  // Palette (OMP dark.json)
176
168
  // ═══════════════════════════════════════════════════════════════════════════
@@ -191,6 +183,7 @@ export type StatusColor =
191
183
  | "statusLineGitDirty"
192
184
  | "statusLineContext"
193
185
  | "statusLineContextElevated"
186
+ | "statusLineCacheHit"
194
187
  | "statusLineStaged"
195
188
  | "statusLineDirty"
196
189
  | "statusLineUntracked"
@@ -212,6 +205,7 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
212
205
  statusLineGitDirty: "#d7af5f",
213
206
  statusLineContext: "#8787af",
214
207
  statusLineContextElevated: "#e07a1f",
208
+ statusLineCacheHit: "#7fb069",
215
209
  statusLineStaged: 70,
216
210
  statusLineDirty: 178,
217
211
  statusLineUntracked: 39,
@@ -229,88 +223,32 @@ export const MAJOR_COLOR_HEXES = [
229
223
  "#00afaf",
230
224
  ];
231
225
 
232
- export type ColorMode = "truecolor" | "256color";
226
+ export type ColorMode = TerminalColorMode;
233
227
 
234
228
  /** Defer to pi's cached terminal detection, which honors PI_TRUE_COLOR, terminal.trueColor, and capability overrides. */
235
229
  export function detectColorMode(): ColorMode {
236
230
  return getTerminalColorMode();
237
231
  }
238
232
 
239
- export function parseHex(hex: string): [number, number, number] | undefined {
240
- const match = /^#?([0-9a-f]{6})$/i.exec(hex);
241
- if (!match) return undefined;
242
- const n = Number.parseInt(match[1], 16);
243
- return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
244
- }
245
-
246
- /** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
247
- export const FADE_SHADE_COUNT = 5;
248
-
249
- /** Stand-in for the terminal's default foreground when a fade must recolor it. */
250
- const DEFAULT_FG_RGB: [number, number, number] = [204, 204, 204];
251
-
252
- /** Every SGR color the layout or a segment can emit: truecolor, 256-color, or the default-fg reset. */
253
- const SGR_COLOR_RE = /\x1b\[(38|48);2;(\d+);(\d+);(\d+)m|\x1b\[(38|48);5;(\d+)m|\x1b\[39m/g;
254
-
255
- /** Raised-cosine ease shared by the discrete and continuous fades. */
256
- export function easeFade(progress: number): number {
257
- const clamped = Math.max(0, Math.min(1, progress));
258
- return 0.5 * (1 - Math.cos(Math.PI * clamped));
259
- }
260
-
261
- /** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
262
- function xterm256ToRgb(n: number): [number, number, number] {
263
- if (n >= 232) {
264
- const v = 8 + (n - 232) * 10;
265
- return [v, v, v];
266
- }
267
- const i = Math.max(0, n - 16);
268
- const c = (v: number) => (v === 0 ? 0 : 55 + v * 51);
269
- return [c(Math.floor(i / 36) % 6), c(Math.floor(i / 6) % 6), c(i % 6)];
270
- }
233
+ export { easeFade, FADE_SHADE_COUNT } from "@underactive/pi-topping-primitives/activity";
271
234
 
272
235
  function colorValueToRgb(color: ColorValue): [number, number, number] {
273
236
  if (typeof color === "number") return xterm256ToRgb(color);
274
237
  return parseHex(color) ?? [0, 0, 0];
275
238
  }
276
239
 
277
- function rgbTo256(r: number, g: number, b: number): number {
278
- if (r === g && g === b) {
279
- if (r < 8) return 16;
280
- if (r > 248) return 231;
281
- return 232 + Math.round(((r - 8) / 247) * 24);
282
- }
283
- const scale = (v: number) => Math.round((v / 255) * 5);
284
- return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
285
- }
286
-
287
- export function mixRgb(
288
- from: readonly [number, number, number],
289
- to: readonly [number, number, number],
290
- amount: number,
291
- ): [number, number, number] {
292
- const clamped = Math.min(1, Math.max(0, amount));
293
- return [
294
- Math.round(from[0] + (to[0] - from[0]) * clamped),
295
- Math.round(from[1] + (to[1] - from[1]) * clamped),
296
- Math.round(from[2] + (to[2] - from[2]) * clamped),
297
- ];
298
- }
299
-
300
- export function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
301
- return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
302
- }
240
+ /** The bar background as a tuple, resolved once at load: `fadeAnsi` sinks colours toward it on every tick. */
241
+ const STATUS_LINE_BG_RGB = colorValueToRgb(STATUS_LINE_BG);
303
242
 
243
+ /** `#rrggbb` as a foreground escape, in the terminal's colour depth unless the caller passes one. */
304
244
  export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
305
- const rgb = parseHex(hex);
306
- if (!rgb) return "";
307
- return rgbToFgAnsi(...rgb, mode);
245
+ return sharedHexToFgAnsi(hex, mode);
308
246
  }
309
247
 
310
248
  function fgAnsi(color: ColorValue, mode: ColorMode): string {
311
249
  if (color === "") return "\x1b[39m";
312
250
  if (typeof color === "number") return `\x1b[38;5;${color}m`;
313
- return hexToFgAnsi(color, mode);
251
+ return sharedHexToFgAnsi(color, mode);
314
252
  }
315
253
 
316
254
  function bgAnsi(color: ColorValue, mode: ColorMode): string {
@@ -402,7 +340,7 @@ class StatusTheme {
402
340
  const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
403
341
  const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
404
342
  const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
405
- const ansi = rgbToFgAnsi(r, g, bl, this.#currentMode());
343
+ const ansi = channelsToFgAnsi(r, g, bl, this.#currentMode());
406
344
  return `${ansi}${text}\x1b[39m`;
407
345
  }
408
346
 
@@ -417,22 +355,7 @@ class StatusTheme {
417
355
  fadeAnsi(text: string, opacity: number): string {
418
356
  const alpha = Math.max(0, Math.min(1, opacity));
419
357
  if (alpha >= 1) return text;
420
- const target = colorValueToRgb(STATUS_LINE_BG);
421
- const mode = this.#currentMode();
422
- return text.replace(SGR_COLOR_RE, (_m, tcPlane, r, g, b, ixPlane, index) => {
423
- let plane = "38";
424
- let rgb: [number, number, number] = DEFAULT_FG_RGB;
425
- if (tcPlane) {
426
- plane = tcPlane;
427
- rgb = [Number(r), Number(g), Number(b)];
428
- } else if (ixPlane) {
429
- plane = ixPlane;
430
- rgb = xterm256ToRgb(Number(index));
431
- }
432
- const mix = (i: number) => Math.round(rgb[i] * alpha + target[i] * (1 - alpha));
433
- const fg = rgbToFgAnsi(mix(0), mix(1), mix(2), mode);
434
- return plane === "38" ? fg : fg.replace("\x1b[38;", "\x1b[48;");
435
- });
358
+ return fadeRgbToward(text, STATUS_LINE_BG_RGB, alpha, this.#currentMode());
436
359
  }
437
360
  }
438
361