@hank-warren/pi-statusline 0.9.0 → 0.10.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,15 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Let other extensions provide custom statusline items as in-process functions through the `pi-statusline:custom-items:request` event, without shell commands or a process spawn on each refresh.
8
+
9
+ Registered items receive the same session payload as command items and share their refresh scheduler, timeouts, failure grace period, and output sanitization. Providers can return `null` to hide an item, receive an abort signal on timeout, and replace an existing registration by id.
10
+
11
+ The `/statusline` custom item list identifies extension-provided rows and persists their enabled state. Settings can pin their order and override refresh intervals and timeouts; existing command items keep priority when an id collides. Providers are discovered at interactive session start and whenever `/statusline` opens, independently of extension load order.
12
+
3
13
  ## 0.9.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -104,7 +104,9 @@ Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); o
104
104
 
105
105
  ## Custom items
106
106
 
107
- Everything above is built in. **Custom items** are the escape hatch: each one runs a shell command, and its output becomes a segment on line 1, after the usage meters and before the worktree line. This is how a personal metric — a self-hosted quota pool, a deploy status, an on-call flag — gets onto the statusline without being packaged for everybody else.
107
+ Everything above is built in. **Custom items** are the escape hatch: each one produces one line, and that line becomes a segment on line 1, after the usage meters and before the worktree line. This is how a personal metric — a self-hosted quota pool, a deploy status, an on-call flag — gets onto the statusline without being packaged for everybody else.
108
+
109
+ A value comes from one of two places: a **shell command** you configure (below), or a **function another extension registers** ([Providing an item from an extension](#providing-an-item-from-an-extension)). They share one scheduler, one timeout, one failure grace and one row in `/statusline`.
108
110
 
109
111
  The contract is deliberately [Claude Code's status line](https://docs.claude.com/en/docs/claude-code/statusline) contract: a command, JSON about the session on **stdin**, one line on **stdout**, ANSI colors passed through. A script written for Claude Code runs here mostly unchanged (see [differences](#differences-from-claude-codes-status-line)).
110
112
 
@@ -136,12 +138,72 @@ By hand, items live under `customItems` in `~/.pi/agent/statusline-settings.json
136
138
  | `refreshInterval` | no | Seconds between forced re-runs, on top of the event-driven ones. Omit for event-driven only. |
137
139
  | `timeout` | no | Seconds before the command is killed. Default `5`, capped at `30`. |
138
140
  | `enabled` | no | `false` hides the item and stops it running. This is the one field `/statusline` writes. |
139
- | `type` | no | Accepted for entries pasted from Claude Code, where it is `"command"`. Any other value is preserved but not run. |
141
+ | `type` | no | `"command"` (the default, and what an entry pasted from Claude Code carries) or `"extension"` for an item another extension provides. Any other value is preserved but not run. |
140
142
 
141
143
  Items render in configuration order, each as its own ` | `-separated segment.
142
144
 
143
145
  **The file is read when a session starts and whenever `/statusline` opens.** After editing it by hand, open `/statusline` and press Esc to load the change into the running session; nothing watches the file.
144
146
 
147
+ ### Providing an item from an extension
148
+
149
+ A command is the right escape hatch for a one-off script, and the wrong shape for anything that wants to be a package: the script has to be on `PATH` on every host, it is updated by whatever put it there rather than by pi, its config lives outside any package, and it costs a process spawn per refresh for a value that could be a function call.
150
+
151
+ So an extension can provide the value directly. pi-statusline emits one event carrying a `register` callback; nothing here imports a provider, and a provider imports nothing from here — the event name is the whole coupling.
152
+
153
+ ```ts
154
+ export default function myProvider(pi: ExtensionAPI): void {
155
+ pi.events?.on("pi-statusline:custom-items:request", (payload) => {
156
+ const register = (payload as { register?: unknown }).register as
157
+ | ((item: {
158
+ id: string;
159
+ run: (payload: Record<string, unknown>, signal: AbortSignal) => Promise<string | null>;
160
+ refreshInterval?: number;
161
+ timeoutMs?: number;
162
+ }) => boolean)
163
+ | undefined;
164
+ if (typeof register !== "function") return;
165
+
166
+ const accepted = register({
167
+ id: "quota",
168
+ refreshInterval: 60,
169
+ timeoutMs: 5_000,
170
+ run: async (_session, signal) => {
171
+ const response = await fetch("http://localhost:9000/quota", { signal });
172
+ if (!response.ok) throw new Error(`quota feed ${response.status}`);
173
+ const { remaining } = (await response.json()) as { remaining: number };
174
+ return remaining > 0 ? `\u001b[32m${remaining}%\u001b[0m` : null;
175
+ },
176
+ });
177
+ // Refused only for an unusable `id` or `run`. Nothing else reports it, so a
178
+ // provider that drops this sees a segment that simply never appears.
179
+ if (!accepted) throw new Error("pi-statusline refused the quota item");
180
+ });
181
+ }
182
+ ```
183
+
184
+ The contract is the command contract, minus the process:
185
+
186
+ - `payload` is **exactly the JSON a command gets on stdin**, already parsed, and built fresh for each run — a script ported into an extension reads the same fields.
187
+ - The return value is the segment: one line, [sanitised the same way](#what-the-command-should-print) and capped at 120 characters. `null`, `undefined` or `""` hides the item.
188
+ - **Throwing is failing.** The message is what `/statusline` shows, and it counts against the same three-failure grace as a non-zero exit.
189
+ - `signal` aborts at `timeoutMs` (default 5s, capped at 30s). A run that ignores it is not awaited past the deadline; its late value is dropped.
190
+ - `register` is idempotent on `id`: registering again replaces the function and cancels the run in flight, so a provider can re-register whenever its configuration changes.
191
+ - `register` **returns `false`** if the `id` is empty or `run` is not a function. It never throws — a throw here would be reported as *your* extension failing rather than as a registration pi-statusline refused — and a refused item has no row and no error anywhere, so the boolean is the only place a typo is visible.
192
+
193
+ **The event is emitted when a session starts and again every time `/statusline` opens**, in interactive mode only. A provider therefore does not have to load before pi-statusline — subscribe in your factory and the request will come.
194
+
195
+ There is no `unregister`. A registration lives as long as **pi-statusline's own** extension instance, not the provider's: re-registering the same `id` replaces it, and everything is discarded when pi-statusline reloads. A provider that is unloaded mid-session without pi-statusline reloading leaves its `run` behind, still being called on the refresh interval — in practice a `/reload` reloads both, so this is a note rather than a caveat.
196
+
197
+ The settings file still owns **order and enabled**:
198
+
199
+ | Entry in `statusline-settings.json` | What happens |
200
+ |---|---|
201
+ | none | the item is appended after your own, switched on |
202
+ | `{ "id": "quota", "type": "extension" }` | binds there: your position, your `enabled`, and any `refreshInterval`/`timeout` you name overrides the provider's |
203
+ | an entry with a `command` and the same `id` | **conflict.** Your command keeps running and the row reads `id also provided by an extension — remove one`; an extension never silently takes over a row you wrote |
204
+
205
+ Switching an extension's item off in `/statusline` writes `{ "id": "quota", "type": "extension", "enabled": false }` to the file — that entry is also how you give it a fixed position among your other items. Rows for these items are tagged `(extension)` in the submenu.
206
+
145
207
  ### When a command runs
146
208
 
147
209
  - at session start,
@@ -186,7 +248,7 @@ The **first line of stdout** becomes the segment. Anything after it is ignored
186
248
 
187
249
  Failures never reach the agent — the statusline is best-effort and stays silent. A failing item keeps its last good value for up to three consecutive failures, then drops it. That grace is deliberate in both directions: one blip (a laptop between networks) should not blank a working display, and a value that has quietly gone stale is worse than an empty slot, because the number stays plausible while describing a world that has moved on.
188
250
 
189
- To see what an item is doing, open `/statusline` → **Custom item list**. Each row shows its current value, or why there isn't one: `disabled`, `missing command`, `exit 3: …`, `timed out after 5s`, `empty output`, or `no value yet`. Enter toggles an item on or off; commands themselves are edited in the file.
251
+ To see what an item is doing, open `/statusline` → **Custom item list**. Each row shows its current value, or why there isn't one: `disabled`, `exit 3: …`, `timed out after 5s`, `empty output`, `no value yet`, or — for an entry with no command — `no command; waiting for an extension to register this id`. Enter toggles an item on or off; commands themselves are edited in the file.
190
252
 
191
253
  ### Keep it fast
192
254
 
package/custom.ts CHANGED
@@ -4,12 +4,20 @@ import { platform } from "node:process";
4
4
  /**
5
5
  * User-defined statusline segments, modelled on Claude Code's `statusLine`.
6
6
  *
7
- * Each item is a shell command that receives a JSON snapshot of the session on
8
- * stdin and prints one line to stdout. That contract is deliberately the same
9
- * one Claude Code uses, so an existing statusline script mostly ports over; the
10
- * differences are that pi renders each item as one *segment* of line 1 rather
11
- * than owning the whole row, and that the payload's usage numbers are remaining
12
- * percentages (see `custom-items.md` in the README).
7
+ * An item's value comes from one of two places. The original is a **shell
8
+ * command** that receives a JSON snapshot of the session on stdin and prints
9
+ * one line to stdout — deliberately Claude Code's contract, so an existing
10
+ * statusline script mostly ports over; the differences are that pi renders each
11
+ * item as one *segment* of line 1 rather than owning the whole row, and that
12
+ * the payload's usage numbers are remaining percentages (see the README).
13
+ *
14
+ * The second is a **function another extension registers** over `pi.events`,
15
+ * which gets the same payload as an object and returns the same one line. That
16
+ * exists because a command forces anything serious onto PATH, into a config
17
+ * file outside any package, and into a process spawn per refresh, for a value
18
+ * the host could compute in-process. Both kinds run through one scheduler here:
19
+ * there is no second code path for turn ends, intervals, overlap, timeouts or
20
+ * the failure grace.
13
21
  */
14
22
 
15
23
  /** How long a command may run before it is killed, when it names no timeout. */
@@ -31,9 +39,66 @@ export const EVENT_MIN_INTERVAL_MS = 1_000;
31
39
  * broken loses it.
32
40
  */
33
41
  export const FAILURE_GRACE = 3;
34
- /** Longest rendered value kept from a command, before the line is truncated. */
42
+ /** Longest rendered value kept from an item, before the line is truncated. */
35
43
  export const MAX_OUTPUT_WIDTH = 120;
36
44
 
45
+ /**
46
+ * The event pi-statusline emits to collect items from other extensions.
47
+ *
48
+ * This name is the entire coupling between the statusline and a provider:
49
+ * nothing here imports a provider, and a provider imports nothing from here.
50
+ * Changing the string is a breaking change for every provider in the wild.
51
+ */
52
+ export const CUSTOM_ITEMS_REQUEST_EVENT = "pi-statusline:custom-items:request";
53
+
54
+ /**
55
+ * A registered item's value function.
56
+ *
57
+ * `payload` is the object the command path serialises onto stdin, built fresh
58
+ * for each run. `signal` aborts at the item's timeout; a run that ignores it is
59
+ * simply not awaited past the deadline. Returning `null`/`undefined`/`""` hides
60
+ * the item, and throwing is a failure, counted exactly like a non-zero exit.
61
+ */
62
+ export type CustomItemRun = (
63
+ payload: Record<string, unknown>,
64
+ signal: AbortSignal,
65
+ ) => string | null | undefined | Promise<string | null | undefined>;
66
+
67
+ /** What a provider passes to `register`. */
68
+ export interface CustomItemRegistration {
69
+ /** Stable name, in the same namespace as a command item's `id`. */
70
+ id: string;
71
+ run: CustomItemRun;
72
+ /** Seconds between forced re-runs; the settings entry wins when it names one. */
73
+ refreshInterval?: number;
74
+ /** Milliseconds before the run is abandoned; capped at {@link MAX_TIMEOUT_MS}. */
75
+ timeoutMs?: number;
76
+ }
77
+
78
+ /** The payload of {@link CUSTOM_ITEMS_REQUEST_EVENT}. */
79
+ export interface CustomItemsRequest {
80
+ /**
81
+ * Returns whether the registration was accepted.
82
+ *
83
+ * A rejected one is dropped silently *here* on purpose — see
84
+ * {@link CustomItemsTracker.register} — so the boolean is the only signal a
85
+ * provider gets that its `id` or `run` was unusable. Ignoring it means a typo
86
+ * shows up as a segment that never appears, with nothing to read anywhere.
87
+ */
88
+ register(registration: CustomItemRegistration): boolean;
89
+ }
90
+
91
+ /** Shown for an entry that names no command and has no registration yet. */
92
+ export const UNBOUND_ERROR = "no command; waiting for an extension to register this id";
93
+ /**
94
+ * Shown when an id is claimed by both a command entry and a registration.
95
+ *
96
+ * The command keeps running: the file is the user's, and an extension must not
97
+ * be able to take over a row somebody wrote by hand. Saying so is the whole
98
+ * remedy — deleting either side resolves it.
99
+ */
100
+ export const COLLISION_ERROR = "id also provided by an extension \u2014 remove one";
101
+
37
102
  /**
38
103
  * One configured item.
39
104
  *
@@ -46,23 +111,45 @@ export const MAX_OUTPUT_WIDTH = 120;
46
111
  export interface CustomItem {
47
112
  id: string;
48
113
  enabled: boolean;
49
- /** Absent when the entry is not runnable; `error` then says why. */
114
+ /** Where the value comes from; drives the `(extension)` tag in the submenu. */
115
+ kind: "command" | "extension";
116
+ /** Absent for an extension item, or when the entry is not runnable. */
50
117
  command?: string;
118
+ /** Bound from a registration; absent until a provider claims this id. */
119
+ run?: CustomItemRun;
51
120
  /** Seconds between forced re-runs. Absent means event-driven only. */
52
121
  refreshInterval?: number;
53
122
  timeoutMs: number;
123
+ /**
124
+ * Whether `timeoutMs` came from the entry's own `timeout` rather than from a
125
+ * default. A registration may only supply the timeout the file left unsaid.
126
+ */
127
+ timeoutExplicit?: boolean;
54
128
  /** Why this entry cannot run, shown in the `/statusline` submenu. */
55
129
  error?: string;
56
- /** The on-disk entry, preserved for round-tripping. */
57
- source: unknown;
130
+ /**
131
+ * Set when the entry can never run as written — not an object, or a `type`
132
+ * this version does not implement. The submenu refuses to enable those, and
133
+ * only those: an entry still waiting for its provider is perfectly valid.
134
+ */
135
+ blocked?: boolean;
136
+ /** The on-disk entry, preserved for round-tripping. Absent when synthesised. */
137
+ source?: unknown;
58
138
  }
59
139
 
60
140
  function isPlainObject(value: unknown): value is Record<string, unknown> {
61
141
  return typeof value === "object" && value !== null && !Array.isArray(value);
62
142
  }
63
143
 
64
- /** Positive finite seconds, or undefined for anything unusable. */
65
- function positiveSeconds(value: unknown): number | undefined {
144
+ /**
145
+ * A positive finite number, or undefined for anything unusable.
146
+ *
147
+ * The same check serves both units: seconds off a settings entry and the
148
+ * milliseconds a registration names. Callers pass the validated result on
149
+ * rather than the raw input, so a future normalisation here cannot be
150
+ * silently bypassed by one of them.
151
+ */
152
+ function positiveNumber(value: unknown): number | undefined {
66
153
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
67
154
  return value;
68
155
  }
@@ -92,7 +179,15 @@ export function normalizeCustomItems(value: unknown): CustomItem[] {
92
179
  if (!isPlainObject(entry)) {
93
180
  const id = uniqueId(fallbackId, taken);
94
181
  taken.add(id);
95
- items.push({ id, enabled: false, timeoutMs: DEFAULT_TIMEOUT_MS, error: "not an object", source: entry });
182
+ items.push({
183
+ id,
184
+ enabled: false,
185
+ kind: "command",
186
+ timeoutMs: DEFAULT_TIMEOUT_MS,
187
+ error: "not an object",
188
+ blocked: true,
189
+ source: entry,
190
+ });
96
191
  return;
97
192
  }
98
193
  const rawId = entry.id;
@@ -100,26 +195,54 @@ export function normalizeCustomItems(value: unknown): CustomItem[] {
100
195
  taken.add(id);
101
196
  // `enabled` is the menu's field; everything else is the user's.
102
197
  const enabled = entry.enabled !== false;
103
- const timeoutSeconds = positiveSeconds(entry.timeout);
198
+ const timeoutSeconds = positiveNumber(entry.timeout);
104
199
  const timeoutMs = Math.min(
105
200
  timeoutSeconds === undefined ? DEFAULT_TIMEOUT_MS : timeoutSeconds * 1000,
106
201
  MAX_TIMEOUT_MS,
107
202
  );
108
- const refreshInterval = positiveSeconds(entry.refreshInterval);
109
- const base = { id, enabled, timeoutMs, source: entry, ...(refreshInterval ? { refreshInterval } : {}) };
203
+ const refreshInterval = positiveNumber(entry.refreshInterval);
204
+ const base = {
205
+ id,
206
+ enabled,
207
+ timeoutMs,
208
+ source: entry,
209
+ ...(timeoutSeconds === undefined ? {} : { timeoutExplicit: true }),
210
+ ...(refreshInterval ? { refreshInterval } : {}),
211
+ };
110
212
  // Claude Code's `statusLine` carries `type: "command"`, so a pasted entry
111
- // may too. That value is accepted; any other is not a mistake this version
112
- // can judge, so the entry is kept and flagged rather than run or dropped.
113
- const type = entry.type ?? "command";
114
- if (type !== "command") {
115
- items.push({ ...base, enabled: false, error: `unsupported type: ${String(type)}` });
213
+ // may too. `"extension"` is this package's own, and optional: an entry with
214
+ // no command is an extension slot whether or not it says so. Any other value
215
+ // is not a mistake this version can judge, so the entry is kept and flagged
216
+ // rather than run or dropped.
217
+ const hasCommand = typeof entry.command === "string" && entry.command.trim().length > 0;
218
+ const type = entry.type ?? (hasCommand ? "command" : "extension");
219
+ if (type !== "command" && type !== "extension") {
220
+ items.push({
221
+ ...base,
222
+ kind: "command",
223
+ enabled: false,
224
+ error: `unsupported type: ${String(type)}`,
225
+ blocked: true,
226
+ });
116
227
  return;
117
228
  }
118
- if (typeof entry.command !== "string" || entry.command.trim().length === 0) {
119
- items.push({ ...base, enabled: false, error: "missing command" });
229
+ if (!hasCommand) {
230
+ // Not an error yet: a provider may register this id later in the session,
231
+ // and the entry is what reserves its position and enabled state.
232
+ items.push({ ...base, kind: "extension", error: UNBOUND_ERROR });
120
233
  return;
121
234
  }
122
- items.push({ ...base, command: entry.command });
235
+ if (type === "extension") {
236
+ items.push({
237
+ ...base,
238
+ kind: "command",
239
+ enabled: false,
240
+ error: "an extension item must not name a command",
241
+ blocked: true,
242
+ });
243
+ return;
244
+ }
245
+ items.push({ ...base, kind: "command", command: entry.command as string });
123
246
  });
124
247
  return items;
125
248
  }
@@ -132,7 +255,9 @@ export function normalizeCustomItems(value: unknown): CustomItem[] {
132
255
  * as the user wrote it rather than accumulating defaults.
133
256
  */
134
257
  export function serializeCustomItems(items: readonly CustomItem[]): unknown[] {
135
- return items.map((item) => {
258
+ // A registration with no entry of its own has no on-disk form until the user
259
+ // toggles it, which is what creates the entry; it must never be written here.
260
+ return items.filter((item) => item.source !== undefined).map((item) => {
136
261
  if (!isPlainObject(item.source)) return item.source;
137
262
  const entry = { ...item.source };
138
263
  if (item.enabled) delete entry.enabled;
@@ -197,10 +322,20 @@ export function sanitizeOutput(raw: string): string {
197
322
  export interface CustomItemState {
198
323
  id: string;
199
324
  enabled: boolean;
200
- /** Sanitized first line of stdout; absent when there is nothing to show. */
325
+ /** Where the value comes from, for the submenu's `(extension)` tag. */
326
+ kind: "command" | "extension";
327
+ /** Sanitized first line of output; absent when there is nothing to show. */
201
328
  value?: string;
202
329
  /** Configuration or run failure, whichever applies. */
203
330
  error?: string;
331
+ /**
332
+ * True when `error` describes the entry itself rather than its last run. A
333
+ * configuration problem outranks "disabled" in the submenu, because the item
334
+ * is off *because* of it; a run failure does not.
335
+ */
336
+ configError?: boolean;
337
+ /** True when the entry can never run as written, so it must not be enabled. */
338
+ blocked?: boolean;
204
339
  /** When the value was produced, as epoch ms. */
205
340
  updatedAt?: number;
206
341
  running: boolean;
@@ -212,6 +347,12 @@ export interface CustomItemsTrackerOptions {
212
347
  spawn?: SpawnFn;
213
348
  now?: () => number;
214
349
  onChange?: () => void;
350
+ /**
351
+ * Called after a registration is accepted. The tracker cannot know whether
352
+ * the footer is live or whether items are switched on, so the extension does
353
+ * the timer and refresh work and this is the notification that it is needed.
354
+ */
355
+ onRegister?: () => void;
215
356
  cwd?: string;
216
357
  schedule?: (callback: () => void, intervalMs: number) => unknown;
217
358
  cancel?: (handle: unknown) => void;
@@ -248,11 +389,17 @@ const TICK_FLOOR_MS = 1_000;
248
389
  * lower refresh rate instead of a pile of processes.
249
390
  */
250
391
  export class CustomItemsTracker {
392
+ /** Entries exactly as configured on disk. */
393
+ private configured: CustomItem[] = [];
394
+ /** Registrations by id, in the order providers claimed them. */
395
+ private readonly registrations = new Map<string, CustomItemRegistration>();
396
+ /** The two merged: what actually renders and runs. */
251
397
  private items: CustomItem[] = [];
252
398
  private readonly records = new Map<string, RunRecord>();
253
399
  private readonly spawnFn: SpawnFn;
254
400
  private readonly now: () => number;
255
401
  private readonly onChange?: () => void;
402
+ private readonly onRegister?: () => void;
256
403
  private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
257
404
  private readonly cancel: (handle: unknown) => void;
258
405
  private cwd: string | undefined;
@@ -264,6 +411,7 @@ export class CustomItemsTracker {
264
411
  this.spawnFn = options.spawn ?? nodeSpawn;
265
412
  this.now = options.now ?? Date.now;
266
413
  this.onChange = options.onChange;
414
+ this.onRegister = options.onRegister;
267
415
  this.cwd = options.cwd;
268
416
  this.schedule = options.schedule ?? defaultSchedule;
269
417
  this.cancel = options.cancel ?? defaultCancel;
@@ -277,7 +425,83 @@ export class CustomItemsTracker {
277
425
  * does not blink on every settings save.
278
426
  */
279
427
  setItems(items: readonly CustomItem[]): void {
280
- this.items = [...items];
428
+ this.configured = [...items];
429
+ this.recompute();
430
+ }
431
+
432
+ /**
433
+ * Adopt an item provided by another extension.
434
+ *
435
+ * Idempotent on id, and it reports rather than throws: this runs inside a
436
+ * provider's event handler, where a throw would surface as *that* extension
437
+ * failing rather than as a registration this one refused. The return value is
438
+ * how a provider learns its `id` or `run` was unusable, since nothing else
439
+ * here can name an item that never got far enough to have a row.
440
+ */
441
+ register(registration: CustomItemRegistration): boolean {
442
+ const id = typeof registration?.id === "string" ? registration.id.trim() : "";
443
+ if (id.length === 0 || typeof registration.run !== "function") return false;
444
+ // A re-registration replaces the function, so whatever the old one is doing
445
+ // is already obsolete; its record keeps the last good value so the footer
446
+ // does not blink while the new one produces its first.
447
+ if (this.registrations.has(id)) this.records.get(id)?.abort?.();
448
+ const refreshInterval = positiveNumber(registration.refreshInterval);
449
+ const timeoutMs = positiveNumber(registration.timeoutMs);
450
+ this.registrations.set(id, {
451
+ id,
452
+ run: registration.run,
453
+ ...(refreshInterval === undefined ? {} : { refreshInterval }),
454
+ ...(timeoutMs === undefined ? {} : { timeoutMs: Math.min(timeoutMs, MAX_TIMEOUT_MS) }),
455
+ });
456
+ this.recompute();
457
+ this.onRegister?.();
458
+ return true;
459
+ }
460
+
461
+ /**
462
+ * Merge configured entries with registrations.
463
+ *
464
+ * The file owns order and enabled; a registration fills in the value function
465
+ * and any field the file left unsaid. An id in both a command entry and a
466
+ * registration is a conflict the user has to resolve, so it is shown rather
467
+ * than decided silently — and the command, being the thing they wrote, wins.
468
+ */
469
+ private recompute(): void {
470
+ const configuredIds = new Set(this.configured.map((item) => item.id));
471
+ const items = this.configured.map((item) => {
472
+ const registration = this.registrations.get(item.id);
473
+ if (item.blocked === true) return item;
474
+ if (item.command !== undefined) {
475
+ return registration === undefined ? item : { ...item, error: COLLISION_ERROR };
476
+ }
477
+ if (registration === undefined) return item;
478
+ const { error: _unbound, ...bound } = item;
479
+ // The entry's own interval wins; the registration supplies the one it
480
+ // left unsaid. Parenthesised because `??` binds tighter than `?:` — the
481
+ // grouping is load-bearing and easy to "fix" wrongly.
482
+ const refreshInterval = item.refreshInterval ?? registration.refreshInterval;
483
+ return {
484
+ ...bound,
485
+ kind: "extension" as const,
486
+ run: registration.run,
487
+ ...(refreshInterval === undefined ? {} : { refreshInterval }),
488
+ timeoutMs: item.timeoutExplicit ? item.timeoutMs : (registration.timeoutMs ?? DEFAULT_TIMEOUT_MS),
489
+ };
490
+ });
491
+ for (const [id, registration] of this.registrations) {
492
+ if (configuredIds.has(id)) continue;
493
+ // No entry claims this id, so it is appended, switched on, and has no
494
+ // `source`: nothing is written to the settings file until it is toggled.
495
+ items.push({
496
+ id,
497
+ enabled: true,
498
+ kind: "extension",
499
+ run: registration.run,
500
+ ...(registration.refreshInterval ? { refreshInterval: registration.refreshInterval } : {}),
501
+ timeoutMs: registration.timeoutMs ?? DEFAULT_TIMEOUT_MS,
502
+ });
503
+ }
504
+ this.items = items;
281
505
  const live = new Set(items.map((item) => item.id));
282
506
  for (const [id, record] of this.records) {
283
507
  if (live.has(id)) continue;
@@ -309,9 +533,11 @@ export class CustomItemsTracker {
309
533
  return {
310
534
  id: item.id,
311
535
  enabled: item.enabled,
536
+ kind: item.kind,
537
+ ...(item.blocked === true ? { blocked: true } : {}),
312
538
  ...(record?.value !== undefined ? { value: record.value } : {}),
313
539
  ...(item.error !== undefined
314
- ? { error: item.error }
540
+ ? { error: item.error, configError: true }
315
541
  : record?.error !== undefined
316
542
  ? { error: record.error }
317
543
  : {}),
@@ -346,7 +572,13 @@ export class CustomItemsTracker {
346
572
  this.tickHandle = undefined;
347
573
  }
348
574
 
349
- /** Stop everything and abandon in-flight commands. */
575
+ /**
576
+ * Stop everything and abandon in-flight runs.
577
+ *
578
+ * Registrations survive: this also fires when the footer is torn down, and a
579
+ * provider has no way to hear about that to register again. They die with the
580
+ * extension instance instead, which is when the next request event is sent.
581
+ */
350
582
  dispose(): void {
351
583
  this.stop();
352
584
  for (const record of this.records.values()) record.abort?.();
@@ -367,7 +599,8 @@ export class CustomItemsTracker {
367
599
  refresh(): void {
368
600
  const now = this.now();
369
601
  for (const item of this.items) {
370
- if (!item.enabled || item.command === undefined) continue;
602
+ if (!item.enabled) continue;
603
+ if (item.command === undefined && item.run === undefined) continue;
371
604
  const record = this.records.get(item.id);
372
605
  if (record?.running) continue;
373
606
  const minimum =
@@ -388,6 +621,67 @@ export class CustomItemsTracker {
388
621
  }
389
622
 
390
623
  private run(item: CustomItem): void {
624
+ if (item.run !== undefined) {
625
+ this.runRegistered(item, item.run);
626
+ return;
627
+ }
628
+ this.runCommand(item);
629
+ }
630
+
631
+ /** How long an item may run, rendered the way both paths report a timeout. */
632
+ private timeoutLabel(item: CustomItem): string {
633
+ return `timed out after ${Math.round(item.timeoutMs / 100) / 10}s`;
634
+ }
635
+
636
+ /**
637
+ * Run a registered function under the command path's guarantees.
638
+ *
639
+ * The deadline is the tracker's, not the provider's: `signal` asks it to stop
640
+ * and the outcome is settled regardless, so a provider that ignores the
641
+ * signal costs a leaked promise rather than a stuck segment.
642
+ */
643
+ private runRegistered(item: CustomItem, run: CustomItemRun): void {
644
+ const record = this.record(item.id);
645
+ record.lastAttempt = this.now();
646
+ record.running = true;
647
+
648
+ const controller = new AbortController();
649
+ let settled = false;
650
+ const finish = (outcome: { value?: string; error?: string }): void => {
651
+ if (settled) return;
652
+ settled = true;
653
+ clearTimeout(timer);
654
+ record.abort = undefined;
655
+ this.settle(item, record, outcome);
656
+ };
657
+
658
+ const timer = setTimeout(() => {
659
+ controller.abort();
660
+ finish({ error: this.timeoutLabel(item) });
661
+ }, item.timeoutMs);
662
+ timer.unref?.();
663
+
664
+ record.abort = () => {
665
+ clearTimeout(timer);
666
+ settled = true;
667
+ record.running = false;
668
+ controller.abort();
669
+ };
670
+
671
+ let result: ReturnType<CustomItemRun>;
672
+ try {
673
+ result = run(this.payloadFactory(), controller.signal);
674
+ } catch (error) {
675
+ finish({ error: error instanceof Error ? error.message : String(error) });
676
+ return;
677
+ }
678
+ Promise.resolve(result).then(
679
+ (value) => finish({ value: value === null || value === undefined ? "" : sanitizeOutput(String(value)) }),
680
+ (error: unknown) => finish({ error: error instanceof Error ? error.message : String(error) }),
681
+ );
682
+ }
683
+
684
+ private runCommand(item: CustomItem): void {
391
685
  const command = item.command;
392
686
  if (command === undefined) return;
393
687
  const record = this.record(item.id);
@@ -426,7 +720,7 @@ export class CustomItemsTracker {
426
720
  child.kill("SIGTERM");
427
721
  // A command ignoring SIGTERM must not outlive the session either.
428
722
  setTimeout(() => child.kill("SIGKILL"), 500).unref?.();
429
- finish({ error: `timed out after ${Math.round(item.timeoutMs / 100) / 10}s` });
723
+ finish({ error: this.timeoutLabel(item) });
430
724
  }, item.timeoutMs);
431
725
  timer.unref?.();
432
726
 
package/index.ts CHANGED
@@ -14,7 +14,12 @@ import {
14
14
  } from "./cache-celebration.ts";
15
15
  import { CelebrationPreview, trackSelectedLabel } from "./celebration-preview.ts";
16
16
  import { DEFAULT_CELEBRATION_STYLE, renderCacheBadge } from "./celebration-styles.ts";
17
- import { CustomItemsTracker } from "./custom.ts";
17
+ import {
18
+ CUSTOM_ITEMS_REQUEST_EVENT,
19
+ type CustomItemRegistration,
20
+ type CustomItemsRequest,
21
+ CustomItemsTracker,
22
+ } from "./custom.ts";
18
23
  import { buildCustomItemSetupPrompt } from "./custom-setup.ts";
19
24
  import { FullRedrawScheduler } from "./redraw.ts";
20
25
  import {
@@ -246,7 +251,34 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
246
251
  const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
247
252
  let tracker: SessionWorktreeTracker | undefined;
248
253
  const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
249
- const customItems = new CustomItemsTracker({ onChange: () => requestRender?.() });
254
+ // One request event is answered by every provider at once, so `onRegister`
255
+ // fires once per item while the work it guards — sizing the timer, refreshing,
256
+ // redrawing — is per-tracker. Coalescing to a single microtask makes N
257
+ // providers cost one restart and one render instead of N, and the registrations
258
+ // are all in place by the time it runs, so the timer is sized once from the
259
+ // complete set rather than N times from a growing one.
260
+ let registrationSettlePending = false;
261
+ const customItems = new CustomItemsTracker({
262
+ onChange: () => requestRender?.(),
263
+ // A registration can arrive at any point in the session, including after
264
+ // the timer was sized from the items that existed without it.
265
+ onRegister: () => {
266
+ if (registrationSettlePending) return;
267
+ registrationSettlePending = true;
268
+ queueMicrotask(() => {
269
+ registrationSettlePending = false;
270
+ if (settings.showCustomItems) {
271
+ // restartTimer alone is a no-op when no item had asked for a timer
272
+ // yet, which is exactly the case a late registration creates; start
273
+ // covers that branch and is idempotent when the timer is already up.
274
+ customItems.restartTimer();
275
+ customItems.start();
276
+ customItems.refresh();
277
+ }
278
+ requestRender?.();
279
+ });
280
+ },
281
+ });
250
282
  let cwdGit: GitRepositoryStatus | null = null;
251
283
  let cwdStatusAbort: AbortController | undefined;
252
284
  let cwdStatusInFlight: Promise<void> | undefined;
@@ -260,6 +292,26 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
260
292
  });
261
293
  };
262
294
 
295
+ /**
296
+ * Ask every other extension for the items it wants on the statusline.
297
+ *
298
+ * Sent at session start rather than from this factory, because extensions
299
+ * load in sequence: a factory-time emit would only ever reach providers that
300
+ * happen to be configured *before* this package. By session start they have
301
+ * all run, and the event fires again whenever `/statusline` opens so that a
302
+ * provider which starts listening mid-session is still picked up.
303
+ *
304
+ * Interactive only. Custom items exist to fill footer segments, and a print
305
+ * or RPC run has no footer, so asking would buy nothing and cost providers a
306
+ * poll loop against a payload this extension never installs.
307
+ */
308
+ const requestCustomItems = (): void => {
309
+ const request: CustomItemsRequest = {
310
+ register: (registration: CustomItemRegistration) => customItems.register(registration),
311
+ };
312
+ pi.events?.emit(CUSTOM_ITEMS_REQUEST_EVENT, request);
313
+ };
314
+
263
315
  const refreshCwdStatus = (ctx: ExtensionContext): Promise<void> => {
264
316
  if (!cwdStatusAbort) return Promise.resolve();
265
317
  if (cwdStatusInFlight) return cwdStatusInFlight.then(() => refreshCwdStatus(ctx));
@@ -418,6 +470,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
418
470
  // starts from what is on disk rather than a snapshot that may be hours
419
471
  // old and missing another session's changes.
420
472
  applySettings(ctx, await settingsStore.load(), false);
473
+ // Same reason the file is re-read: the menu should show what is true now,
474
+ // including an item from a provider that loaded after the session began.
475
+ requestCustomItems();
421
476
 
422
477
  await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
423
478
  const tracked = trackSelectedLabel(getSettingsListTheme());
@@ -450,6 +505,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
450
505
  customItems: createCustomItemsSubmenu(submenuHost),
451
506
  },
452
507
  home,
508
+ customItems.states(),
453
509
  ),
454
510
  10,
455
511
  settingsTheme,
@@ -557,6 +613,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
557
613
  });
558
614
 
559
615
  resetTracker(ctx);
616
+ // Last, so a provider that registers synchronously runs against the payload
617
+ // factory resetTracker just installed rather than an empty one.
618
+ requestCustomItems();
560
619
  });
561
620
 
562
621
  pi.on("tool_call", (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": [
package/settings-menu.ts CHANGED
@@ -10,7 +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
+ import { type CustomItem, type CustomItemState, DEFAULT_TIMEOUT_MS } from "./custom.ts";
14
14
  import { CELEBRATION_STYLE_NAMES, isCelebrationStyleName } from "./celebration-styles.ts";
15
15
  import { isThemeName, THEME_NAMES } from "./themes.ts";
16
16
 
@@ -62,11 +62,17 @@ export function aliasSummary(settings: StatuslineSettings): string {
62
62
  return `${count} alias${count === 1 ? "" : "es"}`;
63
63
  }
64
64
 
65
- /** Row value for the custom-items submenu: how many items are switched on. */
66
- export function customItemsSummary(settings: StatuslineSettings): string {
67
- const total = settings.customItems.length;
65
+ /**
66
+ * Row value for the custom-items submenu: how many items are switched on.
67
+ *
68
+ * Counted from the tracker's states, not from the settings file: an item an
69
+ * extension provides is as real as one the file names, and it may have no entry
70
+ * of its own until somebody toggles it.
71
+ */
72
+ export function customItemsSummary(states: readonly CustomItemState[]): string {
73
+ const total = states.length;
68
74
  if (total === 0) return "none configured";
69
- return `${settings.customItems.filter((item) => item.enabled).length}/${total} on`;
75
+ return `${states.filter((state) => state.enabled).length}/${total} on`;
70
76
  }
71
77
 
72
78
  /**
@@ -75,12 +81,9 @@ export function customItemsSummary(settings: StatuslineSettings): string {
75
81
  * than opening a form: a statusline command is a script plus a JSON entry plus
76
82
  * a test run, which is a conversation, not a field.
77
83
  */
78
- export function customItemRows(
79
- settings: StatuslineSettings,
80
- states: readonly CustomItemState[] = [],
81
- ): SelectItem[] {
84
+ export function customItemRows(states: readonly CustomItemState[] = []): SelectItem[] {
82
85
  return [
83
- ...customItemStateRows(settings, states),
86
+ ...customItemStateRows(states),
84
87
  {
85
88
  value: ADD_CUSTOM_ITEM_VALUE,
86
89
  label: "Add custom item…",
@@ -89,31 +92,30 @@ export function customItemRows(
89
92
  ];
90
93
  }
91
94
 
92
- function customItemStateRows(settings: StatuslineSettings, states: readonly CustomItemState[]): SelectItem[] {
93
- const byId = new Map(states.map((state) => [state.id, state]));
94
- return settings.customItems.map((item) => {
95
- const state = byId.get(item.id);
96
- // Configuration errors outrank run errors: an item that cannot be parsed
97
- // never ran, so a stale run error from a previous config would mislead.
98
- const error = item.error ?? state?.error;
99
- // A broken entry reads as "disabled" unless its reason wins here: it is off
100
- // *because* it cannot run, and "disabled" would suggest the user chose that.
101
- const detail = item.error !== undefined
102
- ? item.error
103
- : !item.enabled
95
+ function customItemStateRows(states: readonly CustomItemState[]): SelectItem[] {
96
+ return states.map((state) => {
97
+ // A broken or unbound entry reads as "disabled" unless its reason wins
98
+ // here: it is off *because* of that, and "disabled" would suggest the user
99
+ // chose it. A run failure is the other way round — an item switched off is
100
+ // off first, and whatever its last run said is history.
101
+ const detail = state.configError === true
102
+ ? (state.error as string)
103
+ : !state.enabled
104
104
  ? "disabled"
105
- : error !== undefined
106
- ? error
107
- : state?.running === true && state.value === undefined
105
+ : state.error !== undefined
106
+ ? state.error
107
+ : state.running && state.value === undefined
108
108
  ? "running…"
109
- : state?.value !== undefined && state.value.length > 0
109
+ : state.value !== undefined && state.value.length > 0
110
110
  ? state.value
111
- : state?.value !== undefined
111
+ : state.value !== undefined
112
112
  ? "empty output"
113
113
  : "no value yet";
114
114
  return {
115
- value: item.id,
116
- label: `${item.enabled ? toggleValue(true) : toggleValue(false)} ${item.id}`,
115
+ value: state.id,
116
+ // The tag says where the value comes from, which is the one thing a row
117
+ // cannot otherwise show: an extension item has no command to point at.
118
+ label: `${toggleValue(state.enabled)} ${state.id}${state.kind === "extension" ? " (extension)" : ""}`,
117
119
  description: detail,
118
120
  };
119
121
  });
@@ -130,6 +132,7 @@ export function buildSettingItems(
130
132
  settings: StatuslineSettings,
131
133
  submenus: SettingSubmenus = {},
132
134
  home: string = homedir(),
135
+ customStates: readonly CustomItemState[] = [],
133
136
  ): SettingItem[] {
134
137
  const items: SettingItem[] = [
135
138
  {
@@ -146,8 +149,9 @@ export function buildSettingItems(
146
149
  for (const row of BOOLEAN_ROWS) {
147
150
  // A toggle for a segment with nothing in it is a row that does nothing;
148
151
  // the list row below is where an item gets created, and the toggle
149
- // appears once there is something to switch off.
150
- if (row.id === "showCustomItems" && settings.customItems.length === 0) continue;
152
+ // appears once there is something to switch off — including something an
153
+ // extension provided, which never appears in the settings file.
154
+ if (row.id === "showCustomItems" && customStates.length === 0) continue;
151
155
  items.push({
152
156
  id: row.id,
153
157
  label: row.label,
@@ -183,8 +187,8 @@ export function buildSettingItems(
183
187
  items.push({
184
188
  id: CUSTOM_ITEMS_ID,
185
189
  label: "Custom item list",
186
- description: "Enable or disable configured items; edit commands in statusline-settings.json.",
187
- currentValue: customItemsSummary(settings),
190
+ description: "Enable or disable items, your own and extensions'; edit commands in statusline-settings.json.",
191
+ currentValue: customItemsSummary(customStates),
188
192
  ...(submenus.customItems ? { submenu: submenus.customItems } : {}),
189
193
  });
190
194
 
@@ -449,11 +453,15 @@ class CustomItemsSubmenu implements Component {
449
453
  this.list = this.buildList();
450
454
  }
451
455
 
456
+ private states(): readonly CustomItemState[] {
457
+ return this.host.customItemStates?.() ?? [];
458
+ }
459
+
452
460
  private buildList(selectedIndex = 0): SelectList {
453
- const rows = customItemRows(this.host.getSettings(), this.host.customItemStates?.() ?? []);
461
+ const rows = customItemRows(this.states());
454
462
  const list = new SelectList(rows, 10, this.host.selectTheme);
455
463
  list.setSelectedIndex(selectedIndex);
456
- list.onCancel = () => this.done(customItemsSummary(this.host.getSettings()));
464
+ list.onCancel = () => this.done(customItemsSummary(this.states()));
457
465
  list.onSelect = (item) => (item.value === ADD_CUSTOM_ITEM_VALUE ? this.add() : this.toggle(item.value));
458
466
  return list;
459
467
  }
@@ -471,7 +479,7 @@ class CustomItemsSubmenu implements Component {
471
479
  this.prompt = undefined;
472
480
  // Close the whole menu before the message lands: the agent's reply
473
481
  // renders in the transcript, which the menu is drawn over.
474
- this.done(customItemsSummary(this.host.getSettings()));
482
+ this.done(customItemsSummary(this.states()));
475
483
  this.host.requestCustomItem?.(value);
476
484
  },
477
485
  () => {
@@ -485,18 +493,34 @@ class CustomItemsSubmenu implements Component {
485
493
  private toggle(id: string): void {
486
494
  if (id.startsWith("\u0000")) return;
487
495
  const settings = this.host.getSettings();
488
- const index = settings.customItems.findIndex((item) => item.id === id);
489
- const target = settings.customItems[index];
490
- if (!target) return;
491
- if (target.error !== undefined && !target.enabled) {
496
+ const state = this.states().find((candidate) => candidate.id === id);
497
+ if (!state) return;
498
+ if (state.blocked === true && !state.enabled) {
492
499
  // Enabling an unparseable entry would only fail again on the next tick.
493
- this.host.notify(`${id} cannot run: ${target.error}`);
500
+ this.host.notify(`${id} cannot run: ${state.error ?? "invalid entry"}`);
494
501
  return;
495
502
  }
503
+ const index = settings.customItems.findIndex((item) => item.id === id);
504
+ const target = settings.customItems[index];
496
505
  const customItems = [...settings.customItems];
497
- customItems[index] = { ...target, enabled: !target.enabled };
506
+ if (target) customItems[index] = { ...target, enabled: !target.enabled };
507
+ else {
508
+ // An item an extension provided has no entry until it is switched off,
509
+ // and that entry is also what gives it a position the user controls.
510
+ const entry: CustomItem = {
511
+ id,
512
+ enabled: false,
513
+ kind: "extension",
514
+ timeoutMs: DEFAULT_TIMEOUT_MS,
515
+ source: { id, type: "extension", enabled: false },
516
+ };
517
+ customItems.push(entry);
518
+ }
498
519
  this.host.commit({ ...settings, customItems });
499
- this.list = this.buildList(index);
520
+ // Re-read the states rather than reusing the old index: writing an entry
521
+ // for a registration can move it out of the appended tail and into order.
522
+ const selected = this.states().findIndex((candidate) => candidate.id === id);
523
+ this.list = this.buildList(Math.max(0, selected));
500
524
  this.host.requestRender();
501
525
  }
502
526