@hank-warren/pi-statusline 0.8.0 → 0.10.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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Let other extensions provide custom statusline items as in-process functions through the `pi-statusline:custom-items:request` event, without shell commands or a process spawn on each refresh.
8
+
9
+ Registered items receive the same session payload as command items and share their refresh scheduler, timeouts, failure grace period, and output sanitization. Providers can return `null` to hide an item, receive an abort signal on timeout, and replace an existing registration by id.
10
+
11
+ The `/statusline` custom item list identifies extension-provided rows and persists their enabled state. Settings can pin their order and override refresh intervals and timeouts; existing command items keep priority when an id collides. Providers are discovered at interactive session start and whenever `/statusline` opens, independently of extension load order.
12
+
13
+ ## 0.9.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Add an optional **Thinking level** segment between the model and the provider.
18
+
19
+ `/statusline` gains a **Thinking level** toggle, off by default. When on, the
20
+ active model's thinking level renders after the model id
21
+ (`gpt-5.6-luna | medium | openai-codex | …`) in a new `thinking` palette role
22
+ present in every theme.
23
+ `off` on a reasoning model is spelled `thinking off`, as Pi's own footer does; a
24
+ model that cannot reason drops the segment entirely, leaving no stray separator.
25
+
26
+ The footer repaints the moment the level changes — the cycle keybinding
27
+ (Shift+Tab by default), `/model`, or an extension calling `pi.setThinkingLevel()`
28
+ — without waiting for a turn.
29
+
3
30
  ## 0.8.0
4
31
 
5
32
  ### Minor Changes
package/README.md CHANGED
@@ -10,7 +10,7 @@ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m |  97·54  80
10
10
 
11
11
  ## What it shows
12
12
 
