@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/custom.ts ADDED
@@ -0,0 +1,486 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { platform } from "node:process";
3
+
4
+ /**
5
+ * User-defined statusline segments, modelled on Claude Code's `statusLine`.
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).
13
+ */
14
+
15
+ /** How long a command may run before it is killed, when it names no timeout. */
16
+ export const DEFAULT_TIMEOUT_MS = 5_000;
17
+ /** Ceiling for a configured timeout: a statusline must never block on a hang. */
18
+ export const MAX_TIMEOUT_MS = 30_000;
19
+ /**
20
+ * Floor between two event-driven runs of the same item. Turn ends are the main
21
+ * trigger and are already coarse, but a session can end several turns in a
22
+ * second, and an item that shells out to `curl` should not follow it there.
23
+ */
24
+ export const EVENT_MIN_INTERVAL_MS = 1_000;
25
+ /**
26
+ * Consecutive failures tolerated before an item's last good value is dropped.
27
+ *
28
+ * A statusline value that quietly goes stale is worse than an empty slot: the
29
+ * number stays plausible while it describes a world that has moved on. One
30
+ * blip (a laptop between networks) keeps the value; a command that is simply
31
+ * broken loses it.
32
+ */
33
+ export const FAILURE_GRACE = 3;
34
+ /** Longest rendered value kept from a command, before the line is truncated. */
35
+ export const MAX_OUTPUT_WIDTH = 120;
36
+
37
+ /**
38
+ * One configured item.
39
+ *
40
+ * `source` is the entry exactly as it appeared on disk. Serialization writes it
41
+ * back verbatim apart from the one field the menu owns (`enabled`), so an entry
42
+ * this version cannot parse — a `type` from a newer release, a key added by a
43
+ * future feature — survives a settings write instead of being silently deleted
44
+ * by the first person who toggles an unrelated row.
45
+ */
46
+ export interface CustomItem {
47
+ id: string;
48
+ enabled: boolean;
49
+ /** Absent when the entry is not runnable; `error` then says why. */
50
+ command?: string;
51
+ /** Seconds between forced re-runs. Absent means event-driven only. */
52
+ refreshInterval?: number;
53
+ timeoutMs: number;
54
+ /** Why this entry cannot run, shown in the `/statusline` submenu. */
55
+ error?: string;
56
+ /** The on-disk entry, preserved for round-tripping. */
57
+ source: unknown;
58
+ }
59
+
60
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+
64
+ /** Positive finite seconds, or undefined for anything unusable. */
65
+ function positiveSeconds(value: unknown): number | undefined {
66
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
67
+ return value;
68
+ }
69
+
70
+ function uniqueId(candidate: string, taken: Set<string>): string {
71
+ if (!taken.has(candidate)) return candidate;
72
+ for (let suffix = 2; ; suffix += 1) {
73
+ const next = `${candidate}#${suffix}`;
74
+ if (!taken.has(next)) return next;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Parse the `customItems` array.
80
+ *
81
+ * Every entry becomes an item, including the ones that cannot run: an invalid
82
+ * entry is reported through `error` rather than dropped, because dropping it
83
+ * would erase it from the file on the next write. Validation failures are
84
+ * per-entry, so one bad command never costs the user their other items.
85
+ */
86
+ export function normalizeCustomItems(value: unknown): CustomItem[] {
87
+ if (!Array.isArray(value)) return [];
88
+ const items: CustomItem[] = [];
89
+ const taken = new Set<string>();
90
+ value.forEach((entry, index) => {
91
+ const fallbackId = `item-${index + 1}`;
92
+ if (!isPlainObject(entry)) {
93
+ const id = uniqueId(fallbackId, taken);
94
+ taken.add(id);
95
+ items.push({ id, enabled: false, timeoutMs: DEFAULT_TIMEOUT_MS, error: "not an object", source: entry });
96
+ return;
97
+ }
98
+ const rawId = entry.id;
99
+ const id = uniqueId(typeof rawId === "string" && rawId.length > 0 ? rawId : fallbackId, taken);
100
+ taken.add(id);
101
+ // `enabled` is the menu's field; everything else is the user's.
102
+ const enabled = entry.enabled !== false;
103
+ const timeoutSeconds = positiveSeconds(entry.timeout);
104
+ const timeoutMs = Math.min(
105
+ timeoutSeconds === undefined ? DEFAULT_TIMEOUT_MS : timeoutSeconds * 1000,
106
+ MAX_TIMEOUT_MS,
107
+ );
108
+ const refreshInterval = positiveSeconds(entry.refreshInterval);
109
+ const base = { id, enabled, timeoutMs, source: entry, ...(refreshInterval ? { refreshInterval } : {}) };
110
+ // 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)}` });
116
+ return;
117
+ }
118
+ if (typeof entry.command !== "string" || entry.command.trim().length === 0) {
119
+ items.push({ ...base, enabled: false, error: "missing command" });
120
+ return;
121
+ }
122
+ items.push({ ...base, command: entry.command });
123
+ });
124
+ return items;
125
+ }
126
+
127
+ /**
128
+ * Write items back to their on-disk form.
129
+ *
130
+ * The source entry wins for every field except `enabled`, which the menu owns:
131
+ * it is written only when false, so toggling an item on again leaves the file
132
+ * as the user wrote it rather than accumulating defaults.
133
+ */
134
+ export function serializeCustomItems(items: readonly CustomItem[]): unknown[] {
135
+ return items.map((item) => {
136
+ if (!isPlainObject(item.source)) return item.source;
137
+ const entry = { ...item.source };
138
+ if (item.enabled) delete entry.enabled;
139
+ else entry.enabled = false;
140
+ return entry;
141
+ });
142
+ }
143
+
144
+ /** Whether two item lists are the same for save-diffing purposes. */
145
+ export function sameCustomItems(a: readonly CustomItem[], b: readonly CustomItem[]): boolean {
146
+ if (a.length !== b.length) return false;
147
+ return a.every((item, index) => {
148
+ const other = b[index];
149
+ return (
150
+ other !== undefined &&
151
+ item.id === other.id &&
152
+ item.enabled === other.enabled &&
153
+ JSON.stringify(item.source) === JSON.stringify(other.source)
154
+ );
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Strip anything that could damage the footer, keeping SGR colour sequences.
160
+ *
161
+ * Scripts are encouraged to colour their output, so `\x1b[32m` has to survive.
162
+ * Every other escape sequence does not: a cursor move or an erase-line writes
163
+ * outside the row the statusline owns and corrupts the frame around it.
164
+ */
165
+ export function sanitizeOutput(raw: string): string {
166
+ const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
167
+ let out = "";
168
+ for (let index = 0; index < firstLine.length; index += 1) {
169
+ const char = firstLine[index] as string;
170
+ if (char === "\x1b") {
171
+ const sgr = /^\x1b\[[0-9;:]*m/.exec(firstLine.slice(index));
172
+ if (sgr) {
173
+ out += sgr[0];
174
+ index += sgr[0].length - 1;
175
+ continue;
176
+ }
177
+ // Any other escape sequence: skip the introducer and its final byte.
178
+ const other = /^\x1b(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/.exec(
179
+ firstLine.slice(index),
180
+ );
181
+ if (other) index += other[0].length - 1;
182
+ continue;
183
+ }
184
+ // eslint-disable-next-line no-control-regex
185
+ if (char === "\t") {
186
+ out += " ";
187
+ continue;
188
+ }
189
+ const code = char.charCodeAt(0);
190
+ if (code < 0x20 || code === 0x7f) continue;
191
+ out += char;
192
+ }
193
+ return out.trim().slice(0, MAX_OUTPUT_WIDTH);
194
+ }
195
+
196
+ /** The last thing an item did, for rendering and for the settings submenu. */
197
+ export interface CustomItemState {
198
+ id: string;
199
+ enabled: boolean;
200
+ /** Sanitized first line of stdout; absent when there is nothing to show. */
201
+ value?: string;
202
+ /** Configuration or run failure, whichever applies. */
203
+ error?: string;
204
+ /** When the value was produced, as epoch ms. */
205
+ updatedAt?: number;
206
+ running: boolean;
207
+ }
208
+
209
+ export type SpawnFn = typeof nodeSpawn;
210
+
211
+ export interface CustomItemsTrackerOptions {
212
+ spawn?: SpawnFn;
213
+ now?: () => number;
214
+ onChange?: () => void;
215
+ cwd?: string;
216
+ schedule?: (callback: () => void, intervalMs: number) => unknown;
217
+ cancel?: (handle: unknown) => void;
218
+ }
219
+
220
+ interface RunRecord {
221
+ value?: string;
222
+ error?: string;
223
+ updatedAt?: number;
224
+ lastAttempt: number;
225
+ failures: number;
226
+ running: boolean;
227
+ abort?: () => void;
228
+ }
229
+
230
+ function defaultSchedule(callback: () => void, intervalMs: number): unknown {
231
+ const handle = setInterval(callback, intervalMs);
232
+ if (typeof handle.unref === "function") handle.unref();
233
+ return handle;
234
+ }
235
+
236
+ function defaultCancel(handle: unknown): void {
237
+ clearInterval(handle as ReturnType<typeof setInterval>);
238
+ }
239
+
240
+ /** Smallest configured refresh interval, which sets the tick rate. */
241
+ const TICK_FLOOR_MS = 1_000;
242
+
243
+ /**
244
+ * Runs the configured items and holds their latest values.
245
+ *
246
+ * Each item runs at most once at a time: a trigger that arrives while a command
247
+ * is still going is dropped rather than queued, so a slow command degrades to a
248
+ * lower refresh rate instead of a pile of processes.
249
+ */
250
+ export class CustomItemsTracker {
251
+ private items: CustomItem[] = [];
252
+ private readonly records = new Map<string, RunRecord>();
253
+ private readonly spawnFn: SpawnFn;
254
+ private readonly now: () => number;
255
+ private readonly onChange?: () => void;
256
+ private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
257
+ private readonly cancel: (handle: unknown) => void;
258
+ private cwd: string | undefined;
259
+ private columns = 80;
260
+ private payloadFactory: () => Record<string, unknown> = () => ({});
261
+ private tickHandle: unknown;
262
+
263
+ constructor(options: CustomItemsTrackerOptions = {}) {
264
+ this.spawnFn = options.spawn ?? nodeSpawn;
265
+ this.now = options.now ?? Date.now;
266
+ this.onChange = options.onChange;
267
+ this.cwd = options.cwd;
268
+ this.schedule = options.schedule ?? defaultSchedule;
269
+ this.cancel = options.cancel ?? defaultCancel;
270
+ }
271
+
272
+ /**
273
+ * Adopt a new configuration, keeping the state of items that survived it.
274
+ *
275
+ * Identity is the item id, so editing a command's text keeps its slot filled
276
+ * with the previous value until the new command first answers — the footer
277
+ * does not blink on every settings save.
278
+ */
279
+ setItems(items: readonly CustomItem[]): void {
280
+ this.items = [...items];
281
+ const live = new Set(items.map((item) => item.id));
282
+ for (const [id, record] of this.records) {
283
+ if (live.has(id)) continue;
284
+ record.abort?.();
285
+ this.records.delete(id);
286
+ }
287
+ }
288
+
289
+ setContext(context: { cwd?: string; columns?: number }): void {
290
+ if (context.cwd !== undefined) this.cwd = context.cwd;
291
+ if (context.columns !== undefined && context.columns > 0) this.columns = context.columns;
292
+ }
293
+
294
+ /**
295
+ * Supply the stdin payload lazily.
296
+ *
297
+ * A factory rather than a value because the timer fires between turns: a
298
+ * snapshot captured at configuration time would hand a script the context
299
+ * usage and quota numbers of whenever the session last had an event.
300
+ */
301
+ setPayloadFactory(factory: () => Record<string, unknown>): void {
302
+ this.payloadFactory = factory;
303
+ }
304
+
305
+ /** Current state of every configured item, in configuration order. */
306
+ states(): CustomItemState[] {
307
+ return this.items.map((item) => {
308
+ const record = this.records.get(item.id);
309
+ return {
310
+ id: item.id,
311
+ enabled: item.enabled,
312
+ ...(record?.value !== undefined ? { value: record.value } : {}),
313
+ ...(item.error !== undefined
314
+ ? { error: item.error }
315
+ : record?.error !== undefined
316
+ ? { error: record.error }
317
+ : {}),
318
+ ...(record?.updatedAt !== undefined ? { updatedAt: record.updatedAt } : {}),
319
+ running: record?.running ?? false,
320
+ };
321
+ });
322
+ }
323
+
324
+ /** Rendered values, in order, for the items that currently have one. */
325
+ values(): string[] {
326
+ return this.items
327
+ .filter((item) => item.enabled)
328
+ .map((item) => this.records.get(item.id)?.value)
329
+ .filter((value): value is string => value !== undefined && value.length > 0);
330
+ }
331
+
332
+ /** Begin ticking, if any item asked for a timer. Idempotent. */
333
+ start(): void {
334
+ if (this.tickHandle !== undefined) return;
335
+ const intervals = this.items
336
+ .filter((item) => item.enabled && item.refreshInterval !== undefined)
337
+ .map((item) => (item.refreshInterval as number) * 1000);
338
+ if (intervals.length === 0) return;
339
+ const tick = Math.max(TICK_FLOOR_MS, Math.min(...intervals));
340
+ this.tickHandle = this.schedule(() => this.refresh(), tick);
341
+ }
342
+
343
+ stop(): void {
344
+ if (this.tickHandle === undefined) return;
345
+ this.cancel(this.tickHandle);
346
+ this.tickHandle = undefined;
347
+ }
348
+
349
+ /** Stop everything and abandon in-flight commands. */
350
+ dispose(): void {
351
+ this.stop();
352
+ for (const record of this.records.values()) record.abort?.();
353
+ this.records.clear();
354
+ }
355
+
356
+ /**
357
+ * Restart the timer after a configuration change, since the tick rate is
358
+ * derived from the items themselves.
359
+ */
360
+ restartTimer(): void {
361
+ const wasRunning = this.tickHandle !== undefined;
362
+ this.stop();
363
+ if (wasRunning) this.start();
364
+ }
365
+
366
+ /** Run every item whose throttle has elapsed. Never rejects. */
367
+ refresh(): void {
368
+ const now = this.now();
369
+ for (const item of this.items) {
370
+ if (!item.enabled || item.command === undefined) continue;
371
+ const record = this.records.get(item.id);
372
+ if (record?.running) continue;
373
+ const minimum =
374
+ item.refreshInterval !== undefined
375
+ ? Math.max(EVENT_MIN_INTERVAL_MS, item.refreshInterval * 1000)
376
+ : EVENT_MIN_INTERVAL_MS;
377
+ if (record !== undefined && now - record.lastAttempt < minimum) continue;
378
+ this.run(item);
379
+ }
380
+ }
381
+
382
+ private record(id: string): RunRecord {
383
+ const existing = this.records.get(id);
384
+ if (existing) return existing;
385
+ const created: RunRecord = { lastAttempt: 0, failures: 0, running: false };
386
+ this.records.set(id, created);
387
+ return created;
388
+ }
389
+
390
+ private run(item: CustomItem): void {
391
+ const command = item.command;
392
+ if (command === undefined) return;
393
+ const record = this.record(item.id);
394
+ record.lastAttempt = this.now();
395
+ record.running = true;
396
+
397
+ const shell = platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "sh";
398
+ const args = platform === "win32" ? ["/d", "/s", "/c", command] : ["-c", command];
399
+
400
+ let child: ReturnType<SpawnFn>;
401
+ try {
402
+ child = this.spawnFn(shell, args, {
403
+ cwd: this.cwd,
404
+ // COLUMNS is how Claude Code tells a script the width it may use;
405
+ // keeping the name means a ported script sizes itself correctly.
406
+ env: { ...process.env, COLUMNS: String(this.columns) },
407
+ stdio: ["pipe", "pipe", "pipe"],
408
+ });
409
+ } catch (error) {
410
+ this.settle(item, record, { error: error instanceof Error ? error.message : String(error) });
411
+ return;
412
+ }
413
+
414
+ let stdout = "";
415
+ let stderr = "";
416
+ let settled = false;
417
+ const finish = (outcome: { value?: string; error?: string }): void => {
418
+ if (settled) return;
419
+ settled = true;
420
+ clearTimeout(timer);
421
+ record.abort = undefined;
422
+ this.settle(item, record, outcome);
423
+ };
424
+
425
+ const timer = setTimeout(() => {
426
+ child.kill("SIGTERM");
427
+ // A command ignoring SIGTERM must not outlive the session either.
428
+ setTimeout(() => child.kill("SIGKILL"), 500).unref?.();
429
+ finish({ error: `timed out after ${Math.round(item.timeoutMs / 100) / 10}s` });
430
+ }, item.timeoutMs);
431
+ timer.unref?.();
432
+
433
+ record.abort = () => {
434
+ clearTimeout(timer);
435
+ settled = true;
436
+ record.running = false;
437
+ child.kill("SIGKILL");
438
+ };
439
+
440
+ child.stdout?.on("data", (chunk: Buffer | string) => {
441
+ // One line is all that is rendered; stop accumulating well before a
442
+ // runaway command can fill memory with output nobody will read.
443
+ if (stdout.length < 64_000) stdout += String(chunk);
444
+ });
445
+ child.stderr?.on("data", (chunk: Buffer | string) => {
446
+ if (stderr.length < 4_000) stderr += String(chunk);
447
+ });
448
+ child.on("error", (error: Error) => finish({ error: error.message }));
449
+ child.on("close", (code: number | null) => {
450
+ if (code === 0) {
451
+ finish({ value: sanitizeOutput(stdout) });
452
+ return;
453
+ }
454
+ const detail = sanitizeOutput(stderr);
455
+ finish({ error: detail.length > 0 ? `exit ${code ?? "?"}: ${detail}` : `exit ${code ?? "?"}` });
456
+ });
457
+
458
+ try {
459
+ child.stdin?.on("error", () => {
460
+ // A command that never reads stdin (`date`, a shell one-liner) closes
461
+ // the pipe under us; that is not a failure of the item.
462
+ });
463
+ child.stdin?.end(`${JSON.stringify(this.payloadFactory())}\n`);
464
+ } catch {
465
+ // Same case, raised synchronously.
466
+ }
467
+ }
468
+
469
+ private settle(item: CustomItem, record: RunRecord, outcome: { value?: string; error?: string }): void {
470
+ record.running = false;
471
+ const previous = record.value;
472
+ if (outcome.error === undefined) {
473
+ record.failures = 0;
474
+ delete record.error;
475
+ // Empty output is a deliberate "nothing to show right now", not a
476
+ // failure: it is how a script hides itself when its subject is idle.
477
+ record.value = outcome.value ?? "";
478
+ record.updatedAt = this.now();
479
+ } else {
480
+ record.failures += 1;
481
+ record.error = outcome.error;
482
+ if (record.failures >= FAILURE_GRACE) delete record.value;
483
+ }
484
+ if (record.value !== previous) this.onChange?.();
485
+ }
486
+ }
package/index.ts CHANGED
@@ -14,16 +14,20 @@ 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";
18
+ import { buildCustomItemSetupPrompt } from "./custom-setup.ts";
17
19
  import { FullRedrawScheduler } from "./redraw.ts";
18
20
  import {
19
21
  applySettingChange,
20
22
  buildSettingItems,
21
23
  CACHE_CELEBRATION_LABEL,
22
24
  createAliasSubmenu,
25
+ createCustomItemsSubmenu,
23
26
  createWorktreeRootSubmenu,
24
27
  } from "./settings-menu.ts";
25
28
  import {
26
29
  changedSettingKeys,
30
+ collapseHome,
27
31
  defaultSettings,
28
32
  repoAlias,
29
33
  SettingsStore,
@@ -50,6 +54,8 @@ export interface StatuslineData {
50
54
  sessionId: string;
51
55
  cacheCelebration?: CacheCelebrationSnapshot;
52
56
  usage?: UsageSnapshot;
57
+ /** Rendered output of each enabled custom item, in configuration order. */
58
+ customValues?: string[];
53
59
  }
54
60
 
55
61
  const RESET = "\x1b[0m";
@@ -124,7 +130,7 @@ function renderRepository(
124
130
  return part;
125
131
  }
126
132
 
127
- export function renderCacheCelebrationLine(
133
+ function renderCacheCelebrationLine(
128
134
  summary: string,
129
135
  celebration: CacheCelebrationSnapshot,
130
136
  palette: StatuslinePalette = DEFAULT_PALETTE,
@@ -177,6 +183,10 @@ export function renderStatusline(
177
183
  formatTokenCount(data.contextTokens),
178
184
  );
179
185
  const usageSegment = settings.showUsage && data.usage ? renderUsageSegment(data.usage, palette) : undefined;
186
+ // Custom items own their own colours, so they are passed through unstyled;
187
+ // they sit after the usage meters, which is where the built-in segments stop
188
+ // and anything the user added begins.
189
+ const customSegments = settings.showCustomItems ? (data.customValues ?? []).filter((value) => value.length > 0) : [];
180
190
  const segments = [
181
191
  settings.showModel ? styled(palette.model, data.model) : undefined,
182
192
  // No provider is a missing segment, not a placeholder: the model id already
@@ -191,6 +201,7 @@ export function renderStatusline(
191
201
  ? `${used}${styled(palette.dim, "/")}${styled(palette.text, formatTokenCount(data.contextWindow))}`
192
202
  : undefined,
193
203
  usageSegment,
204
+ ...customSegments,
194
205
  ].filter((segment): segment is string => segment !== undefined);
195
206
 
196
207
  const showWorktreeLine = settings.showWorktrees && data.worktrees.length > 0;
@@ -227,6 +238,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
227
238
  const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
228
239
  let tracker: SessionWorktreeTracker | undefined;
229
240
  const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
241
+ const customItems = new CustomItemsTracker({ onChange: () => requestRender?.() });
230
242
  let cwdGit: GitRepositoryStatus | null = null;
231
243
  let cwdStatusAbort: AbortController | undefined;
232
244
  let cwdStatusInFlight: Promise<void> | undefined;
@@ -267,6 +279,48 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
267
279
  return refresh;
268
280
  };
269
281
 
282
+ /**
283
+ * The JSON handed to every custom command on stdin.
284
+ *
285
+ * Field names follow Claude Code's statusline payload where the two agents
286
+ * describe the same thing, so a script written for it needs no rewrite. The
287
+ * exception is deliberate: pi's meters are *remaining* headroom, the inverse
288
+ * of Claude Code's `rate_limits.*.used_percentage`, so those fields live
289
+ * under `usage_remaining` where a ported script cannot read them by accident.
290
+ */
291
+ const customPayload = (ctx: ExtensionContext): Record<string, unknown> => {
292
+ const context = ctx.getContextUsage();
293
+ const window = context?.contextWindow ?? ctx.model?.contextWindow ?? 0;
294
+ const tokens = context?.tokens ?? null;
295
+ const usage = usageTracker.snapshot();
296
+ return {
297
+ version: 1,
298
+ session_id: ctx.sessionManager.getSessionId(),
299
+ cwd: ctx.cwd,
300
+ model: { id: ctx.model?.id ?? null, provider: ctx.model?.provider ?? null },
301
+ git: cwdGit
302
+ ? { branch: cwdGit.branch, dirty: cwdGit.dirty, behind: cwdGit.behind }
303
+ : null,
304
+ context_window: {
305
+ used_tokens: tokens,
306
+ context_window_size: window,
307
+ used_percentage: tokens !== null && window > 0 ? Math.round((tokens * 100) / window) : null,
308
+ },
309
+ usage_remaining: {
310
+ claude: usage.claude
311
+ ? {
312
+ five_hour: usage.claude.fiveHour,
313
+ seven_day: usage.claude.sevenDay,
314
+ scoped_weekly: usage.claude.scopedWeekly ?? null,
315
+ }
316
+ : null,
317
+ codex: usage.codex
318
+ ? { five_hour: usage.codex.fiveHour ?? null, weekly: usage.codex.weekly ?? null }
319
+ : null,
320
+ },
321
+ };
322
+ };
323
+
270
324
  const resetTracker = (ctx: ExtensionContext): void => {
271
325
  tracker?.dispose();
272
326
  tracker = undefined;
@@ -279,6 +333,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
279
333
  usageTracker.setActiveProvider(ctx.model?.provider);
280
334
  runInBackground(usageTracker.refresh());
281
335
  }
336
+ customItems.setContext({ cwd: ctx.cwd });
337
+ customItems.setPayloadFactory(() => customPayload(ctx));
338
+ if (settings.showCustomItems) customItems.refresh();
282
339
  // A hidden worktree line must not pay for git/gh polling.
283
340
  if (!settings.showWorktrees) return;
284
341
  const next = new SessionWorktreeTracker({
@@ -298,6 +355,18 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
298
355
  settings = next;
299
356
  settingsStore.set(next);
300
357
 
358
+ // Items are adopted before the visibility check so a disabled segment
359
+ // still shows current config in the submenu; nothing runs while it is off.
360
+ customItems.setItems(next.customItems);
361
+ if (next.showCustomItems) {
362
+ customItems.restartTimer();
363
+ customItems.start();
364
+ customItems.refresh();
365
+ } else if (previous.showCustomItems) {
366
+ // A hidden segment must not keep spawning commands, matching usage above.
367
+ customItems.stop();
368
+ }
369
+
301
370
  if (!next.showWorktrees) {
302
371
  tracker?.dispose();
303
372
  tracker = undefined;
@@ -347,6 +416,16 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
347
416
  const settingsTheme = tracked.theme;
348
417
  const submenuHost = {
349
418
  getSettings: () => settings,
419
+ customItemStates: () => customItems.states(),
420
+ // The contract reaches the agent as a user message, sent when the
421
+ // menu asks for it. This is the whole reason there is no skill: the
422
+ // text costs nothing until someone wants an item, and a message from
423
+ // the menu is discoverable exactly where the feature is.
424
+ requestCustomItem: (request: string) => {
425
+ const prompt = buildCustomItemSetupPrompt(collapseHome(settingsStore.getPath(), home), request);
426
+ if (ctx.isIdle()) pi.sendUserMessage(prompt);
427
+ else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
428
+ },
350
429
  commit: (next: StatuslineSettings) => applySettings(ctx, next),
351
430
  notify: (message: string) => ctx.ui.notify(message, "warning"),
352
431
  requestRender: () => tui.requestRender(),
@@ -360,6 +439,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
360
439
  {
361
440
  worktreeRoot: createWorktreeRootSubmenu(submenuHost),
362
441
  repoAliases: createAliasSubmenu(submenuHost),
442
+ customItems: createCustomItemsSubmenu(submenuHost),
363
443
  },
364
444
  home,
365
445
  ),
@@ -416,6 +496,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
416
496
  // still needs them to move, and a sibling process's poll is worth adopting
417
497
  // before the next turn ends.
418
498
  if (settings.showUsage) usageTracker.start();
499
+ if (settings.showCustomItems) customItems.start();
419
500
  const stopBranchUpdates = footerData.onBranchChange(() => {
420
501
  runInBackground(refreshCwdStatus(ctx));
421
502
  tui.requestRender();
@@ -428,6 +509,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
428
509
  celebrationPreview.dispose();
429
510
  fullRedraw.detach();
430
511
  usageTracker.stop();
512
+ customItems.dispose();
431
513
  requestRender = undefined;
432
514
  },
433
515
  invalidate(): void {},
@@ -435,6 +517,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
435
517
  const usage = ctx.getContextUsage();
436
518
  const cwd = basename(ctx.cwd) || ctx.cwd;
437
519
  const model = ctx.model?.id.split("/").pop() || "no-model";
520
+ // Commands size themselves with COLUMNS, so the tracker needs the
521
+ // width the footer is actually being drawn at.
522
+ customItems.setContext({ columns: width });
438
523
 
439
524
  return fullRedraw.decorate(
440
525
  renderStatusline(
@@ -449,6 +534,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
449
534
  sessionId: ctx.sessionManager.getSessionId(),
450
535
  cacheCelebration: celebrationPreview.snapshot() ?? cacheCelebration.snapshot(),
451
536
  usage: usageTracker.snapshot(),
537
+ customValues: customItems.values(),
452
538
  },
453
539
  width,
454
540
  settings,
@@ -476,6 +562,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
476
562
  usageTracker.setActiveProvider(ctx.model?.provider);
477
563
  runInBackground(usageTracker.refresh());
478
564
  }
565
+ // Turn end is the event-driven trigger, mirroring how Claude Code re-runs a
566
+ // statusline command when a new assistant message arrives.
567
+ if (settings.showCustomItems) customItems.refresh();
479
568
  });
480
569
  // The meters follow the main model's account, so a switch between two logins
481
570
  // of the same provider family has to re-point the tracker before it repaints.
@@ -491,6 +580,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
491
580
  cacheCelebration.dispose();
492
581
  fullRedraw.detach();
493
582
  usageTracker.stop();
583
+ customItems.dispose();
494
584
  tracker?.dispose();
495
585
  tracker = undefined;
496
586
  cwdStatusAbort?.abort();