@hank-warren/pi-statusline 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2dbfc7a: Stop settings saves from clobbering the file. Sessions load settings once at startup and used to persist their entire in-memory snapshot, so changing any row in a session that started before an edit silently reverted that edit — losing hand-written config and other sessions' changes, and collapsing the file to `{}` because serialization is sparse. Saves now write only the keys a change actually touched, merged over the current file contents, and opening `/statusline` re-reads the file first.
8
+
9
+ Also moves the **Cache celebration** row after **Subscription usage** so the menu follows the order elements render in.
10
+
11
+ ## 0.4.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 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.
16
+
17
+ 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.
18
+
19
+ Also fixes `isThemeName` accepting prototype keys such as `__proto__`.
20
+
3
21
  ## 0.3.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -21,11 +21,14 @@ 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
- - **Model**, **Directory & git**, **Context**, **Subscription usage**, **Worktree line**, **Session ID line**, **Cache celebration** — `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.
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
 
28
- Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, written atomically. Only values differing from the defaults are stored, unknown keys from a newer version are preserved, and a missing or malformed file simply yields defaults. The file is read once at session start and is not watched, so hand edits apply to the next session. Concurrent pi sessions are last-writer-wins.
29
+ Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, written atomically. Only values differing from the defaults are stored, unknown keys from a newer version are preserved, and a missing or malformed file simply yields defaults.
30
+
31
+ Saves are per-key rather than whole-file: a change writes only the fields it actually touched over whatever is on disk at that moment. Sessions load settings once at startup, so a whole-file write would let a session that started hours ago revert edits it never saw — including hand edits and changes made in another session. Opening `/statusline` also re-reads the file first, so the menu always edits current state. Two sessions changing the *same* field are still last-writer-wins; everything else merges.
29
32
 
30
33
  ## Themes
31
34
 
@@ -56,7 +59,7 @@ Usage is fetched from the providers' own usage endpoints with Pi's stored tokens
56
59
 
57
60
  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
61
 
59
- ## Neon cache-wave celebration
62
+ ## Cache-hit celebration
60
63
 
61
64
  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
65
 
@@ -64,7 +67,32 @@ Whenever one assistant response reaches a prompt-cache hit rate of at least 96%,
64
67
  gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡96%·CACHE·HIT
65
68
  ```
66
69
 
67
- Only the `⚡96%·CACHE·HIT` badge animates: the whole badge flashes between neon magenta and neon cyan every 60 ms. The existing model, repository, context, separators, worktree, and session-ID rendering do not change.
70
+ 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.
71
+
72
+ ### Animation styles
73
+
74
+ `/statusline → Cache celebration` cycles through `off` and five animations:
75
+
76
+ | Style | Motion |
77
+ |---|---|
78
+ | `flash` | The whole badge alternates between the theme's two celebration colors (the default, and what shipped before 0.4.0) |
79
+ | `wave` | A bright crest sweeps left to right, trailing back into the base color |
80
+ | `pulse` | The whole badge ramps between dim and bright on a triangle wave |
81
+ | `rainbow` | A full-spectrum gradient rotates along the characters |
82
+ | `sparkle` | Random characters flare to the highlight color and decay |
83
+
84
+ 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.
85
+
86
+ 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.
87
+
88
+ To compare styles outside pi, or to iterate on their implementations:
89
+
90
+ ```bash
91
+ npm run demo:celebrations # every style on a real statusline
92
+ npm run demo:celebrations -- --matrix # every style against every theme
93
+ ```
94
+
95
+ That script imports the shipped styles directly, so editing `celebration-styles.ts` and re-running shows exactly what the extension will render.
68
96
 
69
97
  The rate is evaluated per provider response as:
70
98
 
@@ -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,14 +12,23 @@ 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";
22
- import { defaultSettings, repoAlias, SettingsStore, type StatuslineSettings } from "./settings.ts";
25
+ import {
26
+ changedSettingKeys,
27
+ defaultSettings,
28
+ repoAlias,
29
+ SettingsStore,
30
+ type StatuslineSettings,
31
+ } from "./settings.ts";
23
32
  import { resolvePalette, type StatuslinePalette, STATUSLINE_THEMES } from "./themes.ts";
