osdy-pi 1.11.13 → 1.11.15
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/README.md +16 -4
- package/extensions/osdy-pi/account-profile-selector.test.ts +186 -0
- package/extensions/osdy-pi/account-profile-selector.ts +124 -0
- package/extensions/osdy-pi/account-profiles.test.ts +222 -2
- package/extensions/osdy-pi/account-profiles.ts +40 -13
- package/extensions/osdy-pi/context-usage-ui.test.ts +16 -14
- package/extensions/osdy-pi/context-usage-ui.ts +14 -3
- package/extensions/osdy-pi/generation-meter.test.ts +62 -0
- package/extensions/osdy-pi/generation-meter.ts +69 -0
- package/extensions/osdy-pi/runtime.test.ts +70 -5
- package/extensions/osdy-pi/runtime.ts +33 -1
- package/extensions/osdy-pi/types.ts +3 -0
- package/extensions/osdy-pi/ui.test.ts +45 -0
- package/extensions/osdy-pi/ui.ts +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -238,7 +238,7 @@ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` i
|
|
|
238
238
|
|
|
239
239
|
**Quick path:** `/osdyConfig` → **Account** → select a profile → **v** (or **Enter**) for quota bars. Use **r Refresh** to query again or **b Back** / **Esc** to return with the profile selected. From the overview or quota dashboard, **s Switch** opens the same Cancel-first confirmation for the eligible inactive profile; cancelling from quota preserves the view and scroll position. From the overview, **d** sets the default and **c** clears an existing default, also with Cancel-first confirmation. The active profile also supports explicit stored-credential queries.
|
|
240
240
|
|
|
241
|
-
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling.
|
|
241
|
+
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. In the Control Center, only **View usage** and the quota view's **Refresh** query; profile navigation does not. `/osdy-account` → **Switch** queries all saved profiles before opening the selector, with up to four previews in parallel. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain available; Agents configuration remains normal personal Pi only.
|
|
242
242
|
|
|
243
243
|
Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you choose which one starts Pi. `personal` and `work` are only examples—you can create as many named profiles as you need.
|
|
244
244
|
|
|
@@ -248,13 +248,17 @@ Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you ch
|
|
|
248
248
|
|
|
249
249
|
| Action | Behavior |
|
|
250
250
|
| --- | --- |
|
|
251
|
-
| **Switch** |
|
|
251
|
+
| **Switch** | Lists profile names and active/default markers only. Use arrows to highlight, **v** for `/usage`-style quota details, and **Enter from the list only** to switch the exact profile after idle waiting. |
|
|
252
252
|
| **Add** | Creates an isolated profile without restarting or changing the default. Select it with **Switch**, then run `/login`. |
|
|
253
253
|
| **Default** | Shows, changes, or clears the profile used by future plain installed `pi` and `npm run pi:dev` launches. It does not switch the current process. |
|
|
254
254
|
| **Rename** | Renames an inactive profile. The default follows the new name when applicable. |
|
|
255
255
|
| **Remove** | Permanently deletes an inactive profile after exact-name confirmation. Removing the default requires a replacement. |
|
|
256
256
|
| **Account info** | Shows the available profiles and marks the active and default profiles without reading credentials. |
|
|
257
257
|
|
|
258
|
+
In **Switch**, press **v** on the highlighted profile to explore its saved preview using the same dashboard as `/usage`, without selecting or activating it. Scroll details with **↑/↓**, **Page Up/Down**, or **Home/End**. **Esc** or **b** returns to the same highlighted profile; **Enter does nothing in details**. **Esc from the list** cancels selection.
|
|
259
|
+
|
|
260
|
+
Switch previews are read-only and never activate accounts, refresh tokens, use resets or persist auth. Quota and **usage unavailable** messaging appear only in **v** details, never in the Switch list. Failed, expired or unsupported profiles remain selectable, including after viewing unavailable details. Opening details reuses the selector's snapshot without another request; reopening **Switch** queries again (up to four in parallel), never polls saved profiles. Reset timings include relative time and UTC timestamps when supplied by Codex. The detailed selector requires Pi's terminal UI.
|
|
261
|
+
|
|
258
262
|
Pi does not expose a supported API for extensions to invoke its OAuth login dialog. After switching to a newly created profile, run Pi's native `/login` and choose **ChatGPT Plus/Pro (Codex)**. This is the only step that remains a separate Pi command; it does not require leaving Pi or opening another terminal.
|
|
259
263
|
|
|
260
264
|
Terminal commands remain available as recovery and automation alternatives:
|
|
@@ -414,7 +418,7 @@ Animation is enabled by default with an intro animation. Configure it through `O
|
|
|
414
418
|
|
|
415
419
|
In the extended framed editor, the active thinking level is rendered in bold in the top-right model metadata so it remains easy to scan.
|
|
416
420
|
|
|
417
|
-
The full header and mascot render side by side only when the available width and height permit it without an additional width-limited mascot reduction. Otherwise, only the centered mascot renders—there is no scaled or stacked logo. Small and compact modes trim only fully empty mascot-art and tone-map margins before applying one proportional width-and-height scale; mascot width starts near four-fifths of the available width. The fallback mascot uses the available terminal rows, capped at 32 content rows, without reserving rows for a logo. The small-mode footer places the model and styled bare thinking level above usage, path/branch, and dynamic extension statuses. Pi Lens's footer status is hidden in small mode, but Pi Lens continues running. Usage includes
|
|
421
|
+
The full header and mascot render side by side only when the available width and height permit it without an additional width-limited mascot reduction. Otherwise, only the centered mascot renders—there is no scaled or stacked logo. Small and compact modes trim only fully empty mascot-art and tone-map margins before applying one proportional width-and-height scale; mascot width starts near four-fifths of the available width. The fallback mascot uses the available terminal rows, capped at 32 content rows, without reserving rows for a logo. The small-mode footer places the model and styled bare thinking level above usage, path/branch, and dynamic extension statuses. Pi Lens's footer status is hidden in small mode, but Pi Lens continues running. Usage includes response speed, cache-read/cache-write tokens when space permits, cost, and context. Extension statuses are supplied dynamically by Pi/extensions and may include Osdy Pi, MCP, or LSP; they are not hardcoded.
|
|
418
422
|
|
|
419
423
|
The enabled state, editor mode, working-tree visibility preference, header, and mascot persist globally across Pi reloads and sessions, shared by all projects. They are saved in `$PI_CODING_AGENT_DIR/extensions/osdy-pi/settings.json`, or `~/.pi/agent/extensions/osdy-pi/settings.json` when `PI_CODING_AGENT_DIR` is unset. Existing settings without the new visual fields safely default to `osdy-theme` and `current`; the former `raccoon` mascot value migrates to `bts`. The selected editor mode and working-tree placement are restored when the terminal moves normal → small → normal.
|
|
420
424
|
|
|
@@ -507,7 +511,15 @@ Pi renders the colored user card and assistant Markdown natively. User messages
|
|
|
507
511
|
|
|
508
512
|
The default `auto` editor mode preserves the responsive behavior: it uses the framed editor when space permits and Pi's native editor on small terminals. Select `simple` for Pi's native editor at every width, or `extended` to request the framed editor explicitly: `/osdy-pi editor auto|extended|simple`. Simple mode actually unmounts the custom editor component rather than hiding it. Small terminals always use Pi's native editor, including when `extended` is selected. The legacy commands remain compatible where feasible: `on` maps to `extended`, `off` maps to `simple`, and `toggle` switches between extended and simple.
|
|
509
513
|
|
|
510
|
-
In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage
|
|
514
|
+
In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage shows response speed instead of cumulative input/output totals, alongside cache read/write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
|
|
515
|
+
|
|
516
|
+
### Response speed
|
|
517
|
+
|
|
518
|
+
The editor usage line (or native-mode footer) shows **`≈42.3 tok/s` while streaming**, estimated as accumulated streamed UTF-16 characters / 4 divided by seconds since the first nonempty output delta. Text, thinking, and tool-call argument deltas count once; snapshots and block-end events do not. This language-dependent heuristic is **not a tokenizer count or server-measured decode speed**. It updates on stream events, with no additional timer or editor remount.
|
|
519
|
+
|
|
520
|
+
On successful message end, the label becomes **`42.3 tok/s`**: finalized output usage divided by the observed first-output-to-message-end interval. Output already includes reported reasoning tokens; input/cache tokens and subsequent tool execution are excluded. This is an observed response average, not server telemetry. Each assistant message starts a new reading. The completed reading remains until the next response; session replacement/reload clears it.
|
|
521
|
+
|
|
522
|
+
**`tok/s …`** means pending or insufficient timing while streaming. **`tok/s —`** means idle or an unavailable reading after completion/interruption: missing/zero output usage, no output deltas, intervals under 100 ms, invalid timing, aborts, or errors. Narrow layouts simplify context and clip safely. Status and local analytics retain their existing token accounting; speed is volatile and never saved.
|
|
511
523
|
|
|
512
524
|
While work is active, Osdy Pi centers an activity label and compact animated glyph above the editor:
|
|
513
525
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { InteractiveMode, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { TuiAltScreen, visibleWidth, type Component, type OverlayHandle } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { AccountProfileChoice } from "./account-profile-selector.js";
|
|
9
|
+
|
|
10
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
11
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
12
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
13
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
14
|
+
}
|
|
15
|
+
return next(specifier, context);
|
|
16
|
+
} });
|
|
17
|
+
const { AccountProfileSelector, selectAccountProfile } = await import("./account-profile-selector.js");
|
|
18
|
+
const { renderCodexUsageDashboardContentLines } = await import("./codex-usage-ui.js");
|
|
19
|
+
const theme = { fg: (_color: string, text: string) => text };
|
|
20
|
+
const choice: AccountProfileChoice = { profile: "Work", label: "Work · 5h 75% left", usage: {
|
|
21
|
+
status: "ready", profile: "Work", checkedAt: 0, quotaSnapshot: {
|
|
22
|
+
fetchedAt: 0, planType: "plus", credits: undefined, ordinaryUsageAllowed: true,
|
|
23
|
+
bankedResetCount: 2, bankedResetDetails: [{ id: "one", expiresAt: 86400000 }, { id: "two", expiresAt: undefined }],
|
|
24
|
+
buckets: [{ id: "codex", label: undefined,
|
|
25
|
+
primary: { usedPercent: 25, windowMinutes: 300, resetsAt: 3600 },
|
|
26
|
+
secondary: { usedPercent: 60, windowMinutes: 10080, resetsAt: 86400 } },
|
|
27
|
+
{ id: "extra", label: "Extra quota", primary: { usedPercent: 5, windowMinutes: 60, resetsAt: 100 }, secondary: undefined }],
|
|
28
|
+
},
|
|
29
|
+
} };
|
|
30
|
+
|
|
31
|
+
// Exercise the installed SDK's actual custom lifecycle and fullscreen input router,
|
|
32
|
+
// without a real terminal, session, auth, or network. Only its host editor is stubbed.
|
|
33
|
+
async function mountedSelector(choices: readonly AccountProfileChoice[]) {
|
|
34
|
+
let input: (data: string) => void = () => {};
|
|
35
|
+
let resize: () => void = () => {};
|
|
36
|
+
const terminal = {
|
|
37
|
+
columns: 80, rows: 8, kittyProtocolActive: false,
|
|
38
|
+
start: (onInput: (data: string) => void, onResize: () => void) => { input = onInput; resize = onResize; },
|
|
39
|
+
stop() {}, async drainInput() {}, write() {}, moveBy() {}, hideCursor() {},
|
|
40
|
+
showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {},
|
|
41
|
+
setTitle() {}, setProgress() {},
|
|
42
|
+
};
|
|
43
|
+
const tui = new TuiAltScreen(terminal);
|
|
44
|
+
let component: Component | undefined;
|
|
45
|
+
let handle: OverlayHandle | undefined;
|
|
46
|
+
let options: Parameters<ExtensionContext["ui"]["custom"]>[1];
|
|
47
|
+
const host = {
|
|
48
|
+
ui: tui, editor: { getText: () => "preserved", setText() {} },
|
|
49
|
+
editorContainer: { clear() {}, addChild(value: Component) { component = value; } },
|
|
50
|
+
keybindings: {}, disposeActiveSelector() {},
|
|
51
|
+
};
|
|
52
|
+
// This private SDK method is the implementation behind ctx.ui.custom.
|
|
53
|
+
const custom = (InteractiveMode.prototype as unknown as {
|
|
54
|
+
showExtensionCustom: ExtensionContext["ui"]["custom"];
|
|
55
|
+
}).showExtensionCustom;
|
|
56
|
+
const ui = { custom: ((factory, opts) => {
|
|
57
|
+
options = opts;
|
|
58
|
+
return custom.call(host, (sdkTui, sdkTheme, keys, done) => {
|
|
59
|
+
// Inject a deterministic theme instead of initializing user theme discovery.
|
|
60
|
+
const result = factory(sdkTui, theme as typeof sdkTheme, keys, done);
|
|
61
|
+
if (!(result instanceof Promise)) component = result;
|
|
62
|
+
return result;
|
|
63
|
+
}, { ...opts, onHandle: value => { handle = value; opts?.onHandle?.(value); } });
|
|
64
|
+
}) as ExtensionContext["ui"]["custom"] };
|
|
65
|
+
const result = selectAccountProfile(ui, choices);
|
|
66
|
+
await Promise.resolve();
|
|
67
|
+
tui.start();
|
|
68
|
+
tui.renderNow();
|
|
69
|
+
assert(component);
|
|
70
|
+
return { tui, terminal, result, component, options,
|
|
71
|
+
bounds: () => handle?.getBounds(),
|
|
72
|
+
screen: () => (tui as unknown as { previousScreen: string[] }).previousScreen.join("\n"),
|
|
73
|
+
input: (key: string) => { input(key); tui.renderNow(); },
|
|
74
|
+
resize: (columns: number, rows: number) => { terminal.columns = columns; terminal.rows = rows; resize(); tui.renderNow(); },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
void test("custom selector mounts a capturing fullscreen overlay and receives viewport navigation", async () => {
|
|
79
|
+
const choices = Array.from({ length: 30 }, (_value, index) => ({ ...choice, profile: `Profile-${index}`, label: `Profile-${index}`, usage: undefined }));
|
|
80
|
+
const mounted = await mountedSelector(choices);
|
|
81
|
+
try {
|
|
82
|
+
assert.equal(mounted.options?.overlay, true);
|
|
83
|
+
assert(mounted.tui.hasOverlay());
|
|
84
|
+
mounted.input("\u001b[F");
|
|
85
|
+
assert.match(mounted.screen(), /> Profile-29/);
|
|
86
|
+
mounted.input("\u001b[H");
|
|
87
|
+
assert.match(mounted.screen(), /> Profile-0/);
|
|
88
|
+
mounted.input("\u001b[6~");
|
|
89
|
+
assert.match(mounted.screen(), /> Profile-6/);
|
|
90
|
+
mounted.input("\u001b[5~");
|
|
91
|
+
assert.match(mounted.screen(), /> Profile-0/);
|
|
92
|
+
mounted.input("v");
|
|
93
|
+
mounted.input("\r");
|
|
94
|
+
assert(mounted.tui.hasOverlay(), "Enter in details must not complete");
|
|
95
|
+
mounted.input("b");
|
|
96
|
+
mounted.input("\r");
|
|
97
|
+
assert.equal(await mounted.result, "Profile-0");
|
|
98
|
+
assert.equal(mounted.tui.hasOverlay(), false);
|
|
99
|
+
} finally { mounted.tui.stop(); }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
void test("SDK overlay and component share a responsive viewport at short heights and resize", async () => {
|
|
103
|
+
const mounted = await mountedSelector([choice]);
|
|
104
|
+
try {
|
|
105
|
+
assert.equal(mounted.options?.overlay, true);
|
|
106
|
+
const options = typeof mounted.options?.overlayOptions === "function" ? mounted.options.overlayOptions() : mounted.options?.overlayOptions;
|
|
107
|
+
assert.deepEqual(options, { width: "100%", maxHeight: "100%", margin: 0, nonCapturing: false });
|
|
108
|
+
mounted.input("v");
|
|
109
|
+
for (const [width, height] of [[80, 8], [12, 4], [1, 1], [32, 3], [100, 24], [80, 8]] as const) {
|
|
110
|
+
mounted.resize(width, height);
|
|
111
|
+
mounted.input("\u001b[F");
|
|
112
|
+
const lines = mounted.component.render(width);
|
|
113
|
+
assert(lines.length <= height);
|
|
114
|
+
assert(lines.every(line => visibleWidth(line) <= width));
|
|
115
|
+
assert.deepEqual(mounted.bounds(), { row: 0, col: 0, width, height: lines.length });
|
|
116
|
+
if (height >= 3) {
|
|
117
|
+
assert.equal(lines.length, height, "use exactly the SDK viewport, without invisible content");
|
|
118
|
+
assert.match(lines.at(-1)!, /esc\/b/);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
assert.match(mounted.screen(), /Banked resets: 2/);
|
|
122
|
+
mounted.input("\u001b[H");
|
|
123
|
+
assert.match(mounted.screen(), /Checked:/);
|
|
124
|
+
mounted.input("\u001b");
|
|
125
|
+
mounted.input("\u001b");
|
|
126
|
+
assert.equal(await mounted.result, undefined);
|
|
127
|
+
} finally { mounted.tui.stop(); }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
void test("details reuse the complete dashboard without active-model attribution or action controls", () => {
|
|
131
|
+
const results: (string | undefined)[] = [];
|
|
132
|
+
const panel = new AccountProfileSelector([choice], theme, () => 500, () => {}, value => results.push(value));
|
|
133
|
+
panel.handleInput("v");
|
|
134
|
+
assert(choice.usage?.status === "ready");
|
|
135
|
+
const dashboard = renderCodexUsageDashboardContentLines(theme, choice.usage.quotaSnapshot,
|
|
136
|
+
{ profile: "Work", provider: "openai-codex" }, 0, 100);
|
|
137
|
+
assert.deepEqual(panel.render(100).slice(3, -1), dashboard);
|
|
138
|
+
assert.match(dashboard.join("\n"), /Banked resets: 2/);
|
|
139
|
+
assert.match(dashboard.join("\n"), /Extra quota/);
|
|
140
|
+
for (const key of ["\r", "r", "u", "s"]) panel.handleInput(key);
|
|
141
|
+
assert.deepEqual(results, []);
|
|
142
|
+
panel.handleInput("\u001b");
|
|
143
|
+
panel.handleInput("\r");
|
|
144
|
+
panel.handleInput("\r");
|
|
145
|
+
assert.deepEqual(results, ["Work"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
void test("details scrolling and resizing bound every line and reach the final dashboard fields", () => {
|
|
149
|
+
let height = 8;
|
|
150
|
+
let renders = 0;
|
|
151
|
+
const panel = new AccountProfileSelector([choice], theme, () => height, () => { renders++; }, () => assert.fail("details cannot finish"));
|
|
152
|
+
panel.handleInput("v");
|
|
153
|
+
for (const width of [1, 3, 12, 32, 100]) {
|
|
154
|
+
for (const key of ["\u001b[H", "\u001b[B", "\u001b[6~", "\u001b[5~", "\u001b[F"]) {
|
|
155
|
+
panel.render(width);
|
|
156
|
+
panel.handleInput(key);
|
|
157
|
+
const lines = panel.render(width);
|
|
158
|
+
assert(lines.length <= height);
|
|
159
|
+
assert(lines.every(line => visibleWidth(line) <= width));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
assert.match(panel.render(100).join("\n"), /Banked resets: 2/);
|
|
163
|
+
height = 4;
|
|
164
|
+
assert(panel.render(12).length <= height);
|
|
165
|
+
height = 500;
|
|
166
|
+
const expanded = panel.render(100).join("\n");
|
|
167
|
+
assert.match(expanded, /Checked:/);
|
|
168
|
+
assert.match(expanded, /Extra quota/);
|
|
169
|
+
assert(renders > 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
void test("long lists keep highlight visible, back preserves identity, and cancellation returns undefined", () => {
|
|
173
|
+
const choices = Array.from({ length: 30 }, (_value, index) => ({ profile: `Profile-${index}`, label: `Profile-${index}`, usage: undefined }));
|
|
174
|
+
const results: (string | undefined)[] = [];
|
|
175
|
+
const panel = new AccountProfileSelector(choices, theme, () => 5, () => {}, value => results.push(value));
|
|
176
|
+
panel.handleInput("\u001b[F");
|
|
177
|
+
assert.match(panel.render(80).join("\n"), /> Profile-29/);
|
|
178
|
+
panel.handleInput("v");
|
|
179
|
+
assert.match(panel.render(80).join("\n"), /usage unavailable/i);
|
|
180
|
+
panel.handleInput("\u001b[B"); // Scroll, never change the profile in details.
|
|
181
|
+
panel.handleInput("\u001b");
|
|
182
|
+
assert.match(panel.render(80).join("\n"), /> Profile-29/);
|
|
183
|
+
panel.handleInput("\u001b");
|
|
184
|
+
panel.handleInput("\r");
|
|
185
|
+
assert.deepEqual(results, [undefined]);
|
|
186
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { renderCodexUsageDashboardContentLines } from "./codex-usage-ui.js";
|
|
4
|
+
import type { ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
5
|
+
import type { SimpleTheme } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface AccountProfileChoice {
|
|
8
|
+
profile: string;
|
|
9
|
+
label: string;
|
|
10
|
+
usage: ProfileCodexUsageResult | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Snapshot-only explorer: completion is possible only from the profile list. */
|
|
14
|
+
export class AccountProfileSelector implements Component {
|
|
15
|
+
private index = 0;
|
|
16
|
+
private details = false;
|
|
17
|
+
private scroll = 0;
|
|
18
|
+
private width = 80;
|
|
19
|
+
private closed = false;
|
|
20
|
+
|
|
21
|
+
private readonly choices: readonly AccountProfileChoice[];
|
|
22
|
+
private readonly theme: SimpleTheme;
|
|
23
|
+
private readonly height: () => number;
|
|
24
|
+
private readonly requestRender: () => void;
|
|
25
|
+
private readonly done: (profile: string | undefined) => void;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
choices: readonly AccountProfileChoice[],
|
|
29
|
+
theme: SimpleTheme,
|
|
30
|
+
height: () => number,
|
|
31
|
+
requestRender: () => void,
|
|
32
|
+
done: (profile: string | undefined) => void,
|
|
33
|
+
) {
|
|
34
|
+
this.choices = choices;
|
|
35
|
+
this.theme = theme;
|
|
36
|
+
this.height = height;
|
|
37
|
+
this.requestRender = requestRender;
|
|
38
|
+
this.done = done;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private capacity(): number { return Math.max(1, this.height() - 2); }
|
|
42
|
+
|
|
43
|
+
private detailLines(width: number): string[] {
|
|
44
|
+
const choice = this.choices[this.index];
|
|
45
|
+
const usage = choice?.usage;
|
|
46
|
+
if (!choice || usage?.status !== "ready" || usage.profile.toLowerCase() !== choice.profile.toLowerCase()) {
|
|
47
|
+
return [this.theme.fg("muted", "Profile usage unavailable. You can still switch from the list.")];
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
this.theme.fg("muted", `Checked: ${new Date(usage.checkedAt).toLocaleString()} (local time)`),
|
|
51
|
+
this.theme.fg("muted", `Updated: ${new Date(usage.quotaSnapshot.fetchedAt).toLocaleString()} (local time)`),
|
|
52
|
+
...renderCodexUsageDashboardContentLines(this.theme, usage.quotaSnapshot,
|
|
53
|
+
{ profile: choice.profile, provider: "openai-codex" }, usage.checkedAt, width),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
handleInput(data: string): void {
|
|
58
|
+
if (this.closed) return;
|
|
59
|
+
if (matchesKey(data, Key.escape) || (this.details && matchesKey(data, "b"))) {
|
|
60
|
+
if (this.details) { this.details = false; this.scroll = 0; }
|
|
61
|
+
else { this.closed = true; this.done(undefined); }
|
|
62
|
+
} else if (!this.details && matchesKey(data, Key.enter)) {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
this.done(this.choices[this.index]?.profile);
|
|
65
|
+
} else if (!this.details && matchesKey(data, "v")) {
|
|
66
|
+
this.details = true;
|
|
67
|
+
this.scroll = 0;
|
|
68
|
+
} else {
|
|
69
|
+
const maximum = this.details ? Math.max(0, this.detailLines(this.width).length - this.capacity())
|
|
70
|
+
: Math.max(0, this.choices.length - 1);
|
|
71
|
+
let position = this.details ? this.scroll : this.index;
|
|
72
|
+
if (matchesKey(data, Key.up)) position--;
|
|
73
|
+
else if (matchesKey(data, Key.down)) position++;
|
|
74
|
+
else if (matchesKey(data, Key.pageUp)) position -= this.capacity();
|
|
75
|
+
else if (matchesKey(data, Key.pageDown)) position += this.capacity();
|
|
76
|
+
else if (matchesKey(data, Key.home)) position = 0;
|
|
77
|
+
else if (matchesKey(data, Key.end)) position = maximum;
|
|
78
|
+
else return; // Enter, refresh and reset controls never act in details.
|
|
79
|
+
position = Math.max(0, Math.min(maximum, position));
|
|
80
|
+
if (this.details) this.scroll = position;
|
|
81
|
+
else this.index = position;
|
|
82
|
+
}
|
|
83
|
+
this.requestRender();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width: number): string[] {
|
|
87
|
+
this.width = Math.max(1, Math.floor(width));
|
|
88
|
+
const capacity = this.capacity();
|
|
89
|
+
let content: string[];
|
|
90
|
+
let hint: string;
|
|
91
|
+
if (this.details) {
|
|
92
|
+
const lines = this.detailLines(this.width);
|
|
93
|
+
this.scroll = Math.min(this.scroll, Math.max(0, lines.length - capacity));
|
|
94
|
+
content = lines.slice(this.scroll, this.scroll + capacity);
|
|
95
|
+
hint = `esc/b back · ↑/↓ PgUp/PgDn Home/End scroll · ${this.scroll + 1}–${Math.min(lines.length, this.scroll + capacity)}/${lines.length}`;
|
|
96
|
+
} else {
|
|
97
|
+
const start = Math.max(0, this.index - capacity + 1);
|
|
98
|
+
content = this.choices.slice(start, start + capacity).map((choice, offset) =>
|
|
99
|
+
this.theme.fg(start + offset === this.index ? "accent" : "text",
|
|
100
|
+
`${start + offset === this.index ? ">" : " "} ${choice.label}`));
|
|
101
|
+
hint = "↑/↓ highlight · v usage details · enter switch · esc cancel";
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
this.theme.fg("accent", this.details ? `Usage: ${this.choices[this.index]?.profile ?? "unknown"} (read-only)` : "Switch OpenAI account"),
|
|
105
|
+
...content,
|
|
106
|
+
this.theme.fg("muted", hint),
|
|
107
|
+
].slice(0, Math.max(1, this.height())).map(line => truncateToWidth(line, this.width, ""));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
invalidate(): void {} // Output is recomputed with the current theme and viewport.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function selectAccountProfile(
|
|
114
|
+
ui: Pick<ExtensionContext["ui"], "custom">,
|
|
115
|
+
choices: readonly AccountProfileChoice[],
|
|
116
|
+
): Promise<string | undefined> {
|
|
117
|
+
return ui.custom<string | undefined>((tui, theme, _keys, done) => new AccountProfileSelector(
|
|
118
|
+
// Match the SDK's 100%-height, zero-margin overlay viewport on every resize.
|
|
119
|
+
choices, theme, () => Math.max(1, tui.terminal.rows), () => tui.requestRender(), done,
|
|
120
|
+
), {
|
|
121
|
+
overlay: true,
|
|
122
|
+
overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, nonCapturing: false },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdtemp, mkdir, realpath, symlink } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { registerHooks } from "node:module";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
7
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
9
|
import { tmpdir } from "node:os";
|
|
4
10
|
import { join } from "node:path";
|
|
5
11
|
import test from "node:test";
|
|
6
|
-
|
|
12
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
13
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
14
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
15
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
16
|
+
}
|
|
17
|
+
return next(specifier, context);
|
|
18
|
+
} });
|
|
19
|
+
const {
|
|
7
20
|
availableProfiles,
|
|
8
21
|
manageAccountProfile,
|
|
9
22
|
manageAccountProfiles,
|
|
@@ -14,7 +27,214 @@ import {
|
|
|
14
27
|
switchAccountInPlace,
|
|
15
28
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
16
29
|
// @ts-ignore Node's native TypeScript runner resolves test-only TypeScript source imports.
|
|
17
|
-
}
|
|
30
|
+
} = await import("./account-profiles.ts");
|
|
31
|
+
|
|
32
|
+
const down = "\u001b[B";
|
|
33
|
+
const escape = "\u001b";
|
|
34
|
+
|
|
35
|
+
function customHarness(exercise: (panel: Component) => void): ExtensionContext["ui"]["custom"] {
|
|
36
|
+
return (factory) => new Promise(resolve => {
|
|
37
|
+
let completed = false;
|
|
38
|
+
let renders = 0;
|
|
39
|
+
const panel = factory(
|
|
40
|
+
{ terminal: { rows: 30 }, requestRender: () => { renders++; } } as unknown as TUI,
|
|
41
|
+
{ fg: (_color: string, text: string) => text } as unknown as Theme,
|
|
42
|
+
{} as Parameters<typeof factory>[2],
|
|
43
|
+
value => { completed = true; resolve(value); },
|
|
44
|
+
);
|
|
45
|
+
assert(!(panel instanceof Promise));
|
|
46
|
+
exercise(panel);
|
|
47
|
+
assert(completed, "custom selector must finish through done");
|
|
48
|
+
assert(renders > 0, "input changes request a render");
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
void test("Switch previews every profile but lists only names and markers, with quota details through V", async () => {
|
|
53
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
54
|
+
const profiles = ["Work", "work-alt", "expired", "unsupported", "failed"];
|
|
55
|
+
const requests: string[] = [];
|
|
56
|
+
const activated: string[] = [];
|
|
57
|
+
const switchEffects: string[] = [];
|
|
58
|
+
const usage = async (profile: string): Promise<ProfileCodexUsageResult> => {
|
|
59
|
+
requests.push(profile);
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
if (profile === "failed") throw new Error("private auth token");
|
|
62
|
+
if (profile === "expired" || profile === "unsupported")
|
|
63
|
+
return { status: "unavailable", profile, checkedAt: 0, reason: "stored-credentials-unavailable" };
|
|
64
|
+
return { status: "ready", profile, checkedAt: 0, quotaSnapshot: {
|
|
65
|
+
fetchedAt: 0, planType: undefined, credits: undefined, ordinaryUsageAllowed: undefined,
|
|
66
|
+
buckets: [{ id: "codex", label: undefined,
|
|
67
|
+
primary: { usedPercent: 25, windowMinutes: 300, resetsAt: 3600 },
|
|
68
|
+
secondary: { usedPercent: 60, windowMinutes: 10080, resetsAt: 86400 } }],
|
|
69
|
+
} };
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
await manageAccountProfiles({ isIdle: () => true, ui: {
|
|
73
|
+
notify: () => undefined, input: () => Promise.resolve(undefined),
|
|
74
|
+
select: title => {
|
|
75
|
+
assert.equal(title, "OpenAI account manager", "profile picker must use custom UI");
|
|
76
|
+
return Promise.resolve("Switch");
|
|
77
|
+
},
|
|
78
|
+
custom: customHarness(panel => {
|
|
79
|
+
assert.deepEqual(requests, profiles);
|
|
80
|
+
const rows = panel.render(300);
|
|
81
|
+
assert.deepEqual(rows.slice(1, -1), [
|
|
82
|
+
"> Work (active) (default)", " work-alt", " expired", " unsupported", " failed",
|
|
83
|
+
]);
|
|
84
|
+
assert.doesNotMatch(rows.join("\n"), /%|resets|unavailable|private|token/);
|
|
85
|
+
panel.handleInput?.(down);
|
|
86
|
+
panel.handleInput?.("v");
|
|
87
|
+
assert.match(panel.render(100).join("\n"), /work-alt/);
|
|
88
|
+
const details = panel.render(100).join("\n");
|
|
89
|
+
assert.match(details, /Session/);
|
|
90
|
+
assert.match(details, /75%/);
|
|
91
|
+
assert.match(details, /Weekly/);
|
|
92
|
+
assert.match(details, /40%/);
|
|
93
|
+
assert.match(details, /reset/i);
|
|
94
|
+
panel.handleInput?.("\r");
|
|
95
|
+
panel.handleInput?.("r");
|
|
96
|
+
panel.handleInput?.("u");
|
|
97
|
+
assert.deepEqual(activated, []);
|
|
98
|
+
assert.deepEqual(switchEffects, []);
|
|
99
|
+
assert.equal(process.env.OSDY_PI_PROFILE_NAME, previous);
|
|
100
|
+
assert.deepEqual(requests, profiles);
|
|
101
|
+
panel.handleInput?.(escape);
|
|
102
|
+
assert.match(panel.render(300).join("\n"), /> work-alt/);
|
|
103
|
+
panel.handleInput?.("\r");
|
|
104
|
+
}),
|
|
105
|
+
} }, { profiles: () => Promise.resolve(profiles), activeProfile: "WORK",
|
|
106
|
+
run: () => Promise.resolve({ code: 0, stdout: "work", stderr: "" }),
|
|
107
|
+
previewUsage: usage, activate: profile => { activated.push(profile); return Promise.resolve(); },
|
|
108
|
+
refreshUsage: () => { switchEffects.push("refresh"); return Promise.resolve(); },
|
|
109
|
+
requestRender: () => { switchEffects.push("active render"); },
|
|
110
|
+
});
|
|
111
|
+
assert.deepEqual(activated, ["work-alt"]);
|
|
112
|
+
assert.deepEqual(switchEffects, ["active render", "refresh"]);
|
|
113
|
+
} finally {
|
|
114
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
115
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
void test("Switch cancellation never activates; unavailable details stay safe and selectable", async () => {
|
|
120
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
121
|
+
try {
|
|
122
|
+
for (const choice of ["cancel", "unavailable"] as const) {
|
|
123
|
+
let menus = 0;
|
|
124
|
+
const activated: string[] = [];
|
|
125
|
+
await manageAccountProfiles({ isIdle: () => true, ui: {
|
|
126
|
+
notify: () => undefined, input: () => Promise.resolve(undefined),
|
|
127
|
+
select: () => Promise.resolve(menus++ === 0 ? "Switch" : "Cancel"),
|
|
128
|
+
custom: customHarness(panel => {
|
|
129
|
+
assert.deepEqual(panel.render(100).slice(1, -1), ["> Work"]);
|
|
130
|
+
assert.doesNotMatch(panel.render(100).join("\n"), /%|resets|unavailable/);
|
|
131
|
+
panel.handleInput?.("v");
|
|
132
|
+
assert.match(panel.render(100).join("\n"), /Work/);
|
|
133
|
+
assert.match(panel.render(100).join("\n"), /usage unavailable/i);
|
|
134
|
+
panel.handleInput?.("\r");
|
|
135
|
+
assert.deepEqual(activated, []);
|
|
136
|
+
panel.handleInput?.("b");
|
|
137
|
+
assert.deepEqual(panel.render(100).slice(1, -1), ["> Work"]);
|
|
138
|
+
panel.handleInput?.(choice === "cancel" ? escape : "\r");
|
|
139
|
+
}),
|
|
140
|
+
} }, { profiles: () => Promise.resolve(["Work"]),
|
|
141
|
+
run: () => Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }),
|
|
142
|
+
previewUsage: profile => Promise.resolve({ status: "unavailable", profile, checkedAt: 0, reason: "remote-usage-unavailable" }),
|
|
143
|
+
activate: profile => { activated.push(profile); return Promise.resolve(); },
|
|
144
|
+
refreshUsage: () => { assert.equal(choice, "unavailable"); return Promise.resolve(); },
|
|
145
|
+
});
|
|
146
|
+
assert.deepEqual(activated, choice === "unavailable" ? ["Work"] : []);
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
150
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
void test("Switch bounds concurrent previews and safely handles empty, cancelled and mismatched results", async () => {
|
|
155
|
+
const profiles = ["one", "two", "three", "four", "five"];
|
|
156
|
+
const pending: (() => void)[] = [];
|
|
157
|
+
let active = 0;
|
|
158
|
+
let maximum = 0;
|
|
159
|
+
let queried = 0;
|
|
160
|
+
let menu = 0;
|
|
161
|
+
const work = manageAccountProfiles({ isIdle: () => true, ui: {
|
|
162
|
+
notify: () => undefined, input: () => Promise.resolve(undefined),
|
|
163
|
+
select: () => Promise.resolve(menu++ === 0 ? "Switch" : "Cancel"),
|
|
164
|
+
custom: customHarness(panel => {
|
|
165
|
+
assert.equal(queried, profiles.length);
|
|
166
|
+
assert.equal(active, 0);
|
|
167
|
+
const rows = panel.render(100);
|
|
168
|
+
assert.deepEqual(rows.slice(1, -1), profiles.map((profile, index) => `${index === 0 ? ">" : " "} ${profile}`));
|
|
169
|
+
assert.doesNotMatch(rows.join("\n"), /%|resets|unavailable/);
|
|
170
|
+
panel.handleInput?.(down);
|
|
171
|
+
panel.handleInput?.("v");
|
|
172
|
+
assert.match(panel.render(100).join("\n"), /usage unavailable/i);
|
|
173
|
+
assert.doesNotMatch(panel.render(100).join("\n"), /other/);
|
|
174
|
+
panel.handleInput?.(escape);
|
|
175
|
+
panel.handleInput?.(escape);
|
|
176
|
+
}),
|
|
177
|
+
} }, { profiles: () => Promise.resolve(profiles),
|
|
178
|
+
run: () => Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }),
|
|
179
|
+
activate: () => Promise.resolve(assert.fail("preview/cancel cannot activate")),
|
|
180
|
+
previewUsage: profile => new Promise<ProfileCodexUsageResult>(resolve => {
|
|
181
|
+
queried++;
|
|
182
|
+
maximum = Math.max(maximum, ++active);
|
|
183
|
+
pending.push(() => {
|
|
184
|
+
active--;
|
|
185
|
+
resolve(profile === "one" ? { status: "cancelled", profile, checkedAt: 0, reason: "cancelled" }
|
|
186
|
+
: { status: "ready", profile: profile === "two" ? "other" : profile, checkedAt: 0,
|
|
187
|
+
quotaSnapshot: { fetchedAt: 0, buckets: [], planType: undefined, credits: undefined, ordinaryUsageAllowed: undefined } });
|
|
188
|
+
});
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
192
|
+
assert.equal(queried, 4);
|
|
193
|
+
while (pending.length) {
|
|
194
|
+
pending.shift()!();
|
|
195
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
196
|
+
}
|
|
197
|
+
await work;
|
|
198
|
+
assert.equal(maximum, 4);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
void test("Switch details remain read-only while busy and confirmation retains idle guarding", async () => {
|
|
202
|
+
let menu = 0;
|
|
203
|
+
let waits = 0;
|
|
204
|
+
const notices: string[] = [];
|
|
205
|
+
await manageAccountProfiles({ isIdle: () => false,
|
|
206
|
+
waitForIdle: () => { waits++; return Promise.resolve(); }, ui: {
|
|
207
|
+
notify: message => notices.push(message), input: () => Promise.resolve(undefined),
|
|
208
|
+
select: () => Promise.resolve(menu++ === 0 ? "Switch" : "Cancel"),
|
|
209
|
+
custom: customHarness(panel => {
|
|
210
|
+
panel.handleInput?.("v");
|
|
211
|
+
panel.handleInput?.("\r");
|
|
212
|
+
assert.equal(waits, 0);
|
|
213
|
+
panel.handleInput?.(escape);
|
|
214
|
+
panel.handleInput?.("\r");
|
|
215
|
+
}),
|
|
216
|
+
} }, { profiles: () => Promise.resolve(["Work"]),
|
|
217
|
+
run: () => Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }),
|
|
218
|
+
activate: () => Promise.resolve(assert.fail("busy confirmation cannot activate")),
|
|
219
|
+
refreshUsage: () => Promise.resolve(assert.fail("busy confirmation cannot refresh")),
|
|
220
|
+
});
|
|
221
|
+
assert.equal(waits, 1);
|
|
222
|
+
assert.match(notices.join(" "), /require Pi to be idle/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
void test("non-terminal Switch is rejected before reading preview snapshots", async () => {
|
|
226
|
+
let menu = 0;
|
|
227
|
+
const notices: string[] = [];
|
|
228
|
+
await manageAccountProfiles({ mode: "rpc", isIdle: () => true, ui: {
|
|
229
|
+
notify: message => notices.push(message), input: () => Promise.resolve(undefined),
|
|
230
|
+
select: () => Promise.resolve(menu++ === 0 ? "Switch" : "Cancel"),
|
|
231
|
+
custom: () => Promise.resolve(assert.fail("RPC cannot open custom UI")),
|
|
232
|
+
} }, { profiles: () => Promise.resolve(["Work"]),
|
|
233
|
+
run: () => Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }),
|
|
234
|
+
previewUsage: () => Promise.resolve(assert.fail("RPC cannot query previews")),
|
|
235
|
+
});
|
|
236
|
+
assert.match(notices.join(" "), /terminal UI/);
|
|
237
|
+
});
|
|
18
238
|
|
|
19
239
|
void test("stored credential adapter narrows unknown data and conceals reader paths", async () => {
|
|
20
240
|
const credential = { profile: "Work", access: "synthetic", expires: 2_000_000_000_000 };
|
|
@@ -5,6 +5,9 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
8
|
+
import type { ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
9
|
+
import { selectAccountProfile, type AccountProfileChoice } from "./account-profile-selector.js";
|
|
10
|
+
|
|
8
11
|
const PROFILE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})$/;
|
|
9
12
|
const RESERVED_PROFILE_NAMES = new Set(["default", "profiles", "auth.json"]);
|
|
10
13
|
|
|
@@ -20,10 +23,12 @@ export type AccountContext = Pick<ExtensionContext, "isIdle"> & {
|
|
|
20
23
|
ExtensionCommandContext["sessionManager"],
|
|
21
24
|
"getSessionFile"
|
|
22
25
|
>;
|
|
23
|
-
|
|
26
|
+
mode?: ExtensionContext["mode"];
|
|
27
|
+
ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input"> & Partial<Pick<ExtensionContext["ui"], "custom">>;
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
export interface AccountManagementDependencies {
|
|
31
|
+
previewUsage?: ((profile: string) => Promise<ProfileCodexUsageResult>) | undefined;
|
|
27
32
|
profiles(): Promise<string[]>;
|
|
28
33
|
run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }>;
|
|
29
34
|
activeProfile?: string | undefined;
|
|
@@ -33,6 +38,7 @@ export interface AccountManagementDependencies {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface AccountProfilesCommandDependencies {
|
|
41
|
+
previewUsage?: AccountManagementDependencies["previewUsage"];
|
|
36
42
|
refreshUsage?: (() => Promise<void>) | undefined;
|
|
37
43
|
requestRender?: (() => void) | undefined;
|
|
38
44
|
}
|
|
@@ -226,6 +232,31 @@ function markedProfiles(
|
|
|
226
232
|
);
|
|
227
233
|
}
|
|
228
234
|
|
|
235
|
+
/** Keep identity separate from display text and bound simultaneous stored-profile requests. */
|
|
236
|
+
async function switchProfileChoices(
|
|
237
|
+
profiles: string[],
|
|
238
|
+
dependencies: AccountManagementDependencies,
|
|
239
|
+
defaultProfile: string | undefined,
|
|
240
|
+
): Promise<AccountProfileChoice[]> {
|
|
241
|
+
const labels = markedProfiles(profiles, dependencies.activeProfile, defaultProfile);
|
|
242
|
+
const choices: AccountProfileChoice[] = profiles.map((profile, index) => ({ profile, label: labels[index]!, usage: undefined }));
|
|
243
|
+
let next = 0;
|
|
244
|
+
await Promise.all(Array.from({ length: Math.min(4, profiles.length) }, async () => {
|
|
245
|
+
while (next < profiles.length) {
|
|
246
|
+
const index = next++;
|
|
247
|
+
const profile = profiles[index]!;
|
|
248
|
+
try {
|
|
249
|
+
const result = await dependencies.previewUsage?.(profile);
|
|
250
|
+
if (result && sameProfile(result.profile, profile)) choices[index]!.usage = result;
|
|
251
|
+
} catch {
|
|
252
|
+
// Preview failure never blocks activation or exposes credential/network errors.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}));
|
|
257
|
+
return choices;
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
async function currentDefault(
|
|
230
261
|
dependencies: AccountManagementDependencies,
|
|
231
262
|
): Promise<DefaultAccountState> {
|
|
@@ -464,18 +495,13 @@ export async function manageAccountProfiles(
|
|
|
464
495
|
ctx.ui.notify("No account profiles are available.", "warning");
|
|
465
496
|
continue;
|
|
466
497
|
}
|
|
467
|
-
|
|
468
|
-
"
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
);
|
|
475
|
-
if (target === undefined) continue;
|
|
476
|
-
const marker = target.indexOf(" (");
|
|
477
|
-
const profile = marker === -1 ? target : target.slice(0, marker);
|
|
478
|
-
if (!profiles.includes(profile)) continue;
|
|
498
|
+
if (!ctx.ui.custom || (ctx.mode !== undefined && ctx.mode !== "tui")) {
|
|
499
|
+
ctx.ui.notify("Account switching requires Pi's terminal UI.", "warning");
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const choices = await switchProfileChoices(profiles, dependencies, defaultProfile);
|
|
503
|
+
const profile = await selectAccountProfile({ custom: ctx.ui.custom.bind(ctx.ui) }, choices);
|
|
504
|
+
if (profile === undefined || !profiles.includes(profile)) continue;
|
|
479
505
|
if (sameProfile(profile, dependencies.activeProfile)) {
|
|
480
506
|
ctx.ui.notify("That account is already active.", "info");
|
|
481
507
|
continue;
|
|
@@ -528,6 +554,7 @@ export function createAccountManagementDependencies(
|
|
|
528
554
|
profiles: async () => availableProfiles(await sharedAgentDir(process.env)),
|
|
529
555
|
run: runBundledCommand,
|
|
530
556
|
activeProfile: process.env.OSDY_PI_PROFILE_NAME,
|
|
557
|
+
previewUsage: options.previewUsage,
|
|
531
558
|
refreshUsage: options.refreshUsage,
|
|
532
559
|
requestRender: options.requestRender,
|
|
533
560
|
activate: async (profile) => {
|
|
@@ -56,7 +56,7 @@ void test("formats the selected single-line usage shape", () => {
|
|
|
56
56
|
percent: 60,
|
|
57
57
|
contextWindow: 272_000,
|
|
58
58
|
}),
|
|
59
|
-
"tok
|
|
59
|
+
"tok/s — · $0.0000 · ctx <warning>[██████░░░░]</warning> 60%/272.0k",
|
|
60
60
|
);
|
|
61
61
|
});
|
|
62
62
|
|
|
@@ -76,14 +76,16 @@ void test("selects the full or compact usage line at the ANSI-aware width bounda
|
|
|
76
76
|
contextWindow: 272_000,
|
|
77
77
|
};
|
|
78
78
|
const full =
|
|
79
|
-
"tok
|
|
79
|
+
"tok/s — · $0.0000 · ctx \u001B[33m[██████░░░░]\u001B[39m 60%/272.0k";
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
assert.equal(formatContextUsage(ansiTheme, usage,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
81
|
+
const width = visibleWidth(full);
|
|
82
|
+
assert.equal(formatContextUsage(ansiTheme, usage, width), full);
|
|
83
|
+
const compact = formatContextUsage(ansiTheme, usage, width - 1);
|
|
84
|
+
assert.match(compact, /tok\/s —/);
|
|
85
|
+
assert.ok(visibleWidth(compact) <= width - 1);
|
|
86
|
+
for (let columns = 0; columns < width; columns++) {
|
|
87
|
+
assert.ok(visibleWidth(formatContextUsage(ansiTheme, usage, columns)) <= columns);
|
|
88
|
+
}
|
|
87
89
|
});
|
|
88
90
|
|
|
89
91
|
void test("formats a ten-cell green context bar at the inclusive 30% threshold", () => {
|
|
@@ -97,7 +99,7 @@ void test("formats a ten-cell green context bar at the inclusive 30% threshold",
|
|
|
97
99
|
percent: 30,
|
|
98
100
|
contextWindow: 272_000,
|
|
99
101
|
}),
|
|
100
|
-
"tok
|
|
102
|
+
"tok/s — · $0.0000 · ctx <success>[███░░░░░░░]</success> 30%/272.0k",
|
|
101
103
|
);
|
|
102
104
|
});
|
|
103
105
|
|
|
@@ -112,7 +114,7 @@ void test("uses warning and error colors for higher rounded usage", () => {
|
|
|
112
114
|
percent: 30.5,
|
|
113
115
|
contextWindow: 200_000,
|
|
114
116
|
}),
|
|
115
|
-
"tok
|
|
117
|
+
"tok/s — R12 W34 · $1.2000 · ctx <warning>[███░░░░░░░]</warning> 31%/200.0k",
|
|
116
118
|
);
|
|
117
119
|
assert.equal(
|
|
118
120
|
formatContextUsage(theme, {
|
|
@@ -124,7 +126,7 @@ void test("uses warning and error colors for higher rounded usage", () => {
|
|
|
124
126
|
percent: 80.5,
|
|
125
127
|
contextWindow: 200_000,
|
|
126
128
|
}),
|
|
127
|
-
"tok
|
|
129
|
+
"tok/s — · $1.2000 · ctx <error>[████████░░]</error> 81%/200.0k",
|
|
128
130
|
);
|
|
129
131
|
});
|
|
130
132
|
|
|
@@ -139,7 +141,7 @@ void test("clamps usage and safely represents missing context usage", () => {
|
|
|
139
141
|
percent: -10,
|
|
140
142
|
contextWindow: 200_000,
|
|
141
143
|
}),
|
|
142
|
-
"tok
|
|
144
|
+
"tok/s — · $0.0000 · ctx <success>[░░░░░░░░░░]</success> 0%/200.0k",
|
|
143
145
|
);
|
|
144
146
|
assert.equal(
|
|
145
147
|
formatContextUsage(theme, {
|
|
@@ -151,7 +153,7 @@ void test("clamps usage and safely represents missing context usage", () => {
|
|
|
151
153
|
percent: undefined,
|
|
152
154
|
contextWindow: 272_000,
|
|
153
155
|
}),
|
|
154
|
-
"tok
|
|
156
|
+
"tok/s — · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/272.0k",
|
|
155
157
|
);
|
|
156
158
|
assert.equal(
|
|
157
159
|
formatContextUsage(theme, {
|
|
@@ -163,6 +165,6 @@ void test("clamps usage and safely represents missing context usage", () => {
|
|
|
163
165
|
percent: undefined,
|
|
164
166
|
contextWindow: undefined,
|
|
165
167
|
}),
|
|
166
|
-
"tok
|
|
168
|
+
"tok/s — · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/?",
|
|
167
169
|
);
|
|
168
170
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { GenerationReading } from "./generation-meter.js";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import { shortNumber } from "./format.js";
|
|
3
4
|
|
|
4
5
|
export type ContextUsageDisplayData = {
|
|
@@ -7,6 +8,7 @@ export type ContextUsageDisplayData = {
|
|
|
7
8
|
cacheRead: number;
|
|
8
9
|
cacheWrite: number;
|
|
9
10
|
cost: number;
|
|
11
|
+
generation?: GenerationReading | undefined;
|
|
10
12
|
percent: number | undefined;
|
|
11
13
|
contextWindow: number | undefined;
|
|
12
14
|
};
|
|
@@ -53,8 +55,17 @@ export function formatContextUsage(
|
|
|
53
55
|
usageTone(displayPercent),
|
|
54
56
|
`[${usageBar(displayPercent)}]`,
|
|
55
57
|
);
|
|
56
|
-
const
|
|
58
|
+
const generation = usage.generation;
|
|
59
|
+
const rate = generation?.kind === "complete" || generation?.kind === "streaming"
|
|
60
|
+
? generation.tokensPerSecond : null;
|
|
61
|
+
const speed = rate !== null && Number.isFinite(rate) && rate > 0
|
|
62
|
+
? `${generation?.kind === "streaming" ? "≈" : ""}${rate.toFixed(1)} tok/s`
|
|
63
|
+
: generation?.kind === "streaming" ? "tok/s …" : "tok/s —";
|
|
64
|
+
const full = `${speed}${cacheLabel} · $${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%/${contextWindowLabel}`;
|
|
57
65
|
if (availableWidth === undefined || visibleWidth(full) <= availableWidth)
|
|
58
66
|
return full;
|
|
59
|
-
|
|
67
|
+
const compact = `${speed}${cacheLabel} · $${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%`;
|
|
68
|
+
if (visibleWidth(compact) <= availableWidth) return compact;
|
|
69
|
+
const minimal = `${speed} · $${usage.cost.toFixed(4)} · ctx ${percentageLabel}%`;
|
|
70
|
+
return truncateToWidth(minimal, Math.max(0, availableWidth), "");
|
|
60
71
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
// @ts-expect-error Native test runner resolves TypeScript source imports.
|
|
4
|
+
import { createGenerationMeter } from "./generation-meter.ts";
|
|
5
|
+
|
|
6
|
+
void test("estimates characters, then uses finalized output over first-output-to-end time", () => {
|
|
7
|
+
let now = 0;
|
|
8
|
+
const meter = createGenerationMeter(() => now);
|
|
9
|
+
assert.deepEqual(meter.start(), { kind: "streaming", tokensPerSecond: null });
|
|
10
|
+
now = 5000; // Request latency is not generation time.
|
|
11
|
+
meter.delta("abcd");
|
|
12
|
+
now = 6000;
|
|
13
|
+
assert.deepEqual(meter.delta("abcdefghijkl"), { kind: "streaming", tokensPerSecond: 4 });
|
|
14
|
+
now = 7000;
|
|
15
|
+
assert.deepEqual(meter.finish(100, "stop"), { kind: "complete", tokensPerSecond: 50 });
|
|
16
|
+
now = 9000;
|
|
17
|
+
assert.deepEqual(meter.delta("ignored"), { kind: "complete", tokensPerSecond: 50 });
|
|
18
|
+
assert.deepEqual(meter.start(), { kind: "streaming", tokensPerSecond: null });
|
|
19
|
+
assert.deepEqual(meter.reset(), { kind: "idle" });
|
|
20
|
+
assert.deepEqual(meter.finish(100, "stop"), { kind: "idle" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
void test("empty output, short/invalid intervals, invalid usage and interrupted responses are unavailable", () => {
|
|
24
|
+
for (const duration of [0, 99, -1, Number.NaN, Infinity]) {
|
|
25
|
+
let now = 0;
|
|
26
|
+
const meter = createGenerationMeter(() => now);
|
|
27
|
+
meter.start();
|
|
28
|
+
meter.delta("abcd");
|
|
29
|
+
now = duration;
|
|
30
|
+
assert.equal(meter.delta("").kind, "streaming");
|
|
31
|
+
assert.deepEqual(meter.finish(20, "stop"), { kind: "unavailable" });
|
|
32
|
+
}
|
|
33
|
+
for (const output of [undefined, 0, -1, 1.5, Number.NaN, Infinity]) {
|
|
34
|
+
let now = 0;
|
|
35
|
+
const meter = createGenerationMeter(() => now);
|
|
36
|
+
meter.start(); meter.delta("abcd"); now = 1000;
|
|
37
|
+
assert.deepEqual(meter.finish(output, "stop"), { kind: "unavailable" });
|
|
38
|
+
}
|
|
39
|
+
for (const reason of ["aborted", "error", "pending", "deferred", undefined]) {
|
|
40
|
+
let now = 0;
|
|
41
|
+
const meter = createGenerationMeter(() => now);
|
|
42
|
+
meter.start(); meter.delta("abcd"); now = 1000;
|
|
43
|
+
assert.deepEqual(meter.finish(20, reason), { kind: "unavailable" });
|
|
44
|
+
}
|
|
45
|
+
const meter = createGenerationMeter(() => 1000);
|
|
46
|
+
meter.start(); meter.delta("");
|
|
47
|
+
assert.deepEqual(meter.finish(20, "toolUse"), { kind: "unavailable" });
|
|
48
|
+
meter.start();
|
|
49
|
+
assert.deepEqual(meter.interrupt(), { kind: "unavailable" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
void test("clock regression invalidates the response, and normal length/tool-use endings are valid", () => {
|
|
53
|
+
for (const reason of ["length", "toolUse"]) {
|
|
54
|
+
let now = 1000;
|
|
55
|
+
const meter = createGenerationMeter(() => now);
|
|
56
|
+
meter.start(); meter.delta("abcd"); now = 2000;
|
|
57
|
+
assert.deepEqual(meter.finish(20, reason), { kind: "complete", tokensPerSecond: 20 });
|
|
58
|
+
assert.deepEqual(meter.interrupt(), { kind: "complete", tokensPerSecond: 20 });
|
|
59
|
+
meter.start(); meter.delta("abcd"); now = 1900; meter.delta("efgh"); now = 3000;
|
|
60
|
+
assert.deepEqual(meter.finish(20, reason), { kind: "unavailable" });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type GenerationReading =
|
|
2
|
+
| { kind: "idle" | "unavailable" }
|
|
3
|
+
| { kind: "streaming"; tokensPerSecond: number | null }
|
|
4
|
+
| { kind: "complete"; tokensPerSecond: number };
|
|
5
|
+
|
|
6
|
+
export interface GenerationMeter {
|
|
7
|
+
start(): GenerationReading;
|
|
8
|
+
delta(text: string): GenerationReading;
|
|
9
|
+
finish(output: number | undefined, stopReason: string | undefined): GenerationReading;
|
|
10
|
+
interrupt(): GenerationReading;
|
|
11
|
+
reset(): GenerationReading;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Volatile, per-message measurement. No partial-message references or timers.
|
|
15
|
+
* Live UTF-16 characters / 4 is a heuristic, never tokenizer/server telemetry.
|
|
16
|
+
*/
|
|
17
|
+
export function createGenerationMeter(now: () => number = () => performance.now()): GenerationMeter {
|
|
18
|
+
let reading: GenerationReading = { kind: "idle" };
|
|
19
|
+
let firstOutput: number | undefined;
|
|
20
|
+
let lastTime: number | undefined;
|
|
21
|
+
let invalidClock = false;
|
|
22
|
+
let characters = 0;
|
|
23
|
+
const sample = (): number => {
|
|
24
|
+
const time = now();
|
|
25
|
+
if (!Number.isFinite(time) || (lastTime !== undefined && time < lastTime)) invalidClock = true;
|
|
26
|
+
lastTime = time;
|
|
27
|
+
return time;
|
|
28
|
+
};
|
|
29
|
+
const rate = (tokens: number, time: number): number | null => {
|
|
30
|
+
const elapsed = firstOutput === undefined ? 0 : time - firstOutput;
|
|
31
|
+
// Sub-100ms observations are too short to present a useful rate.
|
|
32
|
+
if (invalidClock || elapsed < 100 || !Number.isFinite(elapsed)) return null;
|
|
33
|
+
const value = tokens / (elapsed / 1000);
|
|
34
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
35
|
+
};
|
|
36
|
+
const reset = (): GenerationReading => {
|
|
37
|
+
firstOutput = undefined;
|
|
38
|
+
lastTime = undefined;
|
|
39
|
+
invalidClock = false;
|
|
40
|
+
characters = 0;
|
|
41
|
+
return reading = { kind: "idle" };
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
reset,
|
|
45
|
+
start() {
|
|
46
|
+
reset();
|
|
47
|
+
return reading = { kind: "streaming", tokensPerSecond: null };
|
|
48
|
+
},
|
|
49
|
+
delta(text) {
|
|
50
|
+
if (reading.kind !== "streaming" || !text) return reading;
|
|
51
|
+
const time = sample();
|
|
52
|
+
firstOutput ??= time;
|
|
53
|
+
characters += text.length;
|
|
54
|
+
return reading = { kind: "streaming", tokensPerSecond: rate(characters / 4, time) };
|
|
55
|
+
},
|
|
56
|
+
finish(output, stopReason) {
|
|
57
|
+
if (reading.kind !== "streaming") return reading;
|
|
58
|
+
const time = sample();
|
|
59
|
+
const completed = stopReason === "stop" || stopReason === "length" || stopReason === "toolUse";
|
|
60
|
+
const value = completed && output !== undefined && Number.isSafeInteger(output) && output > 0
|
|
61
|
+
? rate(output, time) : null;
|
|
62
|
+
return reading = value === null ? { kind: "unavailable" } : { kind: "complete", tokensPerSecond: value };
|
|
63
|
+
},
|
|
64
|
+
interrupt() {
|
|
65
|
+
if (reading.kind === "streaming") reading = { kind: "unavailable" };
|
|
66
|
+
return reading;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -50,7 +50,7 @@ registerHooks({
|
|
|
50
50
|
type ShortcutRegistration = Parameters<ExtensionAPI["registerShortcut"]>[1];
|
|
51
51
|
|
|
52
52
|
async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset-ui-runtime-")),
|
|
53
|
-
options: Pick<NonNullable<Parameters<typeof registerOsdyPi>[1]>, "fetchCodexUsage"> = {}) {
|
|
53
|
+
options: Pick<NonNullable<Parameters<typeof registerOsdyPi>[1]>, "fetchCodexUsage" | "generationClock"> = {}) {
|
|
54
54
|
const { registerOsdyPi: registerResetRuntime } = await import("./runtime.js");
|
|
55
55
|
const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
56
56
|
const previousFetch = globalThis.fetch;
|
|
@@ -69,6 +69,10 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
|
|
|
69
69
|
let beforeAuth: () => Promise<void> = () => Promise.resolve();
|
|
70
70
|
let onRender: (view: string) => void = () => {};
|
|
71
71
|
let panel: Component | undefined;
|
|
72
|
+
let footer: Component | undefined;
|
|
73
|
+
let renders = 0;
|
|
74
|
+
let mounts = 0;
|
|
75
|
+
const tui = { terminal: { columns: 120, rows: 40 }, requestRender: () => { renders++; }, hasOverlay: () => false } as unknown as TUI;
|
|
72
76
|
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
73
77
|
const views: string[] = [];
|
|
74
78
|
let quotaReads = 0;
|
|
@@ -99,15 +103,18 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
|
|
|
99
103
|
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) => {
|
|
100
104
|
const list = events.get(name) ?? []; list.push(handler); events.set(name, list);
|
|
101
105
|
}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
|
|
102
|
-
events: { on: () => () => {} }, exec: () => { throw new Error("external execution forbidden"); },
|
|
106
|
+
getThinkingLevel: () => "off", events: { on: () => () => {} }, exec: () => { throw new Error("external execution forbidden"); },
|
|
103
107
|
} as unknown as ExtensionAPI;
|
|
104
108
|
const ctx = { cwd: root, hasUI: true, mode: "tui", model: { provider: "openai-codex", id: "synthetic" }, isIdle: () => true,
|
|
105
|
-
|
|
109
|
+
getContextUsage: () => undefined,
|
|
110
|
+
sessionManager: { getSessionId: () => session, getEntries: () => [] }, modelRegistry: { getProviderAuth: async (provider: string) => {
|
|
106
111
|
assert.equal(provider, "openai-codex");
|
|
107
112
|
const token = `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: account } })).toString("base64url")}.signature`;
|
|
108
113
|
await beforeAuth();
|
|
109
114
|
return { auth: { apiKey: token } };
|
|
110
|
-
} }, ui: { getEditorComponent: () => undefined, setEditorComponent: () => {
|
|
115
|
+
} }, ui: { getEditorComponent: () => undefined, setEditorComponent: () => { mounts++; },
|
|
116
|
+
setHeader: (factory?: (tui: TUI, theme: Theme) => Component & { dispose?(): void }) => { factory?.(tui, ctx.ui.theme).dispose?.(); },
|
|
117
|
+
setFooter: (factory?: (tui: TUI, theme: Theme, data: unknown) => Component) => { footer = factory?.(tui, ctx.ui.theme, {}); },
|
|
111
118
|
setWidget: () => {}, setWorkingVisible: () => {}, notify: (text: string) => { notices.push(text); },
|
|
112
119
|
theme: { name: "dark", appearance: "dark", fg: (_color: string, text: string) => text }, getAllThemes: () => [], setTheme: () => ({ success: false }),
|
|
113
120
|
select: (title: string, options: string[]) => {
|
|
@@ -145,7 +152,11 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
|
|
|
145
152
|
}) } });
|
|
146
153
|
await lifecycle("session_start");
|
|
147
154
|
} catch (error) { globalThis.fetch = previousFetch; if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousDir; throw error; }
|
|
148
|
-
return { root, posts, dialogs, notices, views, expiry,
|
|
155
|
+
return { root, posts, dialogs, notices, views, expiry,
|
|
156
|
+
emit: async (name: string, event: unknown) => { for (const handler of events.get(name) ?? []) await handler(event, ctx); },
|
|
157
|
+
enable: () => commands.get("osdy-pi")!.handler("enable", ctx),
|
|
158
|
+
footer: () => footer?.render(120).join("\n") ?? "", renders: () => renders, mounts: () => mounts,
|
|
159
|
+
quotaReads: () => quotaReads, overlays: () => overlays,
|
|
149
160
|
open: (surface = "usage") => { nextAction = true; return commands.get(surface)!.handler("", ctx); },
|
|
150
161
|
view: (shortcut = false) => { nextAction = false; return shortcut
|
|
151
162
|
? shortcuts.get("ctrl+alt+u")!.handler(ctx) : commands.get("usage")!.handler("", ctx); },
|
|
@@ -163,6 +174,60 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
|
|
|
163
174
|
};
|
|
164
175
|
}
|
|
165
176
|
|
|
177
|
+
void test("generation lifecycle uses delta-only estimates and final event usage without remounting", async () => {
|
|
178
|
+
let now = 0;
|
|
179
|
+
const f = await resetRuntimeFixture(undefined, { generationClock: () => now });
|
|
180
|
+
const message = { role: "assistant", usage: { output: 80, reasoning: 40 }, stopReason: "stop" };
|
|
181
|
+
const delta = (type: string, text: string) => f.emit("message_update", {
|
|
182
|
+
message, assistantMessageEvent: { type, delta: text, partial: message },
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
await f.enable();
|
|
186
|
+
const mounts = f.mounts();
|
|
187
|
+
await f.emit("message_start", { message });
|
|
188
|
+
assert.match(f.footer(), /tok\/s …/);
|
|
189
|
+
now = 5000; await delta("thinking_delta", "abcd");
|
|
190
|
+
now = 6000; await delta("text_delta", "abcd");
|
|
191
|
+
await delta("toolcall_delta", "abcd");
|
|
192
|
+
assert.match(f.footer(), /≈3\.0 tok\/s/);
|
|
193
|
+
await delta("text_end", "not counted");
|
|
194
|
+
assert.match(f.footer(), /≈3\.0 tok\/s/);
|
|
195
|
+
now = 7000; await f.emit("message_end", { message });
|
|
196
|
+
assert.match(f.footer(), /40\.0 tok\/s/);
|
|
197
|
+
assert.doesNotMatch(f.footer(), /≈|tok ↑/);
|
|
198
|
+
await f.emit("message_start", { message: { role: "toolResult" } });
|
|
199
|
+
assert.match(f.footer(), /40\.0 tok\/s/);
|
|
200
|
+
await f.emit("agent_end", {});
|
|
201
|
+
assert.match(f.footer(), /40\.0 tok\/s/);
|
|
202
|
+
assert.equal(f.mounts(), mounts);
|
|
203
|
+
assert.ok(f.renders() > 0);
|
|
204
|
+
await f.emit("message_start", { message });
|
|
205
|
+
await delta("text_delta", "abcd"); now += 1000;
|
|
206
|
+
await f.emit("message_end", { message: { ...message, stopReason: "aborted" } });
|
|
207
|
+
assert.match(f.footer(), /tok\/s —/);
|
|
208
|
+
await f.emit("message_start", { message });
|
|
209
|
+
await delta("text_delta", "abcd"); now += 1000;
|
|
210
|
+
await delta("error", "ignored");
|
|
211
|
+
assert.match(f.footer(), /tok\/s —/);
|
|
212
|
+
await f.emit("message_end", { message });
|
|
213
|
+
assert.match(f.footer(), /tok\/s —/);
|
|
214
|
+
await f.emit("message_start", { message });
|
|
215
|
+
await delta("text_delta", "abcd"); now += 1000;
|
|
216
|
+
await f.emit("agent_end", {});
|
|
217
|
+
assert.match(f.footer(), /tok\/s —/);
|
|
218
|
+
await f.emit("message_start", { message });
|
|
219
|
+
await delta("text_delta", "abcd"); now += 1000;
|
|
220
|
+
await f.replace(); await f.enable();
|
|
221
|
+
await f.emit("message_end", { message });
|
|
222
|
+
assert.match(f.footer(), /tok\/s —/);
|
|
223
|
+
await f.emit("message_start", { message });
|
|
224
|
+
await delta("text_delta", "abcd"); now += 1000;
|
|
225
|
+
await f.shutdown();
|
|
226
|
+
await f.emit("message_end", { message });
|
|
227
|
+
assert.match(f.footer(), /tok\/s —/);
|
|
228
|
+
} finally { await f.close(); }
|
|
229
|
+
});
|
|
230
|
+
|
|
166
231
|
void test("registered active surfaces select and Cancel-first confirm before exactly one mocked POST", async () => {
|
|
167
232
|
for (const surface of ["usage", "osdyConfig"]) {
|
|
168
233
|
const f = await resetRuntimeFixture();
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
+
import { createGenerationMeter, type GenerationReading } from "./generation-meter.js";
|
|
8
9
|
import { createCodexUsageRefresh, type CodexUsageRefreshClock } from "./codex-usage-refresh.js";
|
|
9
10
|
import { createControlCenterTodo } from "./control-center-todo.js";
|
|
10
11
|
import { createControlCenterAgents } from "./control-center-agents.js";
|
|
@@ -26,7 +27,8 @@ import { createAudioPlaybackAdapter } from "./audio-playback.js";
|
|
|
26
27
|
import { subscribeQuestionPromptAudioNotification } from "./plugin-events.js";
|
|
27
28
|
import { createAudioSoundSettingsStore } from "./audio-sound-settings.js";
|
|
28
29
|
import { createEditorSettingsStore } from "./editor-settings.js";
|
|
29
|
-
import { registerAccountProfilesCommand } from "./account-profiles.js";
|
|
30
|
+
import { registerAccountProfilesCommand, readStoredProfileCodexCredential, sharedAgentDir } from "./account-profiles.js";
|
|
31
|
+
import { requestProfileCodexUsage } from "./profile-codex-usage.js";
|
|
30
32
|
import {
|
|
31
33
|
applyOsdyPi,
|
|
32
34
|
clearGentleShellChangesWidget,
|
|
@@ -1121,6 +1123,7 @@ export async function registerOsdyPi(
|
|
|
1121
1123
|
/** Tests inject a synthetic agent-data root; production uses the SDK root. */
|
|
1122
1124
|
agentDir?: () => string;
|
|
1123
1125
|
codexUsageClock?: CodexUsageRefreshClock;
|
|
1126
|
+
generationClock?: () => number;
|
|
1124
1127
|
fetchCodexUsage?: CodexUsageFetcher;
|
|
1125
1128
|
accountFactory?: typeof bindControlCenterAccount;
|
|
1126
1129
|
editorSettingsStore?: ReturnType<typeof createEditorSettingsStore>;
|
|
@@ -1139,6 +1142,12 @@ export async function registerOsdyPi(
|
|
|
1139
1142
|
workingTreeEnabled: true,
|
|
1140
1143
|
workingTreePlacement: "aboveEditor",
|
|
1141
1144
|
};
|
|
1145
|
+
const generationMeter = createGenerationMeter(dependencies.generationClock);
|
|
1146
|
+
state.generation = generationMeter.reset();
|
|
1147
|
+
const presentGeneration = (reading: GenerationReading): void => {
|
|
1148
|
+
state.generation = reading;
|
|
1149
|
+
state.tui?.requestRender();
|
|
1150
|
+
};
|
|
1142
1151
|
const workingState: WorkingWidgetState = {
|
|
1143
1152
|
active: false,
|
|
1144
1153
|
activity: "thinking",
|
|
@@ -1269,6 +1278,9 @@ export async function registerOsdyPi(
|
|
|
1269
1278
|
}
|
|
1270
1279
|
}, dependencies.codexUsageClock);
|
|
1271
1280
|
registerAccountProfilesCommand(pi, {
|
|
1281
|
+
previewUsage: profile => requestProfileCodexUsage(profile, {
|
|
1282
|
+
readCredentials: async name => readStoredProfileCodexCredential(await sharedAgentDir(process.env), name),
|
|
1283
|
+
}),
|
|
1272
1284
|
requestRender: () => {
|
|
1273
1285
|
state.tui?.requestRender();
|
|
1274
1286
|
},
|
|
@@ -1302,11 +1314,29 @@ export async function registerOsdyPi(
|
|
|
1302
1314
|
audioRouter,
|
|
1303
1315
|
);
|
|
1304
1316
|
|
|
1317
|
+
pi.on("message_start", (event) => {
|
|
1318
|
+
if (sessionContext && event.message.role === "assistant") presentGeneration(generationMeter.start());
|
|
1319
|
+
});
|
|
1320
|
+
pi.on("message_update", (event) => {
|
|
1321
|
+
if (!sessionContext || event.message.role !== "assistant") return;
|
|
1322
|
+
const update = event.assistantMessageEvent;
|
|
1323
|
+
if (update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta") {
|
|
1324
|
+
// Count only deltas, not mutable partial snapshots or authoritative block ends.
|
|
1325
|
+
presentGeneration(generationMeter.delta(update.delta));
|
|
1326
|
+
} else if (update.type === "error") presentGeneration(generationMeter.interrupt());
|
|
1327
|
+
});
|
|
1328
|
+
pi.on("message_end", (event) => {
|
|
1329
|
+
if (sessionContext && event.message.role === "assistant") {
|
|
1330
|
+
// message_end precedes persistence. Output already includes reasoning tokens.
|
|
1331
|
+
presentGeneration(generationMeter.finish(event.message.usage?.output, event.message.stopReason));
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1305
1334
|
pi.on("agent_start", () => {
|
|
1306
1335
|
controller.onAgentStart();
|
|
1307
1336
|
audioRouter.onAgentStart();
|
|
1308
1337
|
});
|
|
1309
1338
|
pi.on("agent_end", (_event, ctx) => {
|
|
1339
|
+
presentGeneration(generationMeter.interrupt());
|
|
1310
1340
|
controller.onAgentEnd();
|
|
1311
1341
|
audioRouter.onAgentEnd(ctx);
|
|
1312
1342
|
if (state.enabled) clearGentleShellChangesWidget(ctx);
|
|
@@ -1342,6 +1372,7 @@ export async function registerOsdyPi(
|
|
|
1342
1372
|
});
|
|
1343
1373
|
pi.on("session_shutdown", () => {
|
|
1344
1374
|
usageSettingsReady = false;
|
|
1375
|
+
presentGeneration(generationMeter.reset());
|
|
1345
1376
|
idleUsageRefresh.stop();
|
|
1346
1377
|
runtimeGeneration++;
|
|
1347
1378
|
for (const abort of resetAborts) abort.abort();
|
|
@@ -1359,6 +1390,7 @@ export async function registerOsdyPi(
|
|
|
1359
1390
|
sessionContext = undefined;
|
|
1360
1391
|
});
|
|
1361
1392
|
pi.on("session_start", async (_event, ctx) => {
|
|
1393
|
+
presentGeneration(generationMeter.reset());
|
|
1362
1394
|
controller.onShutdown();
|
|
1363
1395
|
idleUsageRefresh.stop();
|
|
1364
1396
|
codexUsageAbort?.abort();
|
|
@@ -6,6 +6,7 @@ type EditorFactory = Exclude<
|
|
|
6
6
|
undefined
|
|
7
7
|
>;
|
|
8
8
|
import type { CodexUsageSnapshot } from "./codex-usage.js";
|
|
9
|
+
import type { GenerationReading } from "./generation-meter.js";
|
|
9
10
|
|
|
10
11
|
export type AnimationMode = "off" | "intro" | "continuous";
|
|
11
12
|
|
|
@@ -58,6 +59,8 @@ export type CodexUsageState =
|
|
|
58
59
|
|
|
59
60
|
export type OsdyState = {
|
|
60
61
|
codexUsage: CodexUsageState;
|
|
62
|
+
/** Volatile response reading; never persisted as usage or settings. */
|
|
63
|
+
generation?: GenerationReading;
|
|
61
64
|
enabled: boolean;
|
|
62
65
|
/** Actual mounted editor, including while a desired change is deferred. */
|
|
63
66
|
editorEffective: boolean;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { KeybindingsManager } from "../../node_modules/@earendil-works/pi-coding-agent/dist/core/keybindings.js";
|
|
4
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { OsdyState } from "./types.js";
|
|
2
6
|
import { createHash } from "node:crypto";
|
|
3
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
8
|
import { registerHooks } from "node:module";
|
|
@@ -394,6 +398,47 @@ void test("places a managed profile beside the model in simple mode and preserve
|
|
|
394
398
|
);
|
|
395
399
|
});
|
|
396
400
|
|
|
401
|
+
void test("one real editor instance and native footer read live/final speed with ANSI-safe resizing", () => {
|
|
402
|
+
const color = (text: string) => `\u001b[33m${text}\u001b[39m`;
|
|
403
|
+
const theme = { fg: (_name: string, text: string) => color(text), bold: (text: string) => text };
|
|
404
|
+
const state: OsdyState = {
|
|
405
|
+
codexUsage: { kind: "idle" }, enabled: true, editorEffective: true, editorMode: "extended",
|
|
406
|
+
fallbackEditorFactory: undefined, headerVariant: "osdy-theme", mascot: "current", smallMode: false,
|
|
407
|
+
tui: undefined, workingTreeEnabled: false, workingTreePlacement: "aboveEditor",
|
|
408
|
+
generation: { kind: "streaming", tokensPerSecond: 42.3 },
|
|
409
|
+
};
|
|
410
|
+
const ctx = { cwd: "/synthetic", ui: { theme }, model: { provider: "test", id: "test", contextWindow: 200000 },
|
|
411
|
+
getContextUsage: () => ({ percent: 60 }), sessionManager: { getEntries: () => [] },
|
|
412
|
+
} as unknown as ExtensionContext;
|
|
413
|
+
const pi = { getThinkingLevel: () => "off" } as ExtensionAPI;
|
|
414
|
+
const tui = { terminal: { rows: 40, columns: 120 }, requestRender: () => {} } as unknown as TUI;
|
|
415
|
+
const editorTheme: EditorTheme = { borderColor: color, selectList: {
|
|
416
|
+
selectedPrefix: color, selectedText: color, description: color, scrollInfo: color, noMatch: color,
|
|
417
|
+
} };
|
|
418
|
+
const editor = ui.createEditorComponent(pi, ctx, state)(tui, editorTheme, new KeybindingsManager());
|
|
419
|
+
for (const reading of [
|
|
420
|
+
{ kind: "streaming", tokensPerSecond: 42.3 }, { kind: "complete", tokensPerSecond: 50 },
|
|
421
|
+
{ kind: "streaming", tokensPerSecond: null }, { kind: "unavailable" },
|
|
422
|
+
] as const) {
|
|
423
|
+
state.generation = reading;
|
|
424
|
+
const expected = reading.kind === "complete" ? "50.0 tok/s" : reading.kind === "unavailable" ? "tok/s —"
|
|
425
|
+
: reading.tokensPerSecond === null ? "tok/s …" : "≈42.3 tok/s";
|
|
426
|
+
assert.ok(editor.render(120).join("\n").includes(expected));
|
|
427
|
+
for (const width of [4, 15, 30, 48, 80, 120]) {
|
|
428
|
+
assert.ok(editor.render(width).every(line => visibleWidth(line) <= width));
|
|
429
|
+
}
|
|
430
|
+
state.editorEffective = false;
|
|
431
|
+
const footer = ui.createFooterComponent(pi, ctx, state, {
|
|
432
|
+
getGitBranch: () => null, getExtensionStatuses: () => new Map(),
|
|
433
|
+
getAvailableProviderCount: () => 1, onBranchChange: () => () => {},
|
|
434
|
+
}, theme);
|
|
435
|
+
assert.ok(footer.render(120).join("\n").includes(expected));
|
|
436
|
+
assert.doesNotMatch(footer.render(120).join("\n"), /tok ↑/);
|
|
437
|
+
assert.ok(footer.render(30).every(line => visibleWidth(line) <= 30));
|
|
438
|
+
state.editorEffective = true;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
397
442
|
void test("wires themed Codex quota bars below both native footer and extended editor", () => {
|
|
398
443
|
const source = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
|
|
399
444
|
assert.match(source, /renderCompactCodexQuotaBars/);
|
package/extensions/osdy-pi/ui.ts
CHANGED
|
@@ -343,7 +343,7 @@ class OsdyFooter implements Component {
|
|
|
343
343
|
? renderCompactCodexQuotaBars(this.theme, codexSnapshot, width)
|
|
344
344
|
: [];
|
|
345
345
|
const usageLine = truncateToWidth(
|
|
346
|
-
formatContextUsage(this.theme, contextUsageData(this.ctx), width),
|
|
346
|
+
formatContextUsage(this.theme, { ...contextUsageData(this.ctx), generation: this.state.generation }, width),
|
|
347
347
|
width,
|
|
348
348
|
ellipsis,
|
|
349
349
|
);
|
|
@@ -490,7 +490,7 @@ export function createEditorComponent(
|
|
|
490
490
|
const topRight = ctx.ui.theme.fg("muted", layout.topRight);
|
|
491
491
|
const bottomLeft = formatContextUsage(
|
|
492
492
|
ctx.ui.theme,
|
|
493
|
-
contextUsageData(ctx),
|
|
493
|
+
{ ...contextUsageData(ctx), generation: state.generation },
|
|
494
494
|
innerWidth,
|
|
495
495
|
);
|
|
496
496
|
lines[0] = `${borderColor("╭")}${fitBorder(topLeft, topRight, editorWidth - 2, borderColor)}${borderColor("╮")}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osdy-pi",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.15",
|
|
4
4
|
"description": "Osdy Pi package: themes, header, and custom editor for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"typescript": "^5.9.3",
|
|
84
84
|
"typescript-eslint": "^8.46.3"
|
|
85
85
|
},
|
|
86
|
-
"releaseSource": "
|
|
86
|
+
"releaseSource": "a884185d5c9c03e3695f5cecd4c370259dee1e48",
|
|
87
87
|
"repository": {
|
|
88
88
|
"type": "git",
|
|
89
89
|
"url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"
|