@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/index.ts CHANGED
@@ -1,17 +1,33 @@
1
+ import { homedir } from "node:os";
1
2
  import { basename } from "node:path";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ getSelectListTheme,
7
+ getSettingsListTheme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { SettingsList, truncateToWidth } from "@earendil-works/pi-tui";
4
10
  import {
5
11
  CacheCelebrationController,
6
12
  type CacheCelebrationSnapshot,
7
13
  triggerCacheCelebrationForMessage,
8
14
  } from "./cache-celebration.ts";
15
+ import { CelebrationPreview, trackSelectedLabel } from "./celebration-preview.ts";
16
+ import { DEFAULT_CELEBRATION_STYLE, renderCacheBadge } from "./celebration-styles.ts";
9
17
  import { FullRedrawScheduler } from "./redraw.ts";
18
+ import {
19
+ applySettingChange,
20
+ buildSettingItems,
21
+ CACHE_CELEBRATION_LABEL,
22
+ createAliasSubmenu,
23
+ createWorktreeRootSubmenu,
24
+ } from "./settings-menu.ts";
25
+ import { defaultSettings, repoAlias, SettingsStore, type StatuslineSettings } from "./settings.ts";
26
+ import { resolvePalette, type StatuslinePalette, STATUSLINE_THEMES } from "./themes.ts";
10
27
  import { type UsageSnapshot, usageBand, UsageTracker } from "./usage.ts";
11
28
  import {
12
29
  type GitRepositoryStatus,
13
30
  readGitStatus,
14
- repoAlias,
15
31
  type SessionWorktree,
16
32
  SessionWorktreeTracker,
17
33
  } from "./worktrees.ts";
@@ -28,19 +44,8 @@ export interface StatuslineData {
28
44
  usage?: UsageSnapshot;
29
45
  }
30
46
 
31
- const BLUE = "\x1b[38;2;0;153;255m";
32
- const ORANGE = "\x1b[38;2;255;176;85m";
33
- const GREEN = "\x1b[38;2;0;175;80m";
34
- const CYAN = "\x1b[38;2;86;182;194m";
35
- const RED = "\x1b[38;2;255;85;85m";
36
- const YELLOW = "\x1b[38;2;230;200;0m";
37
- const WHITE = "\x1b[38;2;220;220;220m";
38
- const MAGENTA = "\x1b[38;2;190;120;255m";
39
- const NEON_CYAN = "\x1b[38;2;0;255;255m";
40
- const NEON_MAGENTA = "\x1b[38;2;255;0;255m";
41
- const DIM = "\x1b[2m";
42
- const BOLD = "\x1b[1m";
43
47
  const RESET = "\x1b[0m";
48
+ const DEFAULT_PALETTE = STATUSLINE_THEMES.default;
44
49
 
