@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/CHANGELOG.md +26 -0
- package/README.md +166 -1
- package/cache-celebration.ts +3 -3
- package/celebration-preview.ts +1 -1
- package/celebration-styles.ts +3 -3
- package/custom-setup.ts +41 -0
- package/custom.ts +486 -0
- package/index.ts +91 -1
- package/package.json +4 -2
- package/redraw.ts +1 -1
- package/settings-menu.ts +177 -1
- package/settings.ts +46 -12
- package/usage.ts +9 -6
- package/worktrees.ts +1 -1
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
|
-
|
|
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();
|