pi-editor-footer 0.12.2 → 0.13.0
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 +49 -6
- package/README.md +2 -3
- package/dist/agent-run-ledger.js +8 -72
- package/dist/ansi-color.js +6 -6
- package/dist/border-renderer.js +9 -28
- package/dist/cache-command.js +99 -0
- package/dist/cache-cumulative.js +24 -0
- package/dist/cache-export.js +132 -0
- package/dist/cache-format.js +30 -0
- package/dist/cache-graph-view.js +257 -0
- package/dist/cache-math.js +44 -0
- package/dist/cache-scroll-dialog.js +107 -0
- package/dist/cache-session-data.js +44 -0
- package/dist/cache-stats-view.js +72 -0
- package/dist/cache-types.js +6 -0
- package/dist/chrome-composition.js +3 -3
- package/dist/chrome-state.js +3 -22
- package/dist/chrome-theme.js +29 -0
- package/dist/config.js +190 -34
- package/dist/detail-render.js +8 -9
- package/dist/footer.js +6 -8
- package/dist/icons.js +19 -0
- package/dist/layout.js +5 -0
- package/dist/live-border.js +77 -69
- package/dist/model-info.js +23 -101
- package/dist/path-format.js +5 -0
- package/dist/session-entries.js +38 -0
- package/dist/session-orchestrator.js +24 -27
- package/dist/state.js +42 -22
- package/dist/telemetry.js +110 -15
- package/dist/theme-settings.js +31 -228
- package/dist/transcript-timeline.js +5 -11
- package/dist/utils.js +2 -2
- package/docs/specs/04-header.md +2 -0
- package/package.json +1 -1
- package/src/agent-run-ledger.ts +7 -77
- package/src/border-renderer.ts +9 -37
- package/src/cache-command.ts +179 -0
- package/src/cache-cumulative.ts +36 -0
- package/src/cache-export.ts +170 -0
- package/src/cache-format.ts +41 -0
- package/src/cache-graph-view.ts +375 -0
- package/src/cache-math.ts +52 -0
- package/src/cache-scroll-dialog.ts +146 -0
- package/src/cache-session-data.ts +59 -0
- package/src/cache-stats-view.ts +105 -0
- package/src/cache-types.ts +43 -0
- package/src/chrome-state.ts +2 -30
- package/src/chrome-theme.ts +41 -0
- package/src/config.ts +223 -43
- package/src/detail-render.ts +8 -10
- package/src/footer.ts +9 -45
- package/src/icons.ts +40 -0
- package/src/index.ts +1 -65
- package/src/layout.ts +6 -0
- package/src/live-border.ts +101 -70
- package/src/model-info.ts +26 -121
- package/src/path-format.ts +6 -0
- package/src/session-entries.ts +99 -0
- package/src/session-orchestrator.ts +26 -28
- package/src/state.ts +58 -60
- package/src/telemetry.ts +116 -17
- package/src/theme-settings.ts +39 -241
- package/src/transcript-timeline.ts +5 -8
- package/src/utils.ts +8 -2
- package/src/chrome-composition.ts +0 -148
- package/src/footer-controller.ts +0 -77
- package/src/header.ts +0 -170
- package/src/utils-workspace.ts +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
<!-- markdownlint-configure-file { "MD004": { "style": "asterisk" } } -->
|
|
4
|
+
<!-- Bullets stay `*`: semantic-release's preset writes `*`, and this file is excluded from the
|
|
5
|
+
formatters so they cannot normalize them to `-`. Keep `# Changelog` as the FIRST line:
|
|
6
|
+
@semantic-release/changelog rewrites the title in place only while the file starts with
|
|
7
|
+
the configured `changelogTitle`, and prepends release notes above it otherwise. Do not
|
|
8
|
+
move this title below the comments, and do not remove the title. -->
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.13.0] - 2026-09-25
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* **cache:** add /cache graph, stats and export commands (#37)
|
|
17
|
+
* **telemetry:** adopt streamed usage counts and provider-anchored TTFT (#33)
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **timeline:** honor wallTime, tokens and cost toggles in Agent-run rows (#38)
|
|
22
|
+
* **cache:** resolve promptTokens name clash, pin gate test, cover entry-id truncation (#38)
|
|
23
|
+
* **imports:** use .js extensions for NodeNext module resolution
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **changelog:** attribute #38 ledger entries (#38)
|
|
28
|
+
* **review:** resolve architecture-review minors and fossil comments (#38)
|
|
29
|
+
* **agents:** point total-input single source at cache-math (#38)
|
|
30
|
+
|
|
31
|
+
### Miscellaneous Chores
|
|
32
|
+
|
|
33
|
+
* **scaffold:** refresh git contract to current scaffold (#34)
|
|
34
|
+
* **scaffold:** refresh git contract gate to canonical Ledger-Waiver form (#35)
|
|
35
|
+
* **scaffold:** retire release-job baselines in CI workflow (#36)
|
|
36
|
+
|
|
37
|
+
## [0.12.3](https://github.com/Rianico/pi-editor-footer/compare/v0.12.2...v0.12.3) (2026-09-17)
|
|
38
|
+
|
|
39
|
+
### Bug Fixes
|
|
40
|
+
|
|
41
|
+
* **footer:** count total input tokens in footer statistics ([a2153a1](https://github.com/Rianico/pi-editor-footer/commit/a2153a1f0f4884e3879a610744a9e664a9124053))
|
|
42
|
+
|
|
43
|
+
### Documentation
|
|
44
|
+
|
|
45
|
+
* **agents:** mark agent-run token accounting a scope boundary ([ffbbee2](https://github.com/Rianico/pi-editor-footer/commit/ffbbee29300a40d6963e0916c7526e0a1ce49881))
|
|
46
|
+
* document total input token semantics for the footer ([9480bf9](https://github.com/Rianico/pi-editor-footer/commit/9480bf9f1508930d75a5e53b94d944d00d1f6a2a))
|
|
47
|
+
|
|
48
|
+
All notable changes to this project will be documented in this file.
|
|
49
|
+
|
|
1
50
|
## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
|
|
2
51
|
|
|
3
52
|
### Bug Fixes
|
|
4
53
|
|
|
5
54
|
* **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
55
|
|
|
7
|
-
## [Unreleased]
|
|
8
|
-
|
|
9
56
|
## [0.12.1](https://github.com/Rianico/pi-editor-footer/compare/v0.12.0...v0.12.1) (2026-09-07)
|
|
10
57
|
|
|
11
58
|
### Bug Fixes
|
|
@@ -24,10 +71,6 @@
|
|
|
24
71
|
* allow pnpm builds for esbuild and genai ([36e139e](https://github.com/Rianico/pi-editor-footer/commit/36e139e9443ef928a4366c3d7c8afe494b6e0850))
|
|
25
72
|
* make prepare tolerant to missing husky for pi install ([23ebb62](https://github.com/Rianico/pi-editor-footer/commit/23ebb62cd8d732656fd113da27c807ffa34579fc))
|
|
26
73
|
|
|
27
|
-
# Changelog
|
|
28
|
-
|
|
29
|
-
All notable changes to this project will be documented in this file.
|
|
30
|
-
|
|
31
74
|
## [0.11.0] - 2026-08-29
|
|
32
75
|
|
|
33
76
|
### 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`
|
|
@@ -58,7 +58,6 @@ pi-editor-footer/
|
|
|
58
58
|
│ ├── index.ts # extension entry, widget/header/footer install, /model-info
|
|
59
59
|
│ ├── tracking-editor.ts # TrackingEditor (Editor slot, highlight, border, cursor)
|
|
60
60
|
│ ├── footer.ts # renderFooter / installFooter (cwd·git • runtime • tokens·context)
|
|
61
|
-
│ ├── header.ts # header disabled, cwd preserved in footer
|
|
62
61
|
│ ├── detail-render.ts # wrap/scroll/ellipsis (tested)
|
|
63
62
|
│ ├── window-presentation.ts # bordered themed box (tested)
|
|
64
63
|
│ ├── model-info.ts # top border glow+label
|
package/dist/agent-run-ledger.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* occupancy, capped to contextWindow and session totals.
|
|
9
9
|
*
|
|
10
10
|
* Previously this logic was scattered across 3 call sites (telemetry.peekAgentLive
|
|
11
|
-
* max-vs-sum, live-border
|
|
11
|
+
* max-vs-sum, the context-bar refresh since moved into live-border (then 70L capping), index.agent_settled 60L
|
|
12
12
|
* baseline fallback) with divergent caps. Bugs required holding 3 sites in one head.
|
|
13
13
|
*
|
|
14
14
|
* Depth: small interface (setBaseline / startRun / recordTurn / getTotals / getLive
|
|
@@ -62,6 +62,9 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
|
|
|
62
62
|
let stallCount = 0;
|
|
63
63
|
let generationMs = 0;
|
|
64
64
|
let ttftMs = 0;
|
|
65
|
+
// First observed provider TTFT across turns (run's initial request latency);
|
|
66
|
+
// null when no turn saw before_provider_request. Mirrors ttftMs below.
|
|
67
|
+
let ttftProviderMs = null;
|
|
65
68
|
for (const t of turns) {
|
|
66
69
|
inputTokens = Math.max(inputTokens, t.inputTokens);
|
|
67
70
|
outputTokens += t.outputTokens;
|
|
@@ -72,6 +75,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
|
|
|
72
75
|
}
|
|
73
76
|
if (turns.length > 0)
|
|
74
77
|
ttftMs = turns[0].ttftMs;
|
|
78
|
+
ttftProviderMs = turns.map((t) => t.ttftProviderMs).find((v) => v != null) ?? null;
|
|
75
79
|
if (live) {
|
|
76
80
|
inputTokens = Math.max(inputTokens, live.inputTokens);
|
|
77
81
|
outputTokens += live.outputTokens;
|
|
@@ -81,6 +85,8 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
|
|
|
81
85
|
generationMs += live.generationMs;
|
|
82
86
|
if (ttftMs === 0)
|
|
83
87
|
ttftMs = live.ttftMs;
|
|
88
|
+
if (ttftProviderMs === null)
|
|
89
|
+
ttftProviderMs = live.ttftProviderMs ?? null;
|
|
84
90
|
}
|
|
85
91
|
totalTokens = inputTokens + outputTokens;
|
|
86
92
|
const totalMs = startMs === null ? 0 : Math.max(0, now - startMs);
|
|
@@ -101,6 +107,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
|
|
|
101
107
|
totalTokens,
|
|
102
108
|
costUsd: validCost ? costUsd : 0,
|
|
103
109
|
measurementMs,
|
|
110
|
+
ttftProviderMs,
|
|
104
111
|
estimated: live?.estimated === true,
|
|
105
112
|
};
|
|
106
113
|
}
|
|
@@ -246,47 +253,6 @@ export class AgentRunLedger {
|
|
|
246
253
|
cost: snapshotTotals.cost,
|
|
247
254
|
};
|
|
248
255
|
}
|
|
249
|
-
/**
|
|
250
|
-
* Idle display totals for LiveBorder when not running.
|
|
251
|
-
* Prefers liveAgent/liveTelemetry when available; else baseline delta, capped to idle.
|
|
252
|
-
*/
|
|
253
|
-
getIdleDisplayTotals(snapshotTotals, contextTokens, liveAgent) {
|
|
254
|
-
let displayInput;
|
|
255
|
-
let displayOutput;
|
|
256
|
-
let displayCost;
|
|
257
|
-
if (liveAgent) {
|
|
258
|
-
displayInput = liveAgent.inputTokens;
|
|
259
|
-
displayOutput = liveAgent.outputTokens;
|
|
260
|
-
displayCost = liveAgent.costUsd;
|
|
261
|
-
}
|
|
262
|
-
else if (this.baseline) {
|
|
263
|
-
const d = deltaFromBaseline(snapshotTotals, this.baseline);
|
|
264
|
-
displayInput = d.input;
|
|
265
|
-
displayOutput = d.output;
|
|
266
|
-
displayCost = d.cost;
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
displayInput = snapshotTotals.input;
|
|
270
|
-
displayOutput = snapshotTotals.output;
|
|
271
|
-
displayCost = snapshotTotals.cost;
|
|
272
|
-
}
|
|
273
|
-
const cappedInput = capInputForIdle(displayInput, contextTokens, snapshotTotals.input);
|
|
274
|
-
return {
|
|
275
|
-
tps: null,
|
|
276
|
-
ttftMs: 0,
|
|
277
|
-
totalMs: 0,
|
|
278
|
-
inputTokens: cappedInput,
|
|
279
|
-
outputTokens: displayOutput,
|
|
280
|
-
stallMs: 0,
|
|
281
|
-
stallCount: 0,
|
|
282
|
-
rateUsdPerMTokens: null,
|
|
283
|
-
generationMs: 0,
|
|
284
|
-
totalTokens: cappedInput + displayOutput,
|
|
285
|
-
costUsd: displayCost,
|
|
286
|
-
measurementMs: null,
|
|
287
|
-
estimated: false,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
256
|
/**
|
|
291
257
|
* Idle authoritative display (hybrid Q7 b): snapshot delta capped, for use when
|
|
292
258
|
* not running to show billed total without ~ after agent_end.
|
|
@@ -323,36 +289,6 @@ export class AgentRunLedger {
|
|
|
323
289
|
estimated: false,
|
|
324
290
|
};
|
|
325
291
|
}
|
|
326
|
-
/**
|
|
327
|
-
* Live display totals when running: liveTurn input (current window) replaces
|
|
328
|
-
* agentLive input, output/cost remain per-agent sum, capped to live context only.
|
|
329
|
-
* @deprecated — retained for backward compat; prefer getIncrementalLiveDisplayTotals for Q1/Q9.
|
|
330
|
-
*/
|
|
331
|
-
getLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
|
|
332
|
-
if (!agentLive && !liveTurn)
|
|
333
|
-
return null;
|
|
334
|
-
let displayLive = agentLive;
|
|
335
|
-
if (agentLive && liveTurn) {
|
|
336
|
-
displayLive = {
|
|
337
|
-
...agentLive,
|
|
338
|
-
inputTokens: liveTurn.inputTokens,
|
|
339
|
-
totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
else if (!agentLive && liveTurn) {
|
|
343
|
-
// Edge: no agent yet but liveTurn exists — use liveTurn window
|
|
344
|
-
displayLive = liveTurn;
|
|
345
|
-
}
|
|
346
|
-
if (!displayLive)
|
|
347
|
-
return null;
|
|
348
|
-
const cappedInput = capInputForLive(displayLive.inputTokens, contextTokens);
|
|
349
|
-
return {
|
|
350
|
-
...displayLive,
|
|
351
|
-
inputTokens: cappedInput,
|
|
352
|
-
totalTokens: cappedInput + displayLive.outputTokens,
|
|
353
|
-
estimated: true,
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
292
|
/**
|
|
357
293
|
* Incremental live display (Q1/Q6/Q9): trigger + Σ(outputs+tools) + liveDelta, capped to live.
|
|
358
294
|
* Replaces max(input) with synthetic accumulation, per-agent_run independent.
|
package/dist/ansi-color.js
CHANGED
|
@@ -16,11 +16,11 @@ const BASIC16 = [
|
|
|
16
16
|
[0, 255, 255],
|
|
17
17
|
[255, 255, 255],
|
|
18
18
|
];
|
|
19
|
-
/** 6x6x6
|
|
19
|
+
/** 6x6x6 color-cube channel values (indices 0-5). */
|
|
20
20
|
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
21
21
|
/** Grayscale ramp values (indices 232-255: 24 grays from 8 to 238). */
|
|
22
22
|
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
|
23
|
-
/** Anything but the truecolor marker means the 256-
|
|
23
|
+
/** Anything but the truecolor marker means the 256-color ramp. */
|
|
24
24
|
export function normalizeColorMode(mode) {
|
|
25
25
|
return mode === "truecolor" ? "truecolor" : "256color";
|
|
26
26
|
}
|
|
@@ -74,7 +74,7 @@ function colorDistance(r1, g1, b1, r2, g2, b2) {
|
|
|
74
74
|
// Weighted Euclidean distance (the eye is more sensitive to green).
|
|
75
75
|
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
76
76
|
}
|
|
77
|
-
/**
|
|
77
|
+
/** Quantize RGB to the closest xterm-256 index (same rule as the theme loader). */
|
|
78
78
|
export function rgbTo256(r, g, b) {
|
|
79
79
|
const rIdx = findClosestCubeIndex(r);
|
|
80
80
|
const gIdx = findClosestCubeIndex(g);
|
|
@@ -90,7 +90,7 @@ export function rgbTo256(r, g, b) {
|
|
|
90
90
|
const grayIndex = 232 + grayIdx;
|
|
91
91
|
const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
|
|
92
92
|
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
93
|
-
// Only consider grayscale when the
|
|
93
|
+
// Only consider grayscale when the color is nearly neutral AND closer.
|
|
94
94
|
if (spread < 10 && grayDist < cubeDist)
|
|
95
95
|
return grayIndex;
|
|
96
96
|
return cubeIndex;
|
|
@@ -126,14 +126,14 @@ export function parseFgAnsiToRgb(theme, color) {
|
|
|
126
126
|
return indexToRgb(Number(palette[1]));
|
|
127
127
|
return null;
|
|
128
128
|
}
|
|
129
|
-
/** SGR foreground sequence for an RGB value at the terminal's
|
|
129
|
+
/** SGR foreground sequence for an RGB value at the terminal's color fidelity. */
|
|
130
130
|
export function rgbToFgAnsi({ r, g, b }, mode) {
|
|
131
131
|
return normalizeColorMode(mode) === "truecolor"
|
|
132
132
|
? `\x1b[38;2;${r};${g};${b}m`
|
|
133
133
|
: `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
134
134
|
}
|
|
135
135
|
/**
|
|
136
|
-
*
|
|
136
|
+
* Color `s` with an exact hex, bypassing the theme's named-token lookup.
|
|
137
137
|
* Returned painter resets only the foreground (`\x1b[39m`), matching `theme.fg`.
|
|
138
138
|
*/
|
|
139
139
|
export function hexFg(hex, mode) {
|
package/dist/border-renderer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import {
|
|
2
|
+
import { buildLabel, resolveThinkingGlow } from "./model-info.js";
|
|
3
3
|
function stripAnsi(s) {
|
|
4
4
|
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
5
5
|
}
|
|
@@ -28,18 +28,9 @@ export class BorderRenderer {
|
|
|
28
28
|
if (hasTop) {
|
|
29
29
|
const theme = this.getLiveTheme();
|
|
30
30
|
const info = this.getModelInfo();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
theme.getThinkingBorderColor;
|
|
35
|
-
if (typeof maybeGlow === "function")
|
|
36
|
-
return maybeGlow.call(theme, info.level)(s);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// SAFETY: best-effort UI, ignore recoverable error
|
|
40
|
-
}
|
|
41
|
-
return s;
|
|
42
|
-
};
|
|
31
|
+
// C3: single glow home in model-info.ts; identity fallback explicit at this site.
|
|
32
|
+
const glowFor = resolveThinkingGlow(theme);
|
|
33
|
+
const glow = (s) => (glowFor ? glowFor(info.level, s) : s);
|
|
43
34
|
// When tokens line exists, right moves to that line (one padding) — don't duplicate on top
|
|
44
35
|
const topRightForTop = opts.topTokensText ? "" : opts.topRightText;
|
|
45
36
|
if (opts.glowEnabled) {
|
|
@@ -53,8 +44,7 @@ export class BorderRenderer {
|
|
|
53
44
|
out = embedTopWithLeftAndRight(out, width, leftLabel, topRightForTop, glow);
|
|
54
45
|
}
|
|
55
46
|
else {
|
|
56
|
-
// Only left (model+context), no right —
|
|
57
|
-
// Reuse embedTopWithLeftAndRight with empty right
|
|
47
|
+
// Only left (model+context), no right — reuse embedTopWithLeftAndRight with empty right
|
|
58
48
|
out = embedTopWithLeftAndRight(out, width, leftLabel, "", glow);
|
|
59
49
|
}
|
|
60
50
|
}
|
|
@@ -90,18 +80,9 @@ export class BorderRenderer {
|
|
|
90
80
|
// Bottom embedding (telemetry left/right)
|
|
91
81
|
if (opts.telemetryText || opts.bottomLeftText) {
|
|
92
82
|
const theme = this.getLiveTheme();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
theme.getThinkingBorderColor;
|
|
97
|
-
if (typeof maybeGlow === "function")
|
|
98
|
-
return maybeGlow.call(theme, this.getModelInfo().level)(s);
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// SAFETY: best-effort UI, ignore recoverable error
|
|
102
|
-
}
|
|
103
|
-
return s;
|
|
104
|
-
};
|
|
83
|
+
// C3: single glow home; this site re-reads the live model level per call — kept explicit.
|
|
84
|
+
const glowFor = resolveThinkingGlow(theme);
|
|
85
|
+
const glow = (s) => (glowFor ? glowFor(this.getModelInfo().level, s) : s);
|
|
105
86
|
out = embedBottomBorder(out, width, opts.bottomLeftText ?? "", opts.telemetryText ?? "", glow);
|
|
106
87
|
}
|
|
107
88
|
return out;
|
|
@@ -190,7 +171,7 @@ function embedTopRightBorder(lines, width, rightText, getGlow) {
|
|
|
190
171
|
if (/^─── [↑↓] \d+ more/.test(plainTop))
|
|
191
172
|
return lines;
|
|
192
173
|
const rightW = visibleWidth(rightText);
|
|
193
|
-
// left label already embedded
|
|
174
|
+
// left label already embedded on the top border — its visible width is width - middle - right
|
|
194
175
|
// We need to truncate right if too wide, preserving at least 10 chars for left
|
|
195
176
|
const maxRight = Math.max(0, width - 12);
|
|
196
177
|
let displayRight = rightText;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { exportStatsCsv } from "./cache-export.js";
|
|
2
|
+
import { GRAPH_VIEWS, graphViewLabel, renderGraphBody, } from "./cache-graph-view.js";
|
|
3
|
+
import { ScrollDialog } from "./cache-scroll-dialog.js";
|
|
4
|
+
import { collectCacheSessionMetrics } from "./cache-session-data.js";
|
|
5
|
+
import { renderStatsBody } from "./cache-stats-view.js";
|
|
6
|
+
import { chartGlyphsForMode } from "./icons.js";
|
|
7
|
+
function normalizeSubcommand(args) {
|
|
8
|
+
return args.trim().toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
function usageText() {
|
|
11
|
+
return "Usage: /cache graph | /cache stats | /cache export";
|
|
12
|
+
}
|
|
13
|
+
const OVERLAY_OPTIONS = {
|
|
14
|
+
anchor: "center",
|
|
15
|
+
width: "90%",
|
|
16
|
+
maxHeight: "90%",
|
|
17
|
+
margin: 1,
|
|
18
|
+
};
|
|
19
|
+
const SUBCOMMANDS = [
|
|
20
|
+
{ value: "graph", label: "graph", description: "Show cache hit % graph over time" },
|
|
21
|
+
{ value: "stats", label: "stats", description: "Show token/cache breakdown table" },
|
|
22
|
+
{ value: "export", label: "export", description: "Export stats data to a CSV at project root" },
|
|
23
|
+
];
|
|
24
|
+
export function registerCacheCommand(pi, options) {
|
|
25
|
+
pi.registerCommand("cache", {
|
|
26
|
+
description: "Show cache hit graph, token/cache statistics, or export CSV",
|
|
27
|
+
getArgumentCompletions(prefix) {
|
|
28
|
+
const filtered = SUBCOMMANDS.filter((item) => item.value.startsWith(prefix.toLowerCase()));
|
|
29
|
+
return filtered.length > 0 ? filtered : SUBCOMMANDS;
|
|
30
|
+
},
|
|
31
|
+
handler: async (args, ctx) => {
|
|
32
|
+
const subcommand = normalizeSubcommand(args);
|
|
33
|
+
if (subcommand !== "graph" && subcommand !== "stats" && subcommand !== "export") {
|
|
34
|
+
ctx.ui.notify(usageText(), "info");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// SAFETY: pi seam — runtime sessionManager provides getEntries/getBranch/getSessionName/getSessionFile
|
|
38
|
+
const sessionManager = ctx.sessionManager;
|
|
39
|
+
let metrics = collectCacheSessionMetrics(sessionManager);
|
|
40
|
+
if (subcommand === "export") {
|
|
41
|
+
// SAFETY: pi seam — same structural cast as above
|
|
42
|
+
const filePath = await exportStatsCsv(ctx.cwd, ctx.sessionManager, metrics);
|
|
43
|
+
ctx.ui.notify(`Exported cache stats CSV to ${filePath}`, "info");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!ctx.hasUI) {
|
|
47
|
+
ctx.ui.notify("/cache graph and /cache stats require interactive TUI mode. Use /cache export in non-interactive mode.", "info");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (subcommand === "graph") {
|
|
51
|
+
let currentView = "per-turn";
|
|
52
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => new ScrollDialog(theme, {
|
|
53
|
+
title: "Context Cache Graph",
|
|
54
|
+
getTitle: () => `Context Cache Graph — ${graphViewLabel(currentView)}`,
|
|
55
|
+
helpText: "1/2/3 view • r refresh • v cycle • ↑/↓ scroll • PgUp/PgDn • q/Esc close",
|
|
56
|
+
renderBody: (innerWidth) => renderGraphBody(theme, metrics, innerWidth, currentView, chartGlyphsForMode(options.getConfig().icons.mode)),
|
|
57
|
+
onKey: (data) => {
|
|
58
|
+
if (data === "r") {
|
|
59
|
+
metrics = collectCacheSessionMetrics(sessionManager);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (data === "1")
|
|
63
|
+
currentView = "per-turn";
|
|
64
|
+
else if (data === "2")
|
|
65
|
+
currentView = "cumulative-percent";
|
|
66
|
+
else if (data === "3")
|
|
67
|
+
currentView = "cumulative-total";
|
|
68
|
+
else if (data === "v") {
|
|
69
|
+
const idx = GRAPH_VIEWS.indexOf(currentView);
|
|
70
|
+
currentView = GRAPH_VIEWS[(idx + 1) % GRAPH_VIEWS.length];
|
|
71
|
+
}
|
|
72
|
+
else if (data === "V") {
|
|
73
|
+
const idx = GRAPH_VIEWS.indexOf(currentView);
|
|
74
|
+
currentView = GRAPH_VIEWS[(idx + GRAPH_VIEWS.length - 1) % GRAPH_VIEWS.length];
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true; // recognised key — re-render even when the view is unchanged
|
|
80
|
+
},
|
|
81
|
+
}, () => done(undefined)), { overlay: true, overlayOptions: OVERLAY_OPTIONS });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// stats
|
|
85
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => new ScrollDialog(theme, {
|
|
86
|
+
title: "Context Cache Stats",
|
|
87
|
+
helpText: "r refresh • ↑/↓ scroll • PgUp/PgDn • q/Esc close",
|
|
88
|
+
renderBody: (innerWidth) => renderStatsBody(theme, metrics, innerWidth),
|
|
89
|
+
onKey: (data) => {
|
|
90
|
+
if (data === "r") {
|
|
91
|
+
metrics = collectCacheSessionMetrics(sessionManager);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
},
|
|
96
|
+
}, () => done(undefined)), { overlay: true, overlayOptions: OVERLAY_OPTIONS });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { computeCacheHitPercent } from "./cache-math.js";
|
|
2
|
+
/**
|
|
3
|
+
* Running cumulative totals across all messages in session append order.
|
|
4
|
+
* Pure function — no UI/theme dependency.
|
|
5
|
+
*/
|
|
6
|
+
export function computeCumulativeSeries(messages) {
|
|
7
|
+
const cumInput = [];
|
|
8
|
+
const cumCacheRead = [];
|
|
9
|
+
const cumCacheWrite = [];
|
|
10
|
+
const cumHitPercent = [];
|
|
11
|
+
let sumInput = 0;
|
|
12
|
+
let sumCacheRead = 0;
|
|
13
|
+
let sumCacheWrite = 0;
|
|
14
|
+
for (const msg of messages) {
|
|
15
|
+
sumInput += msg.input;
|
|
16
|
+
sumCacheRead += msg.cacheRead;
|
|
17
|
+
sumCacheWrite += msg.cacheWrite;
|
|
18
|
+
cumInput.push(sumInput);
|
|
19
|
+
cumCacheRead.push(sumCacheRead);
|
|
20
|
+
cumCacheWrite.push(sumCacheWrite);
|
|
21
|
+
cumHitPercent.push(computeCacheHitPercent(sumInput, sumCacheRead, sumCacheWrite));
|
|
22
|
+
}
|
|
23
|
+
return { cumInput, cumCacheRead, cumCacheWrite, cumHitPercent };
|
|
24
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { summarizeHitPercent } from "./cache-format.js";
|
|
4
|
+
import { promptTokens } from "./cache-math.js";
|
|
5
|
+
export function csvEscape(value) {
|
|
6
|
+
const text = value == null ? "" : String(value);
|
|
7
|
+
if (/[",\n]/.test(text)) {
|
|
8
|
+
return `"${text.replaceAll('"', '""')}"`;
|
|
9
|
+
}
|
|
10
|
+
return text;
|
|
11
|
+
}
|
|
12
|
+
export function sanitizeFileName(name) {
|
|
13
|
+
const sanitized = name
|
|
14
|
+
.trim()
|
|
15
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
16
|
+
.replace(/-+/g, "-")
|
|
17
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
18
|
+
return sanitized.length > 0 ? sanitized : "session";
|
|
19
|
+
}
|
|
20
|
+
function resolveSessionBaseName(sessionManager) {
|
|
21
|
+
const sessionName = sessionManager.getSessionName()?.trim();
|
|
22
|
+
if (sessionName)
|
|
23
|
+
return sanitizeFileName(sessionName);
|
|
24
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
25
|
+
if (sessionFile) {
|
|
26
|
+
return sanitizeFileName(basename(sessionFile, extname(sessionFile)));
|
|
27
|
+
}
|
|
28
|
+
return "session";
|
|
29
|
+
}
|
|
30
|
+
const headers = [
|
|
31
|
+
"row_type",
|
|
32
|
+
"scope",
|
|
33
|
+
"assistant_messages",
|
|
34
|
+
"sequence",
|
|
35
|
+
"active_branch_sequence",
|
|
36
|
+
"is_on_active_branch",
|
|
37
|
+
"entry_id",
|
|
38
|
+
"timestamp",
|
|
39
|
+
"time",
|
|
40
|
+
"provider",
|
|
41
|
+
"model",
|
|
42
|
+
"model_key",
|
|
43
|
+
"prompt_tokens",
|
|
44
|
+
"received_tokens",
|
|
45
|
+
"cache_hit_tokens",
|
|
46
|
+
"cache_write_tokens",
|
|
47
|
+
"total_tokens",
|
|
48
|
+
"cache_hit_percent",
|
|
49
|
+
"delta_sent_tokens",
|
|
50
|
+
"delta_received_tokens",
|
|
51
|
+
"delta_cache_hit_tokens",
|
|
52
|
+
"delta_cache_write_tokens",
|
|
53
|
+
"delta_hit_rate_percent",
|
|
54
|
+
"notes",
|
|
55
|
+
];
|
|
56
|
+
function summaryRows(metrics) {
|
|
57
|
+
const treeHitRate = summarizeHitPercent(metrics.treeTotals);
|
|
58
|
+
const branchHitRate = summarizeHitPercent(metrics.activeBranchTotals);
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
row_type: "summary",
|
|
62
|
+
scope: "active_branch",
|
|
63
|
+
assistant_messages: metrics.activeBranchTotals.assistantMessages,
|
|
64
|
+
prompt_tokens: promptTokens(metrics.activeBranchTotals.input, metrics.activeBranchTotals.cacheRead, metrics.activeBranchTotals.cacheWrite),
|
|
65
|
+
received_tokens: metrics.activeBranchTotals.output,
|
|
66
|
+
cache_hit_tokens: metrics.activeBranchTotals.cacheRead,
|
|
67
|
+
cache_write_tokens: metrics.activeBranchTotals.cacheWrite,
|
|
68
|
+
total_tokens: metrics.activeBranchTotals.totalTokens,
|
|
69
|
+
cache_hit_percent: branchHitRate,
|
|
70
|
+
notes: "Matches the active-branch cumulative totals shown in /cache stats",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
row_type: "summary",
|
|
74
|
+
scope: "whole_tree",
|
|
75
|
+
assistant_messages: metrics.treeTotals.assistantMessages,
|
|
76
|
+
prompt_tokens: promptTokens(metrics.treeTotals.input, metrics.treeTotals.cacheRead, metrics.treeTotals.cacheWrite),
|
|
77
|
+
received_tokens: metrics.treeTotals.output,
|
|
78
|
+
cache_hit_tokens: metrics.treeTotals.cacheRead,
|
|
79
|
+
cache_write_tokens: metrics.treeTotals.cacheWrite,
|
|
80
|
+
total_tokens: metrics.treeTotals.totalTokens,
|
|
81
|
+
cache_hit_percent: treeHitRate,
|
|
82
|
+
notes: "Matches the whole-tree cumulative totals shown in /cache stats",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
row_type: "summary",
|
|
86
|
+
scope: "delta_tree_minus_branch",
|
|
87
|
+
assistant_messages: metrics.treeTotals.assistantMessages - metrics.activeBranchTotals.assistantMessages,
|
|
88
|
+
delta_sent_tokens: metrics.treeTotals.input - metrics.activeBranchTotals.input,
|
|
89
|
+
delta_received_tokens: metrics.treeTotals.output - metrics.activeBranchTotals.output,
|
|
90
|
+
delta_cache_hit_tokens: metrics.treeTotals.cacheRead - metrics.activeBranchTotals.cacheRead,
|
|
91
|
+
delta_cache_write_tokens: metrics.treeTotals.cacheWrite - metrics.activeBranchTotals.cacheWrite,
|
|
92
|
+
delta_hit_rate_percent: treeHitRate - branchHitRate,
|
|
93
|
+
notes: "Matches the delta row shown in /cache stats",
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
function messageRows(metrics) {
|
|
98
|
+
return metrics.allMessages.map((metric) => ({
|
|
99
|
+
row_type: "message",
|
|
100
|
+
scope: metric.isOnActiveBranch ? "active_branch" : "other_branch",
|
|
101
|
+
sequence: metric.sequence,
|
|
102
|
+
active_branch_sequence: metric.activeBranchSequence ?? "",
|
|
103
|
+
is_on_active_branch: metric.isOnActiveBranch,
|
|
104
|
+
entry_id: metric.entryId,
|
|
105
|
+
timestamp: metric.timestamp,
|
|
106
|
+
time: metric.timestamp.slice(11, 19),
|
|
107
|
+
provider: metric.provider,
|
|
108
|
+
model: metric.model,
|
|
109
|
+
model_key: `${metric.provider}/${metric.model}`,
|
|
110
|
+
prompt_tokens: promptTokens(metric.input, metric.cacheRead, metric.cacheWrite),
|
|
111
|
+
received_tokens: metric.output,
|
|
112
|
+
cache_hit_tokens: metric.cacheRead,
|
|
113
|
+
cache_write_tokens: metric.cacheWrite,
|
|
114
|
+
total_tokens: metric.totalTokens,
|
|
115
|
+
cache_hit_percent: metric.cacheHitPercent,
|
|
116
|
+
notes: "Matches a per-message row in /cache stats",
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
export function buildCsv(metrics) {
|
|
120
|
+
const rows = [...summaryRows(metrics), ...messageRows(metrics)];
|
|
121
|
+
const csvRows = [headers.join(",")];
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
csvRows.push(headers.map((header) => csvEscape(row[header])).join(","));
|
|
124
|
+
}
|
|
125
|
+
return csvRows.join("\n") + "\n";
|
|
126
|
+
}
|
|
127
|
+
export async function exportStatsCsv(cwd, sessionManager, metrics) {
|
|
128
|
+
const fileBaseName = resolveSessionBaseName(sessionManager);
|
|
129
|
+
const filePath = join(cwd, `${fileBaseName}.csv`);
|
|
130
|
+
await writeFile(filePath, buildCsv(metrics), "utf8");
|
|
131
|
+
return filePath;
|
|
132
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { computeCacheHitPercent, promptTokens } from "./cache-math.js";
|
|
2
|
+
// formatInt keeps thousands separators: the stats table aligns columns on
|
|
3
|
+
// full-width numbers, so fmtTokens (k/M compaction) does not fit here.
|
|
4
|
+
export function formatInt(value) {
|
|
5
|
+
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Math.round(value));
|
|
6
|
+
}
|
|
7
|
+
export function formatPercent(value) {
|
|
8
|
+
return `${value.toFixed(1)}%`;
|
|
9
|
+
}
|
|
10
|
+
export function shortModelName(provider, model) {
|
|
11
|
+
return `${provider}/${model}`;
|
|
12
|
+
}
|
|
13
|
+
export function summarizeHitPercent(totals) {
|
|
14
|
+
return computeCacheHitPercent(totals.input, totals.cacheRead, totals.cacheWrite);
|
|
15
|
+
}
|
|
16
|
+
/** Object-shaped delegate — the formula lives in `cache-math.ts:promptTokens`. */
|
|
17
|
+
export function promptTokensOfTotals(totals) {
|
|
18
|
+
return promptTokens(totals.input, totals.cacheRead, totals.cacheWrite);
|
|
19
|
+
}
|
|
20
|
+
export function formatTotalsLine(label, totals) {
|
|
21
|
+
return [
|
|
22
|
+
`${label}:`,
|
|
23
|
+
`${formatInt(totals.assistantMessages)} turns`,
|
|
24
|
+
`prompt ${formatInt(promptTokensOfTotals(totals))}`,
|
|
25
|
+
`received ${formatInt(totals.output)}`,
|
|
26
|
+
`cache hit ${formatInt(totals.cacheRead)}`,
|
|
27
|
+
`cache write ${formatInt(totals.cacheWrite)}`,
|
|
28
|
+
`hit rate ${formatPercent(summarizeHitPercent(totals))}`,
|
|
29
|
+
].join(" • ");
|
|
30
|
+
}
|