@underactive/pi-topping-statusline 0.2.7 → 0.3.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/README.md +11 -10
- package/index.ts +3 -2
- package/package.json +8 -5
- package/src/cache-hit.ts +107 -0
- package/src/compaction-bar.ts +9 -49
- package/src/context-graph.ts +6 -3
- package/src/context.ts +29 -3
- package/src/footer.ts +2 -4
- package/src/nvidia-green.ts +14 -10
- package/src/rainbow.ts +3 -23
- package/src/segments.ts +13 -1
- package/src/settings-menu.ts +73 -41
- package/src/settings.ts +49 -40
- package/src/tests/cache-hit.test.ts +318 -0
- package/src/tests/color-mode.test.ts +20 -0
- package/src/tests/compaction-bar.test.ts +9 -48
- package/src/tests/compaction-embed.test.ts +6 -2
- package/src/tests/context-estimate.test.ts +1 -1
- package/src/tests/feeds.test.ts +29 -11
- package/src/tests/git-status.test.ts +3 -3
- package/src/tests/nvidia-green.test.ts +0 -9
- package/src/tests/rainbow.test.ts +1 -1
- package/src/tests/settings-menu.test.ts +179 -0
- package/src/tests/settings.test.ts +56 -0
- package/src/tests/token-rate.test.ts +1 -1
- package/src/tests/working-status.test.ts +8 -8
- package/src/tests/working.test.ts +1 -7
- package/src/theme.ts +24 -101
- package/src/token-rate.ts +16 -117
- package/src/types.ts +14 -5
- package/src/utils.ts +8 -17
- package/src/working-status-embed.ts +0 -7
- package/src/working-status.ts +4 -5
- package/src/menu.ts +0 -676
- package/src/tests/menu.test.ts +0 -138
package/README.md
CHANGED
|
@@ -62,10 +62,10 @@ and apply live.
|
|
|
62
62
|
╟─ Top Right Segment Group ──────────────────────────────────────╢
|
|
63
63
|
║ [ ] Token rate OFF ║
|
|
64
64
|
║ [■] Session name ON ║
|
|
65
|
-
║ … Bottom Right / Bottom Left groups —
|
|
65
|
+
║ … Bottom Right / Bottom Left groups — 10 more rows ║
|
|
66
66
|
║ ║
|
|
67
67
|
╟─ Feeds ────────────────────────────────────────────────────────╢
|
|
68
|
-
║ 1. type
|
|
68
|
+
║ 1. type my-extension/savings ║
|
|
69
69
|
║ 1. field savedUsd ║
|
|
70
70
|
║ 1. prefix CS ║
|
|
71
71
|
║ 1. format ‹ currency › ║
|
|
@@ -73,7 +73,7 @@ and apply live.
|
|
|
73
73
|
║ + add feed ║
|
|
74
74
|
║ ║
|
|
75
75
|
║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
|
|
76
|
-
╚════════════════════════════════════════════════════════[ 1/
|
|
76
|
+
╚════════════════════════════════════════════════════════[ 1/35 ]╝
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
| Section | Settings |
|
|
@@ -81,10 +81,12 @@ and apply live.
|
|
|
81
81
|
| Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border · NVIDIA-green when using Switchyard · Animate Switchyard green border · Embed status spinners · Embed compaction progress |
|
|
82
82
|
| Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
|
|
83
83
|
| Top Right Segment Group | Token rate · Session name |
|
|
84
|
-
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
|
|
84
|
+
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Cache hit bar · CH bar segments (`3`–`10`) · Context bar · Context stats |
|
|
85
85
|
| Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
|
|
86
86
|
| Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
|
|
87
|
-
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · NVIDIA-green when using Switchyard on · Animate Switchyard green border on · Embed status spinners off · Embed compaction progress on |
|
|
87
|
+
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · NVIDIA-green when using Switchyard on · Animate Switchyard green border on · Embed status spinners off · Embed compaction progress on · CH bar segments `4` |
|
|
88
|
+
|
|
89
|
+
**Cache hit bar** renders a logarithmic graph and its `CH` percentage as a separate bottom-right segment. **CH bar segments** sets its length, from 3 to 10 (default 4). Only a 0% hit rate leaves it empty; any hit fills the first cell, the second fills at 50% and the last at 99.2%, and the cells between are spaced evenly on a log scale of the miss rate, so every length keeps its resolution near 100%. At five segments the cells fill at above 0%, 50%, 87.5%, 96.9% and 99.2%, each quartering the remaining miss rate. The filled cells share one color picked from the hit rate at any length: red below 50%, orange below 87.5%, yellow below 96.9%, then a subdued green. When the Cache hit bar is enabled, the repeated `CH` field is removed from Pi stats; the configured separator sits between Pi stats, the cache-hit segment, and the context graph.
|
|
88
90
|
|
|
89
91
|
With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
|
|
90
92
|
replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
|
|
@@ -175,7 +177,7 @@ Each subscription is four fields, all configurable in the settings TUI:
|
|
|
175
177
|
|
|
176
178
|
| Field | Meaning | Example |
|
|
177
179
|
| --- | --- | --- |
|
|
178
|
-
| type | the publisher's `customType` | `
|
|
180
|
+
| type | the publisher's `customType` | `my-extension/savings` |
|
|
179
181
|
| field | property of the entry's `data` to read | `savedUsd` |
|
|
180
182
|
| prefix | literal label drawn before the value | `CS` |
|
|
181
183
|
| format | `currency`, `number`, or `text` | `currency` |
|
|
@@ -185,10 +187,9 @@ In `settings.json` the first column is persisted as `customType`; the settings T
|
|
|
185
187
|
it as `TOK ` when you want `TOK 42`; leading and trailing spaces survive, while escape
|
|
186
188
|
sequences and control characters are stripped.
|
|
187
189
|
|
|
188
|
-
The list ships
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
own footer cutoff so the two never disagree. That floor hides the negative values published
|
|
190
|
+
The list ships empty. The `currency` rules come from the savings feed that
|
|
191
|
+
pi-topping-compact (formerly pi-prompt-cache, a private extension) used to publish: figures
|
|
192
|
+
under half a cent are hidden, matching that extension's own footer cutoff. That floor hides the negative values published
|
|
192
193
|
early in a session, before the cache-write premium is amortized by reads. Above the floor,
|
|
193
194
|
`currency` shows two decimals below $100 and whole dollars above.
|
|
194
195
|
|
package/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ExtensionContext,
|
|
27
27
|
} from "@earendil-works/pi-coding-agent";
|
|
28
28
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
29
|
+
import { CHANNELS, emit } from "@underactive/pi-topping-primitives/contracts";
|
|
29
30
|
import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "./src/box.js";
|
|
30
31
|
import { hostsCompactionProgress } from "./src/compaction-embed.js";
|
|
31
32
|
import { CompactionMonitor } from "./src/compaction-progress.js";
|
|
@@ -46,7 +47,7 @@ import { theme } from "./src/theme.js";
|
|
|
46
47
|
import { TokenRateMonitor } from "./src/token-rate.js";
|
|
47
48
|
import type { StatusLineSegmentId } from "./src/types.js";
|
|
48
49
|
import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
|
|
49
|
-
import { hostsWorkingStatus
|
|
50
|
+
import { hostsWorkingStatus } from "./src/working-status-embed.js";
|
|
50
51
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
51
52
|
|
|
52
53
|
const STATUS_ANIMATION_FRAME_MS = 30;
|
|
@@ -86,7 +87,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
86
87
|
};
|
|
87
88
|
|
|
88
89
|
// pi-topping slides its loader's response model out only while this bar hosts the loader.
|
|
89
|
-
const announceEmbed = (embedded: boolean): void => pi.events.
|
|
90
|
+
const announceEmbed = (embedded: boolean): void => emit(pi.events, CHANNELS.workingStatusEmbedded, { embedded });
|
|
90
91
|
|
|
91
92
|
const requestRender = () => activeTui?.requestRender();
|
|
92
93
|
builder.setRequestRender(requestRender);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
|
-
"test": "node --import ./src/tests/ts-resolve.ts --test src/tests/*.test.ts"
|
|
34
|
+
"test": "node -C compiled --import ./src/tests/ts-resolve.ts --test src/tests/*.test.ts"
|
|
35
35
|
},
|
|
36
36
|
"pi": {
|
|
37
37
|
"extensions": [
|
|
@@ -45,10 +45,13 @@
|
|
|
45
45
|
"@earendil-works/pi-tui": "*"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-ai": "1.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
50
|
-
"@earendil-works/pi-tui": "1.
|
|
48
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
50
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
51
51
|
"@types/node": "^22.0.0",
|
|
52
52
|
"typescript": "^5.7.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@underactive/pi-topping-primitives": "^0.10.0"
|
|
53
56
|
}
|
|
54
57
|
}
|
package/src/cache-hit.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { FILLED, TROUGH } from "./context-graph.js";
|
|
4
|
+
import { theme, type StatusColor } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
export const CACHE_HIT_MIN_CELLS = 3;
|
|
7
|
+
export const CACHE_HIT_MAX_CELLS = 10;
|
|
8
|
+
export const CACHE_HIT_DEFAULT_CELLS = 4;
|
|
9
|
+
|
|
10
|
+
/** A bar of any size starts its log scale here: the second cell fills at 50% (a 50% miss rate)... */
|
|
11
|
+
const SECOND_CELL_MISS_PERCENT = 50;
|
|
12
|
+
/** ...and the last cell fills at 99.21875% (a 0.78125% miss rate, 64x below the second cell's). */
|
|
13
|
+
const LAST_CELL_MISS_PERCENT = 0.78125;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hit-rate bands for the bar's single color, independent of the bar's size so a
|
|
17
|
+
* given hit rate reads the same at every length: red below 50%, orange below
|
|
18
|
+
* 87.5%, yellow below 96.875%, then a subdued green so high hit rates read as good.
|
|
19
|
+
*/
|
|
20
|
+
const CACHE_HIT_COLOR_BANDS: readonly { below: number; color: StatusColor }[] = [
|
|
21
|
+
{ below: 50, color: "error" },
|
|
22
|
+
{ below: 87.5, color: "statusLineContextElevated" },
|
|
23
|
+
{ below: 96.875, color: "warning" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const tokens = (value: number): number => (Number.isFinite(value) ? Math.max(0, value) : 0);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pi's footer `CH` figure: cache reads over all prompt tokens (cache writes
|
|
30
|
+
* included) for the latest assistant response, not the session total. Null
|
|
31
|
+
* when no assistant response has reported prompt tokens.
|
|
32
|
+
*/
|
|
33
|
+
export function getCacheHitPercent(entries: readonly SessionEntry[]): number | null {
|
|
34
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
35
|
+
const entry = entries[i];
|
|
36
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
37
|
+
const usage: Usage | undefined = entry.message.usage;
|
|
38
|
+
if (!usage) return null;
|
|
39
|
+
const read = tokens(usage.cacheRead);
|
|
40
|
+
const promptTokens = tokens(usage.input) + read + tokens(usage.cacheWrite);
|
|
41
|
+
return promptTokens > 0 ? (read / promptTokens) * 100 : null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `CH<pct>%` figure in pi's footer stats line, when pi shows one. */
|
|
47
|
+
const CH_STAT_RE = /(?:^|\s)CH(\d+(?:\.\d+)?)%/;
|
|
48
|
+
|
|
49
|
+
export function parseCacheHitStat(stats: string | undefined): number | null {
|
|
50
|
+
const match = CH_STAT_RE.exec(stats ?? "");
|
|
51
|
+
return match ? Number(match[1]) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Remove Pi's CH figure after it has been moved beside the cache-hit graph. */
|
|
55
|
+
export function stripCacheHitStat(stats: string | undefined): string | undefined {
|
|
56
|
+
return stats?.replace(CH_STAT_RE, "").trim() || undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A bar size from settings, snapped to a whole number in range; anything unusable becomes the default. */
|
|
60
|
+
export function clampCacheHitCells(value: unknown): number {
|
|
61
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return CACHE_HIT_DEFAULT_CELLS;
|
|
62
|
+
return Math.min(CACHE_HIT_MAX_CELLS, Math.max(CACHE_HIT_MIN_CELLS, Math.round(value)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Hit rates at which cells 2..`cells` fill. The first cell is not listed: any hit
|
|
67
|
+
* above 0% fills it. The rest fill logarithmically, so the miss rate shrinks by
|
|
68
|
+
* the same factor from one cell to the next, from 50% at the second cell to
|
|
69
|
+
* 99.21875% at the last. That keeps the resolution near 100% at any size: five
|
|
70
|
+
* cells quarter the miss rate per cell (50, 87.5, 96.875, 99.21875), four cells
|
|
71
|
+
* cut it to an eighth, ten to roughly 0.59x.
|
|
72
|
+
*/
|
|
73
|
+
export function getCacheHitThresholds(cells: number): number[] {
|
|
74
|
+
const size = clampCacheHitCells(cells);
|
|
75
|
+
const steps = size - 2;
|
|
76
|
+
const thresholds: number[] = [];
|
|
77
|
+
for (let k = 0; k <= steps; k++) {
|
|
78
|
+
const miss = SECOND_CELL_MISS_PERCENT * (LAST_CELL_MISS_PERCENT / SECOND_CELL_MISS_PERCENT) ** (k / steps);
|
|
79
|
+
// Rounded so float noise cannot nudge an exact figure like 87.5 just past its own threshold.
|
|
80
|
+
thresholds.push(Math.round((100 - miss) * 1e6) / 1e6);
|
|
81
|
+
}
|
|
82
|
+
return thresholds;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Filled cells on a logarithmic scale: only 0% is empty, and any hit at all fills the first cell. */
|
|
86
|
+
export function getCacheHitGraphCells(percent: number, cells = CACHE_HIT_DEFAULT_CELLS): number {
|
|
87
|
+
if (!Number.isFinite(percent) || percent <= 0) return 0;
|
|
88
|
+
return 1 + getCacheHitThresholds(cells).filter(threshold => percent >= threshold).length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The bar's single color for a hit rate; the same bands at every bar size. */
|
|
92
|
+
function getCacheHitColor(percent: number): StatusColor {
|
|
93
|
+
return CACHE_HIT_COLOR_BANDS.find(band => percent < band.below)?.color ?? "statusLineCacheHit";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Cache-hit graph of `cells` segments (3–10, default 4). The whole filled bar
|
|
98
|
+
* takes one color from the hit rate: red, orange, yellow, then a subdued green
|
|
99
|
+
* from 96.875% through a perfect hit.
|
|
100
|
+
*/
|
|
101
|
+
export function renderCacheHitGraph(percent: number, cells = CACHE_HIT_DEFAULT_CELLS): string {
|
|
102
|
+
const size = clampCacheHitCells(cells);
|
|
103
|
+
const filled = getCacheHitGraphCells(percent, size);
|
|
104
|
+
const filledPart = filled > 0 ? `${theme.getFgAnsi(getCacheHitColor(percent))}${FILLED.repeat(filled)}` : "";
|
|
105
|
+
const troughPart = filled < size ? `\x1b[39m\x1b[2m${TROUGH.repeat(size - filled)}\x1b[22m` : "";
|
|
106
|
+
return `${filledPart}${troughPart}\x1b[39m`;
|
|
107
|
+
}
|
package/src/compaction-bar.ts
CHANGED
|
@@ -1,58 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The compaction bar
|
|
2
|
+
* The compaction bar: the shared phosphor comet bar from the primitives package, drawn with the
|
|
3
|
+
* same cells and trough glyphs as the context graph. This module reads the terminal's colour
|
|
4
|
+
* depth for it.
|
|
3
5
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import { detectColorMode
|
|
6
|
+
import { renderCometBar } from "@underactive/pi-topping-primitives/color";
|
|
7
|
+
import { detectColorMode } from "./theme.js";
|
|
6
8
|
import type { ColorMode } from "./theme.js";
|
|
7
9
|
|
|
8
|
-
const COMET_SWEEP_MS = 2_000;
|
|
9
|
-
const COMET_START_PADDING_CELLS = 2;
|
|
10
|
-
const COMET_END_PADDING_CELLS = 6;
|
|
11
|
-
const COMET_HALF_WIDTH_CELLS = 1.5;
|
|
12
|
-
const COMET_TRAIL_DECAY_CELLS = 3.2;
|
|
13
|
-
|
|
14
|
-
const DEEP_GREEN: readonly [number, number, number] = [11, 61, 32];
|
|
15
|
-
const BASE_GREEN: readonly [number, number, number] = [114, 241, 184];
|
|
16
|
-
const LIGHT_GREEN: readonly [number, number, number] = [185, 248, 217];
|
|
17
|
-
const TROUGH_GREEN: readonly [number, number, number] = [38, 100, 70];
|
|
18
|
-
|
|
19
|
-
function cosineFalloff(distance: number, halfWidth: number): number {
|
|
20
|
-
if (distance >= halfWidth) return 0;
|
|
21
|
-
return 0.5 * (1 + Math.cos((Math.PI * distance) / halfWidth));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function normalizeElapsedMs(elapsedMs: number | undefined): number {
|
|
25
|
-
return Number.isFinite(elapsedMs) && elapsedMs !== undefined && elapsedMs > 0 ? elapsedMs : 0;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function renderCometCell(position: number, filled: number, elapsedMs: number, mode: ColorMode): string {
|
|
29
|
-
const phase = (elapsedMs % COMET_SWEEP_MS) / COMET_SWEEP_MS;
|
|
30
|
-
const head = filled + COMET_START_PADDING_CELLS - phase * (filled + COMET_END_PADDING_CELLS);
|
|
31
|
-
const behind = position - head;
|
|
32
|
-
const trail = behind >= 0 ? Math.exp(-behind / COMET_TRAIL_DECAY_CELLS) : 0;
|
|
33
|
-
const crest = cosineFalloff(Math.abs(position - head), COMET_HALF_WIDTH_CELLS);
|
|
34
|
-
const intensity = Math.min(1, Math.max(trail * 0.82, crest));
|
|
35
|
-
const trailBase = mixRgb(DEEP_GREEN, BASE_GREEN, 0.62 + 0.38 * trail);
|
|
36
|
-
const color = mixRgb(trailBase, LIGHT_GREEN, intensity * 0.9);
|
|
37
|
-
const emphasized = crest > 0.25;
|
|
38
|
-
return `${emphasized ? "\x1b[1m" : ""}${rgbToFgAnsi(...color, mode)}${FILLED}${emphasized ? "\x1b[22m" : ""}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
10
|
export function renderCompactionBar(
|
|
42
11
|
percent: number,
|
|
43
12
|
opts: { mode?: ColorMode; shimmerElapsedMs?: number } = {},
|
|
44
13
|
): string {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let bar = "";
|
|
50
|
-
for (let i = 0; i < filled; i++) {
|
|
51
|
-
bar += renderCometCell(i, filled, elapsedMs, mode);
|
|
52
|
-
}
|
|
53
|
-
if (filled < CELLS) {
|
|
54
|
-
bar += `\x1b[39m\x1b[2m${rgbToFgAnsi(...TROUGH_GREEN, mode)}${TROUGH.repeat(CELLS - filled)}\x1b[22m`;
|
|
55
|
-
}
|
|
56
|
-
bar += "\x1b[39m";
|
|
57
|
-
return bar;
|
|
14
|
+
return renderCometBar(percent, {
|
|
15
|
+
mode: opts.mode ?? detectColorMode(),
|
|
16
|
+
elapsedMs: opts.shimmerElapsedMs,
|
|
17
|
+
});
|
|
58
18
|
}
|
package/src/context-graph.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
* band its own position falls into, so the graph's gradient matches the bar's
|
|
5
5
|
* context percentage color at every fill level.
|
|
6
6
|
*/
|
|
7
|
+
import {
|
|
8
|
+
BAR_CELLS as CELLS,
|
|
9
|
+
BAR_FILLED as FILLED,
|
|
10
|
+
BAR_TROUGH as TROUGH,
|
|
11
|
+
} from "@underactive/pi-topping-primitives/color";
|
|
7
12
|
import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds.js";
|
|
8
13
|
import { theme } from "./theme.js";
|
|
9
14
|
|
|
10
|
-
export
|
|
11
|
-
export const FILLED = "\u258b";
|
|
12
|
-
export const TROUGH = "\u2591";
|
|
15
|
+
export { CELLS, FILLED, TROUGH };
|
|
13
16
|
|
|
14
17
|
const cellColor = (percent: number, contextWindow: number): string =>
|
|
15
18
|
theme.getFgAnsi(getContextUsageThemeColor(getContextUsageLevel(percent, contextWindow)));
|
package/src/context.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type ExtensionAPI,
|
|
15
15
|
type ExtensionContext,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { getCacheHitPercent, parseCacheHitStat, stripCacheHitStat } from "./cache-hit.js";
|
|
17
18
|
import { FADE_SHADE_COUNT } from "./theme.js";
|
|
18
19
|
import type { FeedDisplayState, SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
|
|
19
20
|
import { feedKey } from "./utils.js";
|
|
@@ -24,6 +25,8 @@ const EXEC_TIMEOUT_MS = 2_000;
|
|
|
24
25
|
|
|
25
26
|
/** getEntries() filters and copies the whole session, so rescans are rate-limited. */
|
|
26
27
|
const FEED_TTL_MS = 2_000;
|
|
28
|
+
/** The fallback cache-hit rate reads the same append-only session data as Pi's footer. */
|
|
29
|
+
const CACHE_HIT_TTL_MS = 250;
|
|
27
30
|
/** Unchanged feed values remain fully visible for five minutes. */
|
|
28
31
|
export const FEED_HOLD_MS = 5 * 60 * 1_000;
|
|
29
32
|
/** Match the existing token-rate fade cadence. */
|
|
@@ -141,6 +144,8 @@ export class SegmentContextBuilder {
|
|
|
141
144
|
#feedTimerAt: number | undefined;
|
|
142
145
|
|
|
143
146
|
#contextEstimate: ContextEstimate | undefined;
|
|
147
|
+
#cacheHitPercent: number | null = null;
|
|
148
|
+
#cacheHitFetchedAt = 0;
|
|
144
149
|
|
|
145
150
|
constructor(pi: ExtensionAPI) {
|
|
146
151
|
this.#pi = pi;
|
|
@@ -160,7 +165,7 @@ export class SegmentContextBuilder {
|
|
|
160
165
|
|
|
161
166
|
attach(ctx: ExtensionContext): void {
|
|
162
167
|
this.#ctx = ctx;
|
|
163
|
-
// A publisher's figure typically covers one process run — pi-
|
|
168
|
+
// A publisher's figure typically covers one process run — pi-topping-compact
|
|
164
169
|
// restarts its savings at zero on every session start — while a resumed
|
|
165
170
|
// session still carries the previous run's entries. Anything predating this
|
|
166
171
|
// attach belongs to that stale run.
|
|
@@ -170,6 +175,8 @@ export class SegmentContextBuilder {
|
|
|
170
175
|
this.#feedsScannedKey = undefined;
|
|
171
176
|
this.#clearFeedLifecycle();
|
|
172
177
|
this.#contextEstimate = undefined;
|
|
178
|
+
this.#cacheHitPercent = null;
|
|
179
|
+
this.#cacheHitFetchedAt = 0;
|
|
173
180
|
if (this.#repoCwd !== ctx.cwd) {
|
|
174
181
|
this.#resetGitState();
|
|
175
182
|
this.#repoCwd = ctx.cwd;
|
|
@@ -307,7 +314,7 @@ export class SegmentContextBuilder {
|
|
|
307
314
|
if (!cwd || !this.#repo || this.#gitStatusInFlight) return;
|
|
308
315
|
this.#gitStatusInFlight = true;
|
|
309
316
|
try {
|
|
310
|
-
const out = await this.#exec("git", ["status", "--porcelain"], cwd);
|
|
317
|
+
const out = await this.#exec("git", ["-c", "core.fsmonitor=false", "status", "--porcelain"], cwd);
|
|
311
318
|
if (this.#repoCwd !== cwd) return;
|
|
312
319
|
this.#gitStatusFetchedAt = Date.now();
|
|
313
320
|
const next = out === undefined ? null : parseGitStatusCounts(out);
|
|
@@ -472,6 +479,13 @@ export class SegmentContextBuilder {
|
|
|
472
479
|
}
|
|
473
480
|
}
|
|
474
481
|
|
|
482
|
+
#deriveCacheHitPercent(ctx: ExtensionContext | undefined, now: number): number | null {
|
|
483
|
+
if (!ctx || now - this.#cacheHitFetchedAt < CACHE_HIT_TTL_MS) return this.#cacheHitPercent;
|
|
484
|
+
this.#cacheHitFetchedAt = now;
|
|
485
|
+
this.#cacheHitPercent = getCacheHitPercent(ctx.sessionManager.getEntries());
|
|
486
|
+
return this.#cacheHitPercent;
|
|
487
|
+
}
|
|
488
|
+
|
|
475
489
|
// ── snapshot ─────────────────────────────────────────────────────────────
|
|
476
490
|
|
|
477
491
|
build(
|
|
@@ -515,6 +529,17 @@ export class SegmentContextBuilder {
|
|
|
515
529
|
: undefined;
|
|
516
530
|
|
|
517
531
|
const { contextWindow, tokens, percent: contextPercent, estimated } = this.#deriveContextUsage(ctx);
|
|
532
|
+
// Pi's own CH figure wins so the graph always agrees with the stats beside it;
|
|
533
|
+
// the derived rate covers hosts or widths where pi's stats line is unavailable.
|
|
534
|
+
const piStats = include.piStats || include.cacheHit ? this.#piStatsProvider?.(width) : undefined;
|
|
535
|
+
const cacheHitPercent =
|
|
536
|
+
include.cacheHit
|
|
537
|
+
? piStats === undefined
|
|
538
|
+
? this.#deriveCacheHitPercent(ctx, now)
|
|
539
|
+
: parseCacheHitStat(piStats)
|
|
540
|
+
: null;
|
|
541
|
+
const shownPiStats =
|
|
542
|
+
include.piStats && include.cacheHit && cacheHitPercent !== null ? stripCacheHitStat(piStats) : piStats;
|
|
518
543
|
|
|
519
544
|
return {
|
|
520
545
|
options,
|
|
@@ -526,6 +551,7 @@ export class SegmentContextBuilder {
|
|
|
526
551
|
contextTokens: tokens,
|
|
527
552
|
contextWindow,
|
|
528
553
|
contextEstimated: estimated,
|
|
554
|
+
cacheHitPercent,
|
|
529
555
|
git: {
|
|
530
556
|
branch: include.git || include.pr ? this.#branch : null,
|
|
531
557
|
status: include.git ? this.#gitStatus : null,
|
|
@@ -533,7 +559,7 @@ export class SegmentContextBuilder {
|
|
|
533
559
|
},
|
|
534
560
|
worktree: this.#worktree,
|
|
535
561
|
scrollHint,
|
|
536
|
-
piStats: include.piStats ?
|
|
562
|
+
piStats: include.piStats ? shownPiStats : undefined,
|
|
537
563
|
tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
|
|
538
564
|
feedData: include.feeds.length > 0 ? this.#feedData : undefined,
|
|
539
565
|
feedDisplayState,
|
package/src/footer.ts
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
* Pure helpers for parsing host-rendered rows: the editor's plain ─── border
|
|
3
3
|
* rows (with or without the scroll hint) and the default footer's stats line.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
import { stripSgr as stripAnsi } from "@underactive/pi-topping-primitives/text";
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
return str.replace(ANSI_RE, "");
|
|
9
|
-
}
|
|
7
|
+
export { stripAnsi };
|
|
10
8
|
|
|
11
9
|
/** Drop host footer rows that contain only whitespace or SGR control codes. */
|
|
12
10
|
export function filterVisibleRows(rows: readonly string[]): string[] {
|
package/src/nvidia-green.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/** Provider-specific NVIDIA green border colorizer for Switchyard. */
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
isSwitchyardProvider,
|
|
4
|
+
mixRgb,
|
|
5
|
+
NVIDIA_GREEN_DARK_HEX,
|
|
6
|
+
NVIDIA_GREEN_HEX,
|
|
7
|
+
parseHex,
|
|
8
|
+
rgbToFgAnsi,
|
|
9
|
+
wrapDeg,
|
|
10
|
+
} from "@underactive/pi-topping-primitives/color";
|
|
11
|
+
import type { BorderColorizer } from "./rainbow.js";
|
|
12
|
+
import { detectColorMode, type ColorMode } from "./theme.js";
|
|
13
|
+
|
|
14
|
+
export { isSwitchyardProvider, NVIDIA_GREEN_DARK_HEX, NVIDIA_GREEN_HEX };
|
|
4
15
|
|
|
5
|
-
const SWITCHYARD_PROVIDER = "switchyard";
|
|
6
|
-
export const NVIDIA_GREEN_HEX = "#84c51a";
|
|
7
|
-
export const NVIDIA_GREEN_DARK_HEX = "#0b3d20";
|
|
8
16
|
const GREEN_SHADE_COUNT = 256;
|
|
9
17
|
const GREEN_BIAS = 0.65;
|
|
10
18
|
|
|
11
|
-
export function isSwitchyardProvider(provider: string | undefined): boolean {
|
|
12
|
-
return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
19
|
const ansiByShadeByMode = new Map<ColorMode, string[]>();
|
|
16
20
|
|
|
17
21
|
function buildAnsiByShade(mode: ColorMode): string[] {
|
|
@@ -28,7 +32,7 @@ function buildAnsiByShade(mode: ColorMode): string[] {
|
|
|
28
32
|
for (let i = 0; i < GREEN_SHADE_COUNT; i++) {
|
|
29
33
|
const progress = i / (GREEN_SHADE_COUNT - 1);
|
|
30
34
|
const rgb = mixRgb(dark, bright, progress);
|
|
31
|
-
ansiByShade.push(rgbToFgAnsi(
|
|
35
|
+
ansiByShade.push(rgbToFgAnsi(rgb, mode));
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
ansiByShadeByMode.set(mode, ansiByShade);
|
package/src/rainbow.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* (renderBoxed in index.ts) or construct a fresh instance with a phase
|
|
13
13
|
* derived from elapsed time (the settings preview).
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { hslToRgb, rgbToFgAnsi, wrapDeg } from "@underactive/pi-topping-primitives/color";
|
|
16
|
+
import { detectColorMode, type ColorMode } from "./theme.js";
|
|
16
17
|
|
|
17
18
|
/** Frame cadence of the sweep (also the settings-preview refresh delay). */
|
|
18
19
|
export const RAINBOW_FRAME_MS = 50;
|
|
@@ -25,9 +26,6 @@ const SATURATION = 1;
|
|
|
25
26
|
const LIGHTNESS = 0.62;
|
|
26
27
|
const ansiByHueByMode = new Map<ColorMode, string[]>();
|
|
27
28
|
|
|
28
|
-
/** Normalize a degree value into the [0, 360) range. */
|
|
29
|
-
export const wrapDeg = (deg: number): number => ((deg % 360) + 360) % 360;
|
|
30
|
-
|
|
31
29
|
/**
|
|
32
30
|
* Number of border cells in the box (rows 0..bottomIdx, cols 0..width-1):
|
|
33
31
|
* two horizontal edges of `width` cells plus two vertical edges of
|
|
@@ -50,24 +48,6 @@ export function perimeterPosition(row: number, col: number, width: number, botto
|
|
|
50
48
|
return 2 * width + 2 * bottomIdx - 2 - row; // col === 0
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
54
|
-
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
55
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
56
|
-
const m = l - c / 2;
|
|
57
|
-
let rgb: [number, number, number];
|
|
58
|
-
if (h < 60) rgb = [c, x, 0];
|
|
59
|
-
else if (h < 120) rgb = [x, c, 0];
|
|
60
|
-
else if (h < 180) rgb = [0, c, x];
|
|
61
|
-
else if (h < 240) rgb = [0, x, c];
|
|
62
|
-
else if (h < 300) rgb = [x, 0, c];
|
|
63
|
-
else rgb = [c, 0, x];
|
|
64
|
-
return [Math.round((rgb[0] + m) * 255), Math.round((rgb[1] + m) * 255), Math.round((rgb[2] + m) * 255)];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function rgbToHex([r, g, b]: [number, number, number]): string {
|
|
68
|
-
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
51
|
export interface BorderColorizer {
|
|
72
52
|
colorChar(ch: string, perimeterPos: number, perimeter: number): string;
|
|
73
53
|
/** ANSI foreground-color escape for a perimeter position, with no glyph or reset. */
|
|
@@ -107,7 +87,7 @@ export class RainbowBorder implements BorderColorizer {
|
|
|
107
87
|
const bucket = Math.round(hue) % 360;
|
|
108
88
|
let ansi = ansiByHue[bucket];
|
|
109
89
|
if (ansi === undefined) {
|
|
110
|
-
ansi = rgbToFgAnsi(
|
|
90
|
+
ansi = rgbToFgAnsi(hslToRgb(bucket, SATURATION, LIGHTNESS), mode);
|
|
111
91
|
ansiByHue[bucket] = ansi;
|
|
112
92
|
}
|
|
113
93
|
return ansi;
|
package/src/segments.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { getCapabilities, hyperlink, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { renderCacheHitGraph } from "./cache-hit.js";
|
|
11
12
|
import { renderContextGraph } from "./context-graph.js";
|
|
12
13
|
import { formatContextUsage } from "./context-thresholds.js";
|
|
13
14
|
import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
|
|
@@ -250,7 +251,7 @@ const tokenRateSegment: StatusLineSegment = {
|
|
|
250
251
|
};
|
|
251
252
|
|
|
252
253
|
/**
|
|
253
|
-
* Half a cent is pi-
|
|
254
|
+
* Half a cent is pi-topping-compact's own display cutoff. Matching it keeps this
|
|
254
255
|
* segment and that extension's footer from ever contradicting each other, and
|
|
255
256
|
* suppresses the negative figures published early in a session while the
|
|
256
257
|
* cache-write premium has yet to be amortized by reads.
|
|
@@ -329,6 +330,16 @@ const workingSegment: StatusLineSegment = {
|
|
|
329
330
|
},
|
|
330
331
|
};
|
|
331
332
|
|
|
333
|
+
/** Cache-hit graph + its moved CH figure; related parts stay in one segment. */
|
|
334
|
+
const cacheHitGraphSegment: StatusLineSegment = {
|
|
335
|
+
id: "cache_hit_graph",
|
|
336
|
+
render(ctx) {
|
|
337
|
+
const percent = ctx.cacheHitPercent;
|
|
338
|
+
if (percent == null) return INVISIBLE;
|
|
339
|
+
return { content: `${renderCacheHitGraph(percent, ctx.options.cacheHit.cells)} ${theme.fg("dim", `CH${percent.toFixed(1)}%`)}`, visible: true };
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
332
343
|
/** Context bar + stats — related parts joined by a space, not the group separator. */
|
|
333
344
|
const contextGraphSegment: StatusLineSegment = {
|
|
334
345
|
id: "context_graph",
|
|
@@ -436,6 +447,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
436
447
|
pi_stats: piStatsSegment,
|
|
437
448
|
token_rate: tokenRateSegment,
|
|
438
449
|
feeds: feedsSegment,
|
|
450
|
+
cache_hit_graph: cacheHitGraphSegment,
|
|
439
451
|
context_graph: contextGraphSegment,
|
|
440
452
|
scroll_hint: scrollHintSegment,
|
|
441
453
|
working: workingSegment,
|