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