@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/CHANGELOG.md +37 -0
- package/README.md +167 -2
- package/custom-setup.ts +41 -0
- package/custom.ts +486 -0
- package/index.ts +105 -0
- package/package.json +3 -1
- package/settings-menu.ts +177 -0
- package/settings.ts +33 -5
- package/themes.ts +7 -0
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,
|
|
@@ -40,6 +44,8 @@ import {
|
|
|
40
44
|
|
|
41
45
|
export interface StatuslineData {
|
|
42
46
|
model: string;
|
|
47
|
+
/** Thinking level of the active model; absent when the model cannot reason. */
|
|
48
|
+
thinkingLevel?: string;
|
|
43
49
|
/** Provider id of the active model; absent when there is no model. */
|
|
44
50
|
provider?: string;
|
|
45
51
|
cwd: string;
|
|
@@ -50,6 +56,8 @@ export interface StatuslineData {
|
|
|
50
56
|
sessionId: string;
|
|
51
57
|
cacheCelebration?: CacheCelebrationSnapshot;
|
|
52
58
|
usage?: UsageSnapshot;
|
|
59
|
+
/** Rendered output of each enabled custom item, in configuration order. */
|
|
60
|
+
customValues?: string[];
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
const RESET = "\x1b[0m";
|
|
@@ -177,8 +185,18 @@ export function renderStatusline(
|
|
|
177
185
|
formatTokenCount(data.contextTokens),
|
|
178
186
|
);
|
|
179
187
|
const usageSegment = settings.showUsage && data.usage ? renderUsageSegment(data.usage, palette) : undefined;
|
|
188
|
+
// Custom items own their own colours, so they are passed through unstyled;
|
|
189
|
+
// they sit after the usage meters, which is where the built-in segments stop
|
|
190
|
+
// and anything the user added begins.
|
|
191
|
+
const customSegments = settings.showCustomItems ? (data.customValues ?? []).filter((value) => value.length > 0) : [];
|
|
180
192
|
const segments = [
|
|
181
193
|
settings.showModel ? styled(palette.model, data.model) : undefined,
|
|
194
|
+
// A non-reasoning model has no level to show, so the segment goes with it.
|
|
195
|
+
// "off" on a reasoning model is a real state and reads as pi's own footer
|
|
196
|
+
// spells it, because a bare "off" between two ids means nothing.
|
|
197
|
+
settings.showThinking && data.thinkingLevel
|
|
198
|
+
? styled(palette.thinking, data.thinkingLevel === "off" ? "thinking off" : data.thinkingLevel)
|
|
199
|
+
: undefined,
|
|
182
200
|
// No provider is a missing segment, not a placeholder: the model id already
|
|
183
201
|
// says "no-model" in that state, and a second one would only add noise.
|
|
184
202
|
settings.showProvider && data.provider ? styled(palette.provider, data.provider) : undefined,
|
|
@@ -191,6 +209,7 @@ export function renderStatusline(
|
|
|
191
209
|
? `${used}${styled(palette.dim, "/")}${styled(palette.text, formatTokenCount(data.contextWindow))}`
|
|
192
210
|
: undefined,
|
|
193
211
|
usageSegment,
|
|
212
|
+
...customSegments,
|
|
194
213
|
].filter((segment): segment is string => segment !== undefined);
|
|
195
214
|
|
|
196
215
|
const showWorktreeLine = settings.showWorktrees && data.worktrees.length > 0;
|
|
@@ -227,6 +246,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
227
246
|
const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
|
|
228
247
|
let tracker: SessionWorktreeTracker | undefined;
|
|
229
248
|
const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
|
|
249
|
+
const customItems = new CustomItemsTracker({ onChange: () => requestRender?.() });
|
|
230
250
|
let cwdGit: GitRepositoryStatus | null = null;
|
|
231
251
|
let cwdStatusAbort: AbortController | undefined;
|
|
232
252
|
let cwdStatusInFlight: Promise<void> | undefined;
|
|
@@ -267,6 +287,48 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
267
287
|
return refresh;
|
|
268
288
|
};
|
|
269
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The JSON handed to every custom command on stdin.
|
|
292
|
+
*
|
|
293
|
+
* Field names follow Claude Code's statusline payload where the two agents
|
|
294
|
+
* describe the same thing, so a script written for it needs no rewrite. The
|
|
295
|
+
* exception is deliberate: pi's meters are *remaining* headroom, the inverse
|
|
296
|
+
* of Claude Code's `rate_limits.*.used_percentage`, so those fields live
|
|
297
|
+
* under `usage_remaining` where a ported script cannot read them by accident.
|
|
298
|
+
*/
|
|
299
|
+
const customPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
300
|
+
const context = ctx.getContextUsage();
|
|
301
|
+
const window = context?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
302
|
+
const tokens = context?.tokens ?? null;
|
|
303
|
+
const usage = usageTracker.snapshot();
|
|
304
|
+
return {
|
|
305
|
+
version: 1,
|
|
306
|
+
session_id: ctx.sessionManager.getSessionId(),
|
|
307
|
+
cwd: ctx.cwd,
|
|
308
|
+
model: { id: ctx.model?.id ?? null, provider: ctx.model?.provider ?? null },
|
|
309
|
+
git: cwdGit
|
|
310
|
+
? { branch: cwdGit.branch, dirty: cwdGit.dirty, behind: cwdGit.behind }
|
|
311
|
+
: null,
|
|
312
|
+
context_window: {
|
|
313
|
+
used_tokens: tokens,
|
|
314
|
+
context_window_size: window,
|
|
315
|
+
used_percentage: tokens !== null && window > 0 ? Math.round((tokens * 100) / window) : null,
|
|
316
|
+
},
|
|
317
|
+
usage_remaining: {
|
|
318
|
+
claude: usage.claude
|
|
319
|
+
? {
|
|
320
|
+
five_hour: usage.claude.fiveHour,
|
|
321
|
+
seven_day: usage.claude.sevenDay,
|
|
322
|
+
scoped_weekly: usage.claude.scopedWeekly ?? null,
|
|
323
|
+
}
|
|
324
|
+
: null,
|
|
325
|
+
codex: usage.codex
|
|
326
|
+
? { five_hour: usage.codex.fiveHour ?? null, weekly: usage.codex.weekly ?? null }
|
|
327
|
+
: null,
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
|
|
270
332
|
const resetTracker = (ctx: ExtensionContext): void => {
|
|
271
333
|
tracker?.dispose();
|
|
272
334
|
tracker = undefined;
|
|
@@ -279,6 +341,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
279
341
|
usageTracker.setActiveProvider(ctx.model?.provider);
|
|
280
342
|
runInBackground(usageTracker.refresh());
|
|
281
343
|
}
|
|
344
|
+
customItems.setContext({ cwd: ctx.cwd });
|
|
345
|
+
customItems.setPayloadFactory(() => customPayload(ctx));
|
|
346
|
+
if (settings.showCustomItems) customItems.refresh();
|
|
282
347
|
// A hidden worktree line must not pay for git/gh polling.
|
|
283
348
|
if (!settings.showWorktrees) return;
|
|
284
349
|
const next = new SessionWorktreeTracker({
|
|
@@ -298,6 +363,18 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
298
363
|
settings = next;
|
|
299
364
|
settingsStore.set(next);
|
|
300
365
|
|
|
366
|
+
// Items are adopted before the visibility check so a disabled segment
|
|
367
|
+
// still shows current config in the submenu; nothing runs while it is off.
|
|
368
|
+
customItems.setItems(next.customItems);
|
|
369
|
+
if (next.showCustomItems) {
|
|
370
|
+
customItems.restartTimer();
|
|
371
|
+
customItems.start();
|
|
372
|
+
customItems.refresh();
|
|
373
|
+
} else if (previous.showCustomItems) {
|
|
374
|
+
// A hidden segment must not keep spawning commands, matching usage above.
|
|
375
|
+
customItems.stop();
|
|
376
|
+
}
|
|
377
|
+
|
|
301
378
|
if (!next.showWorktrees) {
|
|
302
379
|
tracker?.dispose();
|
|
303
380
|
tracker = undefined;
|
|
@@ -347,6 +424,16 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
347
424
|
const settingsTheme = tracked.theme;
|
|
348
425
|
const submenuHost = {
|
|
349
426
|
getSettings: () => settings,
|
|
427
|
+
customItemStates: () => customItems.states(),
|
|
428
|
+
// The contract reaches the agent as a user message, sent when the
|
|
429
|
+
// menu asks for it. This is the whole reason there is no skill: the
|
|
430
|
+
// text costs nothing until someone wants an item, and a message from
|
|
431
|
+
// the menu is discoverable exactly where the feature is.
|
|
432
|
+
requestCustomItem: (request: string) => {
|
|
433
|
+
const prompt = buildCustomItemSetupPrompt(collapseHome(settingsStore.getPath(), home), request);
|
|
434
|
+
if (ctx.isIdle()) pi.sendUserMessage(prompt);
|
|
435
|
+
else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
436
|
+
},
|
|
350
437
|
commit: (next: StatuslineSettings) => applySettings(ctx, next),
|
|
351
438
|
notify: (message: string) => ctx.ui.notify(message, "warning"),
|
|
352
439
|
requestRender: () => tui.requestRender(),
|
|
@@ -360,6 +447,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
360
447
|
{
|
|
361
448
|
worktreeRoot: createWorktreeRootSubmenu(submenuHost),
|
|
362
449
|
repoAliases: createAliasSubmenu(submenuHost),
|
|
450
|
+
customItems: createCustomItemsSubmenu(submenuHost),
|
|
363
451
|
},
|
|
364
452
|
home,
|
|
365
453
|
),
|
|
@@ -416,6 +504,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
416
504
|
// still needs them to move, and a sibling process's poll is worth adopting
|
|
417
505
|
// before the next turn ends.
|
|
418
506
|
if (settings.showUsage) usageTracker.start();
|
|
507
|
+
if (settings.showCustomItems) customItems.start();
|
|
419
508
|
const stopBranchUpdates = footerData.onBranchChange(() => {
|
|
420
509
|
runInBackground(refreshCwdStatus(ctx));
|
|
421
510
|
tui.requestRender();
|
|
@@ -428,6 +517,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
428
517
|
celebrationPreview.dispose();
|
|
429
518
|
fullRedraw.detach();
|
|
430
519
|
usageTracker.stop();
|
|
520
|
+
customItems.dispose();
|
|
431
521
|
requestRender = undefined;
|
|
432
522
|
},
|
|
433
523
|
invalidate(): void {},
|
|
@@ -435,11 +525,18 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
435
525
|
const usage = ctx.getContextUsage();
|
|
436
526
|
const cwd = basename(ctx.cwd) || ctx.cwd;
|
|
437
527
|
const model = ctx.model?.id.split("/").pop() || "no-model";
|
|
528
|
+
// ctx.thinkingLevel is a live getter on the session; the pi accessor is
|
|
529
|
+
// the same value for hosts whose context does not expose it.
|
|
530
|
+
const thinkingLevel = ctx.model?.reasoning ? (ctx.thinkingLevel ?? pi.getThinkingLevel()) : undefined;
|
|
531
|
+
// Commands size themselves with COLUMNS, so the tracker needs the
|
|
532
|
+
// width the footer is actually being drawn at.
|
|
533
|
+
customItems.setContext({ columns: width });
|
|
438
534
|
|
|
439
535
|
return fullRedraw.decorate(
|
|
440
536
|
renderStatusline(
|
|
441
537
|
{
|
|
442
538
|
model,
|
|
539
|
+
thinkingLevel,
|
|
443
540
|
provider: ctx.model?.provider,
|
|
444
541
|
cwd,
|
|
445
542
|
cwdGit,
|
|
@@ -449,6 +546,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
449
546
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
450
547
|
cacheCelebration: celebrationPreview.snapshot() ?? cacheCelebration.snapshot(),
|
|
451
548
|
usage: usageTracker.snapshot(),
|
|
549
|
+
customValues: customItems.values(),
|
|
452
550
|
},
|
|
453
551
|
width,
|
|
454
552
|
settings,
|
|
@@ -476,6 +574,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
476
574
|
usageTracker.setActiveProvider(ctx.model?.provider);
|
|
477
575
|
runInBackground(usageTracker.refresh());
|
|
478
576
|
}
|
|
577
|
+
// Turn end is the event-driven trigger, mirroring how Claude Code re-runs a
|
|
578
|
+
// statusline command when a new assistant message arrives.
|
|
579
|
+
if (settings.showCustomItems) customItems.refresh();
|
|
479
580
|
});
|
|
480
581
|
// The meters follow the main model's account, so a switch between two logins
|
|
481
582
|
// of the same provider family has to re-point the tracker before it repaints.
|
|
@@ -486,11 +587,15 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
486
587
|
}
|
|
487
588
|
requestRender?.();
|
|
488
589
|
});
|
|
590
|
+
// The cycle keybinding (Shift+Tab by default) changes the level without a
|
|
591
|
+
// turn or a model change, so nothing else would repaint the segment.
|
|
592
|
+
pi.on("thinking_level_select", () => requestRender?.());
|
|
489
593
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
|
490
594
|
pi.on("session_shutdown", () => {
|
|
491
595
|
cacheCelebration.dispose();
|
|
492
596
|
fullRedraw.detach();
|
|
493
597
|
usageTracker.stop();
|
|
598
|
+
customItems.dispose();
|
|
494
599
|
tracker?.dispose();
|
|
495
600
|
tracker = undefined;
|
|
496
601
|
cwdStatusAbort?.abort();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": [
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"cache-celebration.ts",
|
|
36
36
|
"celebration-preview.ts",
|
|
37
37
|
"celebration-styles.ts",
|
|
38
|
+
"custom.ts",
|
|
39
|
+
"custom-setup.ts",
|
|
38
40
|
"redraw.ts",
|
|
39
41
|
"settings.ts",
|
|
40
42
|
"settings-menu.ts",
|
package/settings-menu.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
truncateToWidth,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
12
|
import { type BooleanSettingKey, collapseHome, resolveWorktreeRoot, type StatuslineSettings } from "./settings.ts";
|
|
13
|
+
import type { CustomItemState } from "./custom.ts";
|
|
13
14
|
import { CELEBRATION_STYLE_NAMES, isCelebrationStyleName } from "./celebration-styles.ts";
|
|
14
15
|
import { isThemeName, THEME_NAMES } from "./themes.ts";
|
|
15
16
|
|
|
@@ -17,7 +18,9 @@ export const THEME_ID = "theme";
|
|
|
17
18
|
export const CACHE_CELEBRATION_ID = "showCacheCelebration";
|
|
18
19
|
export const WORKTREE_ROOT_ID = "worktreeRoot";
|
|
19
20
|
export const REPO_ALIASES_ID = "repoAliases";
|
|
21
|
+
export const CUSTOM_ITEMS_ID = "customItems";
|
|
20
22
|
export const ADD_ALIAS_VALUE = "\u0000add";
|
|
23
|
+
export const ADD_CUSTOM_ITEM_VALUE = "\u0000add-item";
|
|
21
24
|
|
|
22
25
|
export const ON = "on";
|
|
23
26
|
export const OFF = "off";
|
|
@@ -32,10 +35,12 @@ interface BooleanRow {
|
|
|
32
35
|
/** Toggle rows, in statusline render order. */
|
|
33
36
|
export const BOOLEAN_ROWS: readonly BooleanRow[] = [
|
|
34
37
|
{ id: "showModel", label: "Model", description: "Show the active model id." },
|
|
38
|
+
{ id: "showThinking", label: "Thinking level", description: "Show the thinking level of the active model." },
|
|
35
39
|
{ id: "showProvider", label: "Provider", description: "Show the provider of the active model." },
|
|
36
40
|
{ id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
|
|
37
41
|
{ id: "showContext", label: "Context", description: "Show context tokens used against the window." },
|
|
38
42
|
{ id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
|
|
43
|
+
{ id: "showCustomItems", label: "Custom items", description: "Run your configured commands and show their output." },
|
|
39
44
|
{ id: "showWorktrees", label: "Worktree line", description: "Show touched worktrees and their pull requests." },
|
|
40
45
|
{ id: "showSessionId", label: "Session ID line", description: "Show the full Pi session id on its own line." },
|
|
41
46
|
];
|
|
@@ -57,9 +62,67 @@ export function aliasSummary(settings: StatuslineSettings): string {
|
|
|
57
62
|
return `${count} alias${count === 1 ? "" : "es"}`;
|
|
58
63
|
}
|
|
59
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;
|
|
68
|
+
if (total === 0) return "none configured";
|
|
69
|
+
return `${settings.customItems.filter((item) => item.enabled).length}/${total} on`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rows in the custom-items submenu: each item, what it shows and why, then the
|
|
74
|
+
* one action that creates an item. Adding hands the job to the agent rather
|
|
75
|
+
* than opening a form: a statusline command is a script plus a JSON entry plus
|
|
76
|
+
* a test run, which is a conversation, not a field.
|
|
77
|
+
*/
|
|
78
|
+
export function customItemRows(
|
|
79
|
+
settings: StatuslineSettings,
|
|
80
|
+
states: readonly CustomItemState[] = [],
|
|
81
|
+
): SelectItem[] {
|
|
82
|
+
return [
|
|
83
|
+
...customItemStateRows(settings, states),
|
|
84
|
+
{
|
|
85
|
+
value: ADD_CUSTOM_ITEM_VALUE,
|
|
86
|
+
label: "Add custom item…",
|
|
87
|
+
description: "Ask the agent to write one; it gets the contract and the settings path.",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
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
|
|
104
|
+
? "disabled"
|
|
105
|
+
: error !== undefined
|
|
106
|
+
? error
|
|
107
|
+
: state?.running === true && state.value === undefined
|
|
108
|
+
? "running…"
|
|
109
|
+
: state?.value !== undefined && state.value.length > 0
|
|
110
|
+
? state.value
|
|
111
|
+
: state?.value !== undefined
|
|
112
|
+
? "empty output"
|
|
113
|
+
: "no value yet";
|
|
114
|
+
return {
|
|
115
|
+
value: item.id,
|
|
116
|
+
label: `${item.enabled ? toggleValue(true) : toggleValue(false)} ${item.id}`,
|
|
117
|
+
description: detail,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
60
122
|
export interface SettingSubmenus {
|
|
61
123
|
worktreeRoot?: SettingItem["submenu"];
|
|
62
124
|
repoAliases?: SettingItem["submenu"];
|
|
125
|
+
customItems?: SettingItem["submenu"];
|
|
63
126
|
}
|
|
64
127
|
|
|
65
128
|
/** Build the `/statusline` rows for a settings snapshot. */
|
|
@@ -81,6 +144,10 @@ export function buildSettingItems(
|
|
|
81
144
|
// Rows follow the order their elements render in, so the celebration sits
|
|
82
145
|
// after the usage meters it is appended to on line 1.
|
|
83
146
|
for (const row of BOOLEAN_ROWS) {
|
|
147
|
+
// A toggle for a segment with nothing in it is a row that does nothing;
|
|
148
|
+
// 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;
|
|
84
151
|
items.push({
|
|
85
152
|
id: row.id,
|
|
86
153
|
label: row.label,
|
|
@@ -113,6 +180,13 @@ export function buildSettingItems(
|
|
|
113
180
|
currentValue: aliasSummary(settings),
|
|
114
181
|
...(submenus.repoAliases ? { submenu: submenus.repoAliases } : {}),
|
|
115
182
|
});
|
|
183
|
+
items.push({
|
|
184
|
+
id: CUSTOM_ITEMS_ID,
|
|
185
|
+
label: "Custom item list",
|
|
186
|
+
description: "Enable or disable configured items; edit commands in statusline-settings.json.",
|
|
187
|
+
currentValue: customItemsSummary(settings),
|
|
188
|
+
...(submenus.customItems ? { submenu: submenus.customItems } : {}),
|
|
189
|
+
});
|
|
116
190
|
|
|
117
191
|
return items;
|
|
118
192
|
}
|
|
@@ -183,6 +257,13 @@ export function aliasItems(settings: StatuslineSettings): SelectItem[] {
|
|
|
183
257
|
export interface SubmenuHost {
|
|
184
258
|
/** Read the live settings; the menu edits a single shared snapshot. */
|
|
185
259
|
getSettings(): StatuslineSettings;
|
|
260
|
+
/** Live per-item run state, for the custom-items submenu. */
|
|
261
|
+
customItemStates?(): CustomItemState[];
|
|
262
|
+
/**
|
|
263
|
+
* Hand "add an item" to the agent with the user's one-line description of
|
|
264
|
+
* what it should show. Closes the menu; absent when no agent can be reached.
|
|
265
|
+
*/
|
|
266
|
+
requestCustomItem?(request: string): void;
|
|
186
267
|
/** Commit an edit: persists, applies live, and repaints. */
|
|
187
268
|
commit(settings: StatuslineSettings): void;
|
|
188
269
|
notify(message: string): void;
|
|
@@ -348,3 +429,99 @@ class AliasSubmenu implements Component {
|
|
|
348
429
|
export function createAliasSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
349
430
|
return (_currentValue, done) => new AliasSubmenu(host, done);
|
|
350
431
|
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Custom item list: enable/disable each item and read why it is not showing.
|
|
435
|
+
*
|
|
436
|
+
* Commands are edited in the settings file, not here. A statusline command is a
|
|
437
|
+
* shell line with quoting and pipes in it, which a single-line TUI prompt edits
|
|
438
|
+
* badly, and keeping the field out of the menu means a toggle writes only the
|
|
439
|
+
* `enabled` flag over whatever the file currently holds.
|
|
440
|
+
*/
|
|
441
|
+
class CustomItemsSubmenu implements Component {
|
|
442
|
+
private list: SelectList;
|
|
443
|
+
private prompt: PromptComponent | undefined;
|
|
444
|
+
|
|
445
|
+
constructor(
|
|
446
|
+
private readonly host: SubmenuHost,
|
|
447
|
+
private readonly done: (value?: string) => void,
|
|
448
|
+
) {
|
|
449
|
+
this.list = this.buildList();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private buildList(selectedIndex = 0): SelectList {
|
|
453
|
+
const rows = customItemRows(this.host.getSettings(), this.host.customItemStates?.() ?? []);
|
|
454
|
+
const list = new SelectList(rows, 10, this.host.selectTheme);
|
|
455
|
+
list.setSelectedIndex(selectedIndex);
|
|
456
|
+
list.onCancel = () => this.done(customItemsSummary(this.host.getSettings()));
|
|
457
|
+
list.onSelect = (item) => (item.value === ADD_CUSTOM_ITEM_VALUE ? this.add() : this.toggle(item.value));
|
|
458
|
+
return list;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private add(): void {
|
|
462
|
+
if (!this.host.requestCustomItem) {
|
|
463
|
+
this.host.notify("Add items to statusline-settings.json; see the pi-statusline README");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.prompt = new PromptComponent(
|
|
467
|
+
"What should the item show? (Enter for the agent to ask)",
|
|
468
|
+
"",
|
|
469
|
+
this.host.settingsTheme.hint,
|
|
470
|
+
(value) => {
|
|
471
|
+
this.prompt = undefined;
|
|
472
|
+
// Close the whole menu before the message lands: the agent's reply
|
|
473
|
+
// renders in the transcript, which the menu is drawn over.
|
|
474
|
+
this.done(customItemsSummary(this.host.getSettings()));
|
|
475
|
+
this.host.requestCustomItem?.(value);
|
|
476
|
+
},
|
|
477
|
+
() => {
|
|
478
|
+
this.prompt = undefined;
|
|
479
|
+
this.host.requestRender();
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
this.host.requestRender();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private toggle(id: string): void {
|
|
486
|
+
if (id.startsWith("\u0000")) return;
|
|
487
|
+
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) {
|
|
492
|
+
// Enabling an unparseable entry would only fail again on the next tick.
|
|
493
|
+
this.host.notify(`${id} cannot run: ${target.error}`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const customItems = [...settings.customItems];
|
|
497
|
+
customItems[index] = { ...target, enabled: !target.enabled };
|
|
498
|
+
this.host.commit({ ...settings, customItems });
|
|
499
|
+
this.list = this.buildList(index);
|
|
500
|
+
this.host.requestRender();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
invalidate(): void {
|
|
504
|
+
this.prompt?.invalidate();
|
|
505
|
+
this.list.invalidate();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
render(width: number): string[] {
|
|
509
|
+
if (this.prompt) return this.prompt.render(width);
|
|
510
|
+
return [
|
|
511
|
+
truncateToWidth(this.host.settingsTheme.hint(" Custom items"), width),
|
|
512
|
+
"",
|
|
513
|
+
...this.list.render(width),
|
|
514
|
+
"",
|
|
515
|
+
truncateToWidth(this.host.settingsTheme.hint(" Enter to enable/disable or add · Esc to go back"), width),
|
|
516
|
+
];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
handleInput(data: string): void {
|
|
520
|
+
if (this.prompt) this.prompt.handleInput(data);
|
|
521
|
+
else this.list.handleInput(data);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function createCustomItemsSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
526
|
+
return (_currentValue, done) => new CustomItemsSubmenu(host, done);
|
|
527
|
+
}
|
package/settings.ts
CHANGED
|
@@ -8,15 +8,23 @@ import {
|
|
|
8
8
|
DEFAULT_CELEBRATION_STYLE,
|
|
9
9
|
isCelebrationStyleName,
|
|
10
10
|
} from "./celebration-styles.ts";
|
|
11
|
+
import {
|
|
12
|
+
type CustomItem,
|
|
13
|
+
normalizeCustomItems,
|
|
14
|
+
sameCustomItems,
|
|
15
|
+
serializeCustomItems,
|
|
16
|
+
} from "./custom.ts";
|
|
11
17
|
import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.ts";
|
|
12
18
|
|
|
13
19
|
/** Toggle keys, in the order the `/statusline` menu lists them. */
|
|
14
20
|
export const BOOLEAN_SETTING_KEYS = [
|
|
15
21
|
"showModel",
|
|
22
|
+
"showThinking",
|
|
16
23
|
"showProvider",
|
|
17
24
|
"showDirectory",
|
|
18
25
|
"showContext",
|
|
19
26
|
"showUsage",
|
|
27
|
+
"showCustomItems",
|
|
20
28
|
"showWorktrees",
|
|
21
29
|
"showSessionId",
|
|
22
30
|
"showCacheCelebration",
|
|
@@ -33,6 +41,8 @@ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
|
|
|
33
41
|
worktreeRoot: string;
|
|
34
42
|
/** `repository name -> display alias` overrides for the worktree line. */
|
|
35
43
|
repoAliases: Record<string, string>;
|
|
44
|
+
/** User-defined command segments rendered after the usage meters. */
|
|
45
|
+
customItems: CustomItem[];
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
function defaultWorktreeRoot(home: string = homedir()): string {
|
|
@@ -42,11 +52,17 @@ function defaultWorktreeRoot(home: string = homedir()): string {
|
|
|
42
52
|
export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
43
53
|
return {
|
|
44
54
|
showModel: true,
|
|
55
|
+
// Opt-in: an extra segment on every existing footer would be a surprise,
|
|
56
|
+
// and most sessions keep one thinking level for their whole life.
|
|
57
|
+
showThinking: false,
|
|
45
58
|
// Opt-in: the provider is redundant for anyone with a single login per family.
|
|
46
59
|
showProvider: false,
|
|
47
60
|
showDirectory: true,
|
|
48
61
|
showContext: true,
|
|
49
62
|
showUsage: true,
|
|
63
|
+
// On by default and free until the user configures an item: with an empty
|
|
64
|
+
// list nothing renders, nothing is spawned, and no timer runs.
|
|
65
|
+
showCustomItems: true,
|
|
50
66
|
showWorktrees: true,
|
|
51
67
|
showSessionId: true,
|
|
52
68
|
showCacheCelebration: true,
|
|
@@ -54,6 +70,7 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
|
54
70
|
cacheCelebrationStyle: DEFAULT_CELEBRATION_STYLE,
|
|
55
71
|
worktreeRoot: defaultWorktreeRoot(home),
|
|
56
72
|
repoAliases: {},
|
|
73
|
+
customItems: [],
|
|
57
74
|
};
|
|
58
75
|
}
|
|
59
76
|
|
|
@@ -140,6 +157,7 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
|
|
|
140
157
|
"cacheCelebrationStyle",
|
|
141
158
|
"worktreeRoot",
|
|
142
159
|
"repoAliases",
|
|
160
|
+
"customItems",
|
|
143
161
|
]);
|
|
144
162
|
for (const [key, raw] of Object.entries(value)) {
|
|
145
163
|
if (!known.has(key)) {
|
|
@@ -158,6 +176,12 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
|
|
|
158
176
|
if (aliases) settings.repoAliases = aliases;
|
|
159
177
|
continue;
|
|
160
178
|
}
|
|
179
|
+
if (key === "customItems") {
|
|
180
|
+
// Unusable entries come back as items carrying their parse error rather
|
|
181
|
+
// than being dropped, so a write cannot delete what it failed to read.
|
|
182
|
+
settings.customItems = normalizeCustomItems(raw);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
161
185
|
if (key === "theme") {
|
|
162
186
|
if (isThemeName(raw)) settings.theme = raw;
|
|
163
187
|
continue;
|
|
@@ -185,6 +209,7 @@ export const SETTING_KEYS = [
|
|
|
185
209
|
"cacheCelebrationStyle",
|
|
186
210
|
"worktreeRoot",
|
|
187
211
|
"repoAliases",
|
|
212
|
+
"customItems",
|
|
188
213
|
] as const satisfies readonly (keyof StatuslineSettings)[];
|
|
189
214
|
|
|
190
215
|
export type SettingKey = (typeof SETTING_KEYS)[number];
|
|
@@ -205,11 +230,11 @@ export function changedSettingKeys(
|
|
|
205
230
|
previous: StatuslineSettings,
|
|
206
231
|
next: StatuslineSettings,
|
|
207
232
|
): SettingKey[] {
|
|
208
|
-
return SETTING_KEYS.filter((key) =>
|
|
209
|
-
key === "repoAliases"
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
);
|
|
233
|
+
return SETTING_KEYS.filter((key) => {
|
|
234
|
+
if (key === "repoAliases") return !sameAliases(previous.repoAliases, next.repoAliases);
|
|
235
|
+
if (key === "customItems") return !sameCustomItems(previous.customItems, next.customItems);
|
|
236
|
+
return previous[key] !== next[key];
|
|
237
|
+
});
|
|
213
238
|
}
|
|
214
239
|
|
|
215
240
|
/**
|
|
@@ -232,6 +257,9 @@ export function serializeSettings(
|
|
|
232
257
|
}
|
|
233
258
|
if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
|
|
234
259
|
if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
|
|
260
|
+
// An empty list is the default and stays out of the file; a configured one is
|
|
261
|
+
// written back from each entry's original source object.
|
|
262
|
+
if (settings.customItems.length > 0) out.customItems = serializeCustomItems(settings.customItems);
|
|
235
263
|
return out;
|
|
236
264
|
}
|
|
237
265
|
|
package/themes.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export interface StatuslinePalette {
|
|
6
6
|
/** Active model id. */
|
|
7
7
|
model: string;
|
|
8
|
+
/** Thinking level of the active model. */
|
|
9
|
+
thinking: string;
|
|
8
10
|
/** Provider id of the active model. */
|
|
9
11
|
provider: string;
|
|
10
12
|
/** Repository and directory names. */
|
|
@@ -37,6 +39,7 @@ const rgb = (hex: string): string => {
|
|
|
37
39
|
/** The palette this package shipped before themes existed. */
|
|
38
40
|
const DEFAULT: StatuslinePalette = {
|
|
39
41
|
model: rgb("#0099ff"),
|
|
42
|
+
thinking: rgb("#5fb0ff"),
|
|
40
43
|
provider: rgb("#7aa2c8"),
|
|
41
44
|
path: rgb("#dcdcdc"),
|
|
42
45
|
branch: rgb("#56b6c2"),
|
|
@@ -53,6 +56,7 @@ const DEFAULT: StatuslinePalette = {
|
|
|
53
56
|
/** Dracula, with the pink branch colour from Hank's Herdr sidebar config. */
|
|
54
57
|
const DRACULA: StatuslinePalette = {
|
|
55
58
|
model: rgb("#bd93f9"),
|
|
59
|
+
thinking: rgb("#a98ae0"),
|
|
56
60
|
provider: rgb("#9580c9"),
|
|
57
61
|
path: rgb("#f8f8f2"),
|
|
58
62
|
branch: rgb("#ff79c6"),
|
|
@@ -68,6 +72,7 @@ const DRACULA: StatuslinePalette = {
|
|
|
68
72
|
|
|
69
73
|
const GITHUB_DARK: StatuslinePalette = {
|
|
70
74
|
model: rgb("#58a6ff"),
|
|
75
|
+
thinking: rgb("#6399db"),
|
|
71
76
|
provider: rgb("#6e8bb5"),
|
|
72
77
|
path: rgb("#c9d1d9"),
|
|
73
78
|
branch: rgb("#39c5cf"),
|
|
@@ -83,6 +88,7 @@ const GITHUB_DARK: StatuslinePalette = {
|
|
|
83
88
|
|
|
84
89
|
const CATPPUCCIN_MOCHA: StatuslinePalette = {
|
|
85
90
|
model: rgb("#cba6f7"),
|
|
91
|
+
thinking: rgb("#b89adf"),
|
|
86
92
|
provider: rgb("#a58fc4"),
|
|
87
93
|
path: rgb("#cdd6f4"),
|
|
88
94
|
branch: rgb("#89dceb"),
|
|
@@ -99,6 +105,7 @@ const CATPPUCCIN_MOCHA: StatuslinePalette = {
|
|
|
99
105
|
/** No colour at all: white text, dimmed punctuation, a grey badge flash. */
|
|
100
106
|
const WHITE: StatuslinePalette = {
|
|
101
107
|
model: rgb("#ffffff"),
|
|
108
|
+
thinking: rgb("#ffffff"),
|
|
102
109
|
provider: rgb("#ffffff"),
|
|
103
110
|
path: rgb("#ffffff"),
|
|
104
111
|
branch: rgb("#ffffff"),
|