@hank-warren/pi-statusline 0.7.2 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.7.2",
3
+ "version": "0.8.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": [
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-statusline#readme",
25
25
  "engines": {
26
- "node": ">=18.0.0"
26
+ "node": ">=22.19.0"
27
27
  },
28
28
  "pi": {
29
29
  "extensions": [
@@ -35,6 +35,8 @@
35
35
  "cache-celebration.ts",
36
36
  "celebration-preview.ts",
37
37
  "celebration-styles.ts",
38
+ "custom.ts",
39
+ "custom-setup.ts",
38
40
  "redraw.ts",
39
41
  "settings.ts",
40
42
  "settings-menu.ts",
package/redraw.ts CHANGED
@@ -33,7 +33,7 @@ export interface RedrawTarget {
33
33
  requestRender(force?: boolean): void;
34
34
  }
35
35
 
36
- export interface FullRedrawSchedulerOptions {
36
+ interface FullRedrawSchedulerOptions {
37
37
  minGapMs?: number;
38
38
  rowRefreshIntervalMs?: number;
39
39
  idleIntervalMs?: number;
package/settings-menu.ts CHANGED
@@ -10,6 +10,7 @@ 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 type { CustomItemState } from "./custom.ts";
13
14
  import { CELEBRATION_STYLE_NAMES, isCelebrationStyleName } from "./celebration-styles.ts";
14
15
  import { isThemeName, THEME_NAMES } from "./themes.ts";
15
16
 
@@ -17,13 +18,15 @@ export const THEME_ID = "theme";
17
18
  export const CACHE_CELEBRATION_ID = "showCacheCelebration";
18
19
  export const WORKTREE_ROOT_ID = "worktreeRoot";
19
20
  export const REPO_ALIASES_ID = "repoAliases";
21
+ export const CUSTOM_ITEMS_ID = "customItems";
20
22
  export const ADD_ALIAS_VALUE = "\u0000add";
23
+ export const ADD_CUSTOM_ITEM_VALUE = "\u0000add-item";
21
24
 
22
25
  export const ON = "on";
23
26
  export const OFF = "off";
24
27
  const TOGGLE_VALUES = [ON, OFF];
25
28
 
26
- export interface BooleanRow {
29
+ interface BooleanRow {
27
30
  id: BooleanSettingKey;
28
31
  label: string;
29
32
  description: string;
@@ -36,6 +39,7 @@ export const BOOLEAN_ROWS: readonly BooleanRow[] = [
36
39
  { id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
37
40
  { id: "showContext", label: "Context", description: "Show context tokens used against the window." },
38
41
  { id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
42
+ { id: "showCustomItems", label: "Custom items", description: "Run your configured commands and show their output." },
39
43
  { id: "showWorktrees", label: "Worktree line", description: "Show touched worktrees and their pull requests." },
40
44
  { id: "showSessionId", label: "Session ID line", description: "Show the full Pi session id on its own line." },
41
45
  ];
@@ -57,9 +61,67 @@ export function aliasSummary(settings: StatuslineSettings): string {
57
61
  return `${count} alias${count === 1 ? "" : "es"}`;
58
62
  }
59
63
 
64
+ /** Row value for the custom-items submenu: how many items are switched on. */
65
+ export function customItemsSummary(settings: StatuslineSettings): string {
66
+ const total = settings.customItems.length;
67
+ if (total === 0) return "none configured";
68
+ return `${settings.customItems.filter((item) => item.enabled).length}/${total} on`;
69
+ }
70
+
71
+ /**
72
+ * Rows in the custom-items submenu: each item, what it shows and why, then the
73
+ * one action that creates an item. Adding hands the job to the agent rather
74
+ * than opening a form: a statusline command is a script plus a JSON entry plus
75
+ * a test run, which is a conversation, not a field.
76
+ */
77
+ export function customItemRows(
78
+ settings: StatuslineSettings,
79
+ states: readonly CustomItemState[] = [],
80
+ ): SelectItem[] {
81
+ return [
82
+ ...customItemStateRows(settings, states),
83
+ {
84
+ value: ADD_CUSTOM_ITEM_VALUE,
85
+ label: "Add custom item…",
86
+ description: "Ask the agent to write one; it gets the contract and the settings path.",
87
+ },
88
+ ];
89
+ }
90
+
91
+ function customItemStateRows(settings: StatuslineSettings, states: readonly CustomItemState[]): SelectItem[] {
92
+ const byId = new Map(states.map((state) => [state.id, state]));
93
+ return settings.customItems.map((item) => {
94
+ const state = byId.get(item.id);
95
+ // Configuration errors outrank run errors: an item that cannot be parsed
96
+ // never ran, so a stale run error from a previous config would mislead.
97
+ const error = item.error ?? state?.error;
98
+ // A broken entry reads as "disabled" unless its reason wins here: it is off
99
+ // *because* it cannot run, and "disabled" would suggest the user chose that.
100
+ const detail = item.error !== undefined
101
+ ? item.error
102
+ : !item.enabled
103
+ ? "disabled"
104
+ : error !== undefined
105
+ ? error
106
+ : state?.running === true && state.value === undefined
107
+ ? "running…"
108
+ : state?.value !== undefined && state.value.length > 0
109
+ ? state.value
110
+ : state?.value !== undefined
111
+ ? "empty output"
112
+ : "no value yet";
113
+ return {
114
+ value: item.id,
115
+ label: `${item.enabled ? toggleValue(true) : toggleValue(false)} ${item.id}`,
116
+ description: detail,
117
+ };
118
+ });
119
+ }
120
+
60
121
  export interface SettingSubmenus {
61
122
  worktreeRoot?: SettingItem["submenu"];
62
123
  repoAliases?: SettingItem["submenu"];
124
+ customItems?: SettingItem["submenu"];
63
125
  }
64
126
 
65
127
  /** Build the `/statusline` rows for a settings snapshot. */
@@ -81,6 +143,10 @@ export function buildSettingItems(
81
143
  // Rows follow the order their elements render in, so the celebration sits
82
144
  // after the usage meters it is appended to on line 1.
83
145
  for (const row of BOOLEAN_ROWS) {
146
+ // A toggle for a segment with nothing in it is a row that does nothing;
147
+ // the list row below is where an item gets created, and the toggle
148
+ // appears once there is something to switch off.
149
+ if (row.id === "showCustomItems" && settings.customItems.length === 0) continue;
84
150
  items.push({
85
151
  id: row.id,
86
152
  label: row.label,
@@ -113,6 +179,13 @@ export function buildSettingItems(
113
179
  currentValue: aliasSummary(settings),
114
180
  ...(submenus.repoAliases ? { submenu: submenus.repoAliases } : {}),
115
181
  });
182
+ items.push({
183
+ id: CUSTOM_ITEMS_ID,
184
+ label: "Custom item list",
185
+ description: "Enable or disable configured items; edit commands in statusline-settings.json.",
186
+ currentValue: customItemsSummary(settings),
187
+ ...(submenus.customItems ? { submenu: submenus.customItems } : {}),
188
+ });
116
189
 
117
190
  return items;
118
191
  }
@@ -183,6 +256,13 @@ export function aliasItems(settings: StatuslineSettings): SelectItem[] {
183
256
  export interface SubmenuHost {
184
257
  /** Read the live settings; the menu edits a single shared snapshot. */
185
258
  getSettings(): StatuslineSettings;
259
+ /** Live per-item run state, for the custom-items submenu. */
260
+ customItemStates?(): CustomItemState[];
261
+ /**
262
+ * Hand "add an item" to the agent with the user's one-line description of
263
+ * what it should show. Closes the menu; absent when no agent can be reached.
264
+ */
265
+ requestCustomItem?(request: string): void;
186
266
  /** Commit an edit: persists, applies live, and repaints. */
187
267
  commit(settings: StatuslineSettings): void;
188
268
  notify(message: string): void;
@@ -348,3 +428,99 @@ class AliasSubmenu implements Component {
348
428
  export function createAliasSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
349
429
  return (_currentValue, done) => new AliasSubmenu(host, done);
350
430
  }
431
+
432
+ /**
433
+ * Custom item list: enable/disable each item and read why it is not showing.
434
+ *
435
+ * Commands are edited in the settings file, not here. A statusline command is a
436
+ * shell line with quoting and pipes in it, which a single-line TUI prompt edits
437
+ * badly, and keeping the field out of the menu means a toggle writes only the
438
+ * `enabled` flag over whatever the file currently holds.
439
+ */
440
+ class CustomItemsSubmenu implements Component {
441
+ private list: SelectList;
442
+ private prompt: PromptComponent | undefined;
443
+
444
+ constructor(
445
+ private readonly host: SubmenuHost,
446
+ private readonly done: (value?: string) => void,
447
+ ) {
448
+ this.list = this.buildList();
449
+ }
450
+
451
+ private buildList(selectedIndex = 0): SelectList {
452
+ const rows = customItemRows(this.host.getSettings(), this.host.customItemStates?.() ?? []);
453
+ const list = new SelectList(rows, 10, this.host.selectTheme);
454
+ list.setSelectedIndex(selectedIndex);
455
+ list.onCancel = () => this.done(customItemsSummary(this.host.getSettings()));
456
+ list.onSelect = (item) => (item.value === ADD_CUSTOM_ITEM_VALUE ? this.add() : this.toggle(item.value));
457
+ return list;
458
+ }
459
+
460
+ private add(): void {
461
+ if (!this.host.requestCustomItem) {
462
+ this.host.notify("Add items to statusline-settings.json; see the pi-statusline README");
463
+ return;
464
+ }
465
+ this.prompt = new PromptComponent(
466
+ "What should the item show? (Enter for the agent to ask)",
467
+ "",
468
+ this.host.settingsTheme.hint,
469
+ (value) => {
470
+ this.prompt = undefined;
471
+ // Close the whole menu before the message lands: the agent's reply
472
+ // renders in the transcript, which the menu is drawn over.
473
+ this.done(customItemsSummary(this.host.getSettings()));
474
+ this.host.requestCustomItem?.(value);
475
+ },
476
+ () => {
477
+ this.prompt = undefined;
478
+ this.host.requestRender();
479
+ },
480
+ );
481
+ this.host.requestRender();
482
+ }
483
+
484
+ private toggle(id: string): void {
485
+ if (id.startsWith("\u0000")) return;
486
+ const settings = this.host.getSettings();
487
+ const index = settings.customItems.findIndex((item) => item.id === id);
488
+ const target = settings.customItems[index];
489
+ if (!target) return;
490
+ if (target.error !== undefined && !target.enabled) {
491
+ // Enabling an unparseable entry would only fail again on the next tick.
492
+ this.host.notify(`${id} cannot run: ${target.error}`);
493
+ return;
494
+ }
495
+ const customItems = [...settings.customItems];
496
+ customItems[index] = { ...target, enabled: !target.enabled };
497
+ this.host.commit({ ...settings, customItems });
498
+ this.list = this.buildList(index);
499
+ this.host.requestRender();
500
+ }
501
+
502
+ invalidate(): void {
503
+ this.prompt?.invalidate();
504
+ this.list.invalidate();
505
+ }
506
+
507
+ render(width: number): string[] {
508
+ if (this.prompt) return this.prompt.render(width);
509
+ return [
510
+ truncateToWidth(this.host.settingsTheme.hint(" Custom items"), width),
511
+ "",
512
+ ...this.list.render(width),
513
+ "",
514
+ truncateToWidth(this.host.settingsTheme.hint(" Enter to enable/disable or add · Esc to go back"), width),
515
+ ];
516
+ }
517
+
518
+ handleInput(data: string): void {
519
+ if (this.prompt) this.prompt.handleInput(data);
520
+ else this.list.handleInput(data);
521
+ }
522
+ }
523
+
524
+ export function createCustomItemsSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
525
+ return (_currentValue, done) => new CustomItemsSubmenu(host, done);
526
+ }
package/settings.ts CHANGED
@@ -2,11 +2,18 @@ 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 { getAgentDir } from "@earendil-works/pi-coding-agent";
5
6
  import {
6
7
  type CelebrationStyleName,
7
8
  DEFAULT_CELEBRATION_STYLE,
8
9
  isCelebrationStyleName,
9
10
  } from "./celebration-styles.ts";
11
+ import {
12
+ type CustomItem,
13
+ normalizeCustomItems,
14
+ sameCustomItems,
15
+ serializeCustomItems,
16
+ } from "./custom.ts";
10
17
  import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.ts";
11
18
 
12
19
  /** Toggle keys, in the order the `/statusline` menu lists them. */
@@ -16,6 +23,7 @@ export const BOOLEAN_SETTING_KEYS = [
16
23
  "showDirectory",
17
24
  "showContext",
18
25
  "showUsage",
26
+ "showCustomItems",
19
27
  "showWorktrees",
20
28
  "showSessionId",
21
29
  "showCacheCelebration",
@@ -32,9 +40,11 @@ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
32
40
  worktreeRoot: string;
33
41
  /** `repository name -> display alias` overrides for the worktree line. */
34
42
  repoAliases: Record<string, string>;
43
+ /** User-defined command segments rendered after the usage meters. */
44
+ customItems: CustomItem[];
35
45
  }
36
46
 
37
- export function defaultWorktreeRoot(home: string = homedir()): string {
47
+ function defaultWorktreeRoot(home: string = homedir()): string {
38
48
  return join(home || homedir(), "repos", "worktrees");
39
49
  }
40
50
 
@@ -46,6 +56,9 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
46
56
  showDirectory: true,
47
57
  showContext: true,
48
58
  showUsage: true,
59
+ // On by default and free until the user configures an item: with an empty
60
+ // list nothing renders, nothing is spawned, and no timer runs.
61
+ showCustomItems: true,
49
62
  showWorktrees: true,
50
63
  showSessionId: true,
51
64
  showCacheCelebration: true,
@@ -53,11 +66,21 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
53
66
  cacheCelebrationStyle: DEFAULT_CELEBRATION_STYLE,
54
67
  worktreeRoot: defaultWorktreeRoot(home),
55
68
  repoAliases: {},
69
+ customItems: [],
56
70
  };
57
71
  }
58
72
 
59
- export function defaultSettingsPath(home: string = homedir()): string {
60
- return join(home || homedir(), ".pi", "agent", "statusline-settings.json");
73
+ /**
74
+ * Where the settings live when the caller names no path.
75
+ *
76
+ * The agent dir, not the home dir: pi honours `PI_CODING_AGENT_DIR`, so a
77
+ * session running against a scratch agent dir must save its statusline settings
78
+ * there rather than into the host's real `~/.pi/agent`. `home` still shapes the
79
+ * settings' *content* (the worktree root default, `~` collapsing) — that is a
80
+ * different thing and keeps its own parameter.
81
+ */
82
+ export function defaultSettingsPath(): string {
83
+ return join(getAgentDir(), "statusline-settings.json");
61
84
  }
62
85
 
63
86
  /** Expand a leading `~` (and `$HOME`) against `home`; other paths are returned as-is. */
@@ -78,7 +101,7 @@ export function collapseHome(path: string, home: string = homedir()): string {
78
101
  return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
79
102
  }
80
103
 
81
- export interface WorktreeRootResult {
104
+ interface WorktreeRootResult {
82
105
  path?: string;
83
106
  error?: string;
84
107
  }
@@ -109,7 +132,7 @@ function normalizeAliases(value: unknown): Record<string, string> | undefined {
109
132
  return aliases;
110
133
  }
111
134
 
112
- export interface NormalizedSettings {
135
+ interface NormalizedSettings {
113
136
  settings: StatuslineSettings;
114
137
  /** Top-level keys this version does not know about, preserved on write. */
115
138
  extra: Record<string, unknown>;
@@ -130,6 +153,7 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
130
153
  "cacheCelebrationStyle",
131
154
  "worktreeRoot",
132
155
  "repoAliases",
156
+ "customItems",
133
157
  ]);
134
158
  for (const [key, raw] of Object.entries(value)) {
135
159
  if (!known.has(key)) {
@@ -148,6 +172,12 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
148
172
  if (aliases) settings.repoAliases = aliases;
149
173
  continue;
150
174
  }
175
+ if (key === "customItems") {
176
+ // Unusable entries come back as items carrying their parse error rather
177
+ // than being dropped, so a write cannot delete what it failed to read.
178
+ settings.customItems = normalizeCustomItems(raw);
179
+ continue;
180
+ }
151
181
  if (key === "theme") {
152
182
  if (isThemeName(raw)) settings.theme = raw;
153
183
  continue;
@@ -175,6 +205,7 @@ export const SETTING_KEYS = [
175
205
  "cacheCelebrationStyle",
176
206
  "worktreeRoot",
177
207
  "repoAliases",
208
+ "customItems",
178
209
  ] as const satisfies readonly (keyof StatuslineSettings)[];
179
210
 
180
211
  export type SettingKey = (typeof SETTING_KEYS)[number];
@@ -195,11 +226,11 @@ export function changedSettingKeys(
195
226
  previous: StatuslineSettings,
196
227
  next: StatuslineSettings,
197
228
  ): SettingKey[] {
198
- return SETTING_KEYS.filter((key) =>
199
- key === "repoAliases"
200
- ? !sameAliases(previous.repoAliases, next.repoAliases)
201
- : previous[key] !== next[key],
202
- );
229
+ return SETTING_KEYS.filter((key) => {
230
+ if (key === "repoAliases") return !sameAliases(previous.repoAliases, next.repoAliases);
231
+ if (key === "customItems") return !sameCustomItems(previous.customItems, next.customItems);
232
+ return previous[key] !== next[key];
233
+ });
203
234
  }
204
235
 
205
236
  /**
@@ -222,10 +253,13 @@ export function serializeSettings(
222
253
  }
223
254
  if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
224
255
  if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
256
+ // An empty list is the default and stays out of the file; a configured one is
257
+ // written back from each entry's original source object.
258
+ if (settings.customItems.length > 0) out.customItems = serializeCustomItems(settings.customItems);
225
259
  return out;
226
260
  }
227
261
 
228
- export interface SettingsStoreOptions {
262
+ interface SettingsStoreOptions {
229
263
  path?: string;
230
264
  home?: string;
231
265
  }
@@ -242,7 +276,7 @@ export class SettingsStore {
242
276
 
243
277
  constructor(options: SettingsStoreOptions = {}) {
244
278
  this.home = options.home ?? homedir();
245
- this.path = options.path ?? defaultSettingsPath(this.home);
279
+ this.path = options.path ?? defaultSettingsPath();
246
280
  this.current = defaultSettings(this.home);
247
281
  }
248
282
 
package/usage.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, rename, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  import { pid } from "node:process";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  /**
8
8
  * Remaining (not used) integer percents per provider window.
@@ -84,7 +84,7 @@ export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
84
84
  * process's fresh values appear within one tick instead of at the next turn.
85
85
  * Request volume is unchanged: both throttles still gate every fetch.
86
86
  */
87
- export const USAGE_TICK_INTERVAL_MS = 10_000;
87
+ const USAGE_TICK_INTERVAL_MS = 10_000;
88
88
  const FETCH_TIMEOUT_MS = 10_000;
89
89
  const ONE_DAY_SECONDS = 86_400;
90
90
 
@@ -283,7 +283,7 @@ type ProviderKey = "claude" | "codex";
283
283
  const PROVIDER_KEYS = ["claude", "codex"] as const;
284
284
 
285
285
  /** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
286
- export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
286
+ const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
287
287
  claude: "anthropic",
288
288
  codex: "openai-codex",
289
289
  };
@@ -295,7 +295,7 @@ const PROVIDER_REFRESH_INTERVAL_MS: Record<ProviderKey, number> = {
295
295
  };
296
296
 
297
297
  /** Credential id polled for each family. */
298
- export type UsageAccounts = Record<ProviderKey, string>;
298
+ type UsageAccounts = Record<ProviderKey, string>;
299
299
 
300
300
  /** A freshly fetched value together with the boundary it is valid until. */
301
301
  interface FetchedUsage<K extends ProviderKey> {
@@ -477,8 +477,11 @@ export class UsageTracker {
477
477
  private tickHandle: unknown;
478
478
 
479
479
  constructor(options: UsageTrackerOptions = {}) {
480
- this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
481
- this.cachePath = options.cachePath ?? join(homedir(), ".pi", "agent", "statusline-usage.json");
480
+ // getAgentDir(), not ~/.pi/agent: pi honours PI_CODING_AGENT_DIR, and a
481
+ // hardcoded home path made a session pointed at another agent dir read the
482
+ // wrong credentials and write its usage cache into the host's real one.
483
+ this.authPath = options.authPath ?? join(getAgentDir(), "auth.json");
484
+ this.cachePath = options.cachePath ?? join(getAgentDir(), "statusline-usage.json");
482
485
  this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
483
486
  this.onChange = options.onChange;
484
487
  this.now = options.now ?? Date.now;
package/worktrees.ts CHANGED
@@ -29,7 +29,7 @@ interface WorktreeMetadata {
29
29
  branch: string;
30
30
  }
31
31
 
32
- export interface WorktreeTrackerHost {
32
+ interface WorktreeTrackerHost {
33
33
  exec(
34
34
  command: string,
35
35
  args: string[],