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 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 seam — a pi theme
26
- * missing `fg` degrades to identity rather than throwing, keeping the chrome resilient.
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; // SAFETY: pi theme seam — fg is optional, guarded below
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. */
@@ -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, contextUsageColor } from "./color-policy.js";
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
- function renderBar(theme, pct, barWidth, ascii) {
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
- theme.fg(color, filledCell.repeat(filled)) +
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 contextColor = contextUsageColor(contextPct);
41
- const pctText = theme.fg(contextColor, `${contextPct.toFixed(1)}%`);
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 = `${theme.fg(contextColor, fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg(contextColor, fmtTokens(contextWindow))}`;
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
- ? `${theme.fg(contextColor, glyphs.context)} ${renderBar(theme, contextPct, barWidth, isAscii)} ${baseCore}`
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)}%`;
@@ -5,24 +5,37 @@ export function stressColor(value, warn = 70, danger = 90) {
5
5
  return "warning";
6
6
  return "accent";
7
7
  }
8
- export function contextUsageColor(pct) {
9
- // 12.5 / 25 / 50 quotas four urgency tiers, increasingly aggressive as context fills.
10
- // 0 12.5% dim — plenty of headroom, visually quiet.
11
- // 12.5 25% accent — first nudge, noticeable but calm.
12
- // 25 50% warning — half consumed, needs attention.
13
- // 50 – 100% error — critical, about to run out.
14
- // Uses theme semantic tokens (dim/accent/warning/error) so the progression
15
- // respects the active theme and remains legible on light/dark/custom palettes.
16
- // A fixed hex palette (e.g. grey→sky→amber→red) would be more vivid but
17
- // would ignore the user's theme and can clash with light backgrounds —
18
- // semantic tokens keep the "aggressive" ordering while staying theme-coherent.
19
- if (pct >= 50)
20
- return "error";
21
- if (pct >= 25)
22
- return "warning";
23
- if (pct >= 12.5)
24
- return "accent";
25
- return "dim";
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)
@@ -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: no imports beyond pi-tui's width utils.
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
- function stripAnsi(s) {
42
- return s.replace(/\x1b\[[0-9;]*m/g, "");
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() === "truecolor"
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, contextUsageColor, providerColor, effortColor, } from "./color-policy.js";
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
@@ -33,7 +33,7 @@
33
33
  "@earendil-works/pi-coding-agent": "*",
34
34
  "@earendil-works/pi-tui": "*"
35
35
  },
36
- "version": "0.12.1",
36
+ "version": "0.12.2",
37
37
  "files": [
38
38
  "dist",
39
39
  "src",
@@ -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, colour application, and the chrome format
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 seam — a pi theme
37
- * missing `fg` degrades to identity rather than throwing, keeping the chrome resilient.
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 { fg?: (style: string, s: string) => string }; // SAFETY: pi theme seam — fg is optional, guarded below
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
- /** Colour a string with a theme style (derived once, cast cached). */
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
  }
@@ -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 { cacheHitColor, contextUsageColor } from "./color-policy.js";
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
- function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
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
- theme.fg(color, filledCell.repeat(filled)) +
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 contextColor = contextUsageColor(contextPct);
74
- const pctText = theme.fg(contextColor, `${contextPct.toFixed(1)}%`);
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 = `${theme.fg(contextColor, fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg(contextColor, fmtTokens(contextWindow))}`;
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
- ? `${theme.fg(contextColor, glyphs.context)} ${renderBar(theme, contextPct, barWidth, isAscii)} ${baseCore}`
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)}%`;
@@ -7,21 +7,40 @@ export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
7
7
  return "accent";
8
8
  }
9
9
 
10
- export function contextUsageColor(pct: number): ThemeColor {
11
- // 12.5 / 25 / 50 quotas four urgency tiers, increasingly aggressive as context fills.
12
- // 0 – 12.5% dim — plenty of headroom, visually quiet.
13
- // 12.5 – 25% accent — first nudge, noticeable but calm.
14
- // 25 50% warning — half consumed, needs attention.
15
- // 50 100% error — critical, about to run out.
16
- // Uses theme semantic tokens (dim/accent/warning/error) so the progression
17
- // respects the active theme and remains legible on light/dark/custom palettes.
18
- // A fixed hex palette (e.g. grey→sky→amber→red) would be more vivid but
19
- // would ignore the user's theme and can clash with light backgrounds —
20
- // semantic tokens keep the "aggressive" ordering while staying theme-coherent.
21
- if (pct >= 50) return "error";
22
- if (pct >= 25) return "warning";
23
- if (pct >= 12.5) return "accent";
24
- return "dim";
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: no imports beyond pi-tui's width utils.
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
- function stripAnsi(s: string): string {
69
- return s.replace(/\x1b\[[0-9;]*m/g, "");
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
- contextUsageColor,
27
+ CONTEXT_TIER_HEX,
28
+ CONTEXT_TIER_THEME_COLOR,
29
+ contextUsageTier,
28
30
  providerColor,
29
31
  effortColor,
30
32
  } from "./color-policy.js";