@hank-warren/pi-statusline 0.7.2 → 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 +26 -0
- package/README.md +166 -1
- package/cache-celebration.ts +3 -3
- package/celebration-preview.ts +1 -1
- package/celebration-styles.ts +3 -3
- package/custom-setup.ts +41 -0
- package/custom.ts +486 -0
- package/index.ts +91 -1
- package/package.json +4 -2
- package/redraw.ts +1 -1
- package/settings-menu.ts +177 -1
- package/settings.ts +46 -12
- package/usage.ts +9 -6
- package/worktrees.ts +1 -1
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": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-statusline#readme",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=22.19.0"
|
|
27
27
|
},
|
|
28
28
|
"pi": {
|
|
29
29
|
"extensions": [
|
|
@@ -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/redraw.ts
CHANGED
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,13 +18,15 @@ 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";
|
|
24
27
|
const TOGGLE_VALUES = [ON, OFF];
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
interface BooleanRow {
|
|
27
30
|
id: BooleanSettingKey;
|
|
28
31
|
label: string;
|
|
29
32
|
description: string;
|
|
@@ -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
|
@@ -2,11 +2,18 @@ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { pid } from "node:process";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import {
|
|
6
7
|
type CelebrationStyleName,
|
|
7
8
|
DEFAULT_CELEBRATION_STYLE,
|
|
8
9
|
isCelebrationStyleName,
|
|
9
10
|
} from "./celebration-styles.ts";
|
|
11
|
+
import {
|
|
12
|
+
type CustomItem,
|
|
13
|
+
normalizeCustomItems,
|
|
14
|
+
sameCustomItems,
|
|
15
|
+
serializeCustomItems,
|
|
16
|
+
} from "./custom.ts";
|
|
10
17
|
import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.ts";
|
|
11
18
|
|
|
12
19
|
/** Toggle keys, in the order the `/statusline` menu lists them. */
|
|
@@ -16,6 +23,7 @@ export const BOOLEAN_SETTING_KEYS = [
|
|
|
16
23
|
"showDirectory",
|
|
17
24
|
"showContext",
|
|
18
25
|
"showUsage",
|
|
26
|
+
"showCustomItems",
|
|
19
27
|
"showWorktrees",
|
|
20
28
|
"showSessionId",
|
|
21
29
|
"showCacheCelebration",
|
|
@@ -32,9 +40,11 @@ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
|
|
|
32
40
|
worktreeRoot: string;
|
|
33
41
|
/** `repository name -> display alias` overrides for the worktree line. */
|
|
34
42
|
repoAliases: Record<string, string>;
|
|
43
|
+
/** User-defined command segments rendered after the usage meters. */
|
|
44
|
+
customItems: CustomItem[];
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
function defaultWorktreeRoot(home: string = homedir()): string {
|
|
38
48
|
return join(home || homedir(), "repos", "worktrees");
|
|
39
49
|
}
|
|
40
50
|
|
|
@@ -46,6 +56,9 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
|
46
56
|
showDirectory: true,
|
|
47
57
|
showContext: true,
|
|
48
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,
|
|
49
62
|
showWorktrees: true,
|
|
50
63
|
showSessionId: true,
|
|
51
64
|
showCacheCelebration: true,
|
|
@@ -53,11 +66,21 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
|
53
66
|
cacheCelebrationStyle: DEFAULT_CELEBRATION_STYLE,
|
|
54
67
|
worktreeRoot: defaultWorktreeRoot(home),
|
|
55
68
|
repoAliases: {},
|
|
69
|
+
customItems: [],
|
|
56
70
|
};
|
|
57
71
|
}
|
|
58
72
|
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Where the settings live when the caller names no path.
|
|
75
|
+
*
|
|
76
|
+
* The agent dir, not the home dir: pi honours `PI_CODING_AGENT_DIR`, so a
|
|
77
|
+
* session running against a scratch agent dir must save its statusline settings
|
|
78
|
+
* there rather than into the host's real `~/.pi/agent`. `home` still shapes the
|
|
79
|
+
* settings' *content* (the worktree root default, `~` collapsing) — that is a
|
|
80
|
+
* different thing and keeps its own parameter.
|
|
81
|
+
*/
|
|
82
|
+
export function defaultSettingsPath(): string {
|
|
83
|
+
return join(getAgentDir(), "statusline-settings.json");
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
/** Expand a leading `~` (and `$HOME`) against `home`; other paths are returned as-is. */
|
|
@@ -78,7 +101,7 @@ export function collapseHome(path: string, home: string = homedir()): string {
|
|
|
78
101
|
return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
|
|
79
102
|
}
|
|
80
103
|
|
|
81
|
-
|
|
104
|
+
interface WorktreeRootResult {
|
|
82
105
|
path?: string;
|
|
83
106
|
error?: string;
|
|
84
107
|
}
|
|
@@ -109,7 +132,7 @@ function normalizeAliases(value: unknown): Record<string, string> | undefined {
|
|
|
109
132
|
return aliases;
|
|
110
133
|
}
|
|
111
134
|
|
|
112
|
-
|
|
135
|
+
interface NormalizedSettings {
|
|
113
136
|
settings: StatuslineSettings;
|
|
114
137
|
/** Top-level keys this version does not know about, preserved on write. */
|
|
115
138
|
extra: Record<string, unknown>;
|
|
@@ -130,6 +153,7 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
|
|
|
130
153
|
"cacheCelebrationStyle",
|
|
131
154
|
"worktreeRoot",
|
|
132
155
|
"repoAliases",
|
|
156
|
+
"customItems",
|
|
133
157
|
]);
|
|
134
158
|
for (const [key, raw] of Object.entries(value)) {
|
|
135
159
|
if (!known.has(key)) {
|
|
@@ -148,6 +172,12 @@ export function normalizeSettings(value: unknown, home: string = homedir()): Nor
|
|
|
148
172
|
if (aliases) settings.repoAliases = aliases;
|
|
149
173
|
continue;
|
|
150
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
|
+
}
|
|
151
181
|
if (key === "theme") {
|
|
152
182
|
if (isThemeName(raw)) settings.theme = raw;
|
|
153
183
|
continue;
|
|
@@ -175,6 +205,7 @@ export const SETTING_KEYS = [
|
|
|
175
205
|
"cacheCelebrationStyle",
|
|
176
206
|
"worktreeRoot",
|
|
177
207
|
"repoAliases",
|
|
208
|
+
"customItems",
|
|
178
209
|
] as const satisfies readonly (keyof StatuslineSettings)[];
|
|
179
210
|
|
|
180
211
|
export type SettingKey = (typeof SETTING_KEYS)[number];
|
|
@@ -195,11 +226,11 @@ export function changedSettingKeys(
|
|
|
195
226
|
previous: StatuslineSettings,
|
|
196
227
|
next: StatuslineSettings,
|
|
197
228
|
): SettingKey[] {
|
|
198
|
-
return SETTING_KEYS.filter((key) =>
|
|
199
|
-
key === "repoAliases"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
);
|
|
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
|
+
});
|
|
203
234
|
}
|
|
204
235
|
|
|
205
236
|
/**
|
|
@@ -222,10 +253,13 @@ export function serializeSettings(
|
|
|
222
253
|
}
|
|
223
254
|
if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
|
|
224
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);
|
|
225
259
|
return out;
|
|
226
260
|
}
|
|
227
261
|
|
|
228
|
-
|
|
262
|
+
interface SettingsStoreOptions {
|
|
229
263
|
path?: string;
|
|
230
264
|
home?: string;
|
|
231
265
|
}
|
|
@@ -242,7 +276,7 @@ export class SettingsStore {
|
|
|
242
276
|
|
|
243
277
|
constructor(options: SettingsStoreOptions = {}) {
|
|
244
278
|
this.home = options.home ?? homedir();
|
|
245
|
-
this.path = options.path ?? defaultSettingsPath(
|
|
279
|
+
this.path = options.path ?? defaultSettingsPath();
|
|
246
280
|
this.current = defaultSettings(this.home);
|
|
247
281
|
}
|
|
248
282
|
|
package/usage.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { pid } from "node:process";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Remaining (not used) integer percents per provider window.
|
|
@@ -84,7 +84,7 @@ export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
|
|
|
84
84
|
* process's fresh values appear within one tick instead of at the next turn.
|
|
85
85
|
* Request volume is unchanged: both throttles still gate every fetch.
|
|
86
86
|
*/
|
|
87
|
-
|
|
87
|
+
const USAGE_TICK_INTERVAL_MS = 10_000;
|
|
88
88
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
89
89
|
const ONE_DAY_SECONDS = 86_400;
|
|
90
90
|
|
|
@@ -283,7 +283,7 @@ type ProviderKey = "claude" | "codex";
|
|
|
283
283
|
const PROVIDER_KEYS = ["claude", "codex"] as const;
|
|
284
284
|
|
|
285
285
|
/** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
|
|
286
|
-
|
|
286
|
+
const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
|
|
287
287
|
claude: "anthropic",
|
|
288
288
|
codex: "openai-codex",
|
|
289
289
|
};
|
|
@@ -295,7 +295,7 @@ const PROVIDER_REFRESH_INTERVAL_MS: Record<ProviderKey, number> = {
|
|
|
295
295
|
};
|
|
296
296
|
|
|
297
297
|
/** Credential id polled for each family. */
|
|
298
|
-
|
|
298
|
+
type UsageAccounts = Record<ProviderKey, string>;
|
|
299
299
|
|
|
300
300
|
/** A freshly fetched value together with the boundary it is valid until. */
|
|
301
301
|
interface FetchedUsage<K extends ProviderKey> {
|
|
@@ -477,8 +477,11 @@ export class UsageTracker {
|
|
|
477
477
|
private tickHandle: unknown;
|
|
478
478
|
|
|
479
479
|
constructor(options: UsageTrackerOptions = {}) {
|
|
480
|
-
|
|
481
|
-
|
|
480
|
+
// getAgentDir(), not ~/.pi/agent: pi honours PI_CODING_AGENT_DIR, and a
|
|
481
|
+
// hardcoded home path made a session pointed at another agent dir read the
|
|
482
|
+
// wrong credentials and write its usage cache into the host's real one.
|
|
483
|
+
this.authPath = options.authPath ?? join(getAgentDir(), "auth.json");
|
|
484
|
+
this.cachePath = options.cachePath ?? join(getAgentDir(), "statusline-usage.json");
|
|
482
485
|
this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
|
|
483
486
|
this.onChange = options.onChange;
|
|
484
487
|
this.now = options.now ?? Date.now;
|