@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 +12 -0
- package/package.json +1 -1
- package/src/config.ts +9 -1
- package/src/rendering/renderer.ts +14 -1
- package/src/rendering/theme.ts +187 -6
- package/src/types.ts +5 -0
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
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 */
|
package/src/rendering/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|