@hank-warren/pi-statusline 0.1.2 → 0.1.3

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 CHANGED
@@ -10,12 +10,30 @@ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m
10
10
 
11
11
  ## What it shows
12
12
 
13
- - **Line 1** — active model ID, current directory basename and Git branch, and current context usage/window. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate.
13
+ - **Line 1** — active model ID, current directory basename and Git branch, and current context usage/window. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
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
17
  It uses a fixed true-color palette with context warning thresholds.
18
18
 
19
+ ## Neon cache-wave celebration
20
+
21
+ Whenever one assistant response reaches a prompt-cache hit rate of at least 90%, a temporary module is appended after context usage for about two seconds:
22
+
23
+ ```text
24
+ gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡ 96% CACHE
25
+ ```
26
+
27
+ Only the `⚡ 96% CACHE` badge animates with a cyan → blue → magenta wave every 80 ms. The existing model, repository, context, separators, worktree, and session-ID rendering do not change.
28
+
29
+ The rate is evaluated per provider response as:
30
+
31
+ ```text
32
+ cacheRead / (input + cacheRead + cacheWrite)
33
+ ```
34
+
35
+ Output and reasoning tokens are excluded because they are not prompt-cache candidates. A zero-token denominator does not trigger the effect, exactly 90% does, and only the displayed percentage is rounded. Another qualifying response during the animation restarts it from frame zero with the new percentage. After expiry, the original statusline is restored exactly.
36
+
19
37
  ## Worktree/PR tracking behavior
20
38
 
21
39
  - Only worktrees touched on the active Pi session branch are included.
