@underactive/pi-topping-statusline 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -21,6 +21,9 @@ Then `/reload` (or restart pi). Requirements: a [Nerd Font](https://www.nerdfont
21
21
  truecolor terminal for the default look — the Symbols setting in
22
22
  `/topping-statusline-settings` (`unicode` or `ascii`) drops the font requirement.
23
23
 
24
+ The bar's color depth follows pi's terminal detection, so `PI_TRUE_COLOR` and the
25
+ `terminal.trueColor` setting pick 24-bit or 256-color output for it too.
26
+
24
27
  ## Configuration — `/topping-statusline-settings`
25
28
 
26
29
  `/topping-statusline-settings` opens a settings TUI with a live preview of the box's top and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -45,9 +45,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "0.99.1",
49
- "@earendil-works/pi-coding-agent": "0.99.1",
50
- "@earendil-works/pi-tui": "0.99.1",
48
+ "@earendil-works/pi-ai": "1.0.0",
49
+ "@earendil-works/pi-coding-agent": "1.0.0",
50
+ "@earendil-works/pi-tui": "1.0.0",
51
51
  "@types/node": "^22.0.0",
52
52
  "typescript": "^5.7.0"
53
53
  }
@@ -41,11 +41,9 @@ function buildAnsiByShade(mode: ColorMode): string[] {
41
41
 
42
42
  export class NvidiaGreenBorder implements BorderColorizer {
43
43
  #phaseDeg: number;
44
- readonly #ansiByShade: string[];
45
44
 
46
45
  constructor(phaseDeg = 0) {
47
46
  this.#phaseDeg = wrapDeg(phaseDeg);
48
- this.#ansiByShade = buildAnsiByShade(detectColorMode());
49
47
  }
50
48
 
51
49
  step(deltaDeg: number): void {
@@ -61,6 +59,6 @@ export class NvidiaGreenBorder implements BorderColorizer {
61
59
  const u = (1 - Math.cos(2 * Math.PI * t)) / 2;
62
60
  const eased = u ** GREEN_BIAS;
63
61
  const shadeIndex = Math.round((1 - eased) * (GREEN_SHADE_COUNT - 1));
64
- return this.#ansiByShade[shadeIndex];
62
+ return buildAnsiByShade(detectColorMode())[shadeIndex];
65
63
  }
66
64
  }
package/src/rainbow.ts CHANGED
@@ -80,12 +80,11 @@ export interface BorderColorizer {
80
80
  */
81
81
  export class RainbowBorder implements BorderColorizer {
82
82
  #phaseDeg: number;
83
- readonly #mode: ColorMode;
83
+ #mode: ColorMode | undefined;
84
84
  readonly #ansiByHue: string[] = [];
85
85
 
86
86
  constructor(phaseDeg = 0) {
87
87
  this.#phaseDeg = wrapDeg(phaseDeg);
88
- this.#mode = detectColorMode();
89
88
  }
90
89
 
91
90
  /** Advance the whole spectrum by `deltaDeg` (mod 360). */
@@ -99,12 +98,17 @@ export class RainbowBorder implements BorderColorizer {
99
98
  }
100
99
 
101
100
  prefix(perimeterPos: number, perimeter: number): string {
101
+ const mode = detectColorMode();
102
+ if (mode !== this.#mode) {
103
+ this.#mode = mode;
104
+ this.#ansiByHue.length = 0;
105
+ }
102
106
  const hue = (this.#phaseDeg + (360 * perimeterPos) / perimeter) % 360;
103
107
  const bucket = Math.round(hue) % 360;
104
108
  let ansi = this.#ansiByHue[bucket];
105
109
  if (ansi === undefined) {
106
110
  const hex = rgbToHex(hslToRgb(bucket, SATURATION, LIGHTNESS));
107
- ansi = hexToFgAnsi(hex, this.#mode);
111
+ ansi = hexToFgAnsi(hex, mode);
108
112
  this.#ansiByHue[bucket] = ansi;
109
113
  }
110
114
  return ansi;
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resetCapabilitiesCache, setCapabilities, setCapabilityOverrides } from "@earendil-works/pi-tui";
4
+ import { NvidiaGreenBorder } from "../nvidia-green.ts";
5
+ import { RainbowBorder } from "../rainbow.ts";
6
+ import { detectColorMode, hexToFgAnsi, theme } from "../theme.ts";
7
+
8
+ function forceTrueColor(trueColor: boolean): void {
9
+ setCapabilities({ images: null, trueColor, hyperlinks: false });
10
+ }
11
+
12
+ function resetCapabilities(): void {
13
+ setCapabilityOverrides({});
14
+ resetCapabilitiesCache();
15
+ }
16
+
17
+ test("forced 256-color capabilities select 256-color escapes", () => {
18
+ try {
19
+ forceTrueColor(false);
20
+ assert.equal(detectColorMode(), "256color");
21
+ assert.equal(hexToFgAnsi("#ff0000"), "\x1b[38;5;196m");
22
+ } finally {
23
+ resetCapabilities();
24
+ }
25
+ });
26
+
27
+ test("forced truecolor capabilities select 24-bit escapes", () => {
28
+ try {
29
+ forceTrueColor(true);
30
+ assert.equal(detectColorMode(), "truecolor");
31
+ assert.equal(hexToFgAnsi("#ff0000"), "\x1b[38;2;255;0;0m");
32
+ } finally {
33
+ resetCapabilities();
34
+ }
35
+ });
36
+
37
+ test("the shared theme invalidates cached escapes when the mode changes", () => {
38
+ try {
39
+ forceTrueColor(true);
40
+ assert.match(theme.getFgAnsi("statusLineBg"), /^\x1b\[38;2;/);
41
+ assert.match(theme.getBgAnsi(), /^\x1b\[48;2;/);
42
+
43
+ forceTrueColor(false);
44
+ assert.match(theme.getFgAnsi("statusLineBg"), /^\x1b\[38;5;/);
45
+ assert.match(theme.getBgAnsi(), /^\x1b\[48;5;/);
46
+ assert.match(theme.fadeFg("warning", "statusLineBg", 2, "x"), /^\x1b\[38;5;/);
47
+ assert.match(theme.fadeAnsi("\x1b[38;2;254;188;56mA\x1b[39m", 0.5), /^\x1b\[38;5;/);
48
+
49
+ forceTrueColor(true);
50
+ assert.match(theme.getFgAnsi("statusLineBg"), /^\x1b\[38;2;/);
51
+ } finally {
52
+ resetCapabilities();
53
+ }
54
+ });
55
+
56
+ test("session-long border colorizers follow mode changes", () => {
57
+ try {
58
+ forceTrueColor(true);
59
+ const rainbow = new RainbowBorder(0);
60
+ const nvidia = new NvidiaGreenBorder(0);
61
+ assert.match(rainbow.colorChar("─", 0, 28), /^\x1b\[38;2;/);
62
+ assert.match(nvidia.colorChar("─", 0, 28), /^\x1b\[38;2;/);
63
+
64
+ forceTrueColor(false);
65
+ assert.match(rainbow.colorChar("─", 0, 28), /^\x1b\[38;5;/);
66
+ assert.match(nvidia.colorChar("─", 0, 28), /^\x1b\[38;5;/);
67
+
68
+ forceTrueColor(true);
69
+ assert.match(rainbow.colorChar("─", 0, 28), /^\x1b\[38;2;/);
70
+ assert.match(nvidia.colorChar("─", 0, 28), /^\x1b\[38;2;/);
71
+ } finally {
72
+ resetCapabilities();
73
+ }
74
+ });
75
+
76
+ test("pi capability overrides take precedence over terminal hints", () => {
77
+ const previousColorTerm = process.env.COLORTERM;
78
+ try {
79
+ process.env.COLORTERM = "truecolor";
80
+ setCapabilityOverrides({ trueColor: false });
81
+ assert.equal(detectColorMode(), "256color");
82
+ assert.match(theme.getFgAnsi("statusLineBg"), /^\x1b\[38;5;/);
83
+ } finally {
84
+ if (previousColorTerm === undefined) delete process.env.COLORTERM;
85
+ else process.env.COLORTERM = previousColorTerm;
86
+ resetCapabilities();
87
+ }
88
+ });
89
+
90
+ test("PI_TRUE_COLOR controls capability detection", () => {
91
+ const previousTrueColor = process.env.PI_TRUE_COLOR;
92
+ const previousHyperlinks = process.env.PI_HYPERLINKS;
93
+ try {
94
+ process.env.PI_HYPERLINKS = "0";
95
+ process.env.PI_TRUE_COLOR = "0";
96
+ resetCapabilitiesCache();
97
+ assert.equal(detectColorMode(), "256color");
98
+
99
+ process.env.PI_TRUE_COLOR = "1";
100
+ resetCapabilitiesCache();
101
+ assert.equal(detectColorMode(), "truecolor");
102
+ } finally {
103
+ if (previousTrueColor === undefined) delete process.env.PI_TRUE_COLOR;
104
+ else process.env.PI_TRUE_COLOR = previousTrueColor;
105
+ if (previousHyperlinks === undefined) delete process.env.PI_HYPERLINKS;
106
+ else process.env.PI_HYPERLINKS = previousHyperlinks;
107
+ resetCapabilities();
108
+ }
109
+ });
package/src/theme.ts CHANGED
@@ -8,6 +8,7 @@
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
12
 
12
13
  export type SymbolPreset = "unicode" | "nerd" | "ascii";
13
14
 
@@ -230,13 +231,9 @@ export const MAJOR_COLOR_HEXES = [
230
231
 
231
232
  export type ColorMode = "truecolor" | "256color";
232
233
 
234
+ /** Defer to pi's cached terminal detection, which honors PI_TRUE_COLOR, terminal.trueColor, and capability overrides. */
233
235
  export function detectColorMode(): ColorMode {
234
- const colorterm = process.env.COLORTERM;
235
- if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
236
- if (process.env.WT_SESSION) return "truecolor";
237
- const term = process.env.TERM || "";
238
- if (term === "dumb" || term === "" || term === "linux") return "256color";
239
- return "truecolor";
236
+ return getTerminalColorMode();
240
237
  }
241
238
 
242
239
  export function parseHex(hex: string): [number, number, number] | undefined {
@@ -314,12 +311,26 @@ function bgAnsi(color: ColorValue, mode: ColorMode): string {
314
311
  // ═══════════════════════════════════════════════════════════════════════════
315
312
 
316
313
  class StatusTheme {
317
- #mode: ColorMode = detectColorMode();
314
+ #mode: ColorMode | undefined;
318
315
  #preset: SymbolPreset = "nerd";
319
316
  #symbols: StatusSymbols = NERD_SYMBOLS;
320
317
  #fgCache = new Map<StatusColor, string>();
321
318
  #bgAnsi: string | undefined;
322
319
 
320
+ /**
321
+ * Pi imports extensions before it applies terminal.trueColor and reapplies
322
+ * overrides when settings change, so resolve the mode on use and drop stale ANSI.
323
+ */
324
+ #currentMode(): ColorMode {
325
+ const mode = detectColorMode();
326
+ if (mode !== this.#mode) {
327
+ this.#mode = mode;
328
+ this.#fgCache.clear();
329
+ this.#bgAnsi = undefined;
330
+ }
331
+ return mode;
332
+ }
333
+
323
334
  setSymbolPreset(preset: SymbolPreset): SymbolPreset {
324
335
  const previous = this.#preset;
325
336
  this.#preset = preset;
@@ -345,16 +356,18 @@ class StatusTheme {
345
356
  }
346
357
 
347
358
  getFgAnsi(color: StatusColor): string {
359
+ const mode = this.#currentMode();
348
360
  let ansi = this.#fgCache.get(color);
349
361
  if (ansi === undefined) {
350
- ansi = fgAnsi(FG_COLORS[color], this.#mode);
362
+ ansi = fgAnsi(FG_COLORS[color], mode);
351
363
  this.#fgCache.set(color, ansi);
352
364
  }
353
365
  return ansi;
354
366
  }
355
367
 
356
368
  getBgAnsi(): string {
357
- if (this.#bgAnsi === undefined) this.#bgAnsi = bgAnsi(STATUS_LINE_BG, this.#mode);
369
+ const mode = this.#currentMode();
370
+ if (this.#bgAnsi === undefined) this.#bgAnsi = bgAnsi(STATUS_LINE_BG, mode);
358
371
  return this.#bgAnsi;
359
372
  }
360
373
 
@@ -376,7 +389,7 @@ class StatusTheme {
376
389
  const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
377
390
  const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
378
391
  const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
379
- const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
392
+ const ansi = rgbToFgAnsi(r, g, bl, this.#currentMode());
380
393
  return `${ansi}${text}\x1b[39m`;
381
394
  }
382
395
 
@@ -392,7 +405,7 @@ class StatusTheme {
392
405
  const alpha = Math.max(0, Math.min(1, opacity));
393
406
  if (alpha >= 1) return text;
394
407
  const target = colorValueToRgb(STATUS_LINE_BG);
395
- const mode = this.#mode;
408
+ const mode = this.#currentMode();
396
409
  return text.replace(SGR_COLOR_RE, (_m, tcPlane, r, g, b, ixPlane, index) => {
397
410
  let plane = "38";
398
411
  let rgb: [number, number, number] = DEFAULT_FG_RGB;