@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 +20 -0
- package/README.md +166 -1
- package/custom-setup.ts +41 -0
- package/custom.ts +486 -0
- package/index.ts +90 -0
- package/package.json +3 -1
- package/settings-menu.ts +176 -0
- package/settings.ts +29 -5
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,
|
|
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).
|
package/custom-setup.ts
ADDED
|
@@ -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.
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|