45
50
  function styled(style: string, text: string): string {
46
51
  return `${style}${text}${RESET}`;
@@ -48,32 +53,37 @@ function styled(style: string, text: string): string {
48
53
 
49
54
  const CLAUDE_ICON = "\uec82";
50
55
  const OPENAI_ICON = "\uec81";
51
- const USAGE_BAND_COLORS = { green: GREEN, yellow: YELLOW, orange: ORANGE, red: RED } as const;
52
56
 
53
- function remainingPercent(remaining: number): string {
54
- return styled(USAGE_BAND_COLORS[usageBand(remaining)], `${remaining}`);
57
+ function bandColor(remaining: number, palette: StatuslinePalette): string {
58
+ return { green: palette.ok, yellow: palette.warn, orange: palette.caution, red: palette.danger }[
59
+ usageBand(remaining)
60
+ ];
55
61
  }
56
62
 
57
- export function renderUsageSegment(usage: UsageSnapshot): string | undefined {
63
+ export function renderUsageSegment(
64
+ usage: UsageSnapshot,
65
+ palette: StatuslinePalette = DEFAULT_PALETTE,
66
+ ): string | undefined {
67
+ const percent = (remaining: number) => styled(bandColor(remaining, palette), `${remaining}`);
58
68
  const parts: string[] = [];
59
69
  if (usage.claude) {
60
- const dot = styled(DIM, "\u00b7");
61
- let claude = `${remainingPercent(usage.claude.fiveHour)}${dot}${remainingPercent(usage.claude.sevenDay)}`;
62
- if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${remainingPercent(usage.claude.scopedWeekly)}`;
63
- parts.push(`${styled(WHITE, CLAUDE_ICON)} ${claude}`);
70
+ const dot = styled(palette.dim, "\u00b7");
71
+ let claude = `${percent(usage.claude.fiveHour)}${dot}${percent(usage.claude.sevenDay)}`;
72
+ if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${percent(usage.claude.scopedWeekly)}`;
73
+ parts.push(`${styled(palette.text, CLAUDE_ICON)} ${claude}`);
64
74
  }
65
75
  if (usage.codex) {
66
- parts.push(`${styled(WHITE, OPENAI_ICON)} ${remainingPercent(usage.codex.weekly)}`);
76
+ parts.push(`${styled(palette.text, OPENAI_ICON)} ${percent(usage.codex.weekly)}`);
67
77
  }
68
78
  return parts.length > 0 ? parts.join(" ") : undefined;
69
79
  }
70
80
 
71
- function contextColor(contextTokens: number, contextWindow: number): string {
81
+ function contextColor(contextTokens: number, contextWindow: number, palette: StatuslinePalette): string {
72
82
  const percent = contextWindow > 0 ? Math.floor((contextTokens * 100) / contextWindow) : 0;
73
- if (percent >= 90) return RED;
74
- if (percent >= 70) return YELLOW;
75
- if (percent >= 50) return ORANGE;
76
- return GREEN;
83
+ if (percent >= 90) return palette.danger;
84
+ if (percent >= 70) return palette.warn;
85
+ if (percent >= 50) return palette.caution;
86
+ return palette.ok;
77
87
  }
78
88
 
79
89
  export function formatTokenCount(tokens: number): string {
@@ -86,70 +96,116 @@ export function formatTokenCount(tokens: number): string {
86
96
  return `${safeTokens}`;
87
97
  }
88
98
 
89
- function renderRepository(name: string, status: GitRepositoryStatus, nameColor = WHITE): string {
99
+ function renderRepository(
100
+ name: string,
101
+ status: GitRepositoryStatus,
102
+ palette: StatuslinePalette,
103
+ nameColor = palette.path,
104
+ ): string {
90
105
  let part = styled(nameColor, name);
91
- part += styled(DIM, ":");
92
- part += styled(CYAN, status.branch);
93
- if (status.dirty) part += styled(YELLOW, "*");
94
- if (status.behind > 0) part += ` ${styled(ORANGE, `⇣${status.behind}`)}`;
106
+ part += styled(palette.dim, ":");
107
+ part += styled(palette.branch, status.branch);
108
+ if (status.dirty) part += styled(palette.warn, "*");
109
+ if (status.behind > 0) part += ` ${styled(palette.caution, `⇣${status.behind}`)}`;
95
110
  return part;
96
111
  }
97
112
 
98
113
  export function renderCacheCelebrationLine(
99
114
  summary: string,
100
115
  celebration: CacheCelebrationSnapshot,
116
+ palette: StatuslinePalette = DEFAULT_PALETTE,
117
+ style: string = DEFAULT_CELEBRATION_STYLE,
101
118
  ): string {
102
- const badge = `⚡${celebration.percent}%·CACHE·HIT`;
103
- const color = celebration.frame % 2 === 0 ? NEON_MAGENTA : NEON_CYAN;
104
- const animatedBadge = `${BOLD}${color}${badge}${RESET}`;
105
- return `${summary}${styled(DIM, " | ")}${animatedBadge}`;
119
+ const badge = renderCacheBadge(celebration.percent, celebration.frame, style, palette);
120
+ return `${summary}${styled(palette.dim, " | ")}${badge}`;
106
121
  }
107
122
 
108
- function renderWorktreeLine(worktrees: SessionWorktree[]): string {
109
- const separator = styled(DIM, " | ");
123
+ function renderWorktreeLine(
124
+ worktrees: SessionWorktree[],
125
+ settings: StatuslineSettings,
126
+ palette: StatuslinePalette,
127
+ ): string {
128
+ const separator = styled(palette.dim, " | ");
110
129
  const parts = worktrees.map((worktree) => {
111
- let part = renderRepository(repoAlias(worktree.repo), worktree);
130
+ let part = renderRepository(repoAlias(worktree.repo, settings.repoAliases), worktree, palette);
112
131
  if (worktree.pr !== undefined) {
113
132
  const state = worktree.prState?.toUpperCase();
114
- const color = state === "OPEN" ? GREEN : state === "MERGED" ? MAGENTA : state === "CLOSED" ? RED : DIM;
133
+ const color =
134
+ state === "OPEN"
135
+ ? palette.ok
136
+ : state === "MERGED"
137
+ ? palette.accent
138
+ : state === "CLOSED"
139
+ ? palette.danger
140
+ : palette.dim;
115
141
  part += ` ${styled(color, `#${worktree.pr}`)}`;
116
142
  }
117
143
  return part;
118
144
  });
119
- return `${styled(DIM, "⑂")} ${parts.join(separator)}`;
145
+ return `${styled(palette.dim, "⑂")} ${parts.join(separator)}`;
120
146
  }
121
147
 
122
- export function renderStatusline(data: StatuslineData, width: number): string[] {
123
- const lineCount = data.worktrees.length > 0 ? 3 : 2;
124
- if (width <= 0) return Array.from({ length: lineCount }, () => "");
148
+ /** Home-independent defaults; only `worktreeRoot` varies by host and rendering never reads it. */
149
+ const RENDER_DEFAULTS = defaultSettings("");
125
150
 
126
- const separator = styled(DIM, " | ");
151
+ export function renderStatusline(
152
+ data: StatuslineData,
153
+ width: number,
154
+ settings: StatuslineSettings = RENDER_DEFAULTS,
155
+ ): string[] {
156
+ const palette = resolvePalette(settings.theme);
157
+ const separator = styled(palette.dim, " | ");
127
158
  const used =
128
159
  data.contextTokens === null
129
- ? styled(DIM, "?")
130
- : styled(contextColor(data.contextTokens, data.contextWindow), formatTokenCount(data.contextTokens));
131
- const context = `${used}${styled(DIM, "/")}${styled(WHITE, formatTokenCount(data.contextWindow))}`;
132
- const cwd = data.cwdGit ? renderRepository(data.cwd, data.cwdGit) : styled(CYAN, data.cwd);
133
- const usageSegment = data.usage ? renderUsageSegment(data.usage) : undefined;
134
- const summary =
135
- styled(BLUE, data.model) +
136
- separator +
137
- cwd +
138
- separator +
139
- context +
140
- (usageSegment ? separator + usageSegment : "");
141
- const firstLine = data.cacheCelebration
142
- ? renderCacheCelebrationLine(summary, data.cacheCelebration)
143
- : summary;
144
-
145
- const lines = [truncateToWidth(firstLine, width)];
146
- if (data.worktrees.length > 0) lines.push(truncateToWidth(renderWorktreeLine(data.worktrees), width, "…"));
147
- lines.push(truncateToWidth(styled(DIM, data.sessionId), width));
160
+ ? styled(palette.dim, "?")
161
+ : styled(
162
+ contextColor(data.contextTokens, data.contextWindow, palette),
163
+ formatTokenCount(data.contextTokens),
164
+ );
165
+ const usageSegment = settings.showUsage && data.usage ? renderUsageSegment(data.usage, palette) : undefined;
166
+ const segments = [
167
+ settings.showModel ? styled(palette.model, data.model) : undefined,
168
+ settings.showDirectory
169
+ ? data.cwdGit
170
+ ? renderRepository(data.cwd, data.cwdGit, palette)
171
+ : styled(palette.branch, data.cwd)
172
+ : undefined,
173
+ settings.showContext
174
+ ? `${used}${styled(palette.dim, "/")}${styled(palette.text, formatTokenCount(data.contextWindow))}`
175
+ : undefined,
176
+ usageSegment,
177
+ ].filter((segment): segment is string => segment !== undefined);
178
+
179
+ const showWorktreeLine = settings.showWorktrees && data.worktrees.length > 0;
180
+ const lineCount = (segments.length > 0 ? 1 : 0) + (showWorktreeLine ? 1 : 0) + (settings.showSessionId ? 1 : 0);
181
+ // A footer that renders nothing would collapse the block; keep one stable row.
182
+ if (lineCount === 0) return [""];
183
+ if (width <= 0) return Array.from({ length: lineCount }, () => "");
184
+
185
+ const lines: string[] = [];
186
+ if (segments.length > 0) {
187
+ const summary = segments.join(separator);
188
+ // A badge-only first line is separator soup, so the celebration needs a summary.
189
+ const celebration = settings.showCacheCelebration ? data.cacheCelebration : undefined;
190
+ lines.push(
191
+ truncateToWidth(
192
+ celebration
193
+ ? renderCacheCelebrationLine(summary, celebration, palette, settings.cacheCelebrationStyle)
194
+ : summary,
195
+ width,
196
+ ),
197
+ );
198
+ }
199
+ if (showWorktreeLine) {
200
+ lines.push(truncateToWidth(renderWorktreeLine(data.worktrees, settings, palette), width, "…"));
201
+ }
202
+ if (settings.showSessionId) lines.push(truncateToWidth(styled(palette.dim, data.sessionId), width));
148
203
  return lines;
149
204
  }
150
205
 
151
206
  export default function statuslineExtension(pi: ExtensionAPI): void {
152
207
  let requestRender: (() => void) | undefined;
208
+ const celebrationPreview = new CelebrationPreview(() => requestRender?.());
153
209
  const fullRedraw = new FullRedrawScheduler();
154
210
  const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
155
211
  let tracker: SessionWorktreeTracker | undefined;
@@ -157,6 +213,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
157
213
  let cwdGit: GitRepositoryStatus | null = null;
158
214
  let cwdStatusAbort: AbortController | undefined;
159
215
  let cwdStatusInFlight: Promise<void> | undefined;
216
+ const home = homedir();
217
+ const settingsStore = new SettingsStore({ home });
218
+ let settings = settingsStore.get();
160
219
 
161
220
  const runInBackground = (operation: Promise<void>): void => {
162
221
  operation.catch(() => {
@@ -193,26 +252,128 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
193
252
 
194
253
  const resetTracker = (ctx: ExtensionContext): void => {
195
254
  tracker?.dispose();
255
+ tracker = undefined;
196
256
  cwdStatusAbort?.abort();
197
257
  cwdGit = null;
198
258
  cwdStatusAbort = new AbortController();
199
259
  cwdStatusInFlight = undefined;
260
+ runInBackground(refreshCwdStatus(ctx));
261
+ if (settings.showUsage) runInBackground(usageTracker.refresh());
262
+ // A hidden worktree line must not pay for git/gh polling.
263
+ if (!settings.showWorktrees) return;
200
264
  const next = new SessionWorktreeTracker({
201
265
  exec: (command, args, options) => pi.exec(command, args, options),
202
- home: process.env.HOME ?? "",
266
+ worktreeRoot: settings.worktreeRoot,
267
+ home,
203
268
  onChange: () => requestRender?.(),
204
269
  });
205
270
  tracker = next;
206
- runInBackground(refreshCwdStatus(ctx));
207
271
  runInBackground(next.seedFromEntries(ctx.sessionManager.getBranch()));
208
272
  runInBackground(next.includeCurrentWorktree(ctx.cwd));
209
- runInBackground(usageTracker.refresh());
210
273
  };
211
274
 
275
+ /** Adopt a new settings snapshot: apply it live, then persist in the background. */
276
+ const applySettings = (ctx: ExtensionContext, next: StatuslineSettings, persist = true): void => {
277
+ const previous = settings;
278
+ settings = next;
279
+ settingsStore.set(next);
280
+
281
+ if (!next.showWorktrees) {
282
+ tracker?.dispose();
283
+ tracker = undefined;
284
+ } else if (!previous.showWorktrees || !tracker || previous.worktreeRoot !== next.worktreeRoot) {
285
+ resetTracker(ctx);
286
+ }
287
+ if (next.showUsage && !previous.showUsage) runInBackground(usageTracker.refresh());
288
+ if (!next.showCacheCelebration) cacheCelebration.dispose();
289
+ // Dropping a row leaves a stale one behind in fullscreen mode.
290
+ if (previous.showSessionId !== next.showSessionId || previous.showWorktrees !== next.showWorktrees) {
291
+ fullRedraw.request();
292
+ }
293
+ requestRender?.();
294
+
295
+ if (!persist) return;
296
+ settingsStore.save(next).catch((error: unknown) => {
297
+ ctx.ui.notify(
298
+ `Could not save statusline settings: ${error instanceof Error ? error.message : String(error)}`,
299
+ "warning",
300
+ );
301
+ });
302
+ };
303
+
304
+ pi.registerCommand("statusline", {
305
+ description: "Configure the statusline",
306
+ handler: async (_args, ctx) => {
307
+ if (ctx.mode !== "tui") {
308
+ ctx.ui.notify("/statusline requires interactive TUI mode", "warning");
309
+ return;
310
+ }
311
+
312
+ await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
313
+ const tracked = trackSelectedLabel(getSettingsListTheme());
314
+ const settingsTheme = tracked.theme;
315
+ const submenuHost = {
316
+ getSettings: () => settings,
317
+ commit: (next: StatuslineSettings) => applySettings(ctx, next),
318
+ notify: (message: string) => ctx.ui.notify(message, "warning"),
319
+ requestRender: () => tui.requestRender(),
320
+ settingsTheme,
321
+ selectTheme: getSelectListTheme(),
322
+ home,
323
+ };
324
+ const list = new SettingsList(
325
+ buildSettingItems(
326
+ settings,
327
+ {
328
+ worktreeRoot: createWorktreeRootSubmenu(submenuHost),
329
+ repoAliases: createAliasSubmenu(submenuHost),
330
+ },
331
+ home,
332
+ ),
333
+ 10,
334
+ settingsTheme,
335
+ (id, value) => {
336
+ const change = applySettingChange(settings, id, value, home);
337
+ if (change.kind === "error") ctx.ui.notify(change.message, "warning");
338
+ else if (change.kind === "settings") applySettings(ctx, change.settings);
339
+ tui.requestRender();
340
+ },
341
+ () => done(undefined),
342
+ { enableSearch: true },
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
+ };
366
+ });
367
+ },
368
+ });
369
+
212
370
  pi.on("session_start", (_event, ctx) => {
213
371
  cacheCelebration.dispose();
214
372
  if (ctx.mode !== "tui") return;
215
373
 
374
+ // setFooter is synchronous, so the first frames render with defaults.
375
+ runInBackground(settingsStore.load().then((loaded) => applySettings(ctx, loaded, false)));
376
+
216
377
  ctx.ui.setFooter((tui, _theme, footerData) => {
217
378
  requestRender = () => tui.requestRender();
218
379
  // Fullscreen mode never repaints unchanged rows; the session id line is
@@ -227,6 +388,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
227
388
  dispose(): void {
228
389
  stopBranchUpdates();
229
390
  cacheCelebration.dispose();
391
+ celebrationPreview.dispose();
230
392
  fullRedraw.detach();
231
393
  requestRender = undefined;
232
394
  },
@@ -246,10 +408,11 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
246
408
  contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
247
409
  worktrees: tracker?.getWorktrees() ?? [],
248
410
  sessionId: ctx.sessionManager.getSessionId(),
249
- cacheCelebration: cacheCelebration.snapshot(),
411
+ cacheCelebration: celebrationPreview.snapshot() ?? cacheCelebration.snapshot(),
250
412
  usage: usageTracker.snapshot(),
251
413
  },
252
414
  width,
415
+ settings,
253
416
  ),
254
417
  );
255
418
  },
@@ -263,12 +426,14 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
263
426
  if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
264
427
  });
265
428
  pi.on("turn_end", (event, ctx) => {
266
- if (requestRender) triggerCacheCelebrationForMessage(event.message, cacheCelebration);
429
+ if (requestRender && settings.showCacheCelebration) {
430
+ triggerCacheCelebrationForMessage(event.message, cacheCelebration);
431
+ }
267
432
  fullRedraw.request();
268
433
  requestRender?.();
269
434
  runInBackground(refreshCwdStatus(ctx));
270
435
  if (tracker) runInBackground(tracker.refresh());
271
- runInBackground(usageTracker.refresh());
436
+ if (settings.showUsage) runInBackground(usageTracker.refresh());
272
437
  });
273
438
  pi.on("model_select", () => requestRender?.());
274
439
  pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.2.4",
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,7 +33,12 @@
33
33
  "files": [
34
34
  "index.ts",
35
35
  "cache-celebration.ts",
36
+ "celebration-preview.ts",
37
+ "celebration-styles.ts",
36
38
  "redraw.ts",
39
+ "settings.ts",
40
+ "settings-menu.ts",
41
+ "themes.ts",
37
42
  "usage.ts",
38
43
  "worktrees.ts",
39
44
  "README.md",