@hank-warren/pi-statusline 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1035138: Add custom items: user-defined command segments rendered after the usage meters.
8
+
9
+ Each entry in the new `customItems` setting runs a shell command, hands it JSON
10
+ about the session on stdin, and renders the first line of its stdout as a
11
+ statusline segment — the same contract Claude Code's `statusLine` uses, so a
12
+ script written for it ports over. Items refresh at session start, at each turn
13
+ end, and on an optional `refreshInterval` timer; one run per item at a time,
14
+ with a timeout, a failure grace period before a stale value is dropped, and
15
+ output sanitized to SGR colors so a command cannot corrupt the frame.
16
+
17
+ `/statusline` gains a **Custom items** toggle and a **Custom item list** submenu
18
+ that enables or disables each item and shows why one is not rendering. Commands
19
+ stay in the settings file, and entries this version cannot parse — an unknown
20
+ `type`, an unrecognised key — are preserved verbatim on write rather than
21
+ dropped, so toggling an unrelated setting can never delete a configured item.
22
+
3
23
  ## 0.7.3
4
24
 
5
25
  ### Patch 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, and subscription usage headroom (see below). 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 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.
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
 
@@ -22,6 +22,7 @@ Colors come from a selectable [theme](#themes), with context warning thresholds.
22
22
 
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
+ - **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).
25
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
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.
27
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.
@@ -101,6 +102,166 @@ An account answering `429` is parked for fifteen minutes (tracked per account, s
101
102
 
102
103
  Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
103
104
 
105
+ ## Custom items
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.
108
+
109
+ 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
+
111
+ There is no default item, and with an empty list the feature costs nothing: no process is spawned and no timer runs.
112
+
113
+ ### Adding one
114
+
115
+ The quickest way is `/statusline` → **Custom item list** → **Add custom item…**. Type one line describing what the item should show (or press Enter and let the agent ask), and the menu closes and sends the agent a message carrying the whole contract below plus the settings path. The agent writes the script, tests it the way the statusline will run it, adds the entry, and tells you to reopen `/statusline`. That message is the only place the contract is injected — it costs nothing until you ask for an item, which is why this package ships no skill for it.
116
+
117
+ By hand, items live under `customItems` in `~/.pi/agent/statusline-settings.json`. The file is not created for you until a setting is changed, so write it if it is absent:
118
+
119
+ ```json
120
+ {
121
+ "customItems": [
122
+ {
123
+ "id": "cpa",
124
+ "command": "~/bin/cpa-quota --statusline",
125
+ "refreshInterval": 60,
126
+ "timeout": 5
127
+ }
128
+ ]
129
+ }
130
+ ```
131
+
132
+ | Field | Required | Meaning |
133
+ |---|---|---|
134
+ | `command` | yes | Shell command line. Run through `sh -c` (`cmd /d /s /c` on Windows), so pipes, `$VARS`, and `~` work. |
135
+ | `id` | no | Stable name, used by the `/statusline` submenu and in error messages. Defaults to `item-1`, `item-2`, …; duplicates get a `#2` suffix. |
136
+ | `refreshInterval` | no | Seconds between forced re-runs, on top of the event-driven ones. Omit for event-driven only. |
137
+ | `timeout` | no | Seconds before the command is killed. Default `5`, capped at `30`. |
138
+ | `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. |
140
+
141
+ Items render in configuration order, each as its own ` | `-separated segment.
142
+
143
+ **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
+
145
+ ### When a command runs
146
+
147
+ - at session start,
148
+ - at the end of every turn,
149
+ - every `refreshInterval` seconds, if set.
150
+
151
+ A run is skipped while that item's previous run is still going, so a slow command degrades to a lower refresh rate instead of piling up processes. Two runs of the same item are never closer than one second, whatever triggers them. The timer only exists if some enabled item asked for one, ticks at the shortest interval among them, and is `unref`ed and stopped with the footer — turning **Custom items** off in `/statusline` stops all of it.
152
+
153
+ Use `refreshInterval` for anything whose value moves on a wall clock rather than on your turns: a quota pool refills while you are reading a diff, and an idle session would otherwise show the number from your last turn.
154
+
155
+ ### What the command receives
156
+
157
+ One JSON object on stdin, and `COLUMNS` in the environment (the footer's current width, exactly as Claude Code provides it):
158
+
159
+ ```json
160
+ {
161
+ "version": 1,
162
+ "session_id": "019fafa7-29c0-7e99-9f82-5794d5721848",
163
+ "cwd": "/home/hank/repos/pi-extensions",
164
+ "model": { "id": "gpt-5.6-sol", "provider": "openai-codex" },
165
+ "git": { "branch": "main", "dirty": false, "behind": 0 },
166
+ "context_window": { "used_tokens": 40000, "context_window_size": 1000000, "used_percentage": 4 },
167
+ "usage_remaining": {
168
+ "claude": { "five_hour": 97, "seven_day": 54, "scoped_weekly": 24 },
169
+ "codex": { "five_hour": 92, "weekly": 99 }
170
+ }
171
+ }
172
+ ```
173
+
174
+ `git` is `null` outside a repository, `usage_remaining.claude` / `.codex` are `null` without credentials for that provider, and `context_window.used_tokens` is `null` before Pi can estimate it. Handle absence rather than assuming a field.
175
+
176
+ ### What the command should print
177
+
178
+ The **first line of stdout** becomes the segment. Anything after it is ignored — this is a segment on a shared line, not a row the item owns.
179
+
180
+ - **Colors work.** SGR escapes (`\033[32m`) pass through. Every other escape sequence is stripped, because a cursor move or an erase-line would corrupt the frame the footer is drawn into. Control characters go too, and tabs become spaces.
181
+ - **Print nothing to hide.** Empty output is a valid answer, not a failure: it is how an item shows itself only when it has something to say.
182
+ - **Output is capped** at 120 characters before the statusline's own truncation.
183
+ - **Exit non-zero to signal failure.** The first line of stderr is kept and shown in `/statusline`.
184
+
185
+ ### When a command fails
186
+
187
+ 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
+
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.
190
+
191
+ ### Keep it fast
192
+
193
+ The command runs on the footer's schedule, so treat it like a prompt segment. Do slow work elsewhere — a systemd timer, a cron job, a background daemon — and let the item read the result:
194
+
195
+ ```json
196
+ { "id": "quota", "command": "cat /run/user/1000/quota.txt 2>/dev/null", "refreshInterval": 30 }
197
+ ```
198
+
199
+ If the item must do the work itself, cache it keyed by `session_id` from the payload (a PID changes every run and defeats the cache).
200
+
201
+ ### Examples
202
+
203
+ A clock, the smallest possible item:
204
+
205
+ ```json
206
+ { "id": "clock", "command": "date +%H:%M", "refreshInterval": 30 }
207
+ ```
208
+
209
+ Kubernetes context, colored, hidden when unset:
210
+
211
+ ```json
212
+ { "id": "k8s", "command": "kubectl config current-context 2>/dev/null | sed 's/.*/\\x1b[35m&\\x1b[0m/'", "refreshInterval": 300 }
213
+ ```
214
+
215
+ Pooled subscription headroom across several accounts behind a self-hosted gateway — the case this feature was built for, where the built-in meters cannot help because they read *this* session's credential, not a round-robin pool:
216
+
217
+ ```bash
218
+ #!/usr/bin/env bash
219
+ # ~/bin/cpa-quota --statusline
220
+ curl -sf -m 3 -H "Authorization: Bearer $CPAMP_ADMIN_KEY" \
221
+ "$CPAMP_URL/v0/management/auth-files" |
222
+ jq -r '[.files[] | select(.disabled != true) | .quota.signals]
223
+ | map(select(."X-Codex-Primary-Used-Percent"))
224
+ | if length == 0 then empty else
225
+ "\u001b[36mcpa\u001b[0m " +
226
+ (map(100 - (."X-Codex-Primary-Used-Percent"|tonumber)) | add / length | floor | tostring) + "/" +
227
+ (map(100 - (."X-Codex-Secondary-Used-Percent"|tonumber)) | add / length | floor | tostring)
228
+ end'
229
+ ```
230
+
231
+ Using the session payload — warn only when this session's model is on a nearly exhausted account:
232
+
233
+ ```json
234
+ { "id": "low", "command": "jq -r '.usage_remaining.codex.five_hour // 100 | if . < 15 then \"\\u001b[31mLOW \\(.)%\\u001b[0m\" else empty end'" }
235
+ ```
236
+
237
+ ### Testing an item
238
+
239
+ The command is an ordinary program, so run it the way the statusline does:
240
+
241
+ ```bash
242
+ echo '{"model":{"id":"gpt-5.6-sol"},"usage_remaining":{"codex":{"five_hour":22,"weekly":55}}}' \
243
+ | COLUMNS=120 sh -c '~/bin/cpa-quota --statusline'
244
+ ```
245
+
246
+ If that prints one short line, the item will render.
247
+
248
+ ### Differences from Claude Code's status line
249
+
250
+ | | Claude Code | pi-statusline |
251
+ |---|---|---|
252
+ | Scope | one command owns the whole status line | many items, each a segment after the built-in ones |
253
+ | Config | `statusLine` object in `settings.json` | `customItems` array in `statusline-settings.json` |
254
+ | Multi-line output | each line becomes a row | only the first line is used |
255
+ | Rate-limit fields | `rate_limits.*.used_percentage` | `usage_remaining.*` — **remaining**, the inverse |
256
+ | Refresh | every assistant message, 300 ms debounce | session start, turn end, optional `refreshInterval` |
257
+ | Width | `COLUMNS` and `LINES` | `COLUMNS` |
258
+
259
+ The naming of `usage_remaining` is the one difference worth checking when porting: reading a *remaining* percentage as a *used* one silently inverts the meaning, and a green bar that means "nearly out" is worse than no bar.
260
+
261
+ ### A note on trust
262
+
263
+ An item is a command that runs automatically in every TUI session, so `customItems` is executable configuration, exactly like Claude Code's `statusLine` or a shell rc file. Write there is code execution: keep `~/.pi/agent/statusline-settings.json` under your own account (Pi writes it `0600`), and treat an item copied from the internet with the same suspicion as a shell script from the internet.
264
+
104
265
  ## Cache-hit celebration
105
266
 
106
267
  Whenever one assistant response reaches a prompt-cache hit rate of at least 96%, a temporary module is appended after context usage for about two seconds:
@@ -168,6 +329,10 @@ Both repair layers are skipped entirely in regular TUI mode, which reprints its
168
329
  pi install npm:@hank-warren/pi-statusline
169
330
  ```
170
331
 
332
+ ## Changelog
333
+
334
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
335
+
171
336
  ## License
172
337
 
173
338
  MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,41 @@
1
+ import { DEFAULT_TIMEOUT_MS, MAX_OUTPUT_WIDTH, MAX_TIMEOUT_MS } from "./custom.ts";
2
+
3
+ /**
4
+ * The message `/statusline` → Custom item list → "Add custom item…" sends on the
5
+ * user's behalf.
6
+ *
7
+ * This is how the contract reaches the agent. It is not a skill on purpose: a
8
+ * skill's description line sits in every system prompt of every session with
9
+ * the statusline loaded, to serve a task most users do once. Injecting the
10
+ * contract from the menu costs nothing until someone asks for an item, and it
11
+ * is discoverable where the feature is — the same shape Claude Code's own
12
+ * `/statusline` takes.
13
+ *
14
+ * Everything the agent needs is in the message, because the README's install
15
+ * path is not guessable from inside a session (`~/.pi/agent/npm/node_modules/…`
16
+ * for an npm install, a git checkout for a git one).
17
+ */
18
+ export function buildCustomItemSetupPrompt(settingsPath: string, request: string): string {
19
+ const want = request.trim();
20
+ const ask =
21
+ want.length > 0
22
+ ? `I want a custom statusline item that shows: ${want}`
23
+ : "I want to add a custom statusline item. Ask me what it should show, then set it up.";
24
+ return [
25
+ ask,
26
+ "",
27
+ "Set it up for pi-statusline. The contract is Claude Code's `statusLine` contract, per item:",
28
+ "",
29
+ // An instruction, not a fact: given "items live in <path>", a weaker model
30
+ // in a canary treated the path as a hint and went looking for the "real"
31
+ // file with `find /`. The path is the one this session is actually using.
32
+ `- Write the entry to exactly \`${settingsPath}\` under \`customItems\` (an array). That is the file this session reads; do not search for or edit any other settings file. Create the file or the key if absent, and preserve everything else in it.`,
33
+ "- Each entry: `{ \"id\": \"<short-name>\", \"command\": \"<shell line>\", \"refreshInterval\": <seconds, optional>, \"timeout\": <seconds, optional> }`. The command runs through `sh -c`, so pipes, `$VARS`, and `~` work.",
34
+ "- The command receives one JSON object on **stdin** and `COLUMNS` in its environment. Fields: `session_id`, `cwd`, `model.id`, `model.provider`, `git` (`{branch, dirty, behind}` or `null`), `context_window` (`{used_tokens, context_window_size, used_percentage}`, `used_tokens` may be `null`), `usage_remaining.claude` (`{five_hour, seven_day, scoped_weekly}` or `null`) and `usage_remaining.codex` (`{five_hour, weekly}` or `null`). Those percentages are **remaining** headroom, not used.",
35
+ `- The **first line of stdout** becomes the segment, capped at ${MAX_OUTPUT_WIDTH} characters. SGR colour escapes pass through; every other escape sequence is stripped. **Empty output hides the item** — it is a valid answer, not a failure. Exit non-zero to report a failure; the first line of stderr is shown in \`/statusline\`.`,
36
+ "- It runs at session start, at the end of every turn, and every `refreshInterval` seconds if set. Use `refreshInterval` for values that move on a wall clock rather than on turns. One run per item at a time; a slow command degrades to a lower refresh rate.",
37
+ `- Default timeout ${DEFAULT_TIMEOUT_MS / 1000}s, maximum ${MAX_TIMEOUT_MS / 1000}s. Keep it fast: do slow work in a cron job or daemon and have the item read the result; if the item must do the work itself, cache keyed by \`session_id\`.`,
38
+ "",
39
+ "Steps: write the script if one is needed (make it executable), test it exactly as the statusline runs it — `echo '{\"model\":{\"id\":\"x\"},\"usage_remaining\":{\"codex\":{\"five_hour\":22,\"weekly\":55}}}' | COLUMNS=120 sh -c '<command>'` — and confirm it prints one short line. Then add the entry to the settings file. When done, tell me to run `/statusline` to reload it; the file is read when that menu opens, and the **Custom item list** submenu shows each item's value or why it is not rendering.",
40
+ ].join("\n");
41
+ }
package/custom.ts ADDED
@@ -0,0 +1,486 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { platform } from "node:process";
3
+
4
+ /**
5
+ * User-defined statusline segments, modelled on Claude Code's `statusLine`.
6
+ *
7
+ * Each item is a shell command that receives a JSON snapshot of the session on
8
+ * stdin and prints one line to stdout. That contract is deliberately the same
9
+ * one Claude Code uses, so an existing statusline script mostly ports over; the
10
+ * differences are that pi renders each item as one *segment* of line 1 rather
11
+ * than owning the whole row, and that the payload's usage numbers are remaining
12
+ * percentages (see `custom-items.md` in the README).
13
+ */
14
+
15
+ /** How long a command may run before it is killed, when it names no timeout. */
16
+ export const DEFAULT_TIMEOUT_MS = 5_000;
17
+ /** Ceiling for a configured timeout: a statusline must never block on a hang. */
18
+ export const MAX_TIMEOUT_MS = 30_000;
19
+ /**
20
+ * Floor between two event-driven runs of the same item. Turn ends are the main
21
+ * trigger and are already coarse, but a session can end several turns in a
22
+ * second, and an item that shells out to `curl` should not follow it there.
23
+ */
24
+ export const EVENT_MIN_INTERVAL_MS = 1_000;
25
+ /**
26
+ * Consecutive failures tolerated before an item's last good value is dropped.
27
+ *
28
+ * A statusline value that quietly goes stale is worse than an empty slot: the
29
+ * number stays plausible while it describes a world that has moved on. One
30
+ * blip (a laptop between networks) keeps the value; a command that is simply
31
+ * broken loses it.
32
+ */
33
+ export const FAILURE_GRACE = 3;
34
+ /** Longest rendered value kept from a command, before the line is truncated. */
35
+ export const MAX_OUTPUT_WIDTH = 120;
36
+
37
+ /**
38
+ * One configured item.
39
+ *
40
+ * `source` is the entry exactly as it appeared on disk. Serialization writes it
41
+ * back verbatim apart from the one field the menu owns (`enabled`), so an entry
42
+ * this version cannot parse — a `type` from a newer release, a key added by a
43
+ * future feature — survives a settings write instead of being silently deleted
44
+ * by the first person who toggles an unrelated row.
45
+ */
46
+ export interface CustomItem {
47
+ id: string;
48
+ enabled: boolean;
49
+ /** Absent when the entry is not runnable; `error` then says why. */
50
+ command?: string;
51
+ /** Seconds between forced re-runs. Absent means event-driven only. */
52
+ refreshInterval?: number;
53
+ timeoutMs: number;
54
+ /** Why this entry cannot run, shown in the `/statusline` submenu. */
55
+ error?: string;
56
+ /** The on-disk entry, preserved for round-tripping. */
57
+ source: unknown;
58
+ }
59
+
60
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+
64
+ /** Positive finite seconds, or undefined for anything unusable. */
65
+ function positiveSeconds(value: unknown): number | undefined {
66
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
67
+ return value;
68
+ }
69
+
70
+ function uniqueId(candidate: string, taken: Set<string>): string {
71
+ if (!taken.has(candidate)) return candidate;
72
+ for (let suffix = 2; ; suffix += 1) {
73
+ const next = `${candidate}#${suffix}`;
74
+ if (!taken.has(next)) return next;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Parse the `customItems` array.
80
+ *
81
+ * Every entry becomes an item, including the ones that cannot run: an invalid
82
+ * entry is reported through `error` rather than dropped, because dropping it
83
+ * would erase it from the file on the next write. Validation failures are
84
+ * per-entry, so one bad command never costs the user their other items.
85
+ */
86
+ export function normalizeCustomItems(value: unknown): CustomItem[] {
87
+ if (!Array.isArray(value)) return [];
88
+ const items: CustomItem[] = [];
89
+ const taken = new Set<string>();
90
+ value.forEach((entry, index) => {
91
+ const fallbackId = `item-${index + 1}`;
92
+ if (!isPlainObject(entry)) {
93
+ const id = uniqueId(fallbackId, taken);
94
+ taken.add(id);
95
+ items.push({ id, enabled: false, timeoutMs: DEFAULT_TIMEOUT_MS, error: "not an object", source: entry });
96
+ return;
97
+ }
98
+ const rawId = entry.id;
99
+ const id = uniqueId(typeof rawId === "string" && rawId.length > 0 ? rawId : fallbackId, taken);
100
+ taken.add(id);
101
+ // `enabled` is the menu's field; everything else is the user's.
102
+ const enabled = entry.enabled !== false;
103
+ const timeoutSeconds = positiveSeconds(entry.timeout);
104
+ const timeoutMs = Math.min(
105
+ timeoutSeconds === undefined ? DEFAULT_TIMEOUT_MS : timeoutSeconds * 1000,
106
+ MAX_TIMEOUT_MS,
107
+ );
108
+ const refreshInterval = positiveSeconds(entry.refreshInterval);
109
+ const base = { id, enabled, timeoutMs, source: entry, ...(refreshInterval ? { refreshInterval } : {}) };
110
+ // Claude Code's `statusLine` carries `type: "command"`, so a pasted entry
111
+ // may too. That value is accepted; any other is not a mistake this version
112
+ // can judge, so the entry is kept and flagged rather than run or dropped.
113
+ const type = entry.type ?? "command";
114
+ if (type !== "command") {
115
+ items.push({ ...base, enabled: false, error: `unsupported type: ${String(type)}` });
116
+ return;
117
+ }
118
+ if (typeof entry.command !== "string" || entry.command.trim().length === 0) {
119
+ items.push({ ...base, enabled: false, error: "missing command" });
120
+ return;
121
+ }
122
+ items.push({ ...base, command: entry.command });
123
+ });
124
+ return items;
125
+ }
126
+
127
+ /**
128
+ * Write items back to their on-disk form.
129
+ *
130
+ * The source entry wins for every field except `enabled`, which the menu owns:
131
+ * it is written only when false, so toggling an item on again leaves the file
132
+ * as the user wrote it rather than accumulating defaults.
133
+ */
134
+ export function serializeCustomItems(items: readonly CustomItem[]): unknown[] {
135
+ return items.map((item) => {
136
+ if (!isPlainObject(item.source)) return item.source;
137
+ const entry = { ...item.source };
138
+ if (item.enabled) delete entry.enabled;
139
+ else entry.enabled = false;
140
+ return entry;
141
+ });
142
+ }
143
+
144
+ /** Whether two item lists are the same for save-diffing purposes. */
145
+ export function sameCustomItems(a: readonly CustomItem[], b: readonly CustomItem[]): boolean {
146
+ if (a.length !== b.length) return false;
147
+ return a.every((item, index) => {
148
+ const other = b[index];
149
+ return (
150
+ other !== undefined &&
151
+ item.id === other.id &&
152
+ item.enabled === other.enabled &&
153
+ JSON.stringify(item.source) === JSON.stringify(other.source)
154
+ );
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Strip anything that could damage the footer, keeping SGR colour sequences.
160
+ *
161
+ * Scripts are encouraged to colour their output, so `\x1b[32m` has to survive.
162
+ * Every other escape sequence does not: a cursor move or an erase-line writes
163
+ * outside the row the statusline owns and corrupts the frame around it.
164
+ */
165
+ export function sanitizeOutput(raw: string): string {
166
+ const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
167
+ let out = "";
168
+ for (let index = 0; index < firstLine.length; index += 1) {
169
+ const char = firstLine[index] as string;
170
+ if (char === "\x1b") {
171
+ const sgr = /^\x1b\[[0-9;:]*m/.exec(firstLine.slice(index));
172
+ if (sgr) {
173
+ out += sgr[0];
174
+ index += sgr[0].length - 1;
175
+ continue;
176
+ }
177
+ // Any other escape sequence: skip the introducer and its final byte.
178
+ const other = /^\x1b(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/.exec(
179
+ firstLine.slice(index),
180
+ );
181
+ if (other) index += other[0].length - 1;
182
+ continue;
183
+ }
184
+ // eslint-disable-next-line no-control-regex
185
+ if (char === "\t") {
186
+ out += " ";
187
+ continue;
188
+ }
189
+ const code = char.charCodeAt(0);
190
+ if (code < 0x20 || code === 0x7f) continue;
191
+ out += char;
192
+ }
193
+ return out.trim().slice(0, MAX_OUTPUT_WIDTH);
194
+ }
195
+
196
+ /** The last thing an item did, for rendering and for the settings submenu. */
197
+ export interface CustomItemState {
198
+ id: string;
199
+ enabled: boolean;
200
+ /** Sanitized first line of stdout; absent when there is nothing to show. */
201
+ value?: string;
202
+ /** Configuration or run failure, whichever applies. */
203
+ error?: string;
204
+ /** When the value was produced, as epoch ms. */
205
+ updatedAt?: number;
206
+ running: boolean;
207
+ }
208
+
209
+ export type SpawnFn = typeof nodeSpawn;
210
+
211
+ export interface CustomItemsTrackerOptions {
212
+ spawn?: SpawnFn;
213
+ now?: () => number;
214
+ onChange?: () => void;
215
+ cwd?: string;
216
+ schedule?: (callback: () => void, intervalMs: number) => unknown;
217
+ cancel?: (handle: unknown) => void;
218
+ }
219
+
220
+ interface RunRecord {
221
+ value?: string;
222
+ error?: string;
223
+ updatedAt?: number;
224
+ lastAttempt: number;
225
+ failures: number;
226
+ running: boolean;
227
+ abort?: () => void;
228
+ }
229
+
230
+ function defaultSchedule(callback: () => void, intervalMs: number): unknown {
231
+ const handle = setInterval(callback, intervalMs);
232
+ if (typeof handle.unref === "function") handle.unref();
233
+ return handle;
234
+ }
235
+
236
+ function defaultCancel(handle: unknown): void {
237
+ clearInterval(handle as ReturnType<typeof setInterval>);
238
+ }
239
+
240
+ /** Smallest configured refresh interval, which sets the tick rate. */
241
+ const TICK_FLOOR_MS = 1_000;
242
+
243
+ /**
244
+ * Runs the configured items and holds their latest values.
245
+ *
246
+ * Each item runs at most once at a time: a trigger that arrives while a command
247
+ * is still going is dropped rather than queued, so a slow command degrades to a
248
+ * lower refresh rate instead of a pile of processes.
249
+ */
250
+ export class CustomItemsTracker {
251
+ private items: CustomItem[] = [];
252
+ private readonly records = new Map<string, RunRecord>();
253
+ private readonly spawnFn: SpawnFn;
254
+ private readonly now: () => number;
255
+ private readonly onChange?: () => void;
256
+ private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
257
+ private readonly cancel: (handle: unknown) => void;
258
+ private cwd: string | undefined;
259
+ private columns = 80;
260
+ private payloadFactory: () => Record<string, unknown> = () => ({});
261
+ private tickHandle: unknown;
262
+
263
+ constructor(options: CustomItemsTrackerOptions = {}) {
264
+ this.spawnFn = options.spawn ?? nodeSpawn;
265
+ this.now = options.now ?? Date.now;
266
+ this.onChange = options.onChange;
267
+ this.cwd = options.cwd;
268
+ this.schedule = options.schedule ?? defaultSchedule;
269
+ this.cancel = options.cancel ?? defaultCancel;
270
+ }
271
+
272
+ /**
273
+ * Adopt a new configuration, keeping the state of items that survived it.
274
+ *
275
+ * Identity is the item id, so editing a command's text keeps its slot filled
276
+ * with the previous value until the new command first answers — the footer
277
+ * does not blink on every settings save.
278
+ */
279
+ setItems(items: readonly CustomItem[]): void {
280
+ this.items = [...items];
281
+ const live = new Set(items.map((item) => item.id));
282
+ for (const [id, record] of this.records) {
283
+ if (live.has(id)) continue;
284
+ record.abort?.();
285
+ this.records.delete(id);
286
+ }
287
+ }
288
+
289
+ setContext(context: { cwd?: string; columns?: number }): void {
290
+ if (context.cwd !== undefined) this.cwd = context.cwd;
291
+ if (context.columns !== undefined && context.columns > 0) this.columns = context.columns;
292
+ }
293
+
294
+ /**
295
+ * Supply the stdin payload lazily.
296
+ *
297
+ * A factory rather than a value because the timer fires between turns: a
298
+ * snapshot captured at configuration time would hand a script the context
299
+ * usage and quota numbers of whenever the session last had an event.
300
+ */
301
+ setPayloadFactory(factory: () => Record<string, unknown>): void {
302
+ this.payloadFactory = factory;
303
+ }
304
+
305
+ /** Current state of every configured item, in configuration order. */
306
+ states(): CustomItemState[] {
307
+ return this.items.map((item) => {
308
+ const record = this.records.get(item.id);
309
+ return {
310
+ id: item.id,
311
+ enabled: item.enabled,
312
+ ...(record?.value !== undefined ? { value: record.value } : {}),
313
+ ...(item.error !== undefined
314
+ ? { error: item.error }
315
+ : record?.error !== undefined
316
+ ? { error: record.error }
317
+ : {}),
318
+ ...(record?.updatedAt !== undefined ? { updatedAt: record.updatedAt } : {}),
319
+ running: record?.running ?? false,
320
+ };
321
+ });
322
+ }
323
+
324
+ /** Rendered values, in order, for the items that currently have one. */
325
+ values(): string[] {
326
+ return this.items
327
+ .filter((item) => item.enabled)
328
+ .map((item) => this.records.get(item.id)?.value)
329
+ .filter((value): value is string => value !== undefined && value.length > 0);
330
+ }
331
+
332
+ /** Begin ticking, if any item asked for a timer. Idempotent. */
333
+ start(): void {
334
+ if (this.tickHandle !== undefined) return;
335
+ const intervals = this.items
336
+ .filter((item) => item.enabled && item.refreshInterval !== undefined)
337
+ .map((item) => (item.refreshInterval as number) * 1000);
338
+ if (intervals.length === 0) return;
339
+ const tick = Math.max(TICK_FLOOR_MS, Math.min(...intervals));
340
+ this.tickHandle = this.schedule(() => this.refresh(), tick);
341
+ }
342
+
343
+ stop(): void {
344
+ if (this.tickHandle === undefined) return;
345
+ this.cancel(this.tickHandle);
346
+ this.tickHandle = undefined;
347
+ }
348
+
349
+ /** Stop everything and abandon in-flight commands. */
350
+ dispose(): void {
351
+ this.stop();
352
+ for (const record of this.records.values()) record.abort?.();
353
+ this.records.clear();
354
+ }
355
+
356
+ /**
357
+ * Restart the timer after a configuration change, since the tick rate is
358
+ * derived from the items themselves.
359
+ */
360
+ restartTimer(): void {
361
+ const wasRunning = this.tickHandle !== undefined;
362
+ this.stop();
363
+ if (wasRunning) this.start();
364
+ }
365
+
366
+ /** Run every item whose throttle has elapsed. Never rejects. */
367
+ refresh(): void {
368
+ const now = this.now();
369
+ for (const item of this.items) {
370
+ if (!item.enabled || item.command === undefined) continue;
371
+ const record = this.records.get(item.id);
372
+ if (record?.running) continue;
373
+ const minimum =
374
+ item.refreshInterval !== undefined
375
+ ? Math.max(EVENT_MIN_INTERVAL_MS, item.refreshInterval * 1000)
376
+ : EVENT_MIN_INTERVAL_MS;
377
+ if (record !== undefined && now - record.lastAttempt < minimum) continue;
378
+ this.run(item);
379
+ }
380
+ }
381
+
382
+ private record(id: string): RunRecord {
383
+ const existing = this.records.get(id);
384
+ if (existing) return existing;
385
+ const created: RunRecord = { lastAttempt: 0, failures: 0, running: false };
386
+ this.records.set(id, created);
387
+ return created;
388
+ }
389
+
390
+ private run(item: CustomItem): void {
391
+ const command = item.command;
392
+ if (command === undefined) return;
393
+ const record = this.record(item.id);
394
+ record.lastAttempt = this.now();
395
+ record.running = true;
396
+
397
+ const shell = platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "sh";
398
+ const args = platform === "win32" ? ["/d", "/s", "/c", command] : ["-c", command];
399
+
400
+ let child: ReturnType<SpawnFn>;
401
+ try {
402
+ child = this.spawnFn(shell, args, {
403
+ cwd: this.cwd,
404
+ // COLUMNS is how Claude Code tells a script the width it may use;
405
+ // keeping the name means a ported script sizes itself correctly.
406
+ env: { ...process.env, COLUMNS: String(this.columns) },
407
+ stdio: ["pipe", "pipe", "pipe"],
408
+ });
409
+ } catch (error) {
410
+ this.settle(item, record, { error: error instanceof Error ? error.message : String(error) });
411
+ return;
412
+ }
413
+
414
+ let stdout = "";
415
+ let stderr = "";
416
+ let settled = false;
417
+ const finish = (outcome: { value?: string; error?: string }): void => {
418
+ if (settled) return;
419
+ settled = true;
420
+ clearTimeout(timer);
421
+ record.abort = undefined;
422
+ this.settle(item, record, outcome);
423
+ };
424
+
425
+ const timer = setTimeout(() => {
426
+ child.kill("SIGTERM");
427
+ // A command ignoring SIGTERM must not outlive the session either.
428
+ setTimeout(() => child.kill("SIGKILL"), 500).unref?.();
429
+ finish({ error: `timed out after ${Math.round(item.timeoutMs / 100) / 10}s` });
430
+ }, item.timeoutMs);
431
+ timer.unref?.();
432
+
433
+ record.abort = () => {
434
+ clearTimeout(timer);
435
+ settled = true;
436
+ record.running = false;
437
+ child.kill("SIGKILL");
438
+ };
439
+
440
+ child.stdout?.on("data", (chunk: Buffer | string) => {
441
+ // One line is all that is rendered; stop accumulating well before a
442
+ // runaway command can fill memory with output nobody will read.
443
+ if (stdout.length < 64_000) stdout += String(chunk);
444
+ });
445
+ child.stderr?.on("data", (chunk: Buffer | string) => {
446
+ if (stderr.length < 4_000) stderr += String(chunk);
447
+ });
448
+ child.on("error", (error: Error) => finish({ error: error.message }));
449
+ child.on("close", (code: number | null) => {
450
+ if (code === 0) {
451
+ finish({ value: sanitizeOutput(stdout) });
452
+ return;
453
+ }
454
+ const detail = sanitizeOutput(stderr);
455
+ finish({ error: detail.length > 0 ? `exit ${code ?? "?"}: ${detail}` : `exit ${code ?? "?"}` });
456
+ });
457
+
458
+ try {
459
+ child.stdin?.on("error", () => {
460
+ // A command that never reads stdin (`date`, a shell one-liner) closes
461
+ // the pipe under us; that is not a failure of the item.
462
+ });
463
+ child.stdin?.end(`${JSON.stringify(this.payloadFactory())}\n`);
464
+ } catch {
465
+ // Same case, raised synchronously.
466
+ }
467
+ }
468
+
469
+ private settle(item: CustomItem, record: RunRecord, outcome: { value?: string; error?: string }): void {
470
+ record.running = false;
471
+ const previous = record.value;
472
+ if (outcome.error === undefined) {
473
+ record.failures = 0;
474
+ delete record.error;
475
+ // Empty output is a deliberate "nothing to show right now", not a
476
+ // failure: it is how a script hides itself when its subject is idle.
477
+ record.value = outcome.value ?? "";
478
+ record.updatedAt = this.now();
479
+ } else {
480
+ record.failures += 1;
481
+ record.error = outcome.error;
482
+ if (record.failures >= FAILURE_GRACE) delete record.value;
483
+ }
484
+ if (record.value !== previous) this.onChange?.();
485
+ }
486
+ }
package/index.ts CHANGED
@@ -14,16 +14,20 @@ import {
14
14
  } from "./cache-celebration.ts";
15
15
  import { CelebrationPreview, trackSelectedLabel } from "./celebration-preview.ts";
16
16
  import { DEFAULT_CELEBRATION_STYLE, renderCacheBadge } from "./celebration-styles.ts";
17
+ import { CustomItemsTracker } from "./custom.ts";
18
+ import { buildCustomItemSetupPrompt } from "./custom-setup.ts";
17
19
  import { FullRedrawScheduler } from "./redraw.ts";
18
20
  import {
19
21
  applySettingChange,
20
22
  buildSettingItems,
21
23
  CACHE_CELEBRATION_LABEL,
22
24
  createAliasSubmenu,
25
+ createCustomItemsSubmenu,
23
26
  createWorktreeRootSubmenu,
24
27
  } from "./settings-menu.ts";
25
28
  import {
26
29
  changedSettingKeys,
30
+ collapseHome,
27
31
  defaultSettings,
28
32
  repoAlias,
29
33
  SettingsStore,
@@ -50,6 +54,8 @@ export interface StatuslineData {
50
54
  sessionId: string;
51
55
  cacheCelebration?: CacheCelebrationSnapshot;
52
56
  usage?: UsageSnapshot;
57
+ /** Rendered output of each enabled custom item, in configuration order. */
58
+ customValues?: string[];
53
59
  }
54
60
 
55
61
  const RESET = "\x1b[0m";
@@ -177,6 +183,10 @@ export function renderStatusline(
177
183
  formatTokenCount(data.contextTokens),
178
184
  );
179
185
  const usageSegment = settings.showUsage && data.usage ? renderUsageSegment(data.usage, palette) : undefined;
186
+ // Custom items own their own colours, so they are passed through unstyled;
187
+ // they sit after the usage meters, which is where the built-in segments stop
188
+ // and anything the user added begins.
189
+ const customSegments = settings.showCustomItems ? (data.customValues ?? []).filter((value) => value.length > 0) : [];
180
190
  const segments = [
181
191
  settings.showModel ? styled(palette.model, data.model) : undefined,
182
192
  // No provider is a missing segment, not a placeholder: the model id already
@@ -191,6 +201,7 @@ export function renderStatusline(
191
201
  ? `${used}${styled(palette.dim, "/")}${styled(palette.text, formatTokenCount(data.contextWindow))}`
192
202
  : undefined,
193
203
  usageSegment,
204
+ ...customSegments,
194
205
  ].filter((segment): segment is string => segment !== undefined);
195
206
 
196
207
  const showWorktreeLine = settings.showWorktrees && data.worktrees.length > 0;
@@ -227,6 +238,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
227
238
  const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
228
239
  let tracker: SessionWorktreeTracker | undefined;
229
240
  const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
241
+ const customItems = new CustomItemsTracker({ onChange: () => requestRender?.() });
230
242
  let cwdGit: GitRepositoryStatus | null = null;
231
243
  let cwdStatusAbort: AbortController | undefined;
232
244
  let cwdStatusInFlight: Promise<void> | undefined;
@@ -267,6 +279,48 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
267
279
  return refresh;
268
280
  };
269
281
 
282
+ /**
283
+ * The JSON handed to every custom command on stdin.
284
+ *
285
+ * Field names follow Claude Code's statusline payload where the two agents
286
+ * describe the same thing, so a script written for it needs no rewrite. The
287
+ * exception is deliberate: pi's meters are *remaining* headroom, the inverse
288
+ * of Claude Code's `rate_limits.*.used_percentage`, so those fields live
289
+ * under `usage_remaining` where a ported script cannot read them by accident.
290
+ */
291
+ const customPayload = (ctx: ExtensionContext): Record<string, unknown> => {
292
+ const context = ctx.getContextUsage();
293
+ const window = context?.contextWindow ?? ctx.model?.contextWindow ?? 0;
294
+ const tokens = context?.tokens ?? null;
295
+ const usage = usageTracker.snapshot();
296
+ return {
297
+ version: 1,
298
+ session_id: ctx.sessionManager.getSessionId(),
299
+ cwd: ctx.cwd,
300
+ model: { id: ctx.model?.id ?? null, provider: ctx.model?.provider ?? null },
301
+ git: cwdGit
302
+ ? { branch: cwdGit.branch, dirty: cwdGit.dirty, behind: cwdGit.behind }
303
+ : null,
304
+ context_window: {
305
+ used_tokens: tokens,
306
+ context_window_size: window,
307
+ used_percentage: tokens !== null && window > 0 ? Math.round((tokens * 100) / window) : null,
308
+ },
309
+ usage_remaining: {
310
+ claude: usage.claude
311
+ ? {
312
+ five_hour: usage.claude.fiveHour,
313
+ seven_day: usage.claude.sevenDay,
314
+ scoped_weekly: usage.claude.scopedWeekly ?? null,
315
+ }
316
+ : null,
317
+ codex: usage.codex
318
+ ? { five_hour: usage.codex.fiveHour ?? null, weekly: usage.codex.weekly ?? null }
319
+ : null,
320
+ },
321
+ };
322
+ };
323
+
270
324
  const resetTracker = (ctx: ExtensionContext): void => {
271
325
  tracker?.dispose();
272
326
  tracker = undefined;
@@ -279,6 +333,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
279
333
  usageTracker.setActiveProvider(ctx.model?.provider);
280
334
  runInBackground(usageTracker.refresh());
281
335
  }
336
+ customItems.setContext({ cwd: ctx.cwd });
337
+ customItems.setPayloadFactory(() => customPayload(ctx));
338
+ if (settings.showCustomItems) customItems.refresh();
282
339
  // A hidden worktree line must not pay for git/gh polling.
283
340
  if (!settings.showWorktrees) return;
284
341
  const next = new SessionWorktreeTracker({
@@ -298,6 +355,18 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
298
355
  settings = next;
299
356
  settingsStore.set(next);
300
357
 
358
+ // Items are adopted before the visibility check so a disabled segment
359
+ // still shows current config in the submenu; nothing runs while it is off.
360
+ customItems.setItems(next.customItems);
361
+ if (next.showCustomItems) {
362
+ customItems.restartTimer();
363
+ customItems.start();
364
+ customItems.refresh();
365
+ } else if (previous.showCustomItems) {
366
+ // A hidden segment must not keep spawning commands, matching usage above.
367
+ customItems.stop();
368
+ }
369
+
301
370
  if (!next.showWorktrees) {
302
371
  tracker?.dispose();
303
372
  tracker = undefined;
@@ -347,6 +416,16 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
347
416
  const settingsTheme = tracked.theme;
348
417
  const submenuHost = {
349
418
  getSettings: () => settings,
419
+ customItemStates: () => customItems.states(),
420
+ // The contract reaches the agent as a user message, sent when the
421
+ // menu asks for it. This is the whole reason there is no skill: the
422
+ // text costs nothing until someone wants an item, and a message from
423
+ // the menu is discoverable exactly where the feature is.
424
+ requestCustomItem: (request: string) => {
425
+ const prompt = buildCustomItemSetupPrompt(collapseHome(settingsStore.getPath(), home), request);
426
+ if (ctx.isIdle()) pi.sendUserMessage(prompt);
427
+ else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
428
+ },
350
429
  commit: (next: StatuslineSettings) => applySettings(ctx, next),
351
430
  notify: (message: string) => ctx.ui.notify(message, "warning"),
352
431
  requestRender: () => tui.requestRender(),
@@ -360,6 +439,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
360
439
  {
361
440
  worktreeRoot: createWorktreeRootSubmenu(submenuHost),
362
441
  repoAliases: createAliasSubmenu(submenuHost),
442
+ customItems: createCustomItemsSubmenu(submenuHost),
363
443
  },
364
444
  home,
365
445
  ),
@@ -416,6 +496,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
416
496
  // still needs them to move, and a sibling process's poll is worth adopting
417
497
  // before the next turn ends.
418
498
  if (settings.showUsage) usageTracker.start();
499
+ if (settings.showCustomItems) customItems.start();
419
500
  const stopBranchUpdates = footerData.onBranchChange(() => {
420
501
  runInBackground(refreshCwdStatus(ctx));
421
502
  tui.requestRender();
@@ -428,6 +509,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
428
509
  celebrationPreview.dispose();
429
510
  fullRedraw.detach();
430
511
  usageTracker.stop();
512
+ customItems.dispose();
431
513
  requestRender = undefined;
432
514
  },
433
515
  invalidate(): void {},
@@ -435,6 +517,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
435
517
  const usage = ctx.getContextUsage();
436
518
  const cwd = basename(ctx.cwd) || ctx.cwd;
437
519
  const model = ctx.model?.id.split("/").pop() || "no-model";
520
+ // Commands size themselves with COLUMNS, so the tracker needs the
521
+ // width the footer is actually being drawn at.
522
+ customItems.setContext({ columns: width });
438
523
 
439
524
  return fullRedraw.decorate(
440
525
  renderStatusline(
@@ -449,6 +534,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
449
534
  sessionId: ctx.sessionManager.getSessionId(),
450
535
  cacheCelebration: celebrationPreview.snapshot() ?? cacheCelebration.snapshot(),
451
536
  usage: usageTracker.snapshot(),
537
+ customValues: customItems.values(),
452
538
  },
453
539
  width,
454
540
  settings,
@@ -476,6 +562,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
476
562
  usageTracker.setActiveProvider(ctx.model?.provider);
477
563
  runInBackground(usageTracker.refresh());
478
564
  }
565
+ // Turn end is the event-driven trigger, mirroring how Claude Code re-runs a
566
+ // statusline command when a new assistant message arrives.
567
+ if (settings.showCustomItems) customItems.refresh();
479
568
  });
480
569
  // The meters follow the main model's account, so a switch between two logins
481
570
  // of the same provider family has to re-point the tracker before it repaints.
@@ -491,6 +580,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
491
580
  cacheCelebration.dispose();
492
581
  fullRedraw.detach();
493
582
  usageTracker.stop();
583
+ customItems.dispose();
494
584
  tracker?.dispose();
495
585
  tracker = undefined;
496
586
  cwdStatusAbort?.abort();
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.8.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";
@@ -36,6 +39,7 @@ export const BOOLEAN_ROWS: readonly BooleanRow[] = [
36
39
  { id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
37
40
  { id: "showContext", label: "Context", description: "Show context tokens used against the window." },
38
41
  { id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
42
+ { id: "showCustomItems", label: "Custom items", description: "Run your configured commands and show their output." },
39
43
  { id: "showWorktrees", label: "Worktree line", description: "Show touched worktrees and their pull requests." },
40
44
  { id: "showSessionId", label: "Session ID line", description: "Show the full Pi session id on its own line." },
41
45
  ];
@@ -57,9 +61,67 @@ export function aliasSummary(settings: StatuslineSettings): string {
57
61
  return `${count} alias${count === 1 ? "" : "es"}`;
58
62
  }
59
63
 
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;
67
+ if (total === 0) return "none configured";
68
+ return `${settings.customItems.filter((item) => item.enabled).length}/${total} on`;
69
+ }
70
+
71
+ /**
72
+ * Rows in the custom-items submenu: each item, what it shows and why, then the
73
+ * one action that creates an item. Adding hands the job to the agent rather
74
+ * than opening a form: a statusline command is a script plus a JSON entry plus
75
+ * a test run, which is a conversation, not a field.
76
+ */
77
+ export function customItemRows(
78
+ settings: StatuslineSettings,
79
+ states: readonly CustomItemState[] = [],
80
+ ): SelectItem[] {
81
+ return [
82
+ ...customItemStateRows(settings, states),
83
+ {
84
+ value: ADD_CUSTOM_ITEM_VALUE,
85
+ label: "Add custom item…",
86
+ description: "Ask the agent to write one; it gets the contract and the settings path.",
87
+ },
88
+ ];
89
+ }
90
+
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
103
+ ? "disabled"
104
+ : error !== undefined
105
+ ? error
106
+ : state?.running === true && state.value === undefined
107
+ ? "running…"
108
+ : state?.value !== undefined && state.value.length > 0
109
+ ? state.value
110
+ : state?.value !== undefined
111
+ ? "empty output"
112
+ : "no value yet";
113
+ return {
114
+ value: item.id,
115
+ label: `${item.enabled ? toggleValue(true) : toggleValue(false)} ${item.id}`,
116
+ description: detail,
117
+ };
118
+ });
119
+ }
120
+
60
121
  export interface SettingSubmenus {
61
122
  worktreeRoot?: SettingItem["submenu"];
62
123
  repoAliases?: SettingItem["submenu"];
124
+ customItems?: SettingItem["submenu"];
63
125
  }
64
126
 
65
127
  /** Build the `/statusline` rows for a settings snapshot. */
@@ -81,6 +143,10 @@ export function buildSettingItems(
81
143
  // Rows follow the order their elements render in, so the celebration sits
82
144
  // after the usage meters it is appended to on line 1.
83
145
  for (const row of BOOLEAN_ROWS) {
146
+ // A toggle for a segment with nothing in it is a row that does nothing;
147
+ // 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;
84
150
  items.push({
85
151
  id: row.id,
86
152
  label: row.label,
@@ -113,6 +179,13 @@ export function buildSettingItems(
113
179
  currentValue: aliasSummary(settings),
114
180
  ...(submenus.repoAliases ? { submenu: submenus.repoAliases } : {}),
115
181
  });
182
+ items.push({
183
+ id: CUSTOM_ITEMS_ID,
184
+ label: "Custom item list",
185
+ description: "Enable or disable configured items; edit commands in statusline-settings.json.",
186
+ currentValue: customItemsSummary(settings),
187
+ ...(submenus.customItems ? { submenu: submenus.customItems } : {}),
188
+ });
116
189
 
117
190
  return items;
118
191
  }
@@ -183,6 +256,13 @@ export function aliasItems(settings: StatuslineSettings): SelectItem[] {
183
256
  export interface SubmenuHost {
184
257
  /** Read the live settings; the menu edits a single shared snapshot. */
185
258
  getSettings(): StatuslineSettings;
259
+ /** Live per-item run state, for the custom-items submenu. */
260
+ customItemStates?(): CustomItemState[];
261
+ /**
262
+ * Hand "add an item" to the agent with the user's one-line description of
263
+ * what it should show. Closes the menu; absent when no agent can be reached.
264
+ */
265
+ requestCustomItem?(request: string): void;
186
266
  /** Commit an edit: persists, applies live, and repaints. */
187
267
  commit(settings: StatuslineSettings): void;
188
268
  notify(message: string): void;
@@ -348,3 +428,99 @@ class AliasSubmenu implements Component {
348
428
  export function createAliasSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
349
429
  return (_currentValue, done) => new AliasSubmenu(host, done);
350
430
  }
431
+
432
+ /**
433
+ * Custom item list: enable/disable each item and read why it is not showing.
434
+ *
435
+ * Commands are edited in the settings file, not here. A statusline command is a
436
+ * shell line with quoting and pipes in it, which a single-line TUI prompt edits
437
+ * badly, and keeping the field out of the menu means a toggle writes only the
438
+ * `enabled` flag over whatever the file currently holds.
439
+ */
440
+ class CustomItemsSubmenu implements Component {
441
+ private list: SelectList;
442
+ private prompt: PromptComponent | undefined;
443
+
444
+ constructor(
445
+ private readonly host: SubmenuHost,
446
+ private readonly done: (value?: string) => void,
447
+ ) {
448
+ this.list = this.buildList();
449
+ }
450
+
451
+ private buildList(selectedIndex = 0): SelectList {
452
+ const rows = customItemRows(this.host.getSettings(), this.host.customItemStates?.() ?? []);
453
+ const list = new SelectList(rows, 10, this.host.selectTheme);
454
+ list.setSelectedIndex(selectedIndex);
455
+ list.onCancel = () => this.done(customItemsSummary(this.host.getSettings()));
456
+ list.onSelect = (item) => (item.value === ADD_CUSTOM_ITEM_VALUE ? this.add() : this.toggle(item.value));
457
+ return list;
458
+ }
459
+
460
+ private add(): void {
461
+ if (!this.host.requestCustomItem) {
462
+ this.host.notify("Add items to statusline-settings.json; see the pi-statusline README");
463
+ return;
464
+ }
465
+ this.prompt = new PromptComponent(
466
+ "What should the item show? (Enter for the agent to ask)",
467
+ "",
468
+ this.host.settingsTheme.hint,
469
+ (value) => {
470
+ this.prompt = undefined;
471
+ // Close the whole menu before the message lands: the agent's reply
472
+ // renders in the transcript, which the menu is drawn over.
473
+ this.done(customItemsSummary(this.host.getSettings()));
474
+ this.host.requestCustomItem?.(value);
475
+ },
476
+ () => {
477
+ this.prompt = undefined;
478
+ this.host.requestRender();
479
+ },
480
+ );
481
+ this.host.requestRender();
482
+ }
483
+
484
+ private toggle(id: string): void {
485
+ if (id.startsWith("\u0000")) return;
486
+ 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) {
491
+ // Enabling an unparseable entry would only fail again on the next tick.
492
+ this.host.notify(`${id} cannot run: ${target.error}`);
493
+ return;
494
+ }
495
+ const customItems = [...settings.customItems];
496
+ customItems[index] = { ...target, enabled: !target.enabled };
497
+ this.host.commit({ ...settings, customItems });
498
+ this.list = this.buildList(index);
499
+ this.host.requestRender();
500
+ }
501
+
502
+ invalidate(): void {
503
+ this.prompt?.invalidate();
504
+ this.list.invalidate();
505
+ }
506
+
507
+ render(width: number): string[] {
508
+ if (this.prompt) return this.prompt.render(width);
509
+ return [
510
+ truncateToWidth(this.host.settingsTheme.hint(" Custom items"), width),
511
+ "",
512
+ ...this.list.render(width),
513
+ "",
514
+ truncateToWidth(this.host.settingsTheme.hint(" Enter to enable/disable or add · Esc to go back"), width),
515
+ ];
516
+ }
517
+
518
+ handleInput(data: string): void {
519
+ if (this.prompt) this.prompt.handleInput(data);
520
+ else this.list.handleInput(data);
521
+ }
522
+ }
523
+
524
+ export function createCustomItemsSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
525
+ return (_currentValue, done) => new CustomItemsSubmenu(host, done);
526
+ }
package/settings.ts CHANGED
@@ -8,6 +8,12 @@ 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. */
@@ -17,6 +23,7 @@ export const BOOLEAN_SETTING_KEYS = [
17
23
  "showDirectory",
18
24
  "showContext",
19
25
  "showUsage",
26
+ "showCustomItems",
20
27
  "showWorktrees",
21
28
  "showSessionId",
22
29
  "showCacheCelebration",
@@ -33,6 +40,8 @@ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
33
40
  worktreeRoot: string;
34
41
  /** `repository name -> display alias` overrides for the worktree line. */
35
42
  repoAliases: Record<string, string>;
43
+ /** User-defined command segments rendered after the usage meters. */
44
+ customItems: CustomItem[];
36
45
  }
37
46
 
38
47
  function defaultWorktreeRoot(home: string = homedir()): string {
@@ -47,6 +56,9 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
47
56
  showDirectory: true,
48
57
  showContext: true,
49
58
  showUsage: true,
59
+ // On by default and free until the user configures an item: with an empty
60
+ // list nothing renders, nothing is spawned, and no timer runs.
61
+ showCustomItems: true,
50
62
  showWorktrees: true,
51
63
  showSessionId: true,
52
64
  showCacheCelebration: true,
@@ -54,6 +66,7 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
54
66
  cacheCelebrationStyle: DEFAULT_CELEBRATION_STYLE,
55
67
  worktreeRoot: defaultWorktreeRoot(home),
56
68
  repoAliases: {},
69
+ customItems: [],
57
70
  };
58
71
  }
59
72
 
@@ -140,6 +153,7 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
140
153
  "cacheCelebrationStyle",
141
154
  "worktreeRoot",
142
155
  "repoAliases",
156
+ "customItems",
143
157
  ]);
144
158
  for (const [key, raw] of Object.entries(value)) {
145
159
  if (!known.has(key)) {
@@ -158,6 +172,12 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
158
172
  if (aliases) settings.repoAliases = aliases;
159
173
  continue;
160
174
  }
175
+ if (key === "customItems") {
176
+ // Unusable entries come back as items carrying their parse error rather
177
+ // than being dropped, so a write cannot delete what it failed to read.
178
+ settings.customItems = normalizeCustomItems(raw);
179
+ continue;
180
+ }
161
181
  if (key === "theme") {
162
182
  if (isThemeName(raw)) settings.theme = raw;
163
183
  continue;
@@ -185,6 +205,7 @@ export const SETTING_KEYS = [
185
205
  "cacheCelebrationStyle",
186
206
  "worktreeRoot",
187
207
  "repoAliases",
208
+ "customItems",
188
209
  ] as const satisfies readonly (keyof StatuslineSettings)[];
189
210
 
190
211
  export type SettingKey = (typeof SETTING_KEYS)[number];
@@ -205,11 +226,11 @@ export function changedSettingKeys(
205
226
  previous: StatuslineSettings,
206
227
  next: StatuslineSettings,
207
228
  ): SettingKey[] {
208
- return SETTING_KEYS.filter((key) =>
209
- key === "repoAliases"
210
- ? !sameAliases(previous.repoAliases, next.repoAliases)
211
- : previous[key] !== next[key],
212
- );
229
+ return SETTING_KEYS.filter((key) => {
230
+ if (key === "repoAliases") return !sameAliases(previous.repoAliases, next.repoAliases);
231
+ if (key === "customItems") return !sameCustomItems(previous.customItems, next.customItems);
232
+ return previous[key] !== next[key];
233
+ });
213
234
  }
214
235
 
215
236
  /**
@@ -232,6 +253,9 @@ export function serializeSettings(
232
253
  }
233
254
  if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
234
255
  if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
256
+ // An empty list is the default and stays out of the file; a configured one is
257
+ // written back from each entry's original source object.
258
+ if (settings.customItems.length > 0) out.customItems = serializeCustomItems(settings.customItems);
235
259
  return out;
236
260
  }
237
261