13
- - **Line 1** — active model ID, optionally the provider of that model, current directory basename and Git branch, current context usage/window, subscription usage headroom (see below), and any [custom items](#custom-items) you configure. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
13
+ - **Line 1** — active model ID, optionally its thinking level and provider, current directory basename and Git branch, current context usage/window, subscription usage headroom (see below), and any [custom items](#custom-items) you configure. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
14
14
  - **Worktree lines** — when the session works in or sends tool calls into linked worktrees, one line shows the same branch/dirty/behind state for each worktree plus its associated PR number.
15
15
  - **Final line** — the full Pi session ID.
16
16
 
@@ -23,7 +23,7 @@ Colors come from a selectable [theme](#themes), with context warning thresholds.
23
23
  - **Theme** — the color palette, cycled with Enter or Space. See [Themes](#themes).
24
24
  - **Cache celebration** — `off` or one of five badge animations, cycled with Enter or Space and previewed live in the statusline below. See [Animation styles](#animation-styles).
25
25
  - **Custom item list** — below the alias row: enables or disables each configured item, shows what it is currently doing, and **Add custom item…** hands the job to the agent. A **Custom items** `on`/`off` row for the whole segment appears once at least one item is configured. See [Custom items](#custom-items).
26
- - **Model**, **Provider**, **Directory & git**, **Context**, **Subscription usage**, **Worktree line**, **Session ID line** — `on`/`off`, cycled with Enter or Space. **Provider** is the only one that starts `off`; it shows the provider id exactly as Pi reports it, so a [pi-multi-login](../pi-multi-login) alias renders as `anthropic-team` and names the login actually spending — something a model id like `claude-opus-5` never carries. With no model, or a model reporting no provider, the segment is simply absent. Disabled segments are dropped from line 1 without leaving a stray ` | ` separator; hiding the worktree line also stops its `git`/`gh` polling, and hiding usage stops the usage poller. With every element off the footer collapses to a single blank row.
26
+ - **Model**, **Thinking level**, **Provider**, **Directory & git**, **Context**, **Subscription usage**, **Worktree line**, **Session ID line** — `on`/`off`, cycled with Enter or Space. **Thinking level** starts `off`; it renders the active level (`high`, `medium`, …, or `thinking off` — spelled out, as Pi's own footer does) between the model and the provider, repaints the moment the level changes (the cycle keybinding, Shift+Tab by default, `/model`, or an extension calling `pi.setThinkingLevel()`), and is absent for a model that cannot reason. **Provider** also starts `off`; it shows the provider id exactly as Pi reports it, so a [pi-multi-login](../pi-multi-login) alias renders as `anthropic-team` and names the login actually spending — something a model id like `claude-opus-5` never carries. With no model, or a model reporting no provider, the segment is simply absent. Disabled segments are dropped from line 1 without leaving a stray ` | ` separator; hiding the worktree line also stops its `git`/`gh` polling, and hiding usage stops the usage poller. With every element off the footer collapses to a single blank row.
27
27
  - **Worktree root** — the directory whose immediate children are tracked as session worktrees (default `~/repos/worktrees`). `~` and `$HOME` are expanded; a relative path is rejected and the previous value kept.
28
28
  - **Repo aliases** — short display names for repositories on the worktree line. Enter edits the selected `repo → alias` pair, `d` deletes it, and `Add alias…` creates one from a `repo=alias` line.
29
29
 
@@ -104,7 +104,9 @@ Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); o
104
104
 
105
105
  ## Custom items
106
106
 
107
- Everything above is built in. **Custom items** are the escape hatch: each one runs a shell command, and its output becomes a segment on line 1, after the usage meters and before the worktree line. This is how a personal metric — a self-hosted quota pool, a deploy status, an on-call flag — gets onto the statusline without being packaged for everybody else.
107
+ Everything above is built in. **Custom items** are the escape hatch: each one produces one line, and that line becomes a segment on line 1, after the usage meters and before the worktree line. This is how a personal metric — a self-hosted quota pool, a deploy status, an on-call flag — gets onto the statusline without being packaged for everybody else.
108
+
109
+ A value comes from one of two places: a **shell command** you configure (below), or a **function another extension registers** ([Providing an item from an extension](#providing-an-item-from-an-extension)). They share one scheduler, one timeout, one failure grace and one row in `/statusline`.
108
110
 
109
111
  The contract is deliberately [Claude Code's status line](https://docs.claude.com/en/docs/claude-code/statusline) contract: a command, JSON about the session on **stdin**, one line on **stdout**, ANSI colors passed through. A script written for Claude Code runs here mostly unchanged (see [differences](#differences-from-claude-codes-status-line)).
110
112
 
@@ -136,12 +138,72 @@ By hand, items live under `customItems` in `~/.pi/agent/statusline-settings.json
136
138
  | `refreshInterval` | no | Seconds between forced re-runs, on top of the event-driven ones. Omit for event-driven only. |
137
139
  | `timeout` | no | Seconds before the command is killed. Default `5`, capped at `30`. |
138
140
  | `enabled` | no | `false` hides the item and stops it running. This is the one field `/statusline` writes. |
139
- | `type` | no | Accepted for entries pasted from Claude Code, where it is `"command"`. Any other value is preserved but not run. |
141
+ | `type` | no | `"command"` (the default, and what an entry pasted from Claude Code carries) or `"extension"` for an item another extension provides. Any other value is preserved but not run. |
140
142
 
141
143
  Items render in configuration order, each as its own ` | `-separated segment.
142
144
 
143
145
  **The file is read when a session starts and whenever `/statusline` opens.** After editing it by hand, open `/statusline` and press Esc to load the change into the running session; nothing watches the file.
144
146
 
147
+ ### Providing an item from an extension
148
+
149
+ A command is the right escape hatch for a one-off script, and the wrong shape for anything that wants to be a package: the script has to be on `PATH` on every host, it is updated by whatever put it there rather than by pi, its config lives outside any package, and it costs a process spawn per refresh for a value that could be a function call.
150
+
151
+ So an extension can provide the value directly. pi-statusline emits one event carrying a `register` callback; nothing here imports a provider, and a provider imports nothing from here — the event name is the whole coupling.
152
+
153
+ ```ts
154
+ export default function myProvider(pi: ExtensionAPI): void {
155
+ pi.events?.on("pi-statusline:custom-items:request", (payload) => {
156
+ const register = (payload as { register?: unknown }).register as
157
+ | ((item: {
158
+ id: string;
159
+ run: (payload: Record<string, unknown>, signal: AbortSignal) => Promise<string | null>;
160
+ refreshInterval?: number;
161
+ timeoutMs?: number;
162
+ }) => boolean)
163
+ | undefined;
164
+ if (typeof register !== "function") return;
165
+
166
+ const accepted = register({
167
+ id: "quota",
168
+ refreshInterval: 60,
169
+ timeoutMs: 5_000,
170
+ run: async (_session, signal) => {
171
+ const response = await fetch("http://localhost:9000/quota", { signal });
172
+ if (!response.ok) throw new Error(`quota feed ${response.status}`);
173
+ const { remaining } = (await response.json()) as { remaining: number };
174
+ return remaining > 0 ? `\u001b[32m${remaining}%\u001b[0m` : null;
175
+ },
176
+ });
177
+ // Refused only for an unusable `id` or `run`. Nothing else reports it, so a
178
+ // provider that drops this sees a segment that simply never appears.
179
+ if (!accepted) throw new Error("pi-statusline refused the quota item");
180
+ });
181
+ }
182
+ ```
183
+
184
+ The contract is the command contract, minus the process:
185
+
186
+ - `payload` is **exactly the JSON a command gets on stdin**, already parsed, and built fresh for each run — a script ported into an extension reads the same fields.
187
+ - The return value is the segment: one line, [sanitised the same way](#what-the-command-should-print) and capped at 120 characters. `null`, `undefined` or `""` hides the item.
188
+ - **Throwing is failing.** The message is what `/statusline` shows, and it counts against the same three-failure grace as a non-zero exit.
189
+ - `signal` aborts at `timeoutMs` (default 5s, capped at 30s). A run that ignores it is not awaited past the deadline; its late value is dropped.
190
+ - `register` is idempotent on `id`: registering again replaces the function and cancels the run in flight, so a provider can re-register whenever its configuration changes.
191
+ - `register` **returns `false`** if the `id` is empty or `run` is not a function. It never throws — a throw here would be reported as *your* extension failing rather than as a registration pi-statusline refused — and a refused item has no row and no error anywhere, so the boolean is the only place a typo is visible.
192
+
193
+ **The event is emitted when a session starts and again every time `/statusline` opens**, in interactive mode only. A provider therefore does not have to load before pi-statusline — subscribe in your factory and the request will come.
194
+
195
+ There is no `unregister`. A registration lives as long as **pi-statusline's own** extension instance, not the provider's: re-registering the same `id` replaces it, and everything is discarded when pi-statusline reloads. A provider that is unloaded mid-session without pi-statusline reloading leaves its `run` behind, still being called on the refresh interval — in practice a `/reload` reloads both, so this is a note rather than a caveat.
196
+
197
+ The settings file still owns **order and enabled**:
198
+
199
+ | Entry in `statusline-settings.json` | What happens |
200
+ |---|---|
201
+ | none | the item is appended after your own, switched on |
202
+ | `{ "id": "quota", "type": "extension" }` | binds there: your position, your `enabled`, and any `refreshInterval`/`timeout` you name overrides the provider's |
203
+ | an entry with a `command` and the same `id` | **conflict.** Your command keeps running and the row reads `id also provided by an extension — remove one`; an extension never silently takes over a row you wrote |
204
+
205
+ Switching an extension's item off in `/statusline` writes `{ "id": "quota", "type": "extension", "enabled": false }` to the file — that entry is also how you give it a fixed position among your other items. Rows for these items are tagged `(extension)` in the submenu.
206
+
145
207
  ### When a command runs
146
208
 
147
209
  - at session start,
@@ -186,7 +248,7 @@ The **first line of stdout** becomes the segment. Anything after it is ignored
186
248
 
187
249
  Failures never reach the agent — the statusline is best-effort and stays silent. A failing item keeps its last good value for up to three consecutive failures, then drops it. That grace is deliberate in both directions: one blip (a laptop between networks) should not blank a working display, and a value that has quietly gone stale is worse than an empty slot, because the number stays plausible while describing a world that has moved on.
188
250
 
189
- To see what an item is doing, open `/statusline` → **Custom item list**. Each row shows its current value, or why there isn't one: `disabled`, `missing command`, `exit 3: …`, `timed out after 5s`, `empty output`, or `no value yet`. Enter toggles an item on or off; commands themselves are edited in the file.
251
+ To see what an item is doing, open `/statusline` → **Custom item list**. Each row shows its current value, or why there isn't one: `disabled`, `exit 3: …`, `timed out after 5s`, `empty output`, `no value yet`, or — for an entry with no command — `no command; waiting for an extension to register this id`. Enter toggles an item on or off; commands themselves are edited in the file.
190
252
 
191
253
  ### Keep it fast
192
254
 
package/custom.ts CHANGED
@@ -4,12 +4,20 @@ import { platform } from "node:process";
4
4
  /**
5
5
  * User-defined statusline segments, modelled on Claude Code's `statusLine`.
6
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).
7
+ * An item's value comes from one of two places. The original is a **shell
8
+ * command** that receives a JSON snapshot of the session on stdin and prints
9
+ * one line to stdout — deliberately Claude Code's contract, so an existing
10
+ * statusline script mostly ports over; the differences are that pi renders each
11
+ * item as one *segment* of line 1 rather than owning the whole row, and that
12
+ * the payload's usage numbers are remaining percentages (see the README).
13
+ *
14
+ * The second is a **function another extension registers** over `pi.events`,
15
+ * which gets the same payload as an object and returns the same one line. That
16
+ * exists because a command forces anything serious onto PATH, into a config
17
+ * file outside any package, and into a process spawn per refresh, for a value
18
+ * the host could compute in-process. Both kinds run through one scheduler here:
19
+ * there is no second code path for turn ends, intervals, overlap, timeouts or
20
+ * the failure grace.
13
21
  */
14
22
 
15
23
  /** How long a command may run before it is killed, when it names no timeout. */
@@ -31,9 +39,66 @@ export const EVENT_MIN_INTERVAL_MS = 1_000;
31
39
  * broken loses it.
32
40
  */
33
41
  export const FAILURE_GRACE = 3;
34
- /** Longest rendered value kept from a command, before the line is truncated. */
42
+ /** Longest rendered value kept from an item, before the line is truncated. */
35
43
  export const MAX_OUTPUT_WIDTH = 120;
36
44
 
45
+ /**
46
+ * The event pi-statusline emits to collect items from other extensions.
47
+ *
48
+ * This name is the entire coupling between the statusline and a provider:
49
+ * nothing here imports a provider, and a provider imports nothing from here.
50
+ * Changing the string is a breaking change for every provider in the wild.
51
+ */
52
+ export const CUSTOM_ITEMS_REQUEST_EVENT = "pi-statusline:custom-items:request";
53
+
54
+ /**
55
+ * A registered item's value function.
56
+ *
57
+ * `payload` is the object the command path serialises onto stdin, built fresh
58
+ * for each run. `signal` aborts at the item's timeout; a run that ignores it is
59
+ * simply not awaited past the deadline. Returning `null`/`undefined`/`""` hides
60
+ * the item, and throwing is a failure, counted exactly like a non-zero exit.
61
+ */
62
+ export type CustomItemRun = (
63
+ payload: Record<string, unknown>,
64
+ signal: AbortSignal,
65
+ ) => string | null | undefined | Promise<string | null | undefined>;
66
+
67
+ /** What a provider passes to `register`. */
68
+ export interface CustomItemRegistration {
69
+ /** Stable name, in the same namespace as a command item's `id`. */
70
+ id: string;
71
+ run: CustomItemRun;
72
+ /** Seconds between forced re-runs; the settings entry wins when it names one. */
73
+ refreshInterval?: number;
74
+ /** Milliseconds before the run is abandoned; capped at {@link MAX_TIMEOUT_MS}. */
75
+ timeoutMs?: number;
76
+ }
77
+
78
+ /** The payload of {@link CUSTOM_ITEMS_REQUEST_EVENT}. */
79
+ export interface CustomItemsRequest {
80
+ /**
81
+ * Returns whether the registration was accepted.
82
+ *
83
+ * A rejected one is dropped silently *here* on purpose — see
84
+ * {@link CustomItemsTracker.register} — so the boolean is the only signal a
85
+ * provider gets that its `id` or `run` was unusable. Ignoring it means a typo
86
+ * shows up as a segment that never appears, with nothing to read anywhere.
87
+ */
88
+ register(registration: CustomItemRegistration): boolean;
89
+ }
90
+
91
+ /** Shown for an entry that names no command and has no registration yet. */
92
+ export const UNBOUND_ERROR = "no command; waiting for an extension to register this id";
93
+ /**
94
+ * Shown when an id is claimed by both a command entry and a registration.
95
+ *
96
+ * The command keeps running: the file is the user's, and an extension must not
97
+ * be able to take over a row somebody wrote by hand. Saying so is the whole
98
+ * remedy — deleting either side resolves it.
99
+ */
100
+ export const COLLISION_ERROR = "id also provided by an extension \u2014 remove one";
101
+
37
102
  /**
38
103
  * One configured item.
39
104
  *
@@ -46,23 +111,45 @@ export const MAX_OUTPUT_WIDTH = 120;
46
111
  export interface CustomItem {
47
112
  id: string;
48
113
  enabled: boolean;
49
- /** Absent when the entry is not runnable; `error` then says why. */
114
+ /** Where the value comes from; drives the `(extension)` tag in the submenu. */
115
+ kind: "command" | "extension";
116
+ /** Absent for an extension item, or when the entry is not runnable. */
50
117
  command?: string;
118
+ /** Bound from a registration; absent until a provider claims this id. */
119
+ run?: CustomItemRun;
51
120
  /** Seconds between forced re-runs. Absent means event-driven only. */
52
121
  refreshInterval?: number;
53
122
  timeoutMs: number;
123
+ /**
124
+ * Whether `timeoutMs` came from the entry's own `timeout` rather than from a
125
+ * default. A registration may only supply the timeout the file left unsaid.
126
+ */
127
+ timeoutExplicit?: boolean;
54
128
  /** Why this entry cannot run, shown in the `/statusline` submenu. */
55
129
  error?: string;
56
- /** The on-disk entry, preserved for round-tripping. */
57
- source: unknown;
130
+ /**
131
+ * Set when the entry can never run as written — not an object, or a `type`
132
+ * this version does not implement. The submenu refuses to enable those, and
133
+ * only those: an entry still waiting for its provider is perfectly valid.
134
+ */
135
+ blocked?: boolean;
136
+ /** The on-disk entry, preserved for round-tripping. Absent when synthesised. */
137
+ source?: unknown;
58
138
  }
59
139
 
60
140
  function isPlainObject(value: unknown): value is Record<string, unknown> {
61
141
  return typeof value === "object" && value !== null && !Array.isArray(value);
62
142
  }
63
143
 
64
- /** Positive finite seconds, or undefined for anything unusable. */
65
- function positiveSeconds(value: unknown): number | undefined {
144
+ /**
145
+ * A positive finite number, or undefined for anything unusable.
146
+ *
147
+ * The same check serves both units: seconds off a settings entry and the
148
+ * milliseconds a registration names. Callers pass the validated result on
149
+ * rather than the raw input, so a future normalisation here cannot be
150
+ * silently bypassed by one of them.
151
+ */
152
+ function positiveNumber(value: unknown): number | undefined {
66
153
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
67
154
  return value;
68
155
  }
@@ -92,7 +179,15 @@ export function normalizeCustomItems(value: unknown): CustomItem[] {
92
179
  if (!isPlainObject(entry)) {
93
180
  const id = uniqueId(fallbackId, taken);
94
181
  taken.add(id);
95
- items.push({ id, enabled: false, timeoutMs: DEFAULT_TIMEOUT_MS, error: "not an object", source: entry });
182
+ items.push({
183
+ id,
184
+ enabled: false,
185
+ kind: "command",
186
+ timeoutMs: DEFAULT_TIMEOUT_MS,
187
+ error: "not an object",
188
+ blocked: true,
189
+ source: entry,
190
+ });
96
191
  return;
97
192
  }
98
193
  const rawId = entry.id;
@@ -100,26 +195,54 @@ export function normalizeCustomItems(value: unknown): CustomItem[] {
100
195
  taken.add(id);
101
196
  // `enabled` is the menu's field; everything else is the user's.
102
197
  const enabled = entry.enabled !== false;
103
- const timeoutSeconds = positiveSeconds(entry.timeout);
198
+ const timeoutSeconds = positiveNumber(entry.timeout);
104
199
  const timeoutMs = Math.min(
105
200
  timeoutSeconds === undefined ? DEFAULT_TIMEOUT_MS : timeoutSeconds * 1000,
106
201
  MAX_TIMEOUT_MS,
107
202
  );
108
- const refreshInterval = positiveSeconds(entry.refreshInterval);
109
- const base = { id, enabled, timeoutMs, source: entry, ...(refreshInterval ? { refreshInterval } : {}) };
203
+ const refreshInterval = positiveNumber(entry.refreshInterval);
204
+ const base = {
205
+ id,
206
+ enabled,
207
+ timeoutMs,
208
+ source: entry,
209
+ ...(timeoutSeconds === undefined ? {} : { timeoutExplicit: true }),
210
+ ...(refreshInterval ? { refreshInterval } : {}),
211
+ };
110
212
  // 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)}` });
213
+ // may too. `"extension"` is this package's own, and optional: an entry with
214
+ // no command is an extension slot whether or not it says so. Any other value
215
+ // is not a mistake this version can judge, so the entry is kept and flagged
216
+ // rather than run or dropped.
217
+ const hasCommand = typeof entry.command === "string" && entry.command.trim().length > 0;
218
+ const type = entry.type ?? (hasCommand ? "command" : "extension");
219
+ if (type !== "command" && type !== "extension") {
220
+ items.push({
221
+ ...base,
222
+ kind: "command",
223
+ enabled: false,
224
+ error: `unsupported type: ${String(type)}`,
225
+ blocked: true,
226
+ });
116
227
  return;
117
228
  }
118
- if (typeof entry.command !== "string" || entry.command.trim().length === 0) {
119
- items.push({ ...base, enabled: false, error: "missing command" });
229
+ if (!hasCommand) {
230
+ // Not an error yet: a provider may register this id later in the session,
231
+ // and the entry is what reserves its position and enabled state.
232
+ items.push({ ...base, kind: "extension", error: UNBOUND_ERROR });
120
233
  return;
121
234
  }
122
- items.push({ ...base, command: entry.command });
235
+ if (type === "extension") {
236
+ items.push({
237
+ ...base,
238
+ kind: "command",
239
+ enabled: false,
240
+ error: "an extension item must not name a command",
241
+ blocked: true,
242
+ });
243
+ return;
244
+ }
245
+ items.push({ ...base, kind: "command", command: entry.command as string });
123
246
  });
124
247
  return items;
125
248
  }
@@ -132,7 +255,9 @@ export function normalizeCustomItems(value: unknown): CustomItem[] {
132
255
  * as the user wrote it rather than accumulating defaults.
133
256
  */
134
257
  export function serializeCustomItems(items: readonly CustomItem[]): unknown[] {
135
- return items.map((item) => {
258
+ // A registration with no entry of its own has no on-disk form until the user
259
+ // toggles it, which is what creates the entry; it must never be written here.
260
+ return items.filter((item) => item.source !== undefined).map((item) => {
136
261
  if (!isPlainObject(item.source)) return item.source;
137
262
  const entry = { ...item.source };
138
263
  if (item.enabled) delete entry.enabled;
@@ -197,10 +322,20 @@ export function sanitizeOutput(raw: string): string {
197
322
  export interface CustomItemState {
198
323
  id: string;
199
324
  enabled: boolean;
200
- /** Sanitized first line of stdout; absent when there is nothing to show. */
325
+ /** Where the value comes from, for the submenu's `(extension)` tag. */
326
+ kind: "command" | "extension";
327
+ /** Sanitized first line of output; absent when there is nothing to show. */
201
328
  value?: string;
202
329
  /** Configuration or run failure, whichever applies. */
203
330
  error?: string;
331
+ /**
332
+ * True when `error` describes the entry itself rather than its last run. A
333
+ * configuration problem outranks "disabled" in the submenu, because the item
334
+ * is off *because* of it; a run failure does not.
335
+ */
336
+ configError?: boolean;
337
+ /** True when the entry can never run as written, so it must not be enabled. */
338
+ blocked?: boolean;
204
339
  /** When the value was produced, as epoch ms. */
205
340
  updatedAt?: number;
206
341
  running: boolean;
@@ -212,6 +347,12 @@ export interface CustomItemsTrackerOptions {
212
347
  spawn?: SpawnFn;
213
348
  now?: () => number;
214
349
  onChange?: () => void;
350
+ /**
351
+ * Called after a registration is accepted. The tracker cannot know whether
352
+ * the footer is live or whether items are switched on, so the extension does
353
+ * the timer and refresh work and this is the notification that it is needed.
354
+ */
355
+ onRegister?: () => void;
215
356
  cwd?: string;
216
357
  schedule?: (callback: () => void, intervalMs: number) => unknown;
217
358
  cancel?: (handle: unknown) => void;
@@ -248,11 +389,17 @@ const TICK_FLOOR_MS = 1_000;
248
389
  * lower refresh rate instead of a pile of processes.
249
390
  */
250
391
  export class CustomItemsTracker {
392
+ /** Entries exactly as configured on disk. */
393
+ private configured: CustomItem[] = [];
394
+ /** Registrations by id, in the order providers claimed them. */
395
+ private readonly registrations = new Map<string, CustomItemRegistration>();
396
+ /** The two merged: what actually renders and runs. */
251
397
  private items: CustomItem[] = [];
252
398
  private readonly records = new Map<string, RunRecord>();
253
399
  private readonly spawnFn: SpawnFn;
254
400
  private readonly now: () => number;
255
401
  private readonly onChange?: () => void;
402
+ private readonly onRegister?: () => void;
256
403
  private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
257
404
  private readonly cancel: (handle: unknown) => void;
258
405
  private cwd: string | undefined;
@@ -264,6 +411,7 @@ export class CustomItemsTracker {
264
411
  this.spawnFn = options.spawn ?? nodeSpawn;
265
412
  this.now = options.now ?? Date.now;
266
413
  this.onChange = options.onChange;
414
+ this.onRegister = options.onRegister;
267
415
  this.cwd = options.cwd;
268
416
  this.schedule = options.schedule ?? defaultSchedule;
269
417
  this.cancel = options.cancel ?? defaultCancel;
@@ -277,7 +425,83 @@ export class CustomItemsTracker {
277
425
  * does not blink on every settings save.
278
426
  */
279
427
  setItems(items: readonly CustomItem[]): void {
280
- this.items = [...items];
428
+ this.configured = [...items];
429
+ this.recompute();
430
+ }
431
+
432
+ /**
433
+ * Adopt an item provided by another extension.
434
+ *
435
+ * Idempotent on id, and it reports rather than throws: this runs inside a
436
+ * provider's event handler, where a throw would surface as *that* extension
437
+ * failing rather than as a registration this one refused. The return value is
438
+ * how a provider learns its `id` or `run` was unusable, since nothing else
439
+ * here can name an item that never got far enough to have a row.
440
+ */
441
+ register(registration: CustomItemRegistration): boolean {
442
+ const id = typeof registration?.id === "string" ? registration.id.trim() : "";
443
+ if (id.length === 0 || typeof registration.run !== "function") return false;
444
+ // A re-registration replaces the function, so whatever the old one is doing
445
+ // is already obsolete; its record keeps the last good value so the footer
446
+ // does not blink while the new one produces its first.
447
+ if (this.registrations.has(id)) this.records.get(id)?.abort?.();
448
+ const refreshInterval = positiveNumber(registration.refreshInterval);
449
+ const timeoutMs = positiveNumber(registration.timeoutMs);
450
+ this.registrations.set(id, {
451
+ id,
452
+ run: registration.run,
453
+ ...(refreshInterval === undefined ? {} : { refreshInterval }),
454
+ ...(timeoutMs === undefined ? {} : { timeoutMs: Math.min(timeoutMs, MAX_TIMEOUT_MS) }),
455
+ });
456
+ this.recompute();
457
+ this.onRegister?.();
458
+ return true;
459
+ }
460
+
461
+ /**
462
+ * Merge configured entries with registrations.
463
+ *
464
+ * The file owns order and enabled; a registration fills in the value function
465
+ * and any field the file left unsaid. An id in both a command entry and a
466
+ * registration is a conflict the user has to resolve, so it is shown rather
467
+ * than decided silently — and the command, being the thing they wrote, wins.
468
+ */
469
+ private recompute(): void {
470
+ const configuredIds = new Set(this.configured.map((item) => item.id));
471
+ const items = this.configured.map((item) => {
472
+ const registration = this.registrations.get(item.id);
473
+ if (item.blocked === true) return item;
474
+ if (item.command !== undefined) {
475
+ return registration === undefined ? item : { ...item, error: COLLISION_ERROR };
476
+ }
477
+ if (registration === undefined) return item;
478
+ const { error: _unbound, ...bound } = item;
479
+ // The entry's own interval wins; the registration supplies the one it
480
+ // left unsaid. Parenthesised because `??` binds tighter than `?:` — the
481
+ // grouping is load-bearing and easy to "fix" wrongly.
482
+ const refreshInterval = item.refreshInterval ?? registration.refreshInterval;
483
+ return {
484
+ ...bound,
485
+ kind: "extension" as const,
486
+ run: registration.run,
487
+ ...(refreshInterval === undefined ? {} : { refreshInterval }),
488
+ timeoutMs: item.timeoutExplicit ? item.timeoutMs : (registration.timeoutMs ?? DEFAULT_TIMEOUT_MS),
489
+ };
490
+ });
491
+ for (const [id, registration] of this.registrations) {
492
+ if (configuredIds.has(id)) continue;
493
+ // No entry claims this id, so it is appended, switched on, and has no
494
+ // `source`: nothing is written to the settings file until it is toggled.
495
+ items.push({
496
+ id,
497
+ enabled: true,
498
+ kind: "extension",
499
+ run: registration.run,
500
+ ...(registration.refreshInterval ? { refreshInterval: registration.refreshInterval } : {}),
501
+ timeoutMs: registration.timeoutMs ?? DEFAULT_TIMEOUT_MS,
502
+ });
503
+ }
504
+ this.items = items;
281
505
  const live = new Set(items.map((item) => item.id));
282
506
  for (const [id, record] of this.records) {
283
507
  if (live.has(id)) continue;
@@ -309,9 +533,11 @@ export class CustomItemsTracker {
309
533
  return {
310
534
  id: item.id,
311
535
  enabled: item.enabled,
536
+ kind: item.kind,
537
+ ...(item.blocked === true ? { blocked: true } : {}),
312
538
  ...(record?.value !== undefined ? { value: record.value } : {}),
313
539
  ...(item.error !== undefined
314
- ? { error: item.error }
540
+ ? { error: item.error, configError: true }
315
541
  : record?.error !== undefined
316
542
  ? { error: record.error }
317
543
  : {}),
@@ -346,7 +572,13 @@ export class CustomItemsTracker {
346
572
  this.tickHandle = undefined;
347
573
  }
348
574
 
349
- /** Stop everything and abandon in-flight commands. */
575
+ /**
576
+ * Stop everything and abandon in-flight runs.
577
+ *
578
+ * Registrations survive: this also fires when the footer is torn down, and a
579
+ * provider has no way to hear about that to register again. They die with the
580
+ * extension instance instead, which is when the next request event is sent.
581
+ */
350
582
  dispose(): void {
351
583
  this.stop();
352
584
  for (const record of this.records.values()) record.abort?.();
@@ -367,7 +599,8 @@ export class CustomItemsTracker {
367
599
  refresh(): void {
368
600
  const now = this.now();
369
601
  for (const item of this.items) {
370
- if (!item.enabled || item.command === undefined) continue;
602
+ if (!item.enabled) continue;
603
+ if (item.command === undefined && item.run === undefined) continue;
371
604
  const record = this.records.get(item.id);
372
605
  if (record?.running) continue;
373
606
  const minimum =
@@ -388,6 +621,67 @@ export class CustomItemsTracker {
388
621
  }
389
622
 
390
623
  private run(item: CustomItem): void {
624
+ if (item.run !== undefined) {
625
+ this.runRegistered(item, item.run);
626
+ return;
627
+ }
628
+ this.runCommand(item);
629
+ }
630
+
631
+ /** How long an item may run, rendered the way both paths report a timeout. */
632
+ private timeoutLabel(item: CustomItem): string {
633
+ return `timed out after ${Math.round(item.timeoutMs / 100) / 10}s`;
634
+ }
635
+
636
+ /**
637
+ * Run a registered function under the command path's guarantees.
638
+ *
639
+ * The deadline is the tracker's, not the provider's: `signal` asks it to stop
640
+ * and the outcome is settled regardless, so a provider that ignores the
641
+ * signal costs a leaked promise rather than a stuck segment.
642
+ */
643
+ private runRegistered(item: CustomItem, run: CustomItemRun): void {
644
+ const record = this.record(item.id);
645
+ record.lastAttempt = this.now();
646
+ record.running = true;
647
+
648
+ const controller = new AbortController();
649
+ let settled = false;
650
+ const finish = (outcome: { value?: string; error?: string }): void => {
651
+ if (settled) return;
652
+ settled = true;
653
+ clearTimeout(timer);
654
+ record.abort = undefined;
655
+ this.settle(item, record, outcome);
656
+ };
657
+
658
+ const timer = setTimeout(() => {
659
+ controller.abort();
660
+ finish({ error: this.timeoutLabel(item) });
661
+ }, item.timeoutMs);
662
+ timer.unref?.();
663
+
664
+ record.abort = () => {
665
+ clearTimeout(timer);
666
+ settled = true;
667
+ record.running = false;
668
+ controller.abort();
669
+ };
670
+
671
+ let result: ReturnType<CustomItemRun>;
672
+ try {
673
+ result = run(this.payloadFactory(), controller.signal);
674
+ } catch (error) {
675
+ finish({ error: error instanceof Error ? error.message : String(error) });
676
+ return;
677
+ }
678
+ Promise.resolve(result).then(
679
+ (value) => finish({ value: value === null || value === undefined ? "" : sanitizeOutput(String(value)) }),
680
+ (error: unknown) => finish({ error: error instanceof Error ? error.message : String(error) }),
681
+ );
682
+ }
683
+
684
+ private runCommand(item: CustomItem): void {
391
685
  const command = item.command;
392
686
  if (command === undefined) return;
393
687
  const record = this.record(item.id);
@@ -426,7 +720,7 @@ export class CustomItemsTracker {
426
720
  child.kill("SIGTERM");
427
721
  // A command ignoring SIGTERM must not outlive the session either.
428
722
  setTimeout(() => child.kill("SIGKILL"), 500).unref?.();
429
- finish({ error: `timed out after ${Math.round(item.timeoutMs / 100) / 10}s` });
723
+ finish({ error: this.timeoutLabel(item) });
430
724
  }, item.timeoutMs);
431
725
  timer.unref?.();
432
726
 
package/index.ts CHANGED
@@ -14,7 +14,12 @@ 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";
17
+ import {
18
+ CUSTOM_ITEMS_REQUEST_EVENT,
19
+ type CustomItemRegistration,
20
+ type CustomItemsRequest,
21
+ CustomItemsTracker,
22
+ } from "./custom.ts";
18
23
  import { buildCustomItemSetupPrompt } from "./custom-setup.ts";
19
24
  import { FullRedrawScheduler } from "./redraw.ts";
20
25
  import {
@@ -44,6 +49,8 @@ import {
44
49
 
45
50
  export interface StatuslineData {
46
51
  model: string;
52
+ /** Thinking level of the active model; absent when the model cannot reason. */
53
+ thinkingLevel?: string;
47
54
  /** Provider id of the active model; absent when there is no model. */
48
55
  provider?: string;
49
56
  cwd: string;
@@ -189,6 +196,12 @@ export function renderStatusline(
189
196
  const customSegments = settings.showCustomItems ? (data.customValues ?? []).filter((value) => value.length > 0) : [];
190
197
  const segments = [
191
198
  settings.showModel ? styled(palette.model, data.model) : undefined,
199
+ // A non-reasoning model has no level to show, so the segment goes with it.
200
+ // "off" on a reasoning model is a real state and reads as pi's own footer
201
+ // spells it, because a bare "off" between two ids means nothing.
202
+ settings.showThinking && data.thinkingLevel
203
+ ? styled(palette.thinking, data.thinkingLevel === "off" ? "thinking off" : data.thinkingLevel)
204
+ : undefined,
192
205
  // No provider is a missing segment, not a placeholder: the model id already
193
206
  // says "no-model" in that state, and a second one would only add noise.
194
207
  settings.showProvider && data.provider ? styled(palette.provider, data.provider) : undefined,
@@ -238,7 +251,34 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
238
251
  const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
239
252
  let tracker: SessionWorktreeTracker | undefined;
240
253
  const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
241
- const customItems = new CustomItemsTracker({ onChange: () => requestRender?.() });
254
+ // One request event is answered by every provider at once, so `onRegister`
255
+ // fires once per item while the work it guards — sizing the timer, refreshing,
256
+ // redrawing — is per-tracker. Coalescing to a single microtask makes N
257
+ // providers cost one restart and one render instead of N, and the registrations
258
+ // are all in place by the time it runs, so the timer is sized once from the
259
+ // complete set rather than N times from a growing one.
260
+ let registrationSettlePending = false;
261
+ const customItems = new CustomItemsTracker({
262
+ onChange: () => requestRender?.(),
263
+ // A registration can arrive at any point in the session, including after
264
+ // the timer was sized from the items that existed without it.
265
+ onRegister: () => {
266
+ if (registrationSettlePending) return;
267
+ registrationSettlePending = true;
268
+ queueMicrotask(() => {
269
+ registrationSettlePending = false;
270
+ if (settings.showCustomItems) {
271
+ // restartTimer alone is a no-op when no item had asked for a timer
272
+ // yet, which is exactly the case a late registration creates; start
273
+ // covers that branch and is idempotent when the timer is already up.
274
+ customItems.restartTimer();
275
+ customItems.start();
276
+ customItems.refresh();
277
+ }
278
+ requestRender?.();
279
+ });
280
+ },
281
+ });
242
282
  let cwdGit: GitRepositoryStatus | null = null;
243
283
  let cwdStatusAbort: AbortController | undefined;
244
284
  let cwdStatusInFlight: Promise<void> | undefined;
@@ -252,6 +292,26 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
252
292
  });
253
293
  };
254
294
 
295
+ /**
296
+ * Ask every other extension for the items it wants on the statusline.
297
+ *
298
+ * Sent at session start rather than from this factory, because extensions
299
+ * load in sequence: a factory-time emit would only ever reach providers that
300
+ * happen to be configured *before* this package. By session start they have
301
+ * all run, and the event fires again whenever `/statusline` opens so that a
302
+ * provider which starts listening mid-session is still picked up.
303
+ *
304
+ * Interactive only. Custom items exist to fill footer segments, and a print
305
+ * or RPC run has no footer, so asking would buy nothing and cost providers a
306
+ * poll loop against a payload this extension never installs.
307
+ */
308
+ const requestCustomItems = (): void => {
309
+ const request: CustomItemsRequest = {
310
+ register: (registration: CustomItemRegistration) => customItems.register(registration),
311
+ };
312
+ pi.events?.emit(CUSTOM_ITEMS_REQUEST_EVENT, request);
313
+ };
314
+
255
315
  const refreshCwdStatus = (ctx: ExtensionContext): Promise<void> => {
256
316
  if (!cwdStatusAbort) return Promise.resolve();
257
317
  if (cwdStatusInFlight) return cwdStatusInFlight.then(() => refreshCwdStatus(ctx));
@@ -410,6 +470,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
410
470
  // starts from what is on disk rather than a snapshot that may be hours
411
471
  // old and missing another session's changes.
412
472
  applySettings(ctx, await settingsStore.load(), false);
473
+ // Same reason the file is re-read: the menu should show what is true now,
474
+ // including an item from a provider that loaded after the session began.
475
+ requestCustomItems();
413
476
 
414
477
  await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
415
478
  const tracked = trackSelectedLabel(getSettingsListTheme());
@@ -442,6 +505,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
442
505
  customItems: createCustomItemsSubmenu(submenuHost),
443
506
  },
444
507
  home,
508
+ customItems.states(),
445
509
  ),
446
510
  10,
447
511
  settingsTheme,
@@ -517,6 +581,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
517
581
  const usage = ctx.getContextUsage();
518
582
  const cwd = basename(ctx.cwd) || ctx.cwd;
519
583
  const model = ctx.model?.id.split("/").pop() || "no-model";
584
+ // ctx.thinkingLevel is a live getter on the session; the pi accessor is
585
+ // the same value for hosts whose context does not expose it.
586
+ const thinkingLevel = ctx.model?.reasoning ? (ctx.thinkingLevel ?? pi.getThinkingLevel()) : undefined;
520
587
  // Commands size themselves with COLUMNS, so the tracker needs the
521
588
  // width the footer is actually being drawn at.
522
589
  customItems.setContext({ columns: width });
@@ -525,6 +592,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
525
592
  renderStatusline(
526
593
  {
527
594
  model,
595
+ thinkingLevel,
528
596
  provider: ctx.model?.provider,
529
597
  cwd,
530
598
  cwdGit,
@@ -545,6 +613,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
545
613
  });
546
614
 
547
615
  resetTracker(ctx);
616
+ // Last, so a provider that registers synchronously runs against the payload
617
+ // factory resetTracker just installed rather than an empty one.
618
+ requestCustomItems();
548
619
  });
549
620
 
550
621
  pi.on("tool_call", (event) => {
@@ -575,6 +646,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
575
646
  }
576
647
  requestRender?.();
577
648
  });
649
+ // The cycle keybinding (Shift+Tab by default) changes the level without a
650
+ // turn or a model change, so nothing else would repaint the segment.
651
+ pi.on("thinking_level_select", () => requestRender?.());
578
652
  pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
579
653
  pi.on("session_shutdown", () => {
580
654
  cacheCelebration.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": [
package/settings-menu.ts CHANGED
@@ -10,7 +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
+ import { type CustomItem, type CustomItemState, DEFAULT_TIMEOUT_MS } from "./custom.ts";
14
14
  import { CELEBRATION_STYLE_NAMES, isCelebrationStyleName } from "./celebration-styles.ts";
15
15
  import { isThemeName, THEME_NAMES } from "./themes.ts";
16
16
 
@@ -35,6 +35,7 @@ interface BooleanRow {
35
35
  /** Toggle rows, in statusline render order. */
36
36
  export const BOOLEAN_ROWS: readonly BooleanRow[] = [
37
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." },
38
39
  { id: "showProvider", label: "Provider", description: "Show the provider of the active model." },
39
40
  { id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
40
41
  { id: "showContext", label: "Context", description: "Show context tokens used against the window." },
@@ -61,11 +62,17 @@ export function aliasSummary(settings: StatuslineSettings): string {
61
62
  return `${count} alias${count === 1 ? "" : "es"}`;
62
63
  }
63
64
 
64
- /** Row value for the custom-items submenu: how many items are switched on. */
65
- export function customItemsSummary(settings: StatuslineSettings): string {
66
- const total = settings.customItems.length;
65
+ /**
66
+ * Row value for the custom-items submenu: how many items are switched on.
67
+ *
68
+ * Counted from the tracker's states, not from the settings file: an item an
69
+ * extension provides is as real as one the file names, and it may have no entry
70
+ * of its own until somebody toggles it.
71
+ */
72
+ export function customItemsSummary(states: readonly CustomItemState[]): string {
73
+ const total = states.length;
67
74
  if (total === 0) return "none configured";
68
- return `${settings.customItems.filter((item) => item.enabled).length}/${total} on`;
75
+ return `${states.filter((state) => state.enabled).length}/${total} on`;
69
76
  }
70
77
 
71
78
  /**
@@ -74,12 +81,9 @@ export function customItemsSummary(settings: StatuslineSettings): string {
74
81
  * than opening a form: a statusline command is a script plus a JSON entry plus
75
82
  * a test run, which is a conversation, not a field.
76
83
  */
77
- export function customItemRows(
78
- settings: StatuslineSettings,
79
- states: readonly CustomItemState[] = [],
80
- ): SelectItem[] {
84
+ export function customItemRows(states: readonly CustomItemState[] = []): SelectItem[] {
81
85
  return [
82
- ...customItemStateRows(settings, states),
86
+ ...customItemStateRows(states),
83
87
  {
84
88
  value: ADD_CUSTOM_ITEM_VALUE,
85
89
  label: "Add custom item…",
@@ -88,31 +92,30 @@ export function customItemRows(
88
92
  ];
89
93
  }
90
94
 
91
- function customItemStateRows(settings: StatuslineSettings, states: readonly CustomItemState[]): SelectItem[] {
92
- const byId = new Map(states.map((state) => [state.id, state]));
93
- return settings.customItems.map((item) => {
94
- const state = byId.get(item.id);
95
- // Configuration errors outrank run errors: an item that cannot be parsed
96
- // never ran, so a stale run error from a previous config would mislead.
97
- const error = item.error ?? state?.error;
98
- // A broken entry reads as "disabled" unless its reason wins here: it is off
99
- // *because* it cannot run, and "disabled" would suggest the user chose that.
100
- const detail = item.error !== undefined
101
- ? item.error
102
- : !item.enabled
95
+ function customItemStateRows(states: readonly CustomItemState[]): SelectItem[] {
96
+ return states.map((state) => {
97
+ // A broken or unbound entry reads as "disabled" unless its reason wins
98
+ // here: it is off *because* of that, and "disabled" would suggest the user
99
+ // chose it. A run failure is the other way round — an item switched off is
100
+ // off first, and whatever its last run said is history.
101
+ const detail = state.configError === true
102
+ ? (state.error as string)
103
+ : !state.enabled
103
104
  ? "disabled"
104
- : error !== undefined
105
- ? error
106
- : state?.running === true && state.value === undefined
105
+ : state.error !== undefined
106
+ ? state.error
107
+ : state.running && state.value === undefined
107
108
  ? "running…"
108
- : state?.value !== undefined && state.value.length > 0
109
+ : state.value !== undefined && state.value.length > 0
109
110
  ? state.value
110
- : state?.value !== undefined
111
+ : state.value !== undefined
111
112
  ? "empty output"
112
113
  : "no value yet";
113
114
  return {
114
- value: item.id,
115
- label: `${item.enabled ? toggleValue(true) : toggleValue(false)} ${item.id}`,
115
+ value: state.id,
116
+ // The tag says where the value comes from, which is the one thing a row
117
+ // cannot otherwise show: an extension item has no command to point at.
118
+ label: `${toggleValue(state.enabled)} ${state.id}${state.kind === "extension" ? " (extension)" : ""}`,
116
119
  description: detail,
117
120
  };
118
121
  });
@@ -129,6 +132,7 @@ export function buildSettingItems(
129
132
  settings: StatuslineSettings,
130
133
  submenus: SettingSubmenus = {},
131
134
  home: string = homedir(),
135
+ customStates: readonly CustomItemState[] = [],
132
136
  ): SettingItem[] {
133
137
  const items: SettingItem[] = [
134
138
  {
@@ -145,8 +149,9 @@ export function buildSettingItems(
145
149
  for (const row of BOOLEAN_ROWS) {
146
150
  // A toggle for a segment with nothing in it is a row that does nothing;
147
151
  // the list row below is where an item gets created, and the toggle
148
- // appears once there is something to switch off.
149
- if (row.id === "showCustomItems" && settings.customItems.length === 0) continue;
152
+ // appears once there is something to switch off — including something an
153
+ // extension provided, which never appears in the settings file.
154
+ if (row.id === "showCustomItems" && customStates.length === 0) continue;
150
155
  items.push({
151
156
  id: row.id,
152
157
  label: row.label,
@@ -182,8 +187,8 @@ export function buildSettingItems(
182
187
  items.push({
183
188
  id: CUSTOM_ITEMS_ID,
184
189
  label: "Custom item list",
185
- description: "Enable or disable configured items; edit commands in statusline-settings.json.",
186
- currentValue: customItemsSummary(settings),
190
+ description: "Enable or disable items, your own and extensions'; edit commands in statusline-settings.json.",
191
+ currentValue: customItemsSummary(customStates),
187
192
  ...(submenus.customItems ? { submenu: submenus.customItems } : {}),
188
193
  });
189
194
 
@@ -448,11 +453,15 @@ class CustomItemsSubmenu implements Component {
448
453
  this.list = this.buildList();
449
454
  }
450
455
 
456
+ private states(): readonly CustomItemState[] {
457
+ return this.host.customItemStates?.() ?? [];
458
+ }
459
+
451
460
  private buildList(selectedIndex = 0): SelectList {
452
- const rows = customItemRows(this.host.getSettings(), this.host.customItemStates?.() ?? []);
461
+ const rows = customItemRows(this.states());
453
462
  const list = new SelectList(rows, 10, this.host.selectTheme);
454
463
  list.setSelectedIndex(selectedIndex);
455
- list.onCancel = () => this.done(customItemsSummary(this.host.getSettings()));
464
+ list.onCancel = () => this.done(customItemsSummary(this.states()));
456
465
  list.onSelect = (item) => (item.value === ADD_CUSTOM_ITEM_VALUE ? this.add() : this.toggle(item.value));
457
466
  return list;
458
467
  }
@@ -470,7 +479,7 @@ class CustomItemsSubmenu implements Component {
470
479
  this.prompt = undefined;
471
480
  // Close the whole menu before the message lands: the agent's reply
472
481
  // renders in the transcript, which the menu is drawn over.
473
- this.done(customItemsSummary(this.host.getSettings()));
482
+ this.done(customItemsSummary(this.states()));
474
483
  this.host.requestCustomItem?.(value);
475
484
  },
476
485
  () => {
@@ -484,18 +493,34 @@ class CustomItemsSubmenu implements Component {
484
493
  private toggle(id: string): void {
485
494
  if (id.startsWith("\u0000")) return;
486
495
  const settings = this.host.getSettings();
487
- const index = settings.customItems.findIndex((item) => item.id === id);
488
- const target = settings.customItems[index];
489
- if (!target) return;
490
- if (target.error !== undefined && !target.enabled) {
496
+ const state = this.states().find((candidate) => candidate.id === id);
497
+ if (!state) return;
498
+ if (state.blocked === true && !state.enabled) {
491
499
  // Enabling an unparseable entry would only fail again on the next tick.
492
- this.host.notify(`${id} cannot run: ${target.error}`);
500
+ this.host.notify(`${id} cannot run: ${state.error ?? "invalid entry"}`);
493
501
  return;
494
502
  }
503
+ const index = settings.customItems.findIndex((item) => item.id === id);
504
+ const target = settings.customItems[index];
495
505
  const customItems = [...settings.customItems];
496
- customItems[index] = { ...target, enabled: !target.enabled };
506
+ if (target) customItems[index] = { ...target, enabled: !target.enabled };
507
+ else {
508
+ // An item an extension provided has no entry until it is switched off,
509
+ // and that entry is also what gives it a position the user controls.
510
+ const entry: CustomItem = {
511
+ id,
512
+ enabled: false,
513
+ kind: "extension",
514
+ timeoutMs: DEFAULT_TIMEOUT_MS,
515
+ source: { id, type: "extension", enabled: false },
516
+ };
517
+ customItems.push(entry);
518
+ }
497
519
  this.host.commit({ ...settings, customItems });
498
- this.list = this.buildList(index);
520
+ // Re-read the states rather than reusing the old index: writing an entry
521
+ // for a registration can move it out of the appended tail and into order.
522
+ const selected = this.states().findIndex((candidate) => candidate.id === id);
523
+ this.list = this.buildList(Math.max(0, selected));
499
524
  this.host.requestRender();
500
525
  }
501
526
 
package/settings.ts CHANGED
@@ -19,6 +19,7 @@ import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.t
19
19
  /** Toggle keys, in the order the `/statusline` menu lists them. */
20
20
  export const BOOLEAN_SETTING_KEYS = [
21
21
  "showModel",
22
+ "showThinking",
22
23
  "showProvider",
23
24
  "showDirectory",
24
25
  "showContext",
@@ -51,6 +52,9 @@ function defaultWorktreeRoot(home: string = homedir()): string {
51
52
  export function defaultSettings(home: string = homedir()): StatuslineSettings {
52
53
  return {
53
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,
54
58
  // Opt-in: the provider is redundant for anyone with a single login per family.
55
59
  showProvider: false,
56
60
  showDirectory: true,
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"),