@hank-warren/pi-statusline 0.7.3 → 0.9.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
+ }