@pi-unipi/footer 2.0.3 → 2.0.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
@@ -69,6 +69,7 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
69
69
  "preset": "default",
70
70
  "separator": "powerline-thin",
71
71
  "iconStyle": "nerd",
72
+ "colorMode": "auto",
72
73
  "groups": {
73
74
  "compactor": {
74
75
  "show": true,
@@ -105,6 +106,17 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
105
106
 
106
107
  When `iconStyle` is not set, footer auto-detects Nerd Font support and defaults to `nerd` if available, `emoji` otherwise.
107
108
 
109
+ ### Color Mode
110
+
111
+ | Mode | Description |
112
+ |------|-------------|
113
+ | `auto` | Detect terminal support from environment (default) |
114
+ | `truecolor` | Force 24-bit ANSI color |
115
+ | `256` | Force xterm-256 color fallback |
116
+ | `none` | Disable footer color escapes |
117
+
118
+ `auto` uses truecolor where supported and downgrades to xterm-256 colors for terminals such as Apple Terminal that do not reliably render 24-bit color escapes.
119
+
108
120
  ### Responsive Layout
109
121
 
110
122
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import * as os from "node:os";
11
- import type { FooterSettings, FooterGroupSettings, SeparatorStyle, IconStyle } from "./types.js";
11
+ import type { FooterSettings, FooterGroupSettings, SeparatorStyle, IconStyle, ColorMode } from "./types.js";
12
12
  import { UNIPI_SETTINGS_KEY } from "@pi-unipi/core";
13
13
 
14
14
  /** Default footer settings */
@@ -19,6 +19,7 @@ export const DEFAULT_FOOTER_SETTINGS: FooterSettings = {
19
19
  iconStyle: "nerd",
20
20
  zoneSeparator: "\u2502", // │
21
21
  showFullLabels: false,
22
+ colorMode: "auto",
22
23
  groups: {
23
24
  core: { show: true, segments: {} },
24
25
  compactor: { show: true, segments: {} },
@@ -96,6 +97,7 @@ export function loadFooterSettings(): FooterSettings {
96
97
  iconStyle: isValidIconStyle(footer.iconStyle) ? footer.iconStyle as IconStyle : DEFAULT_FOOTER_SETTINGS.iconStyle,
97
98
  zoneSeparator: typeof footer.zoneSeparator === "string" ? footer.zoneSeparator : DEFAULT_FOOTER_SETTINGS.zoneSeparator,
98
99
  showFullLabels: typeof footer.showFullLabels === "boolean" ? footer.showFullLabels : DEFAULT_FOOTER_SETTINGS.showFullLabels,
100
+ colorMode: isValidColorMode(footer.colorMode) ? footer.colorMode as ColorMode : DEFAULT_FOOTER_SETTINGS.colorMode,
99
101
  groups: mergeGroupSettings(
100
102
  DEFAULT_FOOTER_SETTINGS.groups,
101
103
  footer.groups as Record<string, FooterGroupSettings> | undefined,
@@ -170,6 +172,12 @@ function isValidIconStyle(value: unknown): boolean {
170
172
  return valid.includes(value);
171
173
  }
172
174
 
175
+ function isValidColorMode(value: unknown): boolean {
176
+ if (typeof value !== "string") return false;
177
+ const valid: string[] = ["auto", "truecolor", "256", "none"];
178
+ return valid.includes(value);
179
+ }
180
+
173
181
  function mergeGroupSettings(
174
182
  defaults: Record<string, FooterGroupSettings>,
175
183
  overrides: Record<string, FooterGroupSettings> | undefined,
@@ -11,7 +11,7 @@ import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, Rende
11
11
  import type { FooterRegistry } from "../registry/index.js";
12
12
  import { visibleWidth as piVisibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
13
13
  import { getSeparator, separatorVisibleWidth } from "./separators.js";
14
- import { getDefaultColors } from "./theme.js";
14
+ import { getDefaultColors, setColorMode, refreshColorMode } from "./theme.js";
15
15
  import { setIconStyle } from "./icons.js";
16
16
  import { getPreset } from "../presets.js";
17
17
  import { isSegmentEnabled, isSegmentExplicitlyEnabled, loadFooterSettings } from "../config.js";
@@ -104,6 +104,19 @@ export class FooterRenderer {
104
104
  private syncIconStyle(): void {
105
105
  const settings = loadFooterSettings();
106
106
  setIconStyle(settings.iconStyle);
107
+ this.syncColorMode(settings);
108
+ }
109
+
110
+ /** Sync the colour-emission mode from settings to the theme module. */
111
+ private syncColorMode(settings?: ReturnType<typeof loadFooterSettings>): void {
112
+ const s = settings ?? loadFooterSettings();
113
+ const mode = s.colorMode ?? "auto";
114
+ if (mode === "auto") {
115
+ setColorMode(null);
116
+ refreshColorMode();
117
+ } else {
118
+ setColorMode(mode);
119
+ }
107
120
  }
108
121
 
109
122
  /** Get the active preset name */
@@ -3,11 +3,174 @@
3
3
  *
4
4
  * Maps semantic color names to pi theme colors. Supports
5
5
  * hex overrides via ColorScheme.
6
+ *
7
+ * Color emission is capability-aware: hex values are emitted as 24-bit
8
+ * truecolor on terminals that advertise truecolor (COLORTERM or known
9
+ * truecolor TERM_PROGRAM), and gracefully downgraded to the nearest
10
+ * xterm-256 index on legacy terminals (notably Apple Terminal.app, which
11
+ * silently swallows 24-bit escapes and renders the text uncoloured).
6
12
  */
7
13
 
8
14
  import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
9
15
  import type { ColorScheme, ColorValue, SemanticColor, ThemeLike } from "../types.js";
10
16
 
17
+ // ─── Color mode detection ──────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Detected colour-emission mode for the current terminal.
21
+ * - `truecolor` → emit 24-bit `\x1b[38;2;R;G;Bm`
22
+ * - `256` → emit 8-bit `\x1b[38;5;Nm` (nearest xterm-256 index)
23
+ * - `none` → emit no colour codes (plain text)
24
+ */
25
+ export type ColorMode = "truecolor" | "256" | "none";
26
+
27
+ /** Manual override (set via `setColorMode`) — wins over env-based detection. */
28
+ let manualMode: ColorMode | null = null;
29
+ /** Cached detection result; cleared by `refreshColorMode()`. */
30
+ let detectedMode: ColorMode | null = null;
31
+
32
+ /** Terminals known to honour 24-bit truecolor escapes. */
33
+ const TRUECOLOR_TERM_PROGRAMS = [
34
+ "iTerm.app",
35
+ "WezTerm",
36
+ "Alacritty",
37
+ "vscode",
38
+ "Hyper",
39
+ "Warp",
40
+ "ghostty",
41
+ "Ghostty",
42
+ "zed",
43
+ "Zed",
44
+ "cursor",
45
+ "Cursor",
46
+ ];
47
+
48
+ /**
49
+ * Detect the colour mode of the current terminal from environment.
50
+ * Respects `NO_COLOR`, `FORCE_COLOR`, then sniffs `COLORTERM` / `TERM_PROGRAM`
51
+ * / `TERM`. Apple Terminal is explicitly capped at 256-colour.
52
+ */
53
+ function detectColorMode(): ColorMode {
54
+ const env = process.env;
55
+ if (env.NO_COLOR || env.NODE_DISABLE_COLORS) return "none";
56
+ if (env.FORCE_COLOR) {
57
+ const lvl = parseInt(env.FORCE_COLOR, 10);
58
+ if (Number.isNaN(lvl)) return "truecolor"; // any non-numeric truthy value
59
+ if (lvl >= 3) return "truecolor";
60
+ if (lvl >= 1) return "256";
61
+ return "none";
62
+ }
63
+ const term = env.TERM ?? "";
64
+ const termProgram = env.TERM_PROGRAM ?? "";
65
+ // Apple Terminal.app does NOT support 24-bit colour. Force 256 even if
66
+ // some wrapper leaked COLORTERM into the env.
67
+ if (termProgram === "Apple_Terminal") {
68
+ return "256";
69
+ }
70
+ if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") {
71
+ return "truecolor";
72
+ }
73
+ if (TRUECOLOR_TERM_PROGRAMS.includes(termProgram)) {
74
+ return "truecolor";
75
+ }
76
+ if (term === "dumb") return "none";
77
+ // Anything that talks ANSI but isn't known-truecolor → 256
78
+ if (term.includes("256color") || term.includes("color") || term.includes("xterm") || term.includes("ansi") || term.includes("screen") || term.includes("tmux")) {
79
+ return "256";
80
+ }
81
+ // Default conservative: 256 colour. Better than dropping colours entirely
82
+ // on unknown terminals that almost certainly understand the 256 palette.
83
+ return "256";
84
+ }
85
+
86
+ /**
87
+ * Get the active colour mode (manual override > cached detection > env probe).
88
+ */
89
+ export function getColorMode(): ColorMode {
90
+ if (manualMode) return manualMode;
91
+ if (!detectedMode) detectedMode = detectColorMode();
92
+ return detectedMode;
93
+ }
94
+
95
+ /**
96
+ * Force a specific colour mode (e.g. from user settings). Pass `null` to
97
+ * revert to auto-detection.
98
+ */
99
+ export function setColorMode(mode: ColorMode | null): void {
100
+ manualMode = mode;
101
+ }
102
+
103
+ /** Re-run env-based detection (useful after settings or env changes). */
104
+ export function refreshColorMode(): ColorMode {
105
+ detectedMode = null;
106
+ return getColorMode();
107
+ }
108
+
109
+ // ─── 24-bit → 256 colour downgrade ─────────────────────────────────────────
110
+
111
+ /**
112
+ * Map an RGB triple to the nearest xterm-256 index.
113
+ *
114
+ * xterm-256 layout:
115
+ * 0–15 → system / bright colours (skipped; we route through the cube
116
+ * for predictability)
117
+ * 16–231 → 6×6×6 colour cube
118
+ * 232–255 → 24-step grayscale
119
+ *
120
+ * We pick the better of (cube nearest) and (grayscale nearest) by squared
121
+ * RGB distance, which matches the heuristic used by chalk / ansi-styles.
122
+ */
123
+ function rgbTo256(r: number, g: number, b: number): number {
124
+ // Grayscale candidate
125
+ const grayAvg = Math.round((r + g + b) / 3);
126
+ let grayIdx: number;
127
+ let grayR: number;
128
+ if (grayAvg < 8) {
129
+ grayIdx = 16;
130
+ grayR = 0;
131
+ } else if (grayAvg > 248) {
132
+ grayIdx = 231;
133
+ grayR = 255;
134
+ } else {
135
+ const step = Math.round(((grayAvg - 8) / 247) * 24);
136
+ grayIdx = 232 + step;
137
+ grayR = 8 + step * 10;
138
+ }
139
+ const grayDist = sqDist(r, g, b, grayR, grayR, grayR);
140
+
141
+ // Cube candidate (6 steps: 0, 95, 135, 175, 215, 255)
142
+ const cubeR = cubeStep(r);
143
+ const cubeG = cubeStep(g);
144
+ const cubeB = cubeStep(b);
145
+ const cubeIdx = 16 + 36 * cubeR.idx + 6 * cubeG.idx + cubeB.idx;
146
+ const cubeDist = sqDist(r, g, b, cubeR.value, cubeG.value, cubeB.value);
147
+
148
+ return cubeDist <= grayDist ? cubeIdx : grayIdx;
149
+ }
150
+
151
+ const CUBE_STEPS = [0, 95, 135, 175, 215, 255];
152
+ function cubeStep(v: number): { idx: number; value: number } {
153
+ let bestIdx = 0;
154
+ let bestDist = Infinity;
155
+ for (let i = 0; i < CUBE_STEPS.length; i++) {
156
+ const d = Math.abs(v - CUBE_STEPS[i]);
157
+ if (d < bestDist) {
158
+ bestDist = d;
159
+ bestIdx = i;
160
+ }
161
+ }
162
+ return { idx: bestIdx, value: CUBE_STEPS[bestIdx] };
163
+ }
164
+
165
+ function sqDist(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
166
+ const dr = r1 - r2;
167
+ const dg = g1 - g2;
168
+ const db = b1 - b2;
169
+ return dr * dr + dg * dg + db * db;
170
+ }
171
+
172
+ // ─── Public API ────────────────────────────────────────────────────────────
173
+
11
174
  /** Wrap text in dim ANSI codes for muted placeholder display */
12
175
  export function mutedPlaceholder(text: string): string {
13
176
  return `\x1b[2m${text}\x1b[0m`;
@@ -95,6 +258,26 @@ export function resolveColor(color: ColorValue, theme: ThemeLike): string {
95
258
  return theme.fg(color as ThemeColor, "").replace(/\x1b\[0m$/, "");
96
259
  }
97
260
 
261
+ /**
262
+ * Wrap `text` in an ANSI escape for the given hex value, chosen to match
263
+ * the active colour mode (truecolor / 256 / none).
264
+ */
265
+ function wrapHex(hex: string, text: string): string {
266
+ const mode = getColorMode();
267
+ if (mode === "none") return text;
268
+ const h = hex.startsWith("#") ? hex.slice(1) : hex;
269
+ const r = parseInt(h.slice(0, 2), 16);
270
+ const g = parseInt(h.slice(2, 4), 16);
271
+ const b = parseInt(h.slice(4, 6), 16);
272
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return text;
273
+ if (mode === "truecolor") {
274
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
275
+ }
276
+ // 256-color fallback
277
+ const idx = rgbTo256(r, g, b);
278
+ return `\x1b[38;5;${idx}m${text}\x1b[0m`;
279
+ }
280
+
98
281
  /**
99
282
  * Apply a semantic color to text using the theme.
100
283
  * Falls back to the default theme color if no override is provided.
@@ -109,16 +292,14 @@ export function applyColor(
109
292
  if (!colorValue) {
110
293
  // Use default from the map
111
294
  const defaultColor = DEFAULT_COLOR_MAP[semantic] || "text";
295
+ if (typeof defaultColor === "string" && defaultColor.startsWith("#")) {
296
+ return wrapHex(defaultColor, text);
297
+ }
112
298
  return theme.fg(defaultColor as ThemeColor, text);
113
299
  }
114
300
 
115
301
  if (colorValue.startsWith("#")) {
116
- // Hex color — we need to emit ANSI directly
117
- const hex = colorValue.slice(1);
118
- const r = Number.parseInt(hex.slice(0, 2), 16);
119
- const g = Number.parseInt(hex.slice(2, 4), 16);
120
- const b = Number.parseInt(hex.slice(4, 6), 16);
121
- return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
302
+ return wrapHex(colorValue, text);
122
303
  }
123
304
 
124
305
  return theme.fg(colorValue as ThemeColor, text);
package/src/types.ts CHANGED
@@ -82,6 +82,9 @@ export type ColorScheme = Partial<Record<SemanticColor, ColorValue>>;
82
82
  /** Icon style — determines which icon set is used for segments */
83
83
  export type IconStyle = "nerd" | "emoji" | "text";
84
84
 
85
+ /** Colour-emission mode for terminal output. */
86
+ export type ColorMode = "auto" | "truecolor" | "256" | "none";
87
+
85
88
  /** Separator styles for segment dividers */
86
89
  export type SeparatorStyle =
87
90
  | "powerline"
@@ -190,6 +193,8 @@ export interface FooterSettings {
190
193
  zoneSeparator?: string;
191
194
  /** Show full labels instead of compact short labels */
192
195
  showFullLabels?: boolean;
196
+ /** Terminal colour mode override (default: "auto" — env-detected). */
197
+ colorMode?: ColorMode;
193
198
  /** Per-group settings */
194
199
  groups: Record<string, FooterGroupSettings>;
195
200
  }