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 +30 -4
- package/README.md +2 -2
- package/dist/ansi-color.js +142 -0
- package/dist/chrome-composition.js +10 -5
- package/dist/chrome-state.js +18 -8
- package/dist/color-policy.js +31 -18
- package/dist/footer.js +4 -2
- package/dist/model-info.js +8 -114
- package/dist/state.js +36 -16
- 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/footer.ts +7 -39
- package/src/layout.ts +6 -0
- package/src/model-info.ts +8 -131
- package/src/state.ts +67 -54
- package/src/utils.ts +5 -3
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` (
|
|
16
|
-
- Separators: `·` between `cwd` and `git`, `•` as default between other left components; `
|
|
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,
|
|
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
|
|
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 color 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. */
|
|
@@ -57,7 +62,7 @@ export class ChromeComposition {
|
|
|
57
62
|
this.theme = adaptTheme(rawTheme);
|
|
58
63
|
this.glow = opts.glow ?? resolveGlow(rawTheme);
|
|
59
64
|
}
|
|
60
|
-
/**
|
|
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
|
}
|
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 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
|
-
|
|
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/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
|
-
|
|
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) {
|
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 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
|
-
|
|
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 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()
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
30
|
-
if (!
|
|
43
|
+
const usage = entry.message.usage;
|
|
44
|
+
if (!usage)
|
|
31
45
|
continue;
|
|
32
|
-
|
|
33
|
-
|
|
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 = ((
|
|
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,
|
|
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";
|