@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/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.7.3",
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
- ? !sameAliases(previous.repoAliases, next.repoAliases)
211
- : previous[key] !== next[key],
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"),