@hank-warren/pi-statusline 0.6.0 → 0.7.1
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 +16 -0
- package/README.md +5 -3
- package/index.ts +19 -1
- package/package.json +1 -1
- package/settings-menu.ts +1 -0
- package/settings.ts +3 -0
- package/themes.ts +7 -0
- package/usage.ts +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.7.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 950c926: Tick the subscription usage meters on a clock instead of on turn boundaries. The tracker only refreshed from session start, the end of a turn, and a model switch, while the poll gate that spaces requests is host-wide and five minutes wide. The moment a meter actually moved was therefore the first turn in this session ending more than five minutes after any pi process on the host last polled that account — which made updates look arbitrary, and froze an idle session's numbers indefinitely while the five-hour window was visibly recovering.
|
|
8
|
+
|
|
9
|
+
`UsageTracker` now owns an unref'd ten-second tick, started with the footer and stopped with the footer, with the usage setting, or at session shutdown. Request volume is unchanged, because a throttled refresh already issues no request: it reads `auth.json` and the shared cache, publishes whatever another process has already fetched, finds the gate closed and returns. The tick costs two small local reads and buys the two things the turn-driven cadence could not — an idle session's meters keep moving, and a sibling process's fresh values are adopted within one tick rather than waiting for this session's next turn to end. The poll itself now also fires when the five-minute window opens rather than at the first turn to end after it.
|
|
10
|
+
|
|
11
|
+
Nothing about the meters' appearance, the poll interval, the rate-limit backoff or the shared cache format changes.
|
|
12
|
+
|
|
13
|
+
## 0.7.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 70c8ef9: Add an optional provider segment. A new `Provider` toggle in `/statusline` renders the active model's provider id between the model and the directory (`claude-opus-5 | anthropic-team | pi-extensions:main | 0/1.0m`), so a `pi-multi-login` alias names the login actually spending — something a model id never carries. It is off by default, has its own colour role in every theme, and is omitted entirely when the model reports no provider.
|
|
18
|
+
|
|
3
19
|
## 0.6.0
|
|
4
20
|
|
|
5
21
|
### 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, current directory basename and Git branch, current context usage/window, and subscription usage headroom (see below). A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
|
|
13
|
+
- **Line 1** — active model ID, optionally the provider of that model, current directory basename and Git branch, current context usage/window, and subscription usage headroom (see below). A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
|
|
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,7 +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
|
-
- **Model**, **Directory & git**, **Context**, **Subscription usage**, **Worktree line**, **Session ID line** — `on`/`off`, cycled with Enter or Space. 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.
|
|
25
|
+
- **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
26
|
- **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
27
|
- **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.
|
|
28
28
|
|
|
@@ -75,7 +75,7 @@ When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Clau
|
|
|
75
75
|
|
|
76
76
|
Numbers are colored by remaining headroom: green above 60, yellow 41–60, orange 16–40, red at 15 and below.
|
|
77
77
|
|
|
78
|
-
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches
|
|
78
|
+
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches are driven by session start, each turn, and a ten-second tick while the footer is mounted, throttled to at most once every five minutes (the Anthropic usage endpoint rate-limits aggressively), and are strictly best-effort: on any failure the last-known value is kept, and providers without credentials (or before the first successful fetch) are simply omitted, leaving the statusline exactly as before. While a provider that *does* have credentials still has no value, the throttle drops to 30 seconds — Pi only refreshes an expired OAuth access token when that provider is first used, so a session starting with a stale Anthropic token would otherwise show no Claude meter for a full interval.
|
|
79
79
|
|
|
80
80
|
### Which account each meter shows
|
|
81
81
|
|
|
@@ -87,6 +87,8 @@ When [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) has registered
|
|
|
87
87
|
|
|
88
88
|
Polling is host-wide, not per-session. Usage percentages describe the account rather than the session, and a busy machine runs dozens of pi processes, so every process shares `~/.pi/agent/statusline-usage.json`, written atomically via a temp file and rename. It is keyed by **credential id**, one entry per account, each holding that account's last good values plus the time its last poll was *started*. A session adopts the cached values for its selected accounts on first refresh — so the meters are populated before it has issued a single request, and switching back to an account polled earlier repaints with no request at all — and only polls an account whose timestamp is older than the interval. Keying by account rather than by provider family is what lets two sessions on two different Anthropic logins coexist: keyed by family, each looked like an account switch to the other, so they evicted each other's values and re-polled every cycle.
|
|
89
89
|
|
|
90
|
+
The ten-second tick does not increase request volume; it only stops the meters from being pinned to turn boundaries. A throttled refresh issues no request at all — it reads `auth.json` and the shared cache, publishes whatever another process has already fetched, finds the poll gate closed and returns — so the tick costs two small local reads and buys the two things the previous turn-driven cadence could not: an **idle** session's meters keep moving (quota recovers on a wall clock, not on your turns), and a sibling process's fresh values are adopted within one tick instead of waiting for this session's next turn to end. It also means the poll itself happens when the five-minute window opens rather than at the first turn that ends after it. The tick is `unref`ed, runs only in TUI mode, and stops with the footer — so turning the usage segment off still stops all polling.
|
|
91
|
+
|
|
90
92
|
Cache entries stay compatible in both directions: a Codex entry written by a statusline older than 0.6.0 carries no 5-hour value, so a newer one renders it as a single weekly number until its own next poll fills the second slot, and an older reader ignores the extra value entirely.
|
|
91
93
|
|
|
92
94
|
An account answering `429` is parked for fifteen minutes (tracked per account, so a rate-limited Anthropic never stops codex from updating) and stops counting as pending, since retrying harder is what earns the rate limit in the first place. Every cache entry is keyed to a fingerprint (a sha256 prefix, never the token itself) of the credential that fetched it: switching accounts — or rotating a token — discards that entry's numbers and backoff and polls immediately, so an exhausted old account's meters never masquerade as the new account's. A logged-out account fails the same check, so the file garbage-collects itself.
|
package/index.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
|
|
41
41
|
export interface StatuslineData {
|
|
42
42
|
model: string;
|
|
43
|
+
/** Provider id of the active model; absent when there is no model. */
|
|
44
|
+
provider?: string;
|
|
43
45
|
cwd: string;
|
|
44
46
|
cwdGit: GitRepositoryStatus | null;
|
|
45
47
|
contextTokens: number | null;
|
|
@@ -177,6 +179,9 @@ export function renderStatusline(
|
|
|
177
179
|
const usageSegment = settings.showUsage && data.usage ? renderUsageSegment(data.usage, palette) : undefined;
|
|
178
180
|
const segments = [
|
|
179
181
|
settings.showModel ? styled(palette.model, data.model) : undefined,
|
|
182
|
+
// No provider is a missing segment, not a placeholder: the model id already
|
|
183
|
+
// says "no-model" in that state, and a second one would only add noise.
|
|
184
|
+
settings.showProvider && data.provider ? styled(palette.provider, data.provider) : undefined,
|
|
180
185
|
settings.showDirectory
|
|
181
186
|
? data.cwdGit
|
|
182
187
|
? renderRepository(data.cwd, data.cwdGit, palette)
|
|
@@ -299,7 +304,13 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
299
304
|
} else if (!previous.showWorktrees || !tracker || previous.worktreeRoot !== next.worktreeRoot) {
|
|
300
305
|
resetTracker(ctx);
|
|
301
306
|
}
|
|
302
|
-
if (next.showUsage && !previous.showUsage)
|
|
307
|
+
if (next.showUsage && !previous.showUsage) {
|
|
308
|
+
runInBackground(usageTracker.refresh());
|
|
309
|
+
usageTracker.start();
|
|
310
|
+
} else if (!next.showUsage && previous.showUsage) {
|
|
311
|
+
// A hidden meter must not keep polling, matching the worktree line above.
|
|
312
|
+
usageTracker.stop();
|
|
313
|
+
}
|
|
303
314
|
if (!next.showCacheCelebration) cacheCelebration.dispose();
|
|
304
315
|
// Dropping a row leaves a stale one behind in fullscreen mode.
|
|
305
316
|
if (previous.showSessionId !== next.showSessionId || previous.showWorktrees !== next.showWorktrees) {
|
|
@@ -401,6 +412,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
401
412
|
// Fullscreen mode never repaints unchanged rows; the session id line is
|
|
402
413
|
// static, so it needs periodic forced redraws to shed stale cells.
|
|
403
414
|
fullRedraw.attach(tui);
|
|
415
|
+
// The meters are wall-clock quantities, not per-turn ones: an idle session
|
|
416
|
+
// still needs them to move, and a sibling process's poll is worth adopting
|
|
417
|
+
// before the next turn ends.
|
|
418
|
+
if (settings.showUsage) usageTracker.start();
|
|
404
419
|
const stopBranchUpdates = footerData.onBranchChange(() => {
|
|
405
420
|
runInBackground(refreshCwdStatus(ctx));
|
|
406
421
|
tui.requestRender();
|
|
@@ -412,6 +427,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
412
427
|
cacheCelebration.dispose();
|
|
413
428
|
celebrationPreview.dispose();
|
|
414
429
|
fullRedraw.detach();
|
|
430
|
+
usageTracker.stop();
|
|
415
431
|
requestRender = undefined;
|
|
416
432
|
},
|
|
417
433
|
invalidate(): void {},
|
|
@@ -424,6 +440,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
424
440
|
renderStatusline(
|
|
425
441
|
{
|
|
426
442
|
model,
|
|
443
|
+
provider: ctx.model?.provider,
|
|
427
444
|
cwd,
|
|
428
445
|
cwdGit,
|
|
429
446
|
contextTokens: usage?.tokens ?? null,
|
|
@@ -473,6 +490,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
473
490
|
pi.on("session_shutdown", () => {
|
|
474
491
|
cacheCelebration.dispose();
|
|
475
492
|
fullRedraw.detach();
|
|
493
|
+
usageTracker.stop();
|
|
476
494
|
tracker?.dispose();
|
|
477
495
|
tracker = undefined;
|
|
478
496
|
cwdStatusAbort?.abort();
|
package/package.json
CHANGED
package/settings-menu.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface BooleanRow {
|
|
|
32
32
|
/** Toggle rows, in statusline render order. */
|
|
33
33
|
export const BOOLEAN_ROWS: readonly BooleanRow[] = [
|
|
34
34
|
{ id: "showModel", label: "Model", description: "Show the active model id." },
|
|
35
|
+
{ id: "showProvider", label: "Provider", description: "Show the provider of the active model." },
|
|
35
36
|
{ id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
|
|
36
37
|
{ id: "showContext", label: "Context", description: "Show context tokens used against the window." },
|
|
37
38
|
{ id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
|
package/settings.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.t
|
|
|
12
12
|
/** Toggle keys, in the order the `/statusline` menu lists them. */
|
|
13
13
|
export const BOOLEAN_SETTING_KEYS = [
|
|
14
14
|
"showModel",
|
|
15
|
+
"showProvider",
|
|
15
16
|
"showDirectory",
|
|
16
17
|
"showContext",
|
|
17
18
|
"showUsage",
|
|
@@ -40,6 +41,8 @@ export function defaultWorktreeRoot(home: string = homedir()): string {
|
|
|
40
41
|
export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
41
42
|
return {
|
|
42
43
|
showModel: true,
|
|
44
|
+
// Opt-in: the provider is redundant for anyone with a single login per family.
|
|
45
|
+
showProvider: false,
|
|
43
46
|
showDirectory: true,
|
|
44
47
|
showContext: true,
|
|
45
48
|
showUsage: true,
|
package/themes.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export interface StatuslinePalette {
|
|
6
6
|
/** Active model id. */
|
|
7
7
|
model: string;
|
|
8
|
+
/** Provider id of the active model. */
|
|
9
|
+
provider: string;
|
|
8
10
|
/** Repository and directory names. */
|
|
9
11
|
path: string;
|
|
10
12
|
/** Git branch names. */
|
|
@@ -35,6 +37,7 @@ const rgb = (hex: string): string => {
|
|
|
35
37
|
/** The palette this package shipped before themes existed. */
|
|
36
38
|
const DEFAULT: StatuslinePalette = {
|
|
37
39
|
model: rgb("#0099ff"),
|
|
40
|
+
provider: rgb("#7aa2c8"),
|
|
38
41
|
path: rgb("#dcdcdc"),
|
|
39
42
|
branch: rgb("#56b6c2"),
|
|
40
43
|
text: rgb("#dcdcdc"),
|
|
@@ -50,6 +53,7 @@ const DEFAULT: StatuslinePalette = {
|
|
|
50
53
|
/** Dracula, with the pink branch colour from Hank's Herdr sidebar config. */
|
|
51
54
|
const DRACULA: StatuslinePalette = {
|
|
52
55
|
model: rgb("#bd93f9"),
|
|
56
|
+
provider: rgb("#9580c9"),
|
|
53
57
|
path: rgb("#f8f8f2"),
|
|
54
58
|
branch: rgb("#ff79c6"),
|
|
55
59
|
text: rgb("#f8f8f2"),
|
|
@@ -64,6 +68,7 @@ const DRACULA: StatuslinePalette = {
|
|
|
64
68
|
|
|
65
69
|
const GITHUB_DARK: StatuslinePalette = {
|
|
66
70
|
model: rgb("#58a6ff"),
|
|
71
|
+
provider: rgb("#6e8bb5"),
|
|
67
72
|
path: rgb("#c9d1d9"),
|
|
68
73
|
branch: rgb("#39c5cf"),
|
|
69
74
|
text: rgb("#c9d1d9"),
|
|
@@ -78,6 +83,7 @@ const GITHUB_DARK: StatuslinePalette = {
|
|
|
78
83
|
|
|
79
84
|
const CATPPUCCIN_MOCHA: StatuslinePalette = {
|
|
80
85
|
model: rgb("#cba6f7"),
|
|
86
|
+
provider: rgb("#a58fc4"),
|
|
81
87
|
path: rgb("#cdd6f4"),
|
|
82
88
|
branch: rgb("#89dceb"),
|
|
83
89
|
text: rgb("#cdd6f4"),
|
|
@@ -93,6 +99,7 @@ const CATPPUCCIN_MOCHA: StatuslinePalette = {
|
|
|
93
99
|
/** No colour at all: white text, dimmed punctuation, a grey badge flash. */
|
|
94
100
|
const WHITE: StatuslinePalette = {
|
|
95
101
|
model: rgb("#ffffff"),
|
|
102
|
+
provider: rgb("#ffffff"),
|
|
96
103
|
path: rgb("#ffffff"),
|
|
97
104
|
branch: rgb("#ffffff"),
|
|
98
105
|
text: rgb("#ffffff"),
|
package/usage.ts
CHANGED
|
@@ -35,6 +35,17 @@ export const USAGE_RETRY_INTERVAL_MS = 30_000;
|
|
|
35
35
|
* a break rather than that we are racing ourselves.
|
|
36
36
|
*/
|
|
37
37
|
export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
|
|
38
|
+
/**
|
|
39
|
+
* How often a bound tracker calls itself. This is deliberately far shorter than
|
|
40
|
+
* the poll interval, because a throttled `refresh()` issues no request at all:
|
|
41
|
+
* it reads auth.json and the shared cache, publishes whatever another process
|
|
42
|
+
* has already fetched, finds the poll gate closed, and returns. So the tick
|
|
43
|
+
* costs two small local reads and buys two things the previous turn-driven
|
|
44
|
+
* cadence could not — an idle session's meters keep moving, and a sibling
|
|
45
|
+
* process's fresh values appear within one tick instead of at the next turn.
|
|
46
|
+
* Request volume is unchanged: both throttles still gate every fetch.
|
|
47
|
+
*/
|
|
48
|
+
export const USAGE_TICK_INTERVAL_MS = 10_000;
|
|
38
49
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
39
50
|
const ONE_DAY_SECONDS = 86_400;
|
|
40
51
|
|
|
@@ -150,6 +161,20 @@ export interface UsageTrackerOptions {
|
|
|
150
161
|
now?: () => number;
|
|
151
162
|
/** Provider id of the session's main model; selects which account is polled. */
|
|
152
163
|
activeProvider?: string;
|
|
164
|
+
tickIntervalMs?: number;
|
|
165
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
166
|
+
cancel?: (handle: unknown) => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function defaultSchedule(callback: () => void, intervalMs: number): unknown {
|
|
170
|
+
const timer = setInterval(callback, intervalMs);
|
|
171
|
+
// A usage meter is cosmetic; it must never hold the process open at exit.
|
|
172
|
+
(timer as { unref?: () => void }).unref?.();
|
|
173
|
+
return timer;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function defaultCancel(handle: unknown): void {
|
|
177
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
153
178
|
}
|
|
154
179
|
|
|
155
180
|
type ProviderKey = "claude" | "codex";
|
|
@@ -323,6 +348,10 @@ export class UsageTracker {
|
|
|
323
348
|
private readonly rateLimited = new Set<ProviderKey>();
|
|
324
349
|
private lastAttempt: Record<string, number> = {};
|
|
325
350
|
private inFlight: Promise<void> | undefined;
|
|
351
|
+
private readonly tickIntervalMs: number;
|
|
352
|
+
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
353
|
+
private readonly cancel: (handle: unknown) => void;
|
|
354
|
+
private tickHandle: unknown;
|
|
326
355
|
|
|
327
356
|
constructor(options: UsageTrackerOptions = {}) {
|
|
328
357
|
this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
|
|
@@ -331,12 +360,34 @@ export class UsageTracker {
|
|
|
331
360
|
this.onChange = options.onChange;
|
|
332
361
|
this.now = options.now ?? Date.now;
|
|
333
362
|
this.activeProvider = options.activeProvider;
|
|
363
|
+
this.tickIntervalMs = options.tickIntervalMs ?? USAGE_TICK_INTERVAL_MS;
|
|
364
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
365
|
+
this.cancel = options.cancel ?? defaultCancel;
|
|
334
366
|
}
|
|
335
367
|
|
|
336
368
|
snapshot(): UsageSnapshot {
|
|
337
369
|
return this.current;
|
|
338
370
|
}
|
|
339
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Begin ticking. Idempotent, so the caller may start from whichever lifecycle
|
|
374
|
+
* event happens first without tracking whether it already did.
|
|
375
|
+
*/
|
|
376
|
+
start(): void {
|
|
377
|
+
if (this.tickHandle !== undefined) return;
|
|
378
|
+
this.tickHandle = this.schedule(() => {
|
|
379
|
+
// refresh() swallows its own failures and no-ops while one is in flight.
|
|
380
|
+
void this.refresh();
|
|
381
|
+
}, this.tickIntervalMs);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Stop ticking. Safe to call when never started, and leaves values in place. */
|
|
385
|
+
stop(): void {
|
|
386
|
+
if (this.tickHandle === undefined) return;
|
|
387
|
+
this.cancel(this.tickHandle);
|
|
388
|
+
this.tickHandle = undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
340
391
|
/**
|
|
341
392
|
* Point the meters at the session's main model. A switch between two accounts
|
|
342
393
|
* in the same family changes which one is displayed; the next refresh adopts
|