24
33
  import { type UsageSnapshot, usageBand, UsageTracker } from "./usage.ts";
25
34
  import {
@@ -41,7 +50,6 @@ export interface StatuslineData {
41
50
  usage?: UsageSnapshot;
42
51
  }
43
52
 
44
- const BOLD = "\x1b[1m";
45
53
  const RESET = "\x1b[0m";
46
54
  const DEFAULT_PALETTE = STATUSLINE_THEMES.default;
47
55
 
@@ -112,11 +120,10 @@ export function renderCacheCelebrationLine(
112
120
  summary: string,
113
121
  celebration: CacheCelebrationSnapshot,
114
122
  palette: StatuslinePalette = DEFAULT_PALETTE,
123
+ style: string = DEFAULT_CELEBRATION_STYLE,
115
124
  ): string {
116
- const badge = `⚡${celebration.percent}%·CACHE·HIT`;
117
- const color = palette.celebration[celebration.frame % 2 === 0 ? 0 : 1];
118
- const animatedBadge = `${BOLD}${color}${badge}${RESET}`;
119
- return `${summary}${styled(palette.dim, " | ")}${animatedBadge}`;
125
+ const badge = renderCacheBadge(celebration.percent, celebration.frame, style, palette);
126
+ return `${summary}${styled(palette.dim, " | ")}${badge}`;
120
127
  }
121
128
 
122
129
  function renderWorktreeLine(
@@ -187,7 +194,12 @@ export function renderStatusline(
187
194
  // A badge-only first line is separator soup, so the celebration needs a summary.
188
195
  const celebration = settings.showCacheCelebration ? data.cacheCelebration : undefined;
189
196
  lines.push(
190
- truncateToWidth(celebration ? renderCacheCelebrationLine(summary, celebration, palette) : summary, width),
197
+ truncateToWidth(
198
+ celebration
199
+ ? renderCacheCelebrationLine(summary, celebration, palette, settings.cacheCelebrationStyle)
200
+ : summary,
201
+ width,
202
+ ),
191
203
  );
192
204
  }
193
205
  if (showWorktreeLine) {
@@ -199,6 +211,7 @@ export function renderStatusline(
199
211
 
200
212
  export default function statuslineExtension(pi: ExtensionAPI): void {
201
213
  let requestRender: (() => void) | undefined;
214
+ const celebrationPreview = new CelebrationPreview(() => requestRender?.());
202
215
  const fullRedraw = new FullRedrawScheduler();
203
216
  const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
204
217
  let tracker: SessionWorktreeTracker | undefined;
@@ -286,7 +299,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
286
299
  requestRender?.();
287
300
 
288
301
  if (!persist) return;
289
- settingsStore.save(next).catch((error: unknown) => {
302
+ const changed = changedSettingKeys(previous, next);
303
+ if (changed.length === 0) return;
304
+ settingsStore.save(next, changed).catch((error: unknown) => {
290
305
  ctx.ui.notify(
291
306
  `Could not save statusline settings: ${error instanceof Error ? error.message : String(error)}`,
292
307
  "warning",
@@ -302,8 +317,14 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
302
317
  return;
303
318
  }
304
319
 
320
+ // Settings load once per session; refresh before editing so the menu
321
+ // starts from what is on disk rather than a snapshot that may be hours
322
+ // old and missing another session's changes.
323
+ applySettings(ctx, await settingsStore.load(), false);
324
+
305
325
  await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
306
- const settingsTheme = getSettingsListTheme();
326
+ const tracked = trackSelectedLabel(getSettingsListTheme());
327
+ const settingsTheme = tracked.theme;
307
328
  const submenuHost = {
308
329
  getSettings: () => settings,
309
330
  commit: (next: StatuslineSettings) => applySettings(ctx, next),
@@ -313,7 +334,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
313
334
  selectTheme: getSelectListTheme(),
314
335
  home,
315
336
  };
316
- return new SettingsList(
337
+ const list = new SettingsList(
317
338
  buildSettingItems(
318
339
  settings,
319
340
  {
@@ -333,6 +354,28 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
333
354
  () => done(undefined),
334
355
  { enableSearch: true },
335
356
  );
357
+
358
+ // The preview animates the real footer, so it only needs to wrap the
359
+ // list's render to learn which row currently has focus.
360
+ return {
361
+ dispose(): void {
362
+ // Clear the fake badge from the footer the menu was drawn over.
363
+ celebrationPreview.dispose();
364
+ requestRender?.();
365
+ },
366
+ invalidate(): void {
367
+ list.invalidate();
368
+ },
369
+ handleInput(data: string): void {
370
+ list.handleInput(data);
371
+ },
372
+ render(width: number): string[] {
373
+ tracked.begin();
374
+ const lines = list.render(width);
375
+ celebrationPreview.setActive(tracked.selected() === CACHE_CELEBRATION_LABEL);
376
+ return lines;
377
+ },
378
+ };
336
379
  });
337
380
  },
338
381
  });
@@ -358,6 +401,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
358
401
  dispose(): void {
359
402
  stopBranchUpdates();
360
403
  cacheCelebration.dispose();
404
+ celebrationPreview.dispose();
361
405
  fullRedraw.detach();
362
406
  requestRender = undefined;
363
407
  },
@@ -377,7 +421,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
377
421
  contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
378
422
  worktrees: tracker?.getWorktrees() ?? [],
379
423
  sessionId: ctx.sessionManager.getSessionId(),
380
- cacheCelebration: cacheCelebration.snapshot(),
424
+ cacheCelebration: celebrationPreview.snapshot() ?? cacheCelebration.snapshot(),
381
425
  usage: usageTracker.snapshot(),
382
426
  },
383
427
  width,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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,14 +75,28 @@ export function buildSettingItems(
70
75
  currentValue: settings.theme,
71
76
  values: [...THEME_NAMES],
72
77
  },
73
- ...BOOLEAN_ROWS.map((row) => ({
78
+ ];
79
+
80
+ // Rows follow the order their elements render in, so the celebration sits
81
+ // after the usage meters it is appended to on line 1.
82
+ for (const row of BOOLEAN_ROWS) {
83
+ items.push({
74
84
  id: row.id,
75
85
  label: row.label,
76
86
  description: row.description,
77
87
  currentValue: toggleValue(settings[row.id]),
78
88
  values: TOGGLE_VALUES,
79
- })),
80
- ];
89
+ });
90
+ if (row.id === "showUsage") {
91
+ items.push({
92
+ id: CACHE_CELEBRATION_ID,
93
+ label: CACHE_CELEBRATION_LABEL,
94
+ description: "Badge animation after an exceptional prompt-cache hit; previews in the statusline below.",
95
+ currentValue: celebrationValue(settings),
96
+ values: [OFF, ...CELEBRATION_STYLE_NAMES],
97
+ });
98
+ }
99
+ }
81
100
 
82
101
  items.push({
83
102
  id: WORKTREE_ROOT_ID,
@@ -113,6 +132,21 @@ export function applySettingChange(
113
132
  if (value !== ON && value !== OFF) return { kind: "error", message: `Unknown value for ${id}: ${value}` };
114
133
  return { kind: "settings", settings: { ...settings, [id]: value === ON } };
115
134
  }
135
+ if (id === CACHE_CELEBRATION_ID) {
136
+ if (value === OFF) {
137
+ return settings.showCacheCelebration
138
+ ? { kind: "settings", settings: { ...settings, showCacheCelebration: false } }
139
+ : { kind: "ignored" };
140
+ }
141
+ if (!isCelebrationStyleName(value)) {
142
+ return { kind: "error", message: `Unknown cache celebration style: ${value}` };
143
+ }
144
+ if (settings.showCacheCelebration && settings.cacheCelebrationStyle === value) return { kind: "ignored" };
145
+ return {
146
+ kind: "settings",
147
+ settings: { ...settings, showCacheCelebration: true, cacheCelebrationStyle: value },
148
+ };
149
+ }
116
150
  if (id === THEME_ID) {
117
151
  if (!isThemeName(value)) return { kind: "error", message: `Unknown statusline theme: ${value}` };
118
152
  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>([...BOOLEAN_SETTING_KEYS, "theme", "worktreeRoot", "repoAliases"]);
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
 
@@ -147,6 +165,29 @@ function sameAliases(a: Record<string, string>, b: Record<string, string>): bool
147
165
  return aKeys.every((key) => a[key] === b[key]);
148
166
  }
149
167
 
168
+ /** Every persisted top-level key, so a diff can enumerate them exhaustively. */
169
+ export const SETTING_KEYS = [
170
+ ...BOOLEAN_SETTING_KEYS,
171
+ "theme",
172
+ "cacheCelebrationStyle",
173
+ "worktreeRoot",
174
+ "repoAliases",
175
+ ] as const satisfies readonly (keyof StatuslineSettings)[];
176
+
177
+ export type SettingKey = (typeof SETTING_KEYS)[number];
178
+
179
+ /** The keys one edit actually touched; the unit of a merging save. */
180
+ export function changedSettingKeys(
181
+ previous: StatuslineSettings,
182
+ next: StatuslineSettings,
183
+ ): SettingKey[] {
184
+ return SETTING_KEYS.filter((key) =>
185
+ key === "repoAliases"
186
+ ? !sameAliases(previous.repoAliases, next.repoAliases)
187
+ : previous[key] !== next[key],
188
+ );
189
+ }
190
+
150
191
  /**
151
192
  * Sparse serialization: only values differing from the defaults are written, so
152
193
  * a later default change still reaches hosts that never touched that key.
@@ -162,6 +203,9 @@ export function serializeSettings(
162
203
  if (settings[key] !== defaults[key]) out[key] = settings[key];
163
204
  }
164
205
  if (settings.theme !== defaults.theme) out.theme = settings.theme;
206
+ if (settings.cacheCelebrationStyle !== defaults.cacheCelebrationStyle) {
207
+ out.cacheCelebrationStyle = settings.cacheCelebrationStyle;
208
+ }
165
209
  if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
166
210
  if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
167
211
  return out;
@@ -214,10 +258,30 @@ export class SettingsStore {
214
258
  return settings;
215
259
  }
216
260
 
217
- /** Atomic write via temp file + rename. Rejects so the caller can notify. */
218
- async save(settings: StatuslineSettings = this.current): Promise<void> {
261
+ /** Re-read the file as a plain object; anything unusable reads as empty. */
262
+ private async readRaw(): Promise<Record<string, unknown>> {
263
+ try {
264
+ const parsed: unknown = JSON.parse(await readFile(this.path, "utf8"));
265
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
266
+ return { ...(parsed as Record<string, unknown>) };
267
+ } catch {
268
+ return {};
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Atomic write via temp file + rename. Rejects so the caller can notify.
274
+ *
275
+ * With `changed`, only those keys are written over whatever is on disk right
276
+ * now. Settings load once per session, so a full-snapshot write would let a
277
+ * session started before an edit silently revert it — including edits made by
278
+ * hand or by another session. Passing the keys one change actually touched
279
+ * keeps a writer from claiming fields it was never asked about.
280
+ */
281
+ async save(settings: StatuslineSettings = this.current, changed?: readonly SettingKey[]): Promise<void> {
219
282
  this.current = settings;
220
- const payload = `${JSON.stringify(serializeSettings(settings, this.extra, this.home), null, "\t")}\n`;
283
+ const object = changed ? await this.merge(settings, changed) : serializeSettings(settings, this.extra, this.home);
284
+ const payload = `${JSON.stringify(object, null, "\t")}\n`;
221
285
  const temporary = `${this.path}.${pid}.tmp`;
222
286
  await mkdir(dirname(this.path), { recursive: true });
223
287
  try {
@@ -228,4 +292,19 @@ export class SettingsStore {
228
292
  throw error;
229
293
  }
230
294
  }
295
+
296
+ private async merge(
297
+ settings: StatuslineSettings,
298
+ changed: readonly SettingKey[],
299
+ ): Promise<Record<string, unknown>> {
300
+ const merged = await this.readRaw();
301
+ // Serialization is sparse, so a key absent here is back at its default and
302
+ // must be removed rather than written.
303
+ const desired = serializeSettings(settings, {}, this.home);
304
+ for (const key of changed) {
305
+ if (Object.hasOwn(desired, key)) merged[key] = desired[key];
306
+ else delete merged[key];
307
+ }
308
+ return merged;
309
+ }
231
310
  }
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
- return typeof value === "string" && value in STATUSLINE_THEMES;
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. */