osdy-pi 1.11.13 → 1.11.14

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 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. Only **View usage** and the quota view's **Refresh** query; profile navigation does not. 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.
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** | Waits for idle, safely replaces Pi's canonical auth with the selected profile, and updates active/default together without restarting Pi. |
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:
@@ -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
- import {
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
- } from "./account-profiles.ts";
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
- ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input">;
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
- const target = await ctx.ui.select(
468
- "Switch OpenAI account",
469
- markedProfiles(
470
- profiles,
471
- dependencies.activeProfile,
472
- defaultProfile ?? undefined,
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) => {
@@ -26,7 +26,8 @@ import { createAudioPlaybackAdapter } from "./audio-playback.js";
26
26
  import { subscribeQuestionPromptAudioNotification } from "./plugin-events.js";
27
27
  import { createAudioSoundSettingsStore } from "./audio-sound-settings.js";
28
28
  import { createEditorSettingsStore } from "./editor-settings.js";
29
- import { registerAccountProfilesCommand } from "./account-profiles.js";
29
+ import { registerAccountProfilesCommand, readStoredProfileCodexCredential, sharedAgentDir } from "./account-profiles.js";
30
+ import { requestProfileCodexUsage } from "./profile-codex-usage.js";
30
31
  import {
31
32
  applyOsdyPi,
32
33
  clearGentleShellChangesWidget,
@@ -1269,6 +1270,9 @@ export async function registerOsdyPi(
1269
1270
  }
1270
1271
  }, dependencies.codexUsageClock);
1271
1272
  registerAccountProfilesCommand(pi, {
1273
+ previewUsage: profile => requestProfileCodexUsage(profile, {
1274
+ readCredentials: async name => readStoredProfileCodexCredential(await sharedAgentDir(process.env), name),
1275
+ }),
1272
1276
  requestRender: () => {
1273
1277
  state.tui?.requestRender();
1274
1278
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.11.13",
3
+ "version": "1.11.14",
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": "89f3886287c5062fa45501a1a1869d25e7d46dac",
86
+ "releaseSource": "375d9ec96ba08e538070ee492c60b0ce45bb0bc4",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"