@@ -0,0 +1,132 @@
1
+ export const CACHE_HIT_THRESHOLD = 0.9;
2
+ export const CACHE_CELEBRATION_FRAME_INTERVAL_MS = 80;
3
+ export const CACHE_CELEBRATION_DURATION_MS = 2_000;
4
+
5
+ export interface CacheUsage {
6
+ input: number;
7
+ cacheRead: number;
8
+ cacheWrite: number;
9
+ }
10
+
11
+ export interface CacheCelebrationSnapshot {
12
+ percent: number;
13
+ frame: number;
14
+ }
15
+
16
+ export interface CacheCelebrationTarget {
17
+ start(percent: number): void;
18
+ }
19
+
20
+ export interface CacheCelebrationControllerOptions {
21
+ frameIntervalMs?: number;
22
+ durationMs?: number;
23
+ now?: () => number;
24
+ schedule?: (callback: () => void, intervalMs: number) => unknown;
25
+ cancel?: (handle: unknown) => void;
26
+ }
27
+
28
+ function defaultSchedule(callback: () => void, intervalMs: number): unknown {
29
+ const timer = setInterval(callback, intervalMs);
30
+ // A cosmetic celebration must never keep Pi alive.
31
+ (timer as { unref?: () => void }).unref?.();
32
+ return timer;
33
+ }
34
+
35
+ function defaultCancel(handle: unknown): void {
36
+ clearInterval(handle as ReturnType<typeof setInterval>);
37
+ }
38
+
39
+ /** Return the prompt-cache hit ratio for one response, or null without prompt tokens. */
40
+ export function cacheReadHitRate(usage: CacheUsage): number | null {
41
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
42
+ return promptTokens > 0 ? usage.cacheRead / promptTokens : null;
43
+ }
44
+
45
+ /** Return the rounded badge percentage only when a response qualifies for celebration. */
46
+ export function qualifyingCacheHitPercent(usage: CacheUsage): number | null {
47
+ const rate = cacheReadHitRate(usage);
48
+ return rate !== null && rate >= CACHE_HIT_THRESHOLD ? Math.round(rate * 100) : null;
49
+ }
50
+
51
+ /**
52
+ * Apply the same narrowing used by the turn_end hook and start a celebration
53
+ * for a qualifying assistant response.
54
+ */
55
+ export function triggerCacheCelebrationForMessage(
56
+ message: unknown,
57
+ target: CacheCelebrationTarget,
58
+ ): boolean {
59
+ if (!message || typeof message !== "object") return false;
60
+ const candidate = message as { role?: unknown; usage?: Partial<CacheUsage> };
61
+ if (candidate.role !== "assistant" || !candidate.usage) return false;
62
+ const { input, cacheRead, cacheWrite } = candidate.usage;
63
+ if (typeof input !== "number" || typeof cacheRead !== "number" || typeof cacheWrite !== "number") {
64
+ return false;
65
+ }
66
+ const percent = qualifyingCacheHitPercent({ input, cacheRead, cacheWrite });
67
+ if (percent === null) return false;
68
+ target.start(percent);
69
+ return true;
70
+ }
71
+
72
+ /** Owns the short-lived animation timer and exposes an immutable render snapshot. */
73
+ export class CacheCelebrationController implements CacheCelebrationTarget {
74
+ private current: CacheCelebrationSnapshot | undefined;
75
+ private startedAt = 0;
76
+ private timerHandle: unknown;
77
+ private readonly frameIntervalMs: number;
78
+ private readonly durationMs: number;
79
+ private readonly now: () => number;
80
+ private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
81
+ private readonly cancel: (handle: unknown) => void;
82
+
83
+ constructor(
84
+ private readonly requestRender: () => void,
85
+ options: CacheCelebrationControllerOptions = {},
86
+ ) {
87
+ this.frameIntervalMs = options.frameIntervalMs ?? CACHE_CELEBRATION_FRAME_INTERVAL_MS;
88
+ this.durationMs = options.durationMs ?? CACHE_CELEBRATION_DURATION_MS;
89
+ this.now = options.now ?? Date.now;
90
+ this.schedule = options.schedule ?? defaultSchedule;
91
+ this.cancel = options.cancel ?? defaultCancel;
92
+ }
93
+
94
+ start(percent: number): void {
95
+ this.clearTimer();
96
+ this.startedAt = this.now();
97
+ this.current = { percent, frame: 0 };
98
+ this.requestRender();
99
+ this.timerHandle = this.schedule(() => this.tick(), this.frameIntervalMs);
100
+ }
101
+
102
+ snapshot(): CacheCelebrationSnapshot | undefined {
103
+ return this.current ? { ...this.current } : undefined;
104
+ }
105
+
106
+ /** Stop without requesting a render; intended for footer/session teardown. */
107
+ dispose(): void {
108
+ this.clearTimer();
109
+ this.current = undefined;
110
+ }
111
+
112
+ private tick(): void {
113
+ if (!this.current) return;
114
+ const elapsed = this.now() - this.startedAt;
115
+ if (elapsed >= this.durationMs) {
116
+ this.clearTimer();
117
+ this.current = undefined;
118
+ this.requestRender();
119
+ return;
120
+ }
121
+ this.current = {
122
+ ...this.current,
123
+ frame: Math.floor(elapsed / this.frameIntervalMs),
124
+ };
125
+ this.requestRender();
126
+ }
127
+
128
+ private clearTimer(): void {
129
+ if (this.timerHandle !== undefined) this.cancel(this.timerHandle);
130
+ this.timerHandle = undefined;
131
+ }
132
+ }
package/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { basename } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ CacheCelebrationController,
6
+ type CacheCelebrationSnapshot,
7
+ triggerCacheCelebrationForMessage,
8
+ } from "./cache-celebration.ts";
4
9
  import { FullRedrawScheduler } from "./redraw.ts";
5
10
  import {
6
11
  type GitRepositoryStatus,
@@ -18,6 +23,7 @@ export interface StatuslineData {
18
23
  contextWindow: number;
19
24
  worktrees: SessionWorktree[];
20
25
  sessionId: string;
26
+ cacheCelebration?: CacheCelebrationSnapshot;
21
27
  }
22
28
 
23
29
  const BLUE = "\x1b[38;2;0;153;255m";
@@ -28,8 +34,13 @@ const RED = "\x1b[38;2;255;85;85m";
28
34
  const YELLOW = "\x1b[38;2;230;200;0m";
29
35
  const WHITE = "\x1b[38;2;220;220;220m";
30
36
  const MAGENTA = "\x1b[38;2;190;120;255m";
37
+ const NEON_CYAN = "\x1b[38;2;0;255;255m";
38
+ const NEON_BLUE = "\x1b[38;2;0;125;255m";
39
+ const NEON_MAGENTA = "\x1b[38;2;255;0;255m";
31
40
  const DIM = "\x1b[2m";
41
+ const BOLD = "\x1b[1m";
32
42
  const RESET = "\x1b[0m";
43
+ const NEON_WAVE = [NEON_CYAN, NEON_CYAN, NEON_BLUE, NEON_BLUE, NEON_MAGENTA, NEON_MAGENTA] as const;
33
44
 
34
45
  function styled(style: string, text: string): string {
35
46
  return `${style}${text}${RESET}`;
@@ -62,6 +73,20 @@ function renderRepository(name: string, status: GitRepositoryStatus, nameColor =
62
73
  return part;
63
74
  }
64
75
 
76
+ export function renderCacheCelebrationLine(
77
+ summary: string,
78
+ celebration: CacheCelebrationSnapshot,
79
+ ): string {
80
+ const badge = `⚡ ${celebration.percent}% CACHE`;
81
+ const animatedBadge = Array.from(badge)
82
+ .map((character, index) => {
83
+ const color = NEON_WAVE[(index + celebration.frame) % NEON_WAVE.length];
84
+ return `${BOLD}${color}${character}${RESET}`;
85
+ })
86
+ .join("");
87
+ return `${summary}${styled(DIM, " | ")}${animatedBadge}`;
88
+ }
89
+
65
90
  function renderWorktreeLine(worktrees: SessionWorktree[]): string {
66
91
  const separator = styled(DIM, " | ");
67
92
  const parts = worktrees.map((worktree) => {
@@ -88,8 +113,11 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
88
113
  const context = `${used}${styled(DIM, "/")}${styled(WHITE, formatTokenCount(data.contextWindow))}`;
89
114
  const cwd = data.cwdGit ? renderRepository(data.cwd, data.cwdGit) : styled(CYAN, data.cwd);
90
115
  const summary = styled(BLUE, data.model) + separator + cwd + separator + context;
116
+ const firstLine = data.cacheCelebration
117
+ ? renderCacheCelebrationLine(summary, data.cacheCelebration)
118
+ : summary;
91
119
 
92
- const lines = [truncateToWidth(summary, width)];
120
+ const lines = [truncateToWidth(firstLine, width)];
93
121
  if (data.worktrees.length > 0) lines.push(truncateToWidth(renderWorktreeLine(data.worktrees), width, "…"));
94
122
  lines.push(truncateToWidth(styled(DIM, data.sessionId), width));
95
123
  return lines;
@@ -98,6 +126,7 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
98
126
  export default function statuslineExtension(pi: ExtensionAPI): void {
99
127
  let requestRender: (() => void) | undefined;
100
128
  const fullRedraw = new FullRedrawScheduler();
129
+ const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
101
130
  let tracker: SessionWorktreeTracker | undefined;
102
131
  let cwdGit: GitRepositoryStatus | null = null;
103
132
  let cwdStatusAbort: AbortController | undefined;
@@ -154,6 +183,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
154
183
  };
155
184
 
156
185
  pi.on("session_start", (_event, ctx) => {
186
+ cacheCelebration.dispose();
157
187
  if (ctx.mode !== "tui") return;
158
188
 
159
189
  ctx.ui.setFooter((tui, _theme, footerData) => {
@@ -169,6 +199,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
169
199
  return {
170
200
  dispose(): void {
171
201
  stopBranchUpdates();
202
+ cacheCelebration.dispose();
172
203
  fullRedraw.detach();
173
204
  requestRender = undefined;
174
205
  },
@@ -188,6 +219,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
188
219
  contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
189
220
  worktrees: tracker?.getWorktrees() ?? [],
190
221
  sessionId: ctx.sessionManager.getSessionId(),
222
+ cacheCelebration: cacheCelebration.snapshot(),
191
223
  },
192
224
  width,
193
225
  ),
@@ -202,7 +234,8 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
202
234
  pi.on("tool_call", (event) => {
203
235
  if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
204
236
  });
205
- pi.on("turn_end", (_event, ctx) => {
237
+ pi.on("turn_end", (event, ctx) => {
238
+ if (requestRender) triggerCacheCelebrationForMessage(event.message, cacheCelebration);
206
239
  fullRedraw.request();
207
240
  requestRender?.();
208
241
  runInBackground(refreshCwdStatus(ctx));
@@ -211,6 +244,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
211
244
  pi.on("model_select", () => requestRender?.());
212
245
  pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
213
246
  pi.on("session_shutdown", () => {
247
+ cacheCelebration.dispose();
214
248
  fullRedraw.detach();
215
249
  tracker?.dispose();
216
250
  tracker = undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.1.2",
4
- "description": "Compact Pi footer statusline: model ID, git branch/dirty/behind state, linked worktrees with PR numbers, context usage, and session ID.",
3
+ "version": "0.1.3",
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": [
7
7
  "pi-package",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "index.ts",
35
+ "cache-celebration.ts",
35
36
  "redraw.ts",
36
37
  "worktrees.ts",
37
38
  "README.md",