pi-editor-footer 0.12.1 → 0.12.2
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/CHANGELOG.md +8 -0
- package/dist/ansi-color.js +142 -0
- package/dist/chrome-composition.js +8 -3
- package/dist/chrome-state.js +18 -8
- package/dist/color-policy.js +31 -18
- package/dist/model-info.js +8 -114
- package/dist/utils.js +2 -2
- package/package.json +1 -1
- package/src/ansi-color.ts +186 -0
- package/src/chrome-composition.ts +15 -5
- package/src/chrome-state.ts +30 -8
- package/src/color-policy.ts +34 -15
- package/src/layout.ts +6 -0
- package/src/model-info.ts +8 -131
- package/src/utils.ts +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **context:** tier-color the context window section by usage ([#30](https://github.com/Rianico/pi-editor-footer/issues/30)) ([8152d90](https://github.com/Rianico/pi-editor-footer/commit/8152d90b3c1b4013154eff95cc885e79e7564f6f))
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
1
9
|
## [0.12.1](https://github.com/Rianico/pi-editor-footer/compare/v0.12.0...v0.12.1) (2026-09-07)
|
|
2
10
|
|
|
3
11
|
### Bug Fixes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const BASIC16 = [
|
|
2
|
+
[0, 0, 0],
|
|
3
|
+
[128, 0, 0],
|
|
4
|
+
[0, 128, 0],
|
|
5
|
+
[128, 128, 0],
|
|
6
|
+
[0, 0, 128],
|
|
7
|
+
[128, 0, 128],
|
|
8
|
+
[0, 128, 128],
|
|
9
|
+
[192, 192, 192],
|
|
10
|
+
[128, 128, 128],
|
|
11
|
+
[255, 0, 0],
|
|
12
|
+
[0, 255, 0],
|
|
13
|
+
[255, 255, 0],
|
|
14
|
+
[0, 0, 255],
|
|
15
|
+
[255, 0, 255],
|
|
16
|
+
[0, 255, 255],
|
|
17
|
+
[255, 255, 255],
|
|
18
|
+
];
|
|
19
|
+
/** 6x6x6 colour-cube channel values (indices 0-5). */
|
|
20
|
+
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
21
|
+
/** Grayscale ramp values (indices 232-255: 24 grays from 8 to 238). */
|
|
22
|
+
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
|
23
|
+
/** Anything but the truecolor marker means the 256-colour ramp. */
|
|
24
|
+
export function normalizeColorMode(mode) {
|
|
25
|
+
return mode === "truecolor" ? "truecolor" : "256color";
|
|
26
|
+
}
|
|
27
|
+
export function indexToRgb(n) {
|
|
28
|
+
if (n >= 0 && n < 16) {
|
|
29
|
+
const [r, g, b] = BASIC16[n] ?? [0, 0, 0];
|
|
30
|
+
return { r, g, b };
|
|
31
|
+
}
|
|
32
|
+
if (n >= 16 && n <= 231) {
|
|
33
|
+
const v = n - 16;
|
|
34
|
+
return {
|
|
35
|
+
r: CUBE_VALUES[Math.floor(v / 36)] ?? 0,
|
|
36
|
+
g: CUBE_VALUES[Math.floor(v / 6) % 6] ?? 0,
|
|
37
|
+
b: CUBE_VALUES[v % 6] ?? 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (n >= 232 && n <= 255) {
|
|
41
|
+
const gray = 8 + (n - 232) * 10;
|
|
42
|
+
return { r: gray, g: gray, b: gray };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function findClosestCubeIndex(value) {
|
|
47
|
+
let minDist = Infinity;
|
|
48
|
+
let minIdx = 0;
|
|
49
|
+
for (let i = 0; i < CUBE_VALUES.length; i++) {
|
|
50
|
+
const dist = Math.abs(value - (CUBE_VALUES[i] ?? 0));
|
|
51
|
+
if (dist < minDist) {
|
|
52
|
+
minDist = dist;
|
|
53
|
+
minIdx = i;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return minIdx;
|
|
57
|
+
}
|
|
58
|
+
function findClosestGrayIndex(gray) {
|
|
59
|
+
let minDist = Infinity;
|
|
60
|
+
let minIdx = 0;
|
|
61
|
+
for (let i = 0; i < GRAY_VALUES.length; i++) {
|
|
62
|
+
const dist = Math.abs(gray - (GRAY_VALUES[i] ?? 0));
|
|
63
|
+
if (dist < minDist) {
|
|
64
|
+
minDist = dist;
|
|
65
|
+
minIdx = i;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return minIdx;
|
|
69
|
+
}
|
|
70
|
+
function colorDistance(r1, g1, b1, r2, g2, b2) {
|
|
71
|
+
const dr = r1 - r2;
|
|
72
|
+
const dg = g1 - g2;
|
|
73
|
+
const db = b1 - b2;
|
|
74
|
+
// Weighted Euclidean distance (the eye is more sensitive to green).
|
|
75
|
+
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
76
|
+
}
|
|
77
|
+
/** Quantise RGB to the closest xterm-256 index (same rule as the theme loader). */
|
|
78
|
+
export function rgbTo256(r, g, b) {
|
|
79
|
+
const rIdx = findClosestCubeIndex(r);
|
|
80
|
+
const gIdx = findClosestCubeIndex(g);
|
|
81
|
+
const bIdx = findClosestCubeIndex(b);
|
|
82
|
+
const cubeR = CUBE_VALUES[rIdx] ?? 0;
|
|
83
|
+
const cubeG = CUBE_VALUES[gIdx] ?? 0;
|
|
84
|
+
const cubeB = CUBE_VALUES[bIdx] ?? 0;
|
|
85
|
+
const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
|
|
86
|
+
const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
|
|
87
|
+
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
88
|
+
const grayIdx = findClosestGrayIndex(gray);
|
|
89
|
+
const grayValue = GRAY_VALUES[grayIdx] ?? 0;
|
|
90
|
+
const grayIndex = 232 + grayIdx;
|
|
91
|
+
const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
|
|
92
|
+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
93
|
+
// Only consider grayscale when the colour is nearly neutral AND closer.
|
|
94
|
+
if (spread < 10 && grayDist < cubeDist)
|
|
95
|
+
return grayIndex;
|
|
96
|
+
return cubeIndex;
|
|
97
|
+
}
|
|
98
|
+
/** Parse `#RRGGBB`. Throws on malformed input — callers pass literals, not user data. */
|
|
99
|
+
export function hexToRgb(hex) {
|
|
100
|
+
const cleaned = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
101
|
+
if (cleaned.length !== 6)
|
|
102
|
+
throw new Error(`Invalid hex color: ${hex}`);
|
|
103
|
+
const { r, g, b } = {
|
|
104
|
+
r: parseInt(cleaned.slice(0, 2), 16),
|
|
105
|
+
g: parseInt(cleaned.slice(2, 4), 16),
|
|
106
|
+
b: parseInt(cleaned.slice(4, 6), 16),
|
|
107
|
+
};
|
|
108
|
+
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
|
109
|
+
throw new Error(`Invalid hex color: ${hex}`);
|
|
110
|
+
}
|
|
111
|
+
return { r, g, b };
|
|
112
|
+
}
|
|
113
|
+
/** Parse a theme token's ANSI escape back into RGB (truecolor or 256 index). */
|
|
114
|
+
export function parseFgAnsiToRgb(theme, color) {
|
|
115
|
+
const ansi = theme.getFgAnsi(color);
|
|
116
|
+
const trueColor = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
117
|
+
if (trueColor) {
|
|
118
|
+
return {
|
|
119
|
+
r: Number(trueColor[1]),
|
|
120
|
+
g: Number(trueColor[2]),
|
|
121
|
+
b: Number(trueColor[3]),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const palette = ansi.match(/38;5;(\d+)/);
|
|
125
|
+
if (palette)
|
|
126
|
+
return indexToRgb(Number(palette[1]));
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/** SGR foreground sequence for an RGB value at the terminal's colour fidelity. */
|
|
130
|
+
export function rgbToFgAnsi({ r, g, b }, mode) {
|
|
131
|
+
return normalizeColorMode(mode) === "truecolor"
|
|
132
|
+
? `\x1b[38;2;${r};${g};${b}m`
|
|
133
|
+
: `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Colour `s` with an exact hex, bypassing the theme's named-token lookup.
|
|
137
|
+
* Returned painter resets only the foreground (`\x1b[39m`), matching `theme.fg`.
|
|
138
|
+
*/
|
|
139
|
+
export function hexFg(hex, mode) {
|
|
140
|
+
const ansi = rgbToFgAnsi(hexToRgb(hex), mode);
|
|
141
|
+
return (s) => `${ansi}${s}\x1b[39m`;
|
|
142
|
+
}
|
|
@@ -21,14 +21,19 @@ import { resolveGlyphs, resolveIconMode } from "./icons.js";
|
|
|
21
21
|
import { formatTopContextFromSnapshot } from "./chrome-state.js";
|
|
22
22
|
import { formatRunActivityTopRight } from "./run-activity.js";
|
|
23
23
|
import { formatTelemetryTokens, formatTurnDuration, formatTurnTelemetry } from "./telemetry.js";
|
|
24
|
+
import { hexFg } from "./ansi-color.js";
|
|
24
25
|
/**
|
|
25
|
-
* Read the live pi theme into a typed { fg } surface. The cast is a SAFETY
|
|
26
|
-
* missing `fg` degrades to identity rather than throwing, keeping
|
|
26
|
+
* Read the live pi theme into a typed { fg, fgHex } surface. The cast is a SAFETY
|
|
27
|
+
* seam — a pi theme missing `fg` degrades to identity rather than throwing, keeping
|
|
28
|
+
* the chrome resilient. `fgHex` needs no theme call at all: the named-token lookup
|
|
29
|
+
* rejects raw hex, so hex is rendered straight to SGR at the theme's colour mode.
|
|
27
30
|
*/
|
|
28
31
|
export function adaptTheme(rawTheme) {
|
|
29
|
-
const t = rawTheme;
|
|
32
|
+
const t = rawTheme;
|
|
33
|
+
const mode = typeof t.getColorMode === "function" ? t.getColorMode() : undefined;
|
|
30
34
|
return {
|
|
31
35
|
fg: (style, s) => (typeof t.fg === "function" ? t.fg(style, s) : s),
|
|
36
|
+
fgHex: (hex, s) => hexFg(hex, mode)(s),
|
|
32
37
|
};
|
|
33
38
|
}
|
|
34
39
|
/** Optional thinking-border glow (pi theme extension). Falls back to identity. */
|
package/dist/chrome-state.js
CHANGED
|
@@ -15,20 +15,30 @@
|
|
|
15
15
|
* inside this module, not part of its external seam.
|
|
16
16
|
*/
|
|
17
17
|
import { getUsageTotals } from "./state.js";
|
|
18
|
-
import { cacheHitColor,
|
|
18
|
+
import { cacheHitColor, CONTEXT_TIER_HEX, CONTEXT_TIER_THEME_COLOR, contextUsageTier, } from "./color-policy.js";
|
|
19
19
|
import { fmtTokens } from "./format.js";
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Context bar formatting — moved from footer.ts to centralize chrome rendering.
|
|
22
22
|
// Re-exported from footer.ts for backward compatibility.
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Painter for one context tier: exact hex when the live theme can emit raw hex,
|
|
26
|
+
* else the nearest semantic token (tests/mocks, themes without colour mode).
|
|
27
|
+
*/
|
|
28
|
+
function contextPainter(theme, tier) {
|
|
29
|
+
const hex = CONTEXT_TIER_HEX[tier];
|
|
30
|
+
const fgHex = theme.fgHex;
|
|
31
|
+
if (fgHex)
|
|
32
|
+
return (s) => fgHex.call(theme, hex, s);
|
|
33
|
+
return (s) => theme.fg(CONTEXT_TIER_THEME_COLOR[tier], s);
|
|
34
|
+
}
|
|
35
|
+
function renderBar(theme, paint, pct, barWidth, ascii) {
|
|
25
36
|
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
26
37
|
const empty = barWidth - filled;
|
|
27
|
-
const color = contextUsageColor(pct);
|
|
28
38
|
const filledCell = ascii ? "#" : "█";
|
|
29
39
|
const emptyCell = ascii ? "-" : "░";
|
|
30
40
|
return (theme.fg("dim", "[") +
|
|
31
|
-
|
|
41
|
+
paint(filledCell.repeat(filled)) +
|
|
32
42
|
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
33
43
|
theme.fg("dim", "]"));
|
|
34
44
|
}
|
|
@@ -37,13 +47,13 @@ export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth
|
|
|
37
47
|
if (contextWindow <= 0)
|
|
38
48
|
return "";
|
|
39
49
|
const contextPct = contextUsage?.percent ?? 0;
|
|
40
|
-
const
|
|
41
|
-
const pctText =
|
|
50
|
+
const paint = contextPainter(theme, contextUsageTier(contextPct, contextWindow));
|
|
51
|
+
const pctText = paint(`${contextPct.toFixed(1)}%`);
|
|
42
52
|
const contextTokens = contextUsage?.tokens ?? 0;
|
|
43
|
-
const ctxText = `${
|
|
53
|
+
const ctxText = `${paint(fmtTokens(contextTokens))}${theme.fg("dim", "/")}${paint(fmtTokens(contextWindow))}`;
|
|
44
54
|
const baseCore = `${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
45
55
|
const base = showIconBar
|
|
46
|
-
? `${
|
|
56
|
+
? `${paint(glyphs.context)} ${renderBar(theme, paint, contextPct, barWidth, isAscii)} ${baseCore}`
|
|
47
57
|
: baseCore;
|
|
48
58
|
const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate) ? cacheHitRate : 0;
|
|
49
59
|
const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
|
package/dist/color-policy.js
CHANGED
|
@@ -5,24 +5,37 @@ export function stressColor(value, warn = 70, danger = 90) {
|
|
|
5
5
|
return "warning";
|
|
6
6
|
return "accent";
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Fixed tier palette (nord green / nord yellow / dark red). Hex, not theme tokens:
|
|
10
|
+
* the tiers are an alarm scale the user picked explicitly, so they must read the
|
|
11
|
+
* same on every theme. `CONTEXT_TIER_THEME_COLOR` is only the fallback for themes
|
|
12
|
+
* that cannot emit raw hex.
|
|
13
|
+
*/
|
|
14
|
+
export const CONTEXT_TIER_HEX = {
|
|
15
|
+
ok: "#A3BE8C",
|
|
16
|
+
warn: "#EBCB8B",
|
|
17
|
+
critical: "#9A3939",
|
|
18
|
+
};
|
|
19
|
+
/** Semantic token approximating each tier — used when raw hex is unavailable. */
|
|
20
|
+
export const CONTEXT_TIER_THEME_COLOR = {
|
|
21
|
+
ok: "success",
|
|
22
|
+
warn: "warning",
|
|
23
|
+
critical: "error",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Tier from the share of the context window consumed.
|
|
27
|
+
*
|
|
28
|
+
* Budgets scale with the window: a 1M window only earns 12.5 / 25 % of slack
|
|
29
|
+
* before the same alarm, while smaller windows (compacted far sooner) keep the
|
|
30
|
+
* looser 25 / 50 % steps.
|
|
31
|
+
*/
|
|
32
|
+
export function contextUsageTier(pct, contextWindow) {
|
|
33
|
+
const [warnAt, criticalAt] = contextWindow >= 1_000_000 ? [12.5, 25] : [25, 50];
|
|
34
|
+
if (pct >= criticalAt)
|
|
35
|
+
return "critical";
|
|
36
|
+
if (pct >= warnAt)
|
|
37
|
+
return "warn";
|
|
38
|
+
return "ok";
|
|
26
39
|
}
|
|
27
40
|
export function cacheHitColor(value) {
|
|
28
41
|
if (value < 30)
|
package/dist/model-info.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* model-info-widget/index.ts, MIT-style personal extension) so that the
|
|
6
6
|
* TrackingEditor — which owns the editor slot for this extension — can keep
|
|
7
7
|
* rendering the model label and thinking-level border glow that the original
|
|
8
|
-
* widget provided. Self-contained
|
|
8
|
+
* widget provided. Self-contained apart from pi-tui width utils, the shared
|
|
9
|
+
* ANSI quantiser (ansi-color.ts) and stripAnsi (format.ts).
|
|
9
10
|
*
|
|
10
11
|
* The port is intentional: pi allows exactly ONE custom editor (last
|
|
11
12
|
* `setEditorComponent` writer wins). pi-skill-desc must own the slot to track
|
|
@@ -13,6 +14,8 @@
|
|
|
13
14
|
* its visual behavior lives here instead. See docs/adr/0001.
|
|
14
15
|
*/
|
|
15
16
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import { parseFgAnsiToRgb, rgbToFgAnsi } from "./ansi-color.js";
|
|
18
|
+
import { stripAnsi } from "./format.js";
|
|
16
19
|
const LEVEL_INDEX = {
|
|
17
20
|
off: 0,
|
|
18
21
|
minimal: 1,
|
|
@@ -36,117 +39,10 @@ const GLOW_FACTOR = 0.55;
|
|
|
36
39
|
/** Space padding around the label inside the border (each side). */
|
|
37
40
|
const LABEL_PAD = 1;
|
|
38
41
|
// ---------------------------------------------------------------------------
|
|
39
|
-
// Color helpers: theme ANSI → RGB → boosted glow ANSI
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const BASIC16 = [
|
|
45
|
-
[0, 0, 0],
|
|
46
|
-
[128, 0, 0],
|
|
47
|
-
[0, 128, 0],
|
|
48
|
-
[128, 128, 0],
|
|
49
|
-
[0, 0, 128],
|
|
50
|
-
[128, 0, 128],
|
|
51
|
-
[0, 128, 128],
|
|
52
|
-
[192, 192, 192],
|
|
53
|
-
[128, 128, 128],
|
|
54
|
-
[255, 0, 0],
|
|
55
|
-
[0, 255, 0],
|
|
56
|
-
[255, 255, 0],
|
|
57
|
-
[0, 0, 255],
|
|
58
|
-
[255, 0, 255],
|
|
59
|
-
[0, 255, 255],
|
|
60
|
-
[255, 255, 255],
|
|
61
|
-
];
|
|
62
|
-
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
63
|
-
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
|
64
|
-
function indexToRgb(n) {
|
|
65
|
-
if (n >= 0 && n < 16) {
|
|
66
|
-
const [r, g, b] = BASIC16[n] ?? [0, 0, 0];
|
|
67
|
-
return { r, g, b };
|
|
68
|
-
}
|
|
69
|
-
if (n >= 16 && n <= 231) {
|
|
70
|
-
const v = n - 16;
|
|
71
|
-
return {
|
|
72
|
-
r: CUBE_VALUES[Math.floor(v / 36)] ?? 0,
|
|
73
|
-
g: CUBE_VALUES[Math.floor(v / 6) % 6] ?? 0,
|
|
74
|
-
b: CUBE_VALUES[v % 6] ?? 0,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
if (n >= 232 && n <= 255) {
|
|
78
|
-
const gray = 8 + (n - 232) * 10;
|
|
79
|
-
return { r: gray, g: gray, b: gray };
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
/** Parse a Theme.getFgAnsi() escape back into RGB. */
|
|
84
|
-
function parseFgAnsiToRgb(theme, color) {
|
|
85
|
-
const ansi = theme.getFgAnsi(color);
|
|
86
|
-
const trueColor = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
87
|
-
if (trueColor)
|
|
88
|
-
return {
|
|
89
|
-
r: Number(trueColor[1]),
|
|
90
|
-
g: Number(trueColor[2]),
|
|
91
|
-
b: Number(trueColor[3]),
|
|
92
|
-
};
|
|
93
|
-
const palette = ansi.match(/38;5;(\d+)/);
|
|
94
|
-
if (palette)
|
|
95
|
-
return indexToRgb(Number(palette[1]));
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
function findClosestCubeIndex(value) {
|
|
99
|
-
let minDist = Infinity;
|
|
100
|
-
let minIdx = 0;
|
|
101
|
-
for (let i = 0; i < CUBE_VALUES.length; i++) {
|
|
102
|
-
const dist = Math.abs(value - (CUBE_VALUES[i] ?? 0));
|
|
103
|
-
if (dist < minDist) {
|
|
104
|
-
minDist = dist;
|
|
105
|
-
minIdx = i;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return minIdx;
|
|
109
|
-
}
|
|
110
|
-
function findClosestGrayIndex(gray) {
|
|
111
|
-
let minDist = Infinity;
|
|
112
|
-
let minIdx = 0;
|
|
113
|
-
for (let i = 0; i < GRAY_VALUES.length; i++) {
|
|
114
|
-
const dist = Math.abs(gray - (GRAY_VALUES[i] ?? 0));
|
|
115
|
-
if (dist < minDist) {
|
|
116
|
-
minDist = dist;
|
|
117
|
-
minIdx = i;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return minIdx;
|
|
121
|
-
}
|
|
122
|
-
function colorDistance(r1, g1, b1, r2, g2, b2) {
|
|
123
|
-
const dr = r1 - r2;
|
|
124
|
-
const dg = g1 - g2;
|
|
125
|
-
const db = b1 - b2;
|
|
126
|
-
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
127
|
-
}
|
|
128
|
-
/** Quantize an RGB value to the closest xterm-256 index (same as the theme loader). */
|
|
129
|
-
function rgbTo256(r, g, b) {
|
|
130
|
-
const rIdx = findClosestCubeIndex(r);
|
|
131
|
-
const gIdx = findClosestCubeIndex(g);
|
|
132
|
-
const bIdx = findClosestCubeIndex(b);
|
|
133
|
-
const cubeR = CUBE_VALUES[rIdx] ?? 0;
|
|
134
|
-
const cubeG = CUBE_VALUES[gIdx] ?? 0;
|
|
135
|
-
const cubeB = CUBE_VALUES[bIdx] ?? 0;
|
|
136
|
-
const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
|
|
137
|
-
const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
|
|
138
|
-
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
139
|
-
const grayIdx = findClosestGrayIndex(gray);
|
|
140
|
-
const grayValue = GRAY_VALUES[grayIdx] ?? 0;
|
|
141
|
-
const grayIndex = 232 + grayIdx;
|
|
142
|
-
const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
|
|
143
|
-
const maxC = Math.max(r, g, b);
|
|
144
|
-
const minC = Math.min(r, g, b);
|
|
145
|
-
const spread = maxC - minC;
|
|
146
|
-
if (spread < 10 && grayDist < cubeDist)
|
|
147
|
-
return grayIndex;
|
|
148
|
-
return cubeIndex;
|
|
149
|
-
}
|
|
43
|
+
// Color helpers: theme token ANSI → RGB → boosted glow ANSI
|
|
44
|
+
// (RGB/256 quantisation lives in ansi-color.ts, shared with the chrome tiers)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
150
46
|
/**
|
|
151
47
|
* Build a border color function for a thinking level: takes the theme's
|
|
152
48
|
* per-level color and brightens it toward white proportionally to the level,
|
|
@@ -162,9 +58,7 @@ function buildGlow(theme, level) {
|
|
|
162
58
|
const r = Math.round(base.r + (255 - base.r) * t);
|
|
163
59
|
const g = Math.round(base.g + (255 - base.g) * t);
|
|
164
60
|
const b = Math.round(base.b + (255 - base.b) * t);
|
|
165
|
-
const ansi = theme.getColorMode()
|
|
166
|
-
? `\x1b[38;2;${r};${g};${b}m`
|
|
167
|
-
: `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
61
|
+
const ansi = rgbToFgAnsi({ r, g, b }, theme.getColorMode());
|
|
168
62
|
return (s) => `${ansi}${s}\x1b[39m`;
|
|
169
63
|
}
|
|
170
64
|
// ---------------------------------------------------------------------------
|
package/dist/utils.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Barrel — preserves the old import surface while the codebase migrates to
|
|
2
2
|
// focused modules. New code should import from the owning module directly:
|
|
3
3
|
// path-format → formatCwd, basenamePath, truncateBranch, truncatePath
|
|
4
|
-
// color-policy → stressColor, cacheHitColor, providerColor, effortColor
|
|
4
|
+
// color-policy → stressColor, cacheHitColor, contextUsageTier, providerColor, effortColor
|
|
5
5
|
// format → fmtTokens, formatDuration, formatModelLabel, formatProviderLabel, formatThinkingLabel, sanitizeStatus, stripAnsi
|
|
6
6
|
// layout → alignRight, fitSegmentsByPriority, isEditorBorderLine, findBottomBorderIndex, padRight, center, headerColumnWidths + width constants
|
|
7
7
|
// tip-policy → PI_BUILTIN_SLASH_COMMAND_NAMES, collectPiCommandNames, pickSlashCommandTips
|
|
8
8
|
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
9
|
export { formatCwd, basenamePath, truncateBranch, truncatePath } from "./path-format.js";
|
|
10
10
|
export { fmtTokens, formatDuration, formatModelLabel, formatProviderLabel, formatThinkingLabel, sanitizeStatus, stripAnsi, } from "./format.js";
|
|
11
|
-
export { stressColor, cacheHitColor,
|
|
11
|
+
export { stressColor, cacheHitColor, CONTEXT_TIER_HEX, CONTEXT_TIER_THEME_COLOR, contextUsageTier, providerColor, effortColor, } from "./color-policy.js";
|
|
12
12
|
export { alignRight, fitSegmentsByPriority, isEditorBorderLine, findBottomBorderIndex, padRight, center, headerColumnWidths, MIN_LEFT_WIDTH, MIN_TIPS_WIDTH, MAX_TIPS_WIDTH, } from "./layout.js";
|
|
13
13
|
export { PI_BUILTIN_SLASH_COMMAND_NAMES, collectPiCommandNames, pickSlashCommandTips, } from "./tip-policy.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnsiColor — hex → SGR foreground emission + the shared RGB/256 quantizer.
|
|
3
|
+
*
|
|
4
|
+
* Problem it solves: the pi theme resolves *named* tokens only
|
|
5
|
+
* (`theme.fg("success", s)`) and throws `Unknown theme color: …` for anything
|
|
6
|
+
* else, so a caller that needs an exact hex (context-pressure tiers) cannot
|
|
7
|
+
* reach the theme's own `fgAnsi`. Reimplementing the conversion per call site
|
|
8
|
+
* duplicated ~100 lines of color math that the thinking-border glow in
|
|
9
|
+
* model-info.ts had already written.
|
|
10
|
+
*
|
|
11
|
+
* Depth: one interface (`hexFg`) hides hex parsing, 256-cube quantization and
|
|
12
|
+
* terminal color-mode branching. Two adapters (chrome context tiers, model-info
|
|
13
|
+
* glow) justify the seam.
|
|
14
|
+
*/
|
|
15
|
+
/** Terminal color fidelity reported by the pi theme. */
|
|
16
|
+
export type ColorMode = "truecolor" | "256color";
|
|
17
|
+
|
|
18
|
+
export interface Rgb {
|
|
19
|
+
r: number;
|
|
20
|
+
g: number;
|
|
21
|
+
b: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Structural subset of the pi theme needed to read a token's ANSI sequence. */
|
|
25
|
+
export interface FgAnsiSource {
|
|
26
|
+
getFgAnsi(color: string): string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BASIC16: ReadonlyArray<readonly [number, number, number]> = [
|
|
30
|
+
[0, 0, 0],
|
|
31
|
+
[128, 0, 0],
|
|
32
|
+
[0, 128, 0],
|
|
33
|
+
[128, 128, 0],
|
|
34
|
+
[0, 0, 128],
|
|
35
|
+
[128, 0, 128],
|
|
36
|
+
[0, 128, 128],
|
|
37
|
+
[192, 192, 192],
|
|
38
|
+
[128, 128, 128],
|
|
39
|
+
[255, 0, 0],
|
|
40
|
+
[0, 255, 0],
|
|
41
|
+
[255, 255, 0],
|
|
42
|
+
[0, 0, 255],
|
|
43
|
+
[255, 0, 255],
|
|
44
|
+
[0, 255, 255],
|
|
45
|
+
[255, 255, 255],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** 6x6x6 color-cube channel values (indices 0-5). */
|
|
49
|
+
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
50
|
+
|
|
51
|
+
/** Grayscale ramp values (indices 232-255: 24 grays from 8 to 238). */
|
|
52
|
+
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
|
53
|
+
|
|
54
|
+
/** Anything but the truecolor marker means the 256-color ramp. */
|
|
55
|
+
export function normalizeColorMode(mode: string | undefined): ColorMode {
|
|
56
|
+
return mode === "truecolor" ? "truecolor" : "256color";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function indexToRgb(n: number): Rgb | null {
|
|
60
|
+
if (n >= 0 && n < 16) {
|
|
61
|
+
const [r, g, b] = BASIC16[n] ?? [0, 0, 0];
|
|
62
|
+
return { r, g, b };
|
|
63
|
+
}
|
|
64
|
+
if (n >= 16 && n <= 231) {
|
|
65
|
+
const v = n - 16;
|
|
66
|
+
return {
|
|
67
|
+
r: CUBE_VALUES[Math.floor(v / 36)] ?? 0,
|
|
68
|
+
g: CUBE_VALUES[Math.floor(v / 6) % 6] ?? 0,
|
|
69
|
+
b: CUBE_VALUES[v % 6] ?? 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (n >= 232 && n <= 255) {
|
|
73
|
+
const gray = 8 + (n - 232) * 10;
|
|
74
|
+
return { r: gray, g: gray, b: gray };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findClosestCubeIndex(value: number): number {
|
|
80
|
+
let minDist = Infinity;
|
|
81
|
+
let minIdx = 0;
|
|
82
|
+
for (let i = 0; i < CUBE_VALUES.length; i++) {
|
|
83
|
+
const dist = Math.abs(value - (CUBE_VALUES[i] ?? 0));
|
|
84
|
+
if (dist < minDist) {
|
|
85
|
+
minDist = dist;
|
|
86
|
+
minIdx = i;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return minIdx;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findClosestGrayIndex(gray: number): number {
|
|
93
|
+
let minDist = Infinity;
|
|
94
|
+
let minIdx = 0;
|
|
95
|
+
for (let i = 0; i < GRAY_VALUES.length; i++) {
|
|
96
|
+
const dist = Math.abs(gray - (GRAY_VALUES[i] ?? 0));
|
|
97
|
+
if (dist < minDist) {
|
|
98
|
+
minDist = dist;
|
|
99
|
+
minIdx = i;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return minIdx;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function colorDistance(
|
|
106
|
+
r1: number,
|
|
107
|
+
g1: number,
|
|
108
|
+
b1: number,
|
|
109
|
+
r2: number,
|
|
110
|
+
g2: number,
|
|
111
|
+
b2: number,
|
|
112
|
+
): number {
|
|
113
|
+
const dr = r1 - r2;
|
|
114
|
+
const dg = g1 - g2;
|
|
115
|
+
const db = b1 - b2;
|
|
116
|
+
// Weighted Euclidean distance (the eye is more sensitive to green).
|
|
117
|
+
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Quantize RGB to the closest xterm-256 index (same rule as the theme loader). */
|
|
121
|
+
export function rgbTo256(r: number, g: number, b: number): number {
|
|
122
|
+
const rIdx = findClosestCubeIndex(r);
|
|
123
|
+
const gIdx = findClosestCubeIndex(g);
|
|
124
|
+
const bIdx = findClosestCubeIndex(b);
|
|
125
|
+
const cubeR = CUBE_VALUES[rIdx] ?? 0;
|
|
126
|
+
const cubeG = CUBE_VALUES[gIdx] ?? 0;
|
|
127
|
+
const cubeB = CUBE_VALUES[bIdx] ?? 0;
|
|
128
|
+
const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
|
|
129
|
+
const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
|
|
130
|
+
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
131
|
+
const grayIdx = findClosestGrayIndex(gray);
|
|
132
|
+
const grayValue = GRAY_VALUES[grayIdx] ?? 0;
|
|
133
|
+
const grayIndex = 232 + grayIdx;
|
|
134
|
+
const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
|
|
135
|
+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
136
|
+
// Only consider grayscale when the color is nearly neutral AND closer.
|
|
137
|
+
if (spread < 10 && grayDist < cubeDist) return grayIndex;
|
|
138
|
+
return cubeIndex;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse `#RRGGBB`. Throws on malformed input — callers pass literals, not user data. */
|
|
142
|
+
export function hexToRgb(hex: string): Rgb {
|
|
143
|
+
const cleaned = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
144
|
+
if (cleaned.length !== 6) throw new Error(`Invalid hex color: ${hex}`);
|
|
145
|
+
const { r, g, b } = {
|
|
146
|
+
r: parseInt(cleaned.slice(0, 2), 16),
|
|
147
|
+
g: parseInt(cleaned.slice(2, 4), 16),
|
|
148
|
+
b: parseInt(cleaned.slice(4, 6), 16),
|
|
149
|
+
};
|
|
150
|
+
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
|
151
|
+
throw new Error(`Invalid hex color: ${hex}`);
|
|
152
|
+
}
|
|
153
|
+
return { r, g, b };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Parse a theme token's ANSI escape back into RGB (truecolor or 256 index). */
|
|
157
|
+
export function parseFgAnsiToRgb(theme: FgAnsiSource, color: string): Rgb | null {
|
|
158
|
+
const ansi = theme.getFgAnsi(color);
|
|
159
|
+
const trueColor = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
160
|
+
if (trueColor) {
|
|
161
|
+
return {
|
|
162
|
+
r: Number(trueColor[1]),
|
|
163
|
+
g: Number(trueColor[2]),
|
|
164
|
+
b: Number(trueColor[3]),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const palette = ansi.match(/38;5;(\d+)/);
|
|
168
|
+
if (palette) return indexToRgb(Number(palette[1]));
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** SGR foreground sequence for an RGB value at the terminal's color fidelity. */
|
|
173
|
+
export function rgbToFgAnsi({ r, g, b }: Rgb, mode: string | undefined): string {
|
|
174
|
+
return normalizeColorMode(mode) === "truecolor"
|
|
175
|
+
? `\x1b[38;2;${r};${g};${b}m`
|
|
176
|
+
: `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Color `s` with an exact hex, bypassing the theme's named-token lookup.
|
|
181
|
+
* Returned painter resets only the foreground (`\x1b[39m`), matching `theme.fg`.
|
|
182
|
+
*/
|
|
183
|
+
export function hexFg(hex: string, mode: string | undefined): (s: string) => string {
|
|
184
|
+
const ansi = rgbToFgAnsi(hexToRgb(hex), mode);
|
|
185
|
+
return (s: string) => `${ansi}${s}\x1b[39m`;
|
|
186
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* thus required touching 3-4 modules with no locality.
|
|
9
9
|
*
|
|
10
10
|
* Depth: one small interface (glyphs + isAscii + fg/dim/glow + format* helpers) hides
|
|
11
|
-
* icon-mode resolution, the SAFETY theme cast,
|
|
11
|
+
* icon-mode resolution, the SAFETY theme cast, color application, and the chrome format
|
|
12
12
|
* entry points (context bar, telemetry, tokens, run activity, stall). Callers learn one
|
|
13
13
|
* shape; LiveBorder's islands become thin lookups.
|
|
14
14
|
*
|
|
@@ -26,20 +26,30 @@ import type { RunActivitySnapshot } from "./run-activity.js";
|
|
|
26
26
|
import { formatRunActivityTopRight } from "./run-activity.js";
|
|
27
27
|
import type { TelemetryConfig, TurnTelemetry } from "./telemetry.js";
|
|
28
28
|
import { formatTelemetryTokens, formatTurnDuration, formatTurnTelemetry } from "./telemetry.js";
|
|
29
|
+
import { hexFg } from "./ansi-color.js";
|
|
29
30
|
|
|
30
31
|
/** Typed fg surface — the only theme capability the chrome needs. */
|
|
31
32
|
export interface ChromeThemeLike {
|
|
32
33
|
fg(style: string, s: string): string;
|
|
34
|
+
fgHex(hex: string, s: string): string;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
|
-
* Read the live pi theme into a typed { fg } surface. The cast is a SAFETY
|
|
37
|
-
* missing `fg` degrades to identity rather than throwing, keeping
|
|
38
|
+
* Read the live pi theme into a typed { fg, fgHex } surface. The cast is a SAFETY
|
|
39
|
+
* seam — a pi theme missing `fg` degrades to identity rather than throwing, keeping
|
|
40
|
+
* the chrome resilient. `fgHex` needs no theme call at all: the named-token lookup
|
|
41
|
+
* rejects raw hex, so hex is rendered straight to SGR at the theme's color mode.
|
|
38
42
|
*/
|
|
39
43
|
export function adaptTheme(rawTheme: unknown): ChromeThemeLike {
|
|
40
|
-
const t = rawTheme as {
|
|
44
|
+
const t = rawTheme as {
|
|
45
|
+
fg?: (style: string, s: string) => string;
|
|
46
|
+
// SAFETY: pi theme seam — getColorMode is optional, defaulted below
|
|
47
|
+
getColorMode?: () => string;
|
|
48
|
+
};
|
|
49
|
+
const mode = typeof t.getColorMode === "function" ? t.getColorMode() : undefined;
|
|
41
50
|
return {
|
|
42
51
|
fg: (style, s) => (typeof t.fg === "function" ? t.fg(style, s) : s),
|
|
52
|
+
fgHex: (hex, s) => hexFg(hex, mode)(s),
|
|
43
53
|
};
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -80,7 +90,7 @@ export class ChromeComposition {
|
|
|
80
90
|
this.glow = opts.glow ?? resolveGlow(rawTheme);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
/**
|
|
93
|
+
/** Color a string with a theme style (derived once, cast cached). */
|
|
84
94
|
fg(style: string, s: string): string {
|
|
85
95
|
return this.theme.fg(style, s);
|
|
86
96
|
}
|
package/src/chrome-state.ts
CHANGED
|
@@ -21,7 +21,13 @@ import type { FooterState, UsageTotals } from "./state.js";
|
|
|
21
21
|
import { getUsageTotals } from "./state.js";
|
|
22
22
|
import type { IconGlyphs } from "./icons.js";
|
|
23
23
|
import type { Theme } from "./layout.js";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
cacheHitColor,
|
|
26
|
+
CONTEXT_TIER_HEX,
|
|
27
|
+
CONTEXT_TIER_THEME_COLOR,
|
|
28
|
+
contextUsageTier,
|
|
29
|
+
type ContextTier,
|
|
30
|
+
} from "./color-policy.js";
|
|
25
31
|
import { fmtTokens } from "./format.js";
|
|
26
32
|
|
|
27
33
|
export interface ContextUsage {
|
|
@@ -44,15 +50,31 @@ export interface ChromeSnapshot {
|
|
|
44
50
|
// Re-exported from footer.ts for backward compatibility.
|
|
45
51
|
// ---------------------------------------------------------------------------
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Painter for one context tier: exact hex when the live theme can emit raw hex,
|
|
55
|
+
* else the nearest semantic token (tests/mocks, themes without color mode).
|
|
56
|
+
*/
|
|
57
|
+
function contextPainter(theme: Theme, tier: ContextTier): (s: string) => string {
|
|
58
|
+
const hex = CONTEXT_TIER_HEX[tier];
|
|
59
|
+
const fgHex = theme.fgHex;
|
|
60
|
+
if (fgHex) return (s) => fgHex.call(theme, hex, s);
|
|
61
|
+
return (s) => theme.fg(CONTEXT_TIER_THEME_COLOR[tier], s);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderBar(
|
|
65
|
+
theme: Theme,
|
|
66
|
+
paint: (s: string) => string,
|
|
67
|
+
pct: number,
|
|
68
|
+
barWidth: number,
|
|
69
|
+
ascii: boolean,
|
|
70
|
+
): string {
|
|
48
71
|
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
49
72
|
const empty = barWidth - filled;
|
|
50
|
-
const color = contextUsageColor(pct);
|
|
51
73
|
const filledCell = ascii ? "#" : "█";
|
|
52
74
|
const emptyCell = ascii ? "-" : "░";
|
|
53
75
|
return (
|
|
54
76
|
theme.fg("dim", "[") +
|
|
55
|
-
|
|
77
|
+
paint(filledCell.repeat(filled)) +
|
|
56
78
|
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
57
79
|
theme.fg("dim", "]")
|
|
58
80
|
);
|
|
@@ -70,13 +92,13 @@ export function formatContextBar(
|
|
|
70
92
|
const contextWindow = contextUsage?.contextWindow ?? 0;
|
|
71
93
|
if (contextWindow <= 0) return "";
|
|
72
94
|
const contextPct = contextUsage?.percent ?? 0;
|
|
73
|
-
const
|
|
74
|
-
const pctText =
|
|
95
|
+
const paint = contextPainter(theme, contextUsageTier(contextPct, contextWindow));
|
|
96
|
+
const pctText = paint(`${contextPct.toFixed(1)}%`);
|
|
75
97
|
const contextTokens = contextUsage?.tokens ?? 0;
|
|
76
|
-
const ctxText = `${
|
|
98
|
+
const ctxText = `${paint(fmtTokens(contextTokens))}${theme.fg("dim", "/")}${paint(fmtTokens(contextWindow))}`;
|
|
77
99
|
const baseCore = `${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
78
100
|
const base = showIconBar
|
|
79
|
-
? `${
|
|
101
|
+
? `${paint(glyphs.context)} ${renderBar(theme, paint, contextPct, barWidth, isAscii)} ${baseCore}`
|
|
80
102
|
: baseCore;
|
|
81
103
|
const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate) ? cacheHitRate : 0;
|
|
82
104
|
const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
|
package/src/color-policy.ts
CHANGED
|
@@ -7,21 +7,40 @@ export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
|
|
|
7
7
|
return "accent";
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
10
|
+
/** Context-pressure tier: green → amber → red as the window fills. */
|
|
11
|
+
export type ContextTier = "ok" | "warn" | "critical";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fixed tier palette (nord green / nord yellow / dark red). Hex, not theme tokens:
|
|
15
|
+
* the tiers are an alarm scale the user picked explicitly, so they must read the
|
|
16
|
+
* same on every theme. `CONTEXT_TIER_THEME_COLOR` is only the fallback for themes
|
|
17
|
+
* that cannot emit raw hex.
|
|
18
|
+
*/
|
|
19
|
+
export const CONTEXT_TIER_HEX: Record<ContextTier, string> = {
|
|
20
|
+
ok: "#A3BE8C",
|
|
21
|
+
warn: "#EBCB8B",
|
|
22
|
+
critical: "#9A3939",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Semantic token approximating each tier — used when raw hex is unavailable. */
|
|
26
|
+
export const CONTEXT_TIER_THEME_COLOR: Record<ContextTier, ThemeColor> = {
|
|
27
|
+
ok: "success",
|
|
28
|
+
warn: "warning",
|
|
29
|
+
critical: "error",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tier from the share of the context window consumed.
|
|
34
|
+
*
|
|
35
|
+
* Budgets scale with the window: a 1M window only earns 12.5 / 25 % of slack
|
|
36
|
+
* before the same alarm, while smaller windows (compacted far sooner) keep the
|
|
37
|
+
* looser 25 / 50 % steps.
|
|
38
|
+
*/
|
|
39
|
+
export function contextUsageTier(pct: number, contextWindow: number): ContextTier {
|
|
40
|
+
const [warnAt, criticalAt] = contextWindow >= 1_000_000 ? [12.5, 25] : [25, 50];
|
|
41
|
+
if (pct >= criticalAt) return "critical";
|
|
42
|
+
if (pct >= warnAt) return "warn";
|
|
43
|
+
return "ok";
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
export function cacheHitColor(value: number): ThemeColor {
|
package/src/layout.ts
CHANGED
|
@@ -6,6 +6,12 @@ export { stripAnsi } from "./format.js";
|
|
|
6
6
|
|
|
7
7
|
export interface Theme {
|
|
8
8
|
fg(style: string, s: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Optional raw-hex foreground. The pi theme resolves named tokens only and
|
|
11
|
+
* throws on unknown names, so exact-hex callers (context tiers) need this
|
|
12
|
+
* escape hatch; adapters that cannot provide it fall back to semantic tokens.
|
|
13
|
+
*/
|
|
14
|
+
fgHex?(hex: string, s: string): string;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
export type PrioritizedSegment = {
|
package/src/model-info.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* model-info-widget/index.ts, MIT-style personal extension) so that the
|
|
6
6
|
* TrackingEditor — which owns the editor slot for this extension — can keep
|
|
7
7
|
* rendering the model label and thinking-level border glow that the original
|
|
8
|
-
* widget provided. Self-contained
|
|
8
|
+
* widget provided. Self-contained apart from pi-tui width utils, the shared
|
|
9
|
+
* ANSI quantizer (ansi-color.ts) and stripAnsi (format.ts).
|
|
9
10
|
*
|
|
10
11
|
* The port is intentional: pi allows exactly ONE custom editor (last
|
|
11
12
|
* `setEditorComponent` writer wins). pi-skill-desc must own the slot to track
|
|
@@ -13,6 +14,8 @@
|
|
|
13
14
|
* its visual behavior lives here instead. See docs/adr/0001.
|
|
14
15
|
*/
|
|
15
16
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import { parseFgAnsiToRgb, rgbToFgAnsi } from "./ansi-color.js";
|
|
18
|
+
import { stripAnsi } from "./format.js";
|
|
16
19
|
|
|
17
20
|
/** Structural subset of pi's Theme used by the glow/label rendering. */
|
|
18
21
|
export interface ThemeLike {
|
|
@@ -62,133 +65,10 @@ const GLOW_FACTOR = 0.55;
|
|
|
62
65
|
const LABEL_PAD = 1;
|
|
63
66
|
|
|
64
67
|
// ---------------------------------------------------------------------------
|
|
65
|
-
// Color helpers: theme ANSI → RGB → boosted glow ANSI
|
|
66
68
|
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const BASIC16: Array<[number, number, number]> = [
|
|
73
|
-
[0, 0, 0],
|
|
74
|
-
[128, 0, 0],
|
|
75
|
-
[0, 128, 0],
|
|
76
|
-
[128, 128, 0],
|
|
77
|
-
[0, 0, 128],
|
|
78
|
-
[128, 0, 128],
|
|
79
|
-
[0, 128, 128],
|
|
80
|
-
[192, 192, 192],
|
|
81
|
-
[128, 128, 128],
|
|
82
|
-
[255, 0, 0],
|
|
83
|
-
[0, 255, 0],
|
|
84
|
-
[255, 255, 0],
|
|
85
|
-
[0, 0, 255],
|
|
86
|
-
[255, 0, 255],
|
|
87
|
-
[0, 255, 255],
|
|
88
|
-
[255, 255, 255],
|
|
89
|
-
];
|
|
90
|
-
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
91
|
-
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
|
92
|
-
|
|
93
|
-
function indexToRgb(n: number): { r: number; g: number; b: number } | null {
|
|
94
|
-
if (n >= 0 && n < 16) {
|
|
95
|
-
const [r, g, b] = BASIC16[n] ?? [0, 0, 0];
|
|
96
|
-
return { r, g, b };
|
|
97
|
-
}
|
|
98
|
-
if (n >= 16 && n <= 231) {
|
|
99
|
-
const v = n - 16;
|
|
100
|
-
return {
|
|
101
|
-
r: CUBE_VALUES[Math.floor(v / 36)] ?? 0,
|
|
102
|
-
g: CUBE_VALUES[Math.floor(v / 6) % 6] ?? 0,
|
|
103
|
-
b: CUBE_VALUES[v % 6] ?? 0,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
if (n >= 232 && n <= 255) {
|
|
107
|
-
const gray = 8 + (n - 232) * 10;
|
|
108
|
-
return { r: gray, g: gray, b: gray };
|
|
109
|
-
}
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Parse a Theme.getFgAnsi() escape back into RGB. */
|
|
114
|
-
function parseFgAnsiToRgb(
|
|
115
|
-
theme: ThemeLike,
|
|
116
|
-
color: string,
|
|
117
|
-
): { r: number; g: number; b: number } | null {
|
|
118
|
-
const ansi = theme.getFgAnsi(color);
|
|
119
|
-
const trueColor = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
120
|
-
if (trueColor)
|
|
121
|
-
return {
|
|
122
|
-
r: Number(trueColor[1]),
|
|
123
|
-
g: Number(trueColor[2]),
|
|
124
|
-
b: Number(trueColor[3]),
|
|
125
|
-
};
|
|
126
|
-
const palette = ansi.match(/38;5;(\d+)/);
|
|
127
|
-
if (palette) return indexToRgb(Number(palette[1]));
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function findClosestCubeIndex(value: number): number {
|
|
132
|
-
let minDist = Infinity;
|
|
133
|
-
let minIdx = 0;
|
|
134
|
-
for (let i = 0; i < CUBE_VALUES.length; i++) {
|
|
135
|
-
const dist = Math.abs(value - (CUBE_VALUES[i] ?? 0));
|
|
136
|
-
if (dist < minDist) {
|
|
137
|
-
minDist = dist;
|
|
138
|
-
minIdx = i;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return minIdx;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function findClosestGrayIndex(gray: number): number {
|
|
145
|
-
let minDist = Infinity;
|
|
146
|
-
let minIdx = 0;
|
|
147
|
-
for (let i = 0; i < GRAY_VALUES.length; i++) {
|
|
148
|
-
const dist = Math.abs(gray - (GRAY_VALUES[i] ?? 0));
|
|
149
|
-
if (dist < minDist) {
|
|
150
|
-
minDist = dist;
|
|
151
|
-
minIdx = i;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return minIdx;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function colorDistance(
|
|
158
|
-
r1: number,
|
|
159
|
-
g1: number,
|
|
160
|
-
b1: number,
|
|
161
|
-
r2: number,
|
|
162
|
-
g2: number,
|
|
163
|
-
b2: number,
|
|
164
|
-
): number {
|
|
165
|
-
const dr = r1 - r2;
|
|
166
|
-
const dg = g1 - g2;
|
|
167
|
-
const db = b1 - b2;
|
|
168
|
-
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Quantize an RGB value to the closest xterm-256 index (same as the theme loader). */
|
|
172
|
-
function rgbTo256(r: number, g: number, b: number): number {
|
|
173
|
-
const rIdx = findClosestCubeIndex(r);
|
|
174
|
-
const gIdx = findClosestCubeIndex(g);
|
|
175
|
-
const bIdx = findClosestCubeIndex(b);
|
|
176
|
-
const cubeR = CUBE_VALUES[rIdx] ?? 0;
|
|
177
|
-
const cubeG = CUBE_VALUES[gIdx] ?? 0;
|
|
178
|
-
const cubeB = CUBE_VALUES[bIdx] ?? 0;
|
|
179
|
-
const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
|
|
180
|
-
const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
|
|
181
|
-
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
182
|
-
const grayIdx = findClosestGrayIndex(gray);
|
|
183
|
-
const grayValue = GRAY_VALUES[grayIdx] ?? 0;
|
|
184
|
-
const grayIndex = 232 + grayIdx;
|
|
185
|
-
const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
|
|
186
|
-
const maxC = Math.max(r, g, b);
|
|
187
|
-
const minC = Math.min(r, g, b);
|
|
188
|
-
const spread = maxC - minC;
|
|
189
|
-
if (spread < 10 && grayDist < cubeDist) return grayIndex;
|
|
190
|
-
return cubeIndex;
|
|
191
|
-
}
|
|
69
|
+
// Color helpers: theme token ANSI → RGB → boosted glow ANSI
|
|
70
|
+
// (RGB/256 quantization lives in ansi-color.ts, shared with the chrome tiers)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
192
72
|
|
|
193
73
|
/**
|
|
194
74
|
* Build a border color function for a thinking level: takes the theme's
|
|
@@ -205,10 +85,7 @@ function buildGlow(theme: ThemeLike, level: string): (s: string) => string {
|
|
|
205
85
|
const r = Math.round(base.r + (255 - base.r) * t);
|
|
206
86
|
const g = Math.round(base.g + (255 - base.g) * t);
|
|
207
87
|
const b = Math.round(base.b + (255 - base.b) * t);
|
|
208
|
-
const ansi =
|
|
209
|
-
theme.getColorMode() === "truecolor"
|
|
210
|
-
? `\x1b[38;2;${r};${g};${b}m`
|
|
211
|
-
: `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
88
|
+
const ansi = rgbToFgAnsi({ r, g, b }, theme.getColorMode());
|
|
212
89
|
return (s: string) => `${ansi}${s}\x1b[39m`;
|
|
213
90
|
}
|
|
214
91
|
|
package/src/utils.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Barrel — preserves the old import surface while the codebase migrates to
|
|
2
2
|
// focused modules. New code should import from the owning module directly:
|
|
3
3
|
// path-format → formatCwd, basenamePath, truncateBranch, truncatePath
|
|
4
|
-
// color-policy → stressColor, cacheHitColor, providerColor, effortColor
|
|
4
|
+
// color-policy → stressColor, cacheHitColor, contextUsageTier, providerColor, effortColor
|
|
5
5
|
// format → fmtTokens, formatDuration, formatModelLabel, formatProviderLabel, formatThinkingLabel, sanitizeStatus, stripAnsi
|
|
6
6
|
// layout → alignRight, fitSegmentsByPriority, isEditorBorderLine, findBottomBorderIndex, padRight, center, headerColumnWidths + width constants
|
|
7
7
|
// tip-policy → PI_BUILTIN_SLASH_COMMAND_NAMES, collectPiCommandNames, pickSlashCommandTips
|
|
8
8
|
|
|
9
9
|
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
10
|
|
|
11
|
-
export type { ThemeColor, ThinkingLevel } from "./color-policy.js";
|
|
11
|
+
export type { ThemeColor, ThinkingLevel, ContextTier } from "./color-policy.js";
|
|
12
12
|
export type { Theme, PrioritizedSegment } from "./layout.js";
|
|
13
13
|
|
|
14
14
|
export { formatCwd, basenamePath, truncateBranch, truncatePath } from "./path-format.js";
|
|
@@ -24,7 +24,9 @@ export {
|
|
|
24
24
|
export {
|
|
25
25
|
stressColor,
|
|
26
26
|
cacheHitColor,
|
|
27
|
-
|
|
27
|
+
CONTEXT_TIER_HEX,
|
|
28
|
+
CONTEXT_TIER_THEME_COLOR,
|
|
29
|
+
contextUsageTier,
|
|
28
30
|
providerColor,
|
|
29
31
|
effortColor,
|
|
30
32
|
} from "./color-policy.js";
|