@hank-warren/pi-statusline 0.3.0 → 0.4.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 +10 -0
- package/README.md +29 -3
- package/celebration-preview.ts +125 -0
- package/celebration-styles.ts +158 -0
- package/index.ts +40 -9
- package/package.json +3 -1
- package/settings-menu.ts +32 -5
- package/settings.ts +22 -1
- package/themes.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c5e6e34: Add five cache-celebration animation styles — `flash` (the previous behaviour, still the default), `wave`, `pulse`, `rainbow`, and `sparkle`. The `/statusline` **Cache celebration** row now cycles `off` plus each style instead of `on`/`off`, and selecting the row loops the badge live in the statusline below so you can compare them in place. Choosing `off` preserves the selected style.
|
|
8
|
+
|
|
9
|
+
Every style except `rainbow` derives from the active theme's two `celebration` colours, so it recolours with the theme. `npm run demo:celebrations` previews all of them, and `-- --matrix` compares every style against every theme.
|
|
10
|
+
|
|
11
|
+
Also fixes `isThemeName` accepting prototype keys such as `__proto__`.
|
|
12
|
+
|
|
3
13
|
## 0.3.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -21,7 +21,8 @@ Colors come from a selectable [theme](#themes), with context warning thresholds.
|
|
|
21
21
|
`/statusline` opens a `/settings`-style menu (TUI mode only) for configuring the footer. Changes apply live and persist immediately:
|
|
22
22
|
|
|
23
23
|
- **Theme** — the color palette, cycled with Enter or Space. See [Themes](#themes).
|
|
24
|
-
- **
|
|
24
|
+
- **Cache celebration** — `off` or one of five badge animations, cycled with Enter or Space and previewed live in the statusline below. See [Animation styles](#animation-styles).
|
|
25
|
+
- **Model**, **Directory & git**, **Context**, **Subscription usage**, **Worktree line**, **Session ID line** — `on`/`off`, cycled with Enter or Space. Disabled segments are dropped from line 1 without leaving a stray ` | ` separator; hiding the worktree line also stops its `git`/`gh` polling, and hiding usage stops the usage poller. With every element off the footer collapses to a single blank row.
|
|
25
26
|
- **Worktree root** — the directory whose immediate children are tracked as session worktrees (default `~/repos/worktrees`). `~` and `$HOME` are expanded; a relative path is rejected and the previous value kept.
|
|
26
27
|
- **Repo aliases** — short display names for repositories on the worktree line. Enter edits the selected `repo → alias` pair, `d` deletes it, and `Add alias…` creates one from a `repo=alias` line.
|
|
27
28
|
|
|
@@ -56,7 +57,7 @@ Usage is fetched from the providers' own usage endpoints with Pi's stored tokens
|
|
|
56
57
|
|
|
57
58
|
Polling is host-wide, not per-session. Usage percentages describe the account rather than the session, and a busy machine runs dozens of pi processes, so every process shares `~/.pi/agent/statusline-usage.json`: it holds the last good snapshot plus the time the last poll was *started*, written atomically via a temp file and rename. A session adopts the cached values on its first refresh — so the meters are populated before it has issued a single request — and only polls when that shared timestamp is older than the interval. A provider answering `429` is parked for fifteen minutes (tracked per provider, so a rate-limited Anthropic never stops codex from updating) and stops counting as pending, since retrying harder is what earns the rate limit in the first place. Each provider's cache entry is keyed to a fingerprint (a sha256 prefix, never the token itself) of the credentials that fetched it: switching accounts — or rotating a token — discards that provider's cached numbers and backoff and polls immediately, so an exhausted old account's meters never masquerade as the new account's. Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
|
|
58
59
|
|
|
59
|
-
##
|
|
60
|
+
## Cache-hit celebration
|
|
60
61
|
|
|
61
62
|
Whenever one assistant response reaches a prompt-cache hit rate of at least 96%, a temporary module is appended after context usage for about two seconds:
|
|
62
63
|
|
|
@@ -64,7 +65,32 @@ Whenever one assistant response reaches a prompt-cache hit rate of at least 96%,
|
|
|
64
65
|
gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡96%·CACHE·HIT
|
|
65
66
|
```
|
|
66
67
|
|
|
67
|
-
Only the `⚡96%·CACHE·HIT` badge animates
|
|
68
|
+
Only the `⚡96%·CACHE·HIT` badge animates, at 60 ms per frame. The existing model, repository, context, separators, worktree, and session-ID rendering do not change.
|
|
69
|
+
|
|
70
|
+
### Animation styles
|
|
71
|
+
|
|
72
|
+
`/statusline → Cache celebration` cycles through `off` and five animations:
|
|
73
|
+
|
|
74
|
+
| Style | Motion |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `flash` | The whole badge alternates between the theme's two celebration colors (the default, and what shipped before 0.4.0) |
|
|
77
|
+
| `wave` | A bright crest sweeps left to right, trailing back into the base color |
|
|
78
|
+
| `pulse` | The whole badge ramps between dim and bright on a triangle wave |
|
|
79
|
+
| `rainbow` | A full-spectrum gradient rotates along the characters |
|
|
80
|
+
| `sparkle` | Random characters flare to the highlight color and decay |
|
|
81
|
+
|
|
82
|
+
Every style except `rainbow` is derived from the active [theme](#themes)'s two `celebration` roles, so they recolor with the theme — in the `white` theme they stay greyscale. `rainbow` is full-spectrum by design and ignores the palette.
|
|
83
|
+
|
|
84
|
+
Selecting the row previews it live: the real statusline below the menu loops the badge at 96% so you see the final rendering in context, and the loop stops as soon as you move to another row or close the menu. Choosing `off` keeps your style, so turning the celebration back on restores it.
|
|
85
|
+
|
|
86
|
+
To compare styles outside pi, or to iterate on their implementations:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm run demo:celebrations # every style on a real statusline
|
|
90
|
+
npm run demo:celebrations -- --matrix # every style against every theme
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
That script imports the shipped styles directly, so editing `celebration-styles.ts` and re-running shows exactly what the extension will render.
|
|
68
94
|
|
|
69
95
|
The rate is evaluated per provider response as:
|
|
70
96
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CACHE_CELEBRATION_FRAME_INTERVAL_MS,
|
|
3
|
+
type CacheCelebrationSnapshot,
|
|
4
|
+
} from "./cache-celebration.ts";
|
|
5
|
+
import type { SettingsListTheme } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
/** Percentage shown by the settings-menu preview badge. */
|
|
8
|
+
export const PREVIEW_PERCENT = 96;
|
|
9
|
+
|
|
10
|
+
export interface CelebrationPreviewOptions {
|
|
11
|
+
frameIntervalMs?: number;
|
|
12
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
13
|
+
cancel?: (handle: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function defaultSchedule(callback: () => void, intervalMs: number): unknown {
|
|
17
|
+
const timer = setInterval(callback, intervalMs);
|
|
18
|
+
// A settings preview must never keep Pi alive.
|
|
19
|
+
(timer as { unref?: () => void }).unref?.();
|
|
20
|
+
return timer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Loops a fake celebration snapshot for the statusline footer while the
|
|
25
|
+
* celebration row is selected in `/statusline`.
|
|
26
|
+
*
|
|
27
|
+
* Unlike CacheCelebrationController this never ends on its own: it runs until
|
|
28
|
+
* the row loses focus or the menu closes.
|
|
29
|
+
*/
|
|
30
|
+
export class CelebrationPreview {
|
|
31
|
+
private frame = 0;
|
|
32
|
+
private running = false;
|
|
33
|
+
private timerHandle: unknown;
|
|
34
|
+
private readonly frameIntervalMs: number;
|
|
35
|
+
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
36
|
+
private readonly cancel: (handle: unknown) => void;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly requestRender: () => void,
|
|
40
|
+
options: CelebrationPreviewOptions = {},
|
|
41
|
+
) {
|
|
42
|
+
this.frameIntervalMs = options.frameIntervalMs ?? CACHE_CELEBRATION_FRAME_INTERVAL_MS;
|
|
43
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
44
|
+
this.cancel = options.cancel ?? ((handle) => clearInterval(handle as ReturnType<typeof setInterval>));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Start looping, or keep looping if already started. Idempotent. */
|
|
48
|
+
start(): void {
|
|
49
|
+
if (this.running) return;
|
|
50
|
+
this.running = true;
|
|
51
|
+
this.frame = 0;
|
|
52
|
+
this.timerHandle = this.schedule(() => this.tick(), this.frameIntervalMs);
|
|
53
|
+
this.requestRender();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stop looping and clear the preview. Idempotent; only repaints if it was running. */
|
|
57
|
+
stop(): void {
|
|
58
|
+
if (!this.running) return;
|
|
59
|
+
this.running = false;
|
|
60
|
+
this.clearTimer();
|
|
61
|
+
this.requestRender();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Start or stop to match `active`, repainting only on a real transition. */
|
|
65
|
+
setActive(active: boolean): void {
|
|
66
|
+
if (active) this.start();
|
|
67
|
+
else this.stop();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isRunning(): boolean {
|
|
71
|
+
return this.running;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
snapshot(): CacheCelebrationSnapshot | undefined {
|
|
75
|
+
return this.running ? { percent: PREVIEW_PERCENT, frame: this.frame } : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
dispose(): void {
|
|
79
|
+
this.running = false;
|
|
80
|
+
this.clearTimer();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private tick(): void {
|
|
84
|
+
if (!this.running) return;
|
|
85
|
+
this.frame += 1;
|
|
86
|
+
this.requestRender();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private clearTimer(): void {
|
|
90
|
+
if (this.timerHandle !== undefined) this.cancel(this.timerHandle);
|
|
91
|
+
this.timerHandle = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wrap a SettingsList theme so rendering reports which row is highlighted.
|
|
97
|
+
*
|
|
98
|
+
* SettingsList keeps `selectedIndex` private, but it calls `theme.label(text,
|
|
99
|
+
* selected)` for every visible row, so the render pass itself tells us. That
|
|
100
|
+
* keeps working through search filtering and submenus, where index arithmetic
|
|
101
|
+
* on our side would silently drift.
|
|
102
|
+
*/
|
|
103
|
+
export function trackSelectedLabel(theme: SettingsListTheme): {
|
|
104
|
+
theme: SettingsListTheme;
|
|
105
|
+
/** Reset before a render pass; read the captured label after it. */
|
|
106
|
+
begin(): void;
|
|
107
|
+
selected(): string | undefined;
|
|
108
|
+
} {
|
|
109
|
+
let captured: string | undefined;
|
|
110
|
+
return {
|
|
111
|
+
theme: {
|
|
112
|
+
...theme,
|
|
113
|
+
label(text: string, selected: boolean): string {
|
|
114
|
+
if (selected) captured = text.trim();
|
|
115
|
+
return theme.label(text, selected);
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
begin(): void {
|
|
119
|
+
captured = undefined;
|
|
120
|
+
},
|
|
121
|
+
selected(): string | undefined {
|
|
122
|
+
return captured;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-hit badge animations.
|
|
3
|
+
*
|
|
4
|
+
* Every style is a pure `(frame, palette) -> string[]` colouring of the badge
|
|
5
|
+
* characters, so a style can be rendered for any frame in any order. That keeps
|
|
6
|
+
* them testable, and lets the settings menu loop a preview without touching the
|
|
7
|
+
* real celebration timer.
|
|
8
|
+
*/
|
|
9
|
+
import type { StatuslinePalette } from "./themes.ts";
|
|
10
|
+
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const BOLD = "\x1b[1m";
|
|
13
|
+
|
|
14
|
+
export interface Rgb {
|
|
15
|
+
r: number;
|
|
16
|
+
g: number;
|
|
17
|
+
b: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const fgCode = ({ r, g, b }: Rgb): string =>
|
|
21
|
+
`\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(b)}m`;
|
|
22
|
+
|
|
23
|
+
/** Parse an SGR true-colour prefix back into channels; anything else reads as white. */
|
|
24
|
+
export function parseFg(code: string): Rgb {
|
|
25
|
+
const match = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(code);
|
|
26
|
+
if (!match) return { r: 255, g: 255, b: 255 };
|
|
27
|
+
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mix = (a: Rgb, b: Rgb, t: number): Rgb => ({
|
|
31
|
+
r: a.r + (b.r - a.r) * t,
|
|
32
|
+
g: a.g + (b.g - a.g) * t,
|
|
33
|
+
b: a.b + (b.b - a.b) * t,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const scale = ({ r, g, b }: Rgb, factor: number): Rgb => ({
|
|
37
|
+
r: Math.min(255, r * factor),
|
|
38
|
+
g: Math.min(255, g * factor),
|
|
39
|
+
b: Math.min(255, b * factor),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Deterministic per-(index, frame) noise; no Math.random so frames are reproducible. */
|
|
43
|
+
function noise(index: number, frame: number): number {
|
|
44
|
+
const x = Math.sin(index * 127.1 + frame * 311.7) * 43758.5453;
|
|
45
|
+
return x - Math.floor(x);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hue(degrees: number): Rgb {
|
|
49
|
+
const h = (((degrees % 360) + 360) % 360) / 60;
|
|
50
|
+
const sector = Math.floor(h);
|
|
51
|
+
const f = h - sector;
|
|
52
|
+
const q = 255 * (1 - f);
|
|
53
|
+
const t = 255 * f;
|
|
54
|
+
switch (sector) {
|
|
55
|
+
case 0:
|
|
56
|
+
return { r: 255, g: t, b: 0 };
|
|
57
|
+
case 1:
|
|
58
|
+
return { r: q, g: 255, b: 0 };
|
|
59
|
+
case 2:
|
|
60
|
+
return { r: 0, g: 255, b: t };
|
|
61
|
+
case 3:
|
|
62
|
+
return { r: 0, g: q, b: 255 };
|
|
63
|
+
case 4:
|
|
64
|
+
return { r: t, g: 0, b: 255 };
|
|
65
|
+
default:
|
|
66
|
+
return { r: 255, g: 0, b: q };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Colour every character of `badge` for one frame. */
|
|
71
|
+
export type CelebrationStyle = (badge: string, frame: number, palette: StatuslinePalette) => string;
|
|
72
|
+
|
|
73
|
+
const uniform = (badge: string, colour: Rgb): string => `${BOLD}${fgCode(colour)}${badge}${RESET}`;
|
|
74
|
+
|
|
75
|
+
const perCharacter = (badge: string, colourAt: (index: number) => Rgb): string => {
|
|
76
|
+
const chars = [...badge];
|
|
77
|
+
let out = BOLD;
|
|
78
|
+
let previous = "";
|
|
79
|
+
for (const [index, char] of chars.entries()) {
|
|
80
|
+
const code = fgCode(colourAt(index));
|
|
81
|
+
if (code !== previous) {
|
|
82
|
+
out += code;
|
|
83
|
+
previous = code;
|
|
84
|
+
}
|
|
85
|
+
out += char;
|
|
86
|
+
}
|
|
87
|
+
return out + RESET;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The original two-frame alternation. */
|
|
91
|
+
const flash: CelebrationStyle = (badge, frame, palette) =>
|
|
92
|
+
uniform(badge, parseFg(palette.celebration[frame % 2 === 0 ? 0 : 1]));
|
|
93
|
+
|
|
94
|
+
/** A bright crest sweeping left to right, trailing back into the base colour. */
|
|
95
|
+
const wave: CelebrationStyle = (badge, frame, palette) => {
|
|
96
|
+
const base = parseFg(palette.celebration[0]);
|
|
97
|
+
const crest = parseFg(palette.celebration[1]);
|
|
98
|
+
const length = [...badge].length;
|
|
99
|
+
const period = length + 6;
|
|
100
|
+
const position = (frame % period) - 3;
|
|
101
|
+
return perCharacter(badge, (index) => {
|
|
102
|
+
const distance = Math.abs(index - position);
|
|
103
|
+
const intensity = Math.max(0, 1 - distance / 3);
|
|
104
|
+
return scale(mix(base, crest, intensity), 1 + intensity * 0.35);
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Whole badge ramping between dim and bright on a triangle wave. */
|
|
109
|
+
const pulse: CelebrationStyle = (badge, frame, palette) => {
|
|
110
|
+
const base = parseFg(palette.celebration[0]);
|
|
111
|
+
const peak = parseFg(palette.celebration[1]);
|
|
112
|
+
const period = 20;
|
|
113
|
+
const phase = (frame % period) / period;
|
|
114
|
+
const triangle = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
|
|
115
|
+
return uniform(badge, scale(mix(scale(base, 0.45), peak, triangle), 1 + triangle * 0.25));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Full spectrum rotating along the badge; ignores the theme by design. */
|
|
119
|
+
const rainbow: CelebrationStyle = (badge, frame) =>
|
|
120
|
+
perCharacter(badge, (index) => hue(index * 18 - frame * 12));
|
|
121
|
+
|
|
122
|
+
/** Random characters flare to the highlight colour and decay back. */
|
|
123
|
+
const sparkle: CelebrationStyle = (badge, frame, palette) => {
|
|
124
|
+
const base = parseFg(palette.celebration[0]);
|
|
125
|
+
const spark = parseFg(palette.celebration[1]);
|
|
126
|
+
return perCharacter(badge, (index) => {
|
|
127
|
+
const intensity = Math.max(noise(index, frame) ** 6, noise(index, frame - 1) ** 6 * 0.5);
|
|
128
|
+
return scale(mix(base, spark, intensity), 1 + intensity * 0.5);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const CELEBRATION_STYLES = { flash, wave, pulse, rainbow, sparkle } as const;
|
|
133
|
+
|
|
134
|
+
export type CelebrationStyleName = keyof typeof CELEBRATION_STYLES;
|
|
135
|
+
|
|
136
|
+
export const DEFAULT_CELEBRATION_STYLE: CelebrationStyleName = "flash";
|
|
137
|
+
export const CELEBRATION_STYLE_NAMES = Object.keys(CELEBRATION_STYLES) as CelebrationStyleName[];
|
|
138
|
+
|
|
139
|
+
export function isCelebrationStyleName(value: unknown): value is CelebrationStyleName {
|
|
140
|
+
// hasOwn, not `in`: "__proto__" and "toString" are on the prototype chain.
|
|
141
|
+
return typeof value === "string" && Object.hasOwn(CELEBRATION_STYLES, value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Format the badge text for a hit percentage. */
|
|
145
|
+
export const cacheBadgeText = (percent: number): string => `\u26a1${percent}%\u00b7CACHE\u00b7HIT`;
|
|
146
|
+
|
|
147
|
+
/** Render one animation frame of the cache badge. */
|
|
148
|
+
export function renderCacheBadge(
|
|
149
|
+
percent: number,
|
|
150
|
+
frame: number,
|
|
151
|
+
style: string,
|
|
152
|
+
palette: StatuslinePalette,
|
|
153
|
+
): string {
|
|
154
|
+
const chosen = isCelebrationStyleName(style) ? style : DEFAULT_CELEBRATION_STYLE;
|
|
155
|
+
// Math.max(0, NaN) is NaN, which would reach the SGR channels verbatim.
|
|
156
|
+
const safeFrame = Number.isFinite(frame) ? Math.max(0, Math.trunc(frame)) : 0;
|
|
157
|
+
return CELEBRATION_STYLES[chosen](cacheBadgeText(percent), safeFrame, palette);
|
|
158
|
+
}
|
package/index.ts
CHANGED
|
@@ -12,10 +12,13 @@ import {
|
|
|
12
12
|
type CacheCelebrationSnapshot,
|
|
13
13
|
triggerCacheCelebrationForMessage,
|
|
14
14
|
} from "./cache-celebration.ts";
|
|
15
|
+
import { CelebrationPreview, trackSelectedLabel } from "./celebration-preview.ts";
|
|
16
|
+
import { DEFAULT_CELEBRATION_STYLE, renderCacheBadge } from "./celebration-styles.ts";
|
|
15
17
|
import { FullRedrawScheduler } from "./redraw.ts";
|
|
16
18
|
import {
|
|
17
19
|
applySettingChange,
|
|
18
20
|
buildSettingItems,
|
|
21
|
+
CACHE_CELEBRATION_LABEL,
|
|
19
22
|
createAliasSubmenu,
|
|
20
23
|
createWorktreeRootSubmenu,
|
|
21
24
|
} from "./settings-menu.ts";
|
|
@@ -41,7 +44,6 @@ export interface StatuslineData {
|
|
|
41
44
|
usage?: UsageSnapshot;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
const BOLD = "\x1b[1m";
|
|
45
47
|
const RESET = "\x1b[0m";
|
|
46
48
|
const DEFAULT_PALETTE = STATUSLINE_THEMES.default;
|
|
47
49
|
|
|
@@ -112,11 +114,10 @@ export function renderCacheCelebrationLine(
|
|
|
112
114
|
summary: string,
|
|
113
115
|
celebration: CacheCelebrationSnapshot,
|
|
114
116
|
palette: StatuslinePalette = DEFAULT_PALETTE,
|
|
117
|
+
style: string = DEFAULT_CELEBRATION_STYLE,
|
|
115
118
|
): string {
|
|
116
|
-
const badge =
|
|
117
|
-
|
|
118
|
-
const animatedBadge = `${BOLD}${color}${badge}${RESET}`;
|
|
119
|
-
return `${summary}${styled(palette.dim, " | ")}${animatedBadge}`;
|
|
119
|
+
const badge = renderCacheBadge(celebration.percent, celebration.frame, style, palette);
|
|
120
|
+
return `${summary}${styled(palette.dim, " | ")}${badge}`;
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
function renderWorktreeLine(
|
|
@@ -187,7 +188,12 @@ export function renderStatusline(
|
|
|
187
188
|
// A badge-only first line is separator soup, so the celebration needs a summary.
|
|
188
189
|
const celebration = settings.showCacheCelebration ? data.cacheCelebration : undefined;
|
|
189
190
|
lines.push(
|
|
190
|
-
truncateToWidth(
|
|
191
|
+
truncateToWidth(
|
|
192
|
+
celebration
|
|
193
|
+
? renderCacheCelebrationLine(summary, celebration, palette, settings.cacheCelebrationStyle)
|
|
194
|
+
: summary,
|
|
195
|
+
width,
|
|
196
|
+
),
|
|
191
197
|
);
|
|
192
198
|
}
|
|
193
199
|
if (showWorktreeLine) {
|
|
@@ -199,6 +205,7 @@ export function renderStatusline(
|
|
|
199
205
|
|
|
200
206
|
export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
201
207
|
let requestRender: (() => void) | undefined;
|
|
208
|
+
const celebrationPreview = new CelebrationPreview(() => requestRender?.());
|
|
202
209
|
const fullRedraw = new FullRedrawScheduler();
|
|
203
210
|
const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
|
|
204
211
|
let tracker: SessionWorktreeTracker | undefined;
|
|
@@ -303,7 +310,8 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
303
310
|
}
|
|
304
311
|
|
|
305
312
|
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
|
306
|
-
const
|
|
313
|
+
const tracked = trackSelectedLabel(getSettingsListTheme());
|
|
314
|
+
const settingsTheme = tracked.theme;
|
|
307
315
|
const submenuHost = {
|
|
308
316
|
getSettings: () => settings,
|
|
309
317
|
commit: (next: StatuslineSettings) => applySettings(ctx, next),
|
|
@@ -313,7 +321,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
313
321
|
selectTheme: getSelectListTheme(),
|
|
314
322
|
home,
|
|
315
323
|
};
|
|
316
|
-
|
|
324
|
+
const list = new SettingsList(
|
|
317
325
|
buildSettingItems(
|
|
318
326
|
settings,
|
|
319
327
|
{
|
|
@@ -333,6 +341,28 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
333
341
|
() => done(undefined),
|
|
334
342
|
{ enableSearch: true },
|
|
335
343
|
);
|
|
344
|
+
|
|
345
|
+
// The preview animates the real footer, so it only needs to wrap the
|
|
346
|
+
// list's render to learn which row currently has focus.
|
|
347
|
+
return {
|
|
348
|
+
dispose(): void {
|
|
349
|
+
// Clear the fake badge from the footer the menu was drawn over.
|
|
350
|
+
celebrationPreview.dispose();
|
|
351
|
+
requestRender?.();
|
|
352
|
+
},
|
|
353
|
+
invalidate(): void {
|
|
354
|
+
list.invalidate();
|
|
355
|
+
},
|
|
356
|
+
handleInput(data: string): void {
|
|
357
|
+
list.handleInput(data);
|
|
358
|
+
},
|
|
359
|
+
render(width: number): string[] {
|
|
360
|
+
tracked.begin();
|
|
361
|
+
const lines = list.render(width);
|
|
362
|
+
celebrationPreview.setActive(tracked.selected() === CACHE_CELEBRATION_LABEL);
|
|
363
|
+
return lines;
|
|
364
|
+
},
|
|
365
|
+
};
|
|
336
366
|
});
|
|
337
367
|
},
|
|
338
368
|
});
|
|
@@ -358,6 +388,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
358
388
|
dispose(): void {
|
|
359
389
|
stopBranchUpdates();
|
|
360
390
|
cacheCelebration.dispose();
|
|
391
|
+
celebrationPreview.dispose();
|
|
361
392
|
fullRedraw.detach();
|
|
362
393
|
requestRender = undefined;
|
|
363
394
|
},
|
|
@@ -377,7 +408,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
377
408
|
contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
378
409
|
worktrees: tracker?.getWorktrees() ?? [],
|
|
379
410
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
380
|
-
cacheCelebration: cacheCelebration.snapshot(),
|
|
411
|
+
cacheCelebration: celebrationPreview.snapshot() ?? cacheCelebration.snapshot(),
|
|
381
412
|
usage: usageTracker.snapshot(),
|
|
382
413
|
},
|
|
383
414
|
width,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"index.ts",
|
|
35
35
|
"cache-celebration.ts",
|
|
36
|
+
"celebration-preview.ts",
|
|
37
|
+
"celebration-styles.ts",
|
|
36
38
|
"redraw.ts",
|
|
37
39
|
"settings.ts",
|
|
38
40
|
"settings-menu.ts",
|
package/settings-menu.ts
CHANGED
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
truncateToWidth,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
12
|
import { type BooleanSettingKey, collapseHome, resolveWorktreeRoot, type StatuslineSettings } from "./settings.ts";
|
|
13
|
+
import { CELEBRATION_STYLE_NAMES, isCelebrationStyleName } from "./celebration-styles.ts";
|
|
13
14
|
import { isThemeName, THEME_NAMES } from "./themes.ts";
|
|
14
15
|
|
|
15
16
|
export const THEME_ID = "theme";
|
|
17
|
+
export const CACHE_CELEBRATION_ID = "showCacheCelebration";
|
|
16
18
|
export const WORKTREE_ROOT_ID = "worktreeRoot";
|
|
17
19
|
export const REPO_ALIASES_ID = "repoAliases";
|
|
18
20
|
export const ADD_ALIAS_VALUE = "\u0000add";
|
|
@@ -35,13 +37,16 @@ export const BOOLEAN_ROWS: readonly BooleanRow[] = [
|
|
|
35
37
|
{ id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
|
|
36
38
|
{ id: "showWorktrees", label: "Worktree line", description: "Show touched worktrees and their pull requests." },
|
|
37
39
|
{ id: "showSessionId", label: "Session ID line", description: "Show the full Pi session id on its own line." },
|
|
38
|
-
{
|
|
39
|
-
id: "showCacheCelebration",
|
|
40
|
-
label: "Cache celebration",
|
|
41
|
-
description: "Flash a badge after an exceptional prompt-cache hit.",
|
|
42
|
-
},
|
|
43
40
|
];
|
|
44
41
|
|
|
42
|
+
/** Label of the celebration row, used to drive the settings-menu preview loop. */
|
|
43
|
+
export const CACHE_CELEBRATION_LABEL = "Cache celebration";
|
|
44
|
+
|
|
45
|
+
/** The celebration row folds "off" into the style list, so it is one row, not two. */
|
|
46
|
+
export function celebrationValue(settings: StatuslineSettings): string {
|
|
47
|
+
return settings.showCacheCelebration ? settings.cacheCelebrationStyle : OFF;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
export function toggleValue(enabled: boolean): string {
|
|
46
51
|
return enabled ? ON : OFF;
|
|
47
52
|
}
|
|
@@ -70,6 +75,13 @@ export function buildSettingItems(
|
|
|
70
75
|
currentValue: settings.theme,
|
|
71
76
|
values: [...THEME_NAMES],
|
|
72
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: CACHE_CELEBRATION_ID,
|
|
80
|
+
label: CACHE_CELEBRATION_LABEL,
|
|
81
|
+
description: "Badge animation after an exceptional prompt-cache hit; previews in the statusline below.",
|
|
82
|
+
currentValue: celebrationValue(settings),
|
|
83
|
+
values: [OFF, ...CELEBRATION_STYLE_NAMES],
|
|
84
|
+
},
|
|
73
85
|
...BOOLEAN_ROWS.map((row) => ({
|
|
74
86
|
id: row.id,
|
|
75
87
|
label: row.label,
|
|
@@ -113,6 +125,21 @@ export function applySettingChange(
|
|
|
113
125
|
if (value !== ON && value !== OFF) return { kind: "error", message: `Unknown value for ${id}: ${value}` };
|
|
114
126
|
return { kind: "settings", settings: { ...settings, [id]: value === ON } };
|
|
115
127
|
}
|
|
128
|
+
if (id === CACHE_CELEBRATION_ID) {
|
|
129
|
+
if (value === OFF) {
|
|
130
|
+
return settings.showCacheCelebration
|
|
131
|
+
? { kind: "settings", settings: { ...settings, showCacheCelebration: false } }
|
|
132
|
+
: { kind: "ignored" };
|
|
133
|
+
}
|
|
134
|
+
if (!isCelebrationStyleName(value)) {
|
|
135
|
+
return { kind: "error", message: `Unknown cache celebration style: ${value}` };
|
|
136
|
+
}
|
|
137
|
+
if (settings.showCacheCelebration && settings.cacheCelebrationStyle === value) return { kind: "ignored" };
|
|
138
|
+
return {
|
|
139
|
+
kind: "settings",
|
|
140
|
+
settings: { ...settings, showCacheCelebration: true, cacheCelebrationStyle: value },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
116
143
|
if (id === THEME_ID) {
|
|
117
144
|
if (!isThemeName(value)) return { kind: "error", message: `Unknown statusline theme: ${value}` };
|
|
118
145
|
if (value === settings.theme) return { kind: "ignored" };
|
package/settings.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { pid } from "node:process";
|
|
5
|
+
import {
|
|
6
|
+
type CelebrationStyleName,
|
|
7
|
+
DEFAULT_CELEBRATION_STYLE,
|
|
8
|
+
isCelebrationStyleName,
|
|
9
|
+
} from "./celebration-styles.ts";
|
|
5
10
|
import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.ts";
|
|
6
11
|
|
|
7
12
|
/** Toggle keys, in the order the `/statusline` menu lists them. */
|
|
@@ -20,6 +25,8 @@ export type BooleanSettingKey = (typeof BOOLEAN_SETTING_KEYS)[number];
|
|
|
20
25
|
export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
|
|
21
26
|
/** Colour palette name; see themes.ts. */
|
|
22
27
|
theme: StatuslineThemeName;
|
|
28
|
+
/** Cache-hit badge animation; only consulted when showCacheCelebration is on. */
|
|
29
|
+
cacheCelebrationStyle: CelebrationStyleName;
|
|
23
30
|
/** Directory whose immediate children are treated as session worktrees. */
|
|
24
31
|
worktreeRoot: string;
|
|
25
32
|
/** `repository name -> display alias` overrides for the worktree line. */
|
|
@@ -40,6 +47,7 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
|
40
47
|
showSessionId: true,
|
|
41
48
|
showCacheCelebration: true,
|
|
42
49
|
theme: DEFAULT_THEME,
|
|
50
|
+
cacheCelebrationStyle: DEFAULT_CELEBRATION_STYLE,
|
|
43
51
|
worktreeRoot: defaultWorktreeRoot(home),
|
|
44
52
|
repoAliases: {},
|
|
45
53
|
};
|
|
@@ -113,7 +121,13 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
|
|
|
113
121
|
const extra: Record<string, unknown> = {};
|
|
114
122
|
if (!isPlainObject(value)) return { settings, extra };
|
|
115
123
|
|
|
116
|
-
const known = new Set<string>([
|
|
124
|
+
const known = new Set<string>([
|
|
125
|
+
...BOOLEAN_SETTING_KEYS,
|
|
126
|
+
"theme",
|
|
127
|
+
"cacheCelebrationStyle",
|
|
128
|
+
"worktreeRoot",
|
|
129
|
+
"repoAliases",
|
|
130
|
+
]);
|
|
117
131
|
for (const [key, raw] of Object.entries(value)) {
|
|
118
132
|
if (!known.has(key)) {
|
|
119
133
|
extra[key] = raw;
|
|
@@ -135,6 +149,10 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
|
|
|
135
149
|
if (isThemeName(raw)) settings.theme = raw;
|
|
136
150
|
continue;
|
|
137
151
|
}
|
|
152
|
+
if (key === "cacheCelebrationStyle") {
|
|
153
|
+
if (isCelebrationStyleName(raw)) settings.cacheCelebrationStyle = raw;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
138
156
|
if (typeof raw === "boolean") settings[key as BooleanSettingKey] = raw;
|
|
139
157
|
}
|
|
140
158
|
|
|
@@ -162,6 +180,9 @@ export function serializeSettings(
|
|
|
162
180
|
if (settings[key] !== defaults[key]) out[key] = settings[key];
|
|
163
181
|
}
|
|
164
182
|
if (settings.theme !== defaults.theme) out.theme = settings.theme;
|
|
183
|
+
if (settings.cacheCelebrationStyle !== defaults.cacheCelebrationStyle) {
|
|
184
|
+
out.cacheCelebrationStyle = settings.cacheCelebrationStyle;
|
|
185
|
+
}
|
|
165
186
|
if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
|
|
166
187
|
if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
|
|
167
188
|
return out;
|
package/themes.ts
CHANGED
|
@@ -119,7 +119,8 @@ export const DEFAULT_THEME: StatuslineThemeName = "default";
|
|
|
119
119
|
export const THEME_NAMES = Object.keys(STATUSLINE_THEMES) as StatuslineThemeName[];
|
|
120
120
|
|
|
121
121
|
export function isThemeName(value: unknown): value is StatuslineThemeName {
|
|
122
|
-
|
|
122
|
+
// hasOwn, not `in`: "__proto__" and "toString" are on the prototype chain.
|
|
123
|
+
return typeof value === "string" && Object.hasOwn(STATUSLINE_THEMES, value);
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
/** Resolve a theme name to its palette; anything unknown falls back to the default. */
|