pi-editor-footer 0.12.1 → 0.12.3

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,33 @@
1
+ # Changelog
2
+
3
+ ## [0.12.3](https://github.com/Rianico/pi-editor-footer/compare/v0.12.2...v0.12.3) (2026-09-17)
4
+
5
+ ### Bug Fixes
6
+
7
+ * **footer:** count total input tokens in footer statistics ([a2153a1](https://github.com/Rianico/pi-editor-footer/commit/a2153a1f0f4884e3879a610744a9e664a9124053))
8
+
9
+ ### Documentation
10
+
11
+ * **agents:** mark agent-run token accounting a scope boundary ([ffbbee2](https://github.com/Rianico/pi-editor-footer/commit/ffbbee29300a40d6963e0916c7526e0a1ce49881))
12
+ * document total input token semantics for the footer ([9480bf9](https://github.com/Rianico/pi-editor-footer/commit/9480bf9f1508930d75a5e53b94d944d00d1f6a2a))
13
+
14
+ <!-- markdownlint-configure-file { "MD004": { "style": "asterisk" } } -->
15
+ <!-- Bullets stay `*`: semantic-release's preset writes `*`, and this file is excluded from the
16
+ formatters so they cannot normalize them to `-`. Keep `# Changelog` as the FIRST line:
17
+ @semantic-release/changelog rewrites the title in place only while the file starts with
18
+ the configured `changelogTitle`, and prepends release notes above it otherwise. Do not
19
+ move this title below the comments, and do not remove the title. -->
20
+
21
+ All notable changes to this project will be documented in this file.
22
+
23
+ ## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
24
+
25
+ ### Bug Fixes
26
+
27
+ * **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))
28
+
29
+ ## [Unreleased]
30
+
1
31
  ## [0.12.1](https://github.com/Rianico/pi-editor-footer/compare/v0.12.0...v0.12.1) (2026-09-07)
2
32
 
3
33
  ### Bug Fixes
@@ -16,10 +46,6 @@
16
46
  * allow pnpm builds for esbuild and genai ([36e139e](https://github.com/Rianico/pi-editor-footer/commit/36e139e9443ef928a4366c3d7c8afe494b6e0850))
17
47
  * make prepare tolerant to missing husky for pi install ([23ebb62](https://github.com/Rianico/pi-editor-footer/commit/23ebb62cd8d732656fd113da27c807ffa34579fc))
18
48
 
19
- # Changelog
20
-
21
- All notable changes to this project will be documented in this file.
22
-
23
49
  ## [0.11.0] - 2026-08-29
24
50
 
25
51
  ### Changed
package/README.md CHANGED
@@ -12,8 +12,8 @@ A [pi](https://pi.dev) extension that turns the editor chrome into a project-awa
12
12
 
13
13
  **Footer** — single line below the input, responsive via `fitSegmentsByPriority` and `alignRight`:
14
14
  - Left: `cwd` (`·` `git branch` + status `[! ? + ↑↓]` + stashed/conflicted) `•` `runtime` (`node`/`python`/`rust`/`go`… + version) `•` `timer` (`working`/`done`)
15
- - Right: `tokens` (` input | output |  $cost`) immediately next to `context` (` [bar] % · tokens/contextWindow`)
16
- - Separators: `·` between `cwd` and `git`, `•` as default between other left components; `tokens` sits directly left of the context bar
15
+ - Right: `tokens` (`↑ <total input> · <output> · $<cost>`) `↑` counts every billed input token (uncached input + cache read + cache write), so it tracks the context bar's token figure instead of `usage.input` alone
16
+ - Separators: `·` between `cwd` and `git`, `•` as default between other left components; the `context` bar (`<pct> · <tokens>/<window> | c <hit%>`) sits on the top border next to the model label, not in the footer
17
17
  - `cwd` respects `workspaceDisplay` (`~/development/ai/pi-skill-desc` vs `pi-skill-desc`, switchable in settings)
18
18
  - `context` bar uses `stressColor` and `renderBar` (12 cols max) with `•`/`·` handling
19
19
  - Extension statuses line (`wrapTextWithAnsi`) when `footerSegments.extensionStatuses`
@@ -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 color-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-color 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
+ /** Quantize 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 color 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 color 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
+ * Color `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
+ }
@@ -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
  *
@@ -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 color 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. */
@@ -57,7 +62,7 @@ export class ChromeComposition {
57
62
  this.theme = adaptTheme(rawTheme);
58
63
  this.glow = opts.glow ?? resolveGlow(rawTheme);
59
64
  }
60
- /** Colour a string with a theme style (derived once, cast cached). */
65
+ /** Color a string with a theme style (derived once, cast cached). */
61
66
  fg(style, s) {
62
67
  return this.theme.fg(style, s);
63
68
  }
@@ -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 color 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)
package/dist/footer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { getUsageTotals } from "./state.js";
2
+ import { getUsageTotals, totalInputTokens } from "./state.js";
3
3
  import { runtimeSymbol } from "./icons.js";
4
4
  import { ChromeComposition } from "./chrome-composition.js";
5
5
  import { alignRight, fitSegmentsByPriority, } from "./layout.js";
@@ -141,7 +141,9 @@ export function renderFooter(width, state, config, theme, ctx) {
141
141
  }
142
142
  const stats = [];
143
143
  if (segments.tokens) {
144
- stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
144
+ // Total input billed for the model — includes the cached prompt prefix, not just
145
+ // `usage.input` (which excludes cacheRead/cacheWrite). See totalInputTokens.
146
+ stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totalInputTokens(totals))}`));
145
147
  stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
146
148
  }
147
149
  if (segments.cost) {
@@ -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
  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 quantization 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/state.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { emptyGitStatus } from "./git.js";
2
2
  import { fmtTokens } from "./format.js";
3
3
  import { formatProviderLabel } from "./format.js";
4
+ /**
5
+ * Total input tokens billed to the model: uncached input + cache read + cache write.
6
+ *
7
+ * pi-ai `Usage.input` excludes the cached prompt prefix (it lives in cacheRead/cacheWrite),
8
+ * so `input` alone under-reports the prompt by the whole cached history on every turn.
9
+ */
10
+ export function totalInputTokens(totals) {
11
+ return totals.input + totals.cacheRead + totals.cacheWrite;
12
+ }
4
13
  let usageCache;
5
14
  function entriesKey(ctx) {
6
15
  const entries = ctx.sessionManager?.getEntries() ?? [];
@@ -8,9 +17,7 @@ function entriesKey(ctx) {
8
17
  return `${entries.length}:${String(last?.id ?? "")}:${String(last?.timestamp ?? "")}`;
9
18
  }
10
19
  export function getUsageTotals(ctx) {
11
- const key = entriesKey(
12
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime
13
- ctx);
20
+ const key = entriesKey(ctx);
14
21
  if (usageCache && usageCache.key === key)
15
22
  return usageCache.totals;
16
23
  const totals = {
@@ -21,23 +28,36 @@ export function getUsageTotals(ctx) {
21
28
  cost: 0,
22
29
  latestCacheHitRate: undefined,
23
30
  };
24
- const entries =
25
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime
26
- ctx.sessionManager?.getEntries() ?? [];
27
- for (const entry of entries) {
31
+ const addUsage = (usage) => {
32
+ totals.input += usage.input ?? 0;
33
+ totals.output += usage.output ?? 0;
34
+ totals.cacheRead += usage.cacheRead ?? 0;
35
+ totals.cacheWrite += usage.cacheWrite ?? 0;
36
+ totals.cost += usage.cost?.total ?? 0;
37
+ };
38
+ for (const entry of ctx.sessionManager?.getEntries() ?? []) {
39
+ // Assistant turns carry the cache split the hit rate is derived from; tool results
40
+ // and summaries are usage from nested model calls — billed, so they count toward the
41
+ // session totals (parity with pi core's addUsageToTotals loop).
28
42
  if (entry.type === "message" && entry.message?.role === "assistant") {
29
- const u = entry.message.usage;
30
- if (!u)
43
+ const usage = entry.message.usage;
44
+ if (!usage)
31
45
  continue;
32
- totals.input += u.input ?? 0;
33
- totals.output += u.output ?? 0;
34
- totals.cacheRead += u.cacheRead ?? 0;
35
- totals.cacheWrite += u.cacheWrite ?? 0;
36
- totals.cost += u.cost?.total ?? 0;
37
- const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
46
+ addUsage(usage);
47
+ const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
38
48
  if (promptTokens > 0) {
39
- totals.latestCacheHitRate = ((u.cacheRead ?? 0) / promptTokens) * 100;
49
+ totals.latestCacheHitRate = ((usage.cacheRead ?? 0) / promptTokens) * 100;
40
50
  }
51
+ continue;
52
+ }
53
+ if (entry.type === "message" && entry.message?.role === "toolResult") {
54
+ if (entry.message.usage)
55
+ addUsage(entry.message.usage);
56
+ continue;
57
+ }
58
+ if (entry.type === "branch_summary" || entry.type === "compaction") {
59
+ if (entry.usage)
60
+ addUsage(entry.usage);
41
61
  }
42
62
  }
43
63
  usageCache = { key, totals };
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.3",
37
37
  "files": [
38
38
  "dist",
39
39
  "src",