@hank-warren/pi-statusline 0.2.4 → 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 CHANGED
@@ -1,5 +1,25 @@
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
+
13
+ ## 0.3.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 0e6c204: Add a `/statusline` settings menu for choosing a theme, toggling every footer element, setting the worktree-tracker root, and managing repo aliases. Settings persist to `~/.pi/agent/statusline-settings.json` and apply live.
18
+
19
+ Themes: `default` (unchanged), `dracula`, `github-dark`, `catppuccin-mocha`, and `white`. A theme maps eleven colour roles, so switching recolours every element without changing the rendered text.
20
+
21
+ Breaking: the built-in repo aliases and the automatic `platform-` prefix strip are gone — they were hardcoded to one machine. Repository names now render verbatim; add your own pairs under `/statusline → Repo aliases`.
22
+
3
23
  ## 0.2.4
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -14,7 +14,35 @@ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m |  97·54  80
14
14
  - **Worktree lines** — when the session works in or sends tool calls into linked worktrees, one line shows the same branch/dirty/behind state for each worktree plus its associated PR number.
15
15
  - **Final line** — the full Pi session ID.
16
16
 
17
- It uses a fixed true-color palette with context warning thresholds.
17
+ Colors come from a selectable [theme](#themes), with context warning thresholds. Every element can be turned off from [`/statusline`](#statusline).
18
+
19
+ ## `/statusline`
20
+
21
+ `/statusline` opens a `/settings`-style menu (TUI mode only) for configuring the footer. Changes apply live and persist immediately:
22
+
23
+ - **Theme** — the color palette, cycled with Enter or Space. See [Themes](#themes).
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.
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.
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.
28
+
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. 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.
30
+
31
+ ## Themes
32
+
33
+ | Name | Notes |
34
+ |---|---|
35
+ | `default` | The palette this package has always used — blue model, cyan branch, neon magenta/cyan cache badge |
36
+ | `dracula` | [Dracula](https://draculatheme.com), with the pink `#ff79c6` branch color used in Herdr sidebar configs |
37
+ | `github-dark` | GitHub's dark default |
38
+ | `catppuccin-mocha` | [Catppuccin](https://catppuccin.com) Mocha |
39
+ | `white` | No color: white text, dimmed punctuation, a white/grey badge flash |
40
+
41
+ A theme maps eleven roles — `model`, `path`, `branch`, `text`, `dim`, `ok`, `warn`, `caution`, `danger`, `accent`, and the two `celebration` badge frames — so switching recolors every element at once without changing a single character of rendered text. `ok`/`warn`/`caution`/`danger` drive the context meter, the usage meters, and the dirty/behind markers alike, so the warning gradient stays legible in every theme. An unknown theme name in the settings file falls back to `default`.
42
+
43
+ ### Repo aliases are no longer built in
44
+
45
+ Up to 0.2.x this package hardcoded five alias pairs and stripped a `platform-` prefix from every other repository. Those rules were specific to one machine and shipped to everyone. From 0.3.0 the alias map starts empty and repository names render verbatim — add whatever pairs you want under `/statusline → Repo aliases`.
18
46
 
19
47
  ## Subscription usage meters
20
48
 
@@ -29,7 +57,7 @@ Usage is fetched from the providers' own usage endpoints with Pi's stored tokens
29
57
 
30
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.
31
59
 
32
- ## Neon cache-wave celebration
60
+ ## Cache-hit celebration
33
61
 
34
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:
35
63
 
@@ -37,7 +65,32 @@ Whenever one assistant response reaches a prompt-cache hit rate of at least 96%,
37
65
  gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡96%·CACHE·HIT
38
66
  ```
39
67
 
40
- 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.
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.
41
94
 
42
95
  The rate is evaluated per provider response as:
43
96
 
@@ -49,7 +102,7 @@ Output and reasoning tokens are excluded because they are not prompt-cache candi
49
102
 
50
103
  ## Worktree/PR tracking behavior
51
104
 
52
- - Only worktrees touched on the active Pi session branch are included.
105
+ - Only worktrees directly under the configured worktree root and touched on the active Pi session branch are included. Absolute, `~/`, and `$HOME/` spellings of that root all match.
53
106
  - Worktree paths appearing only inside Bash heredoc payloads are ignored.
54
107
  - Merged and closed PR worktrees are hidden.
55
108
  - State is rebuilt on reload and tree navigation; deleted worktrees are pruned.
@@ -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
+ }