@victor-software-house/pi-multicodex 1.0.7 → 1.0.9

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
@@ -6,6 +6,8 @@
6
6
 
7
7
  ## What it does
8
8
 
9
+ - overrides the normal `openai-codex` path instead of requiring a separate provider to be selected
10
+ - auto-imports pi's stored `openai-codex` auth when it is new or changed
9
11
  - rotates accounts on quota and rate-limit failures
10
12
  - prefers untouched accounts when usage data is available
11
13
  - otherwise prefers the account whose weekly window resets first
@@ -45,12 +47,11 @@ pi -e ./index.ts
45
47
 
46
48
  ## Commands
47
49
 
48
- - `/multicodex-login <email>`
49
- - Add or update a Codex account in the rotation pool.
50
- - `/multicodex-use`
51
- - Select an account manually for the current session.
50
+ - `/multicodex-use [identifier]`
51
+ - Use an existing managed account, or start the Codex login flow when the account is missing or the stored auth is no longer valid.
52
+ - With no argument, opens an account picker.
52
53
  - `/multicodex-status`
53
- - Show account state and cached usage information.
54
+ - Show managed account state and cached usage information.
54
55
  - `/multicodex-footer`
55
56
  - Open an interactive panel to configure footer fields and ordering.
56
57
 
@@ -68,12 +69,11 @@ Current direction:
68
69
 
69
70
  Current next step:
70
71
 
71
- - add active-account usage visibility in pi for this extension's managed Codex accounts
72
72
  - mirror the existing codex usage footer style, including support for displaying both reset countdowns
73
- - show footer usage only when the selected model uses the `multicodex` provider override
74
- - show the active account identifier beside the 5h and 7d usage metrics
75
- - configure footer fields and ordering through an interactive panel
76
- - refresh the footer from the active managed account without polling aggressively
73
+ - debounce expensive refresh work during rapid model cycling
74
+ - move each reset countdown next to its matching usage period
75
+ - add live preview to the `/multicodex-footer` panel before locking the final style
76
+ - tighten footer updates so account switches and quota rotation are reflected immediately
77
77
 
78
78
  ## Release validation
79
79
 
@@ -91,6 +91,13 @@ Release flow:
91
91
  3. Create and push a matching `v*` tag.
92
92
  4. Let GitHub Actions publish through trusted publishing.
93
93
 
94
+ Local push protection:
95
+
96
+ - `lefthook` runs `mise run pre-push`
97
+ - the `pre-push` mise task runs the same core validations as the publish workflow:
98
+ - `pnpm check`
99
+ - `npm pack --dry-run`
100
+
94
101
  Prepare locally:
95
102
 
96
103
  ```bash
@@ -2,6 +2,7 @@ import {
2
2
  type OAuthCredentials,
3
3
  refreshOpenAICodexToken,
4
4
  } from "@mariozechner/pi-ai/oauth";
5
+ import { loadImportedOpenAICodexAuth } from "./auth";
5
6
  import { isAccountAvailable, pickBestAccount } from "./selection";
6
7
  import {
7
8
  type Account,
@@ -17,6 +18,7 @@ const USAGE_REQUEST_TIMEOUT_MS = 10 * 1000;
17
18
  const QUOTA_COOLDOWN_MS = 60 * 60 * 1000;
18
19
 
19
20
  type WarningHandler = (message: string) => void;
21
+ type StateChangeHandler = () => void;
20
22
 
21
23
  function getErrorMessage(error: unknown): string {
22
24
  if (error instanceof Error) return error.message;
@@ -28,6 +30,7 @@ export class AccountManager {
28
30
  private usageCache = new Map<string, CodexUsageSnapshot>();
29
31
  private warningHandler?: WarningHandler;
30
32
  private manualEmail?: string;
33
+ private stateChangeHandlers = new Set<StateChangeHandler>();
31
34
 
32
35
  constructor() {
33
36
  this.data = loadStorage();
@@ -37,6 +40,19 @@ export class AccountManager {
37
40
  saveStorage(this.data);
38
41
  }
39
42
 
43
+ private notifyStateChanged(): void {
44
+ for (const handler of this.stateChangeHandlers) {
45
+ handler();
46
+ }
47
+ }
48
+
49
+ onStateChange(handler: StateChangeHandler): () => void {
50
+ this.stateChangeHandlers.add(handler);
51
+ return () => {
52
+ this.stateChangeHandlers.delete(handler);
53
+ };
54
+ }
55
+
40
56
  getAccounts(): Account[] {
41
57
  return this.data.accounts;
42
58
  }
@@ -49,7 +65,14 @@ export class AccountManager {
49
65
  this.warningHandler = handler;
50
66
  }
51
67
 
52
- addOrUpdateAccount(email: string, creds: OAuthCredentials): void {
68
+ addOrUpdateAccount(
69
+ email: string,
70
+ creds: OAuthCredentials,
71
+ options?: {
72
+ importSource?: "pi-openai-codex";
73
+ importFingerprint?: string;
74
+ },
75
+ ): void {
53
76
  const existing = this.getAccount(email);
54
77
  const accountId =
55
78
  typeof creds.accountId === "string" ? creds.accountId : undefined;
@@ -57,6 +80,8 @@ export class AccountManager {
57
80
  existing.accessToken = creds.access;
58
81
  existing.refreshToken = creds.refresh;
59
82
  existing.expiresAt = creds.expires;
83
+ existing.importSource = options?.importSource;
84
+ existing.importFingerprint = options?.importFingerprint;
60
85
  if (accountId) {
61
86
  existing.accountId = accountId;
62
87
  }
@@ -67,10 +92,11 @@ export class AccountManager {
67
92
  refreshToken: creds.refresh,
68
93
  expiresAt: creds.expires,
69
94
  accountId,
95
+ importSource: options?.importSource,
96
+ importFingerprint: options?.importFingerprint,
70
97
  });
71
98
  }
72
99
  this.setActiveAccount(email);
73
- this.save();
74
100
  }
75
101
 
76
102
  getActiveAccount(): Account | undefined {
@@ -99,6 +125,7 @@ export class AccountManager {
99
125
  setActiveAccount(email: string): void {
100
126
  this.data.activeEmail = email;
101
127
  this.save();
128
+ this.notifyStateChanged();
102
129
  }
103
130
 
104
131
  setManualAccount(email: string): void {
@@ -106,10 +133,45 @@ export class AccountManager {
106
133
  if (!account) return;
107
134
  this.manualEmail = email;
108
135
  account.lastUsed = Date.now();
136
+ this.notifyStateChanged();
109
137
  }
110
138
 
111
139
  clearManualAccount(): void {
140
+ if (!this.manualEmail) return;
112
141
  this.manualEmail = undefined;
142
+ this.notifyStateChanged();
143
+ }
144
+
145
+ getImportedAccount(): Account | undefined {
146
+ return this.data.accounts.find(
147
+ (account) => account.importSource === "pi-openai-codex",
148
+ );
149
+ }
150
+
151
+ async syncImportedOpenAICodexAuth(): Promise<boolean> {
152
+ const imported = await loadImportedOpenAICodexAuth();
153
+ if (!imported) return false;
154
+
155
+ const existingImported = this.getImportedAccount();
156
+ if (
157
+ existingImported?.importFingerprint === imported.fingerprint &&
158
+ existingImported.email === imported.identifier
159
+ ) {
160
+ return false;
161
+ }
162
+
163
+ if (existingImported && existingImported.email !== imported.identifier) {
164
+ const target = this.getAccount(imported.identifier);
165
+ if (!target) {
166
+ existingImported.email = imported.identifier;
167
+ }
168
+ }
169
+
170
+ this.addOrUpdateAccount(imported.identifier, imported.credentials, {
171
+ importSource: "pi-openai-codex",
172
+ importFingerprint: imported.fingerprint,
173
+ });
174
+ return true;
113
175
  }
114
176
 
115
177
  getAvailableManualAccount(options?: {
@@ -129,6 +191,7 @@ export class AccountManager {
129
191
  if (account) {
130
192
  account.quotaExhaustedUntil = until;
131
193
  this.save();
194
+ this.notifyStateChanged();
132
195
  }
133
196
  }
134
197
 
@@ -157,6 +220,7 @@ export class AccountManager {
157
220
  timeoutMs: USAGE_REQUEST_TIMEOUT_MS,
158
221
  });
159
222
  this.usageCache.set(account.email, usage);
223
+ this.notifyStateChanged();
160
224
  return usage;
161
225
  } catch (error) {
162
226
  this.warningHandler?.(
@@ -239,6 +303,7 @@ export class AccountManager {
239
303
  }
240
304
  if (changed) {
241
305
  this.save();
306
+ this.notifyStateChanged();
242
307
  }
243
308
  }
244
309
 
@@ -257,6 +322,7 @@ export class AccountManager {
257
322
  account.accountId = accountId;
258
323
  }
259
324
  this.save();
325
+ this.notifyStateChanged();
260
326
  return account.accessToken;
261
327
  }
262
328
  }
package/auth.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
5
+
6
+ const AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
7
+ const IMPORTED_ACCOUNT_PREFIX = "OpenAI Codex";
8
+
9
+ interface AuthEntry {
10
+ type?: string;
11
+ access?: string | null;
12
+ refresh?: string | null;
13
+ expires?: number | null;
14
+ accountId?: string | null;
15
+ account_id?: string | null;
16
+ }
17
+
18
+ export interface ImportedOpenAICodexAuth {
19
+ identifier: string;
20
+ fingerprint: string;
21
+ credentials: OAuthCredentials;
22
+ }
23
+
24
+ function asAuthEntry(value: unknown): AuthEntry | undefined {
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
26
+ return undefined;
27
+ }
28
+ return value as AuthEntry;
29
+ }
30
+
31
+ function getAccountId(entry: AuthEntry): string | undefined {
32
+ const accountId = entry.accountId ?? entry.account_id;
33
+ return typeof accountId === "string" && accountId.trim()
34
+ ? accountId.trim()
35
+ : undefined;
36
+ }
37
+
38
+ function getRequiredString(
39
+ value: string | null | undefined,
40
+ ): string | undefined {
41
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
42
+ }
43
+
44
+ function createImportedIdentifier(accountId: string): string {
45
+ return `${IMPORTED_ACCOUNT_PREFIX} ${accountId.slice(0, 8)}`;
46
+ }
47
+
48
+ function createFingerprint(entry: {
49
+ access: string;
50
+ refresh: string;
51
+ expires: number;
52
+ accountId?: string;
53
+ }): string {
54
+ return JSON.stringify({
55
+ access: entry.access,
56
+ refresh: entry.refresh,
57
+ expires: entry.expires,
58
+ accountId: entry.accountId ?? null,
59
+ });
60
+ }
61
+
62
+ export function parseImportedOpenAICodexAuth(
63
+ auth: Record<string, unknown>,
64
+ ): ImportedOpenAICodexAuth | undefined {
65
+ const entry = asAuthEntry(auth["openai-codex"]);
66
+ if (entry?.type !== "oauth") return undefined;
67
+
68
+ const access = getRequiredString(entry.access);
69
+ const refresh = getRequiredString(entry.refresh);
70
+ const accountId = getAccountId(entry);
71
+ const expires = entry.expires;
72
+ if (!access || !refresh || typeof expires !== "number") {
73
+ return undefined;
74
+ }
75
+
76
+ const credentials: OAuthCredentials = {
77
+ access,
78
+ refresh,
79
+ expires,
80
+ accountId,
81
+ };
82
+ return {
83
+ identifier: createImportedIdentifier(accountId ?? "default"),
84
+ fingerprint: createFingerprint({ access, refresh, expires, accountId }),
85
+ credentials,
86
+ };
87
+ }
88
+
89
+ export async function loadImportedOpenAICodexAuth(): Promise<
90
+ ImportedOpenAICodexAuth | undefined
91
+ > {
92
+ try {
93
+ const raw = await fs.readFile(AUTH_FILE, "utf8");
94
+ const parsed = JSON.parse(raw) as unknown;
95
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
96
+ return undefined;
97
+ }
98
+ return parseImportedOpenAICodexAuth(parsed as Record<string, unknown>);
99
+ } catch (error) {
100
+ const withCode = error as Error & { code?: string };
101
+ if (withCode.code === "ENOENT") {
102
+ return undefined;
103
+ }
104
+ throw error;
105
+ }
106
+ }
package/commands.ts CHANGED
@@ -13,59 +13,85 @@ function getErrorMessage(error: unknown): string {
13
13
  return typeof error === "string" ? error : JSON.stringify(error);
14
14
  }
15
15
 
16
+ async function loginAndActivateAccount(
17
+ pi: ExtensionAPI,
18
+ ctx: ExtensionCommandContext,
19
+ accountManager: AccountManager,
20
+ identifier: string,
21
+ ): Promise<boolean> {
22
+ try {
23
+ ctx.ui.notify(
24
+ `Starting login for ${identifier}... Check your browser.`,
25
+ "info",
26
+ );
27
+
28
+ const creds = await loginOpenAICodex({
29
+ onAuth: ({ url }) => {
30
+ void openLoginInBrowser(pi, ctx, url);
31
+ ctx.ui.notify(`Please open this URL to login: ${url}`, "info");
32
+ console.log(`[multicodex] Login URL: ${url}`);
33
+ },
34
+ onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
35
+ });
36
+
37
+ accountManager.addOrUpdateAccount(identifier, creds);
38
+ accountManager.setManualAccount(identifier);
39
+ ctx.ui.notify(`Now using ${identifier}`, "info");
40
+ return true;
41
+ } catch (error) {
42
+ ctx.ui.notify(`Login failed: ${getErrorMessage(error)}`, "error");
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function useOrLoginAccount(
48
+ pi: ExtensionAPI,
49
+ ctx: ExtensionCommandContext,
50
+ accountManager: AccountManager,
51
+ identifier: string,
52
+ ): Promise<void> {
53
+ const existing = accountManager.getAccount(identifier);
54
+ if (existing) {
55
+ try {
56
+ await accountManager.ensureValidToken(existing);
57
+ accountManager.setManualAccount(identifier);
58
+ ctx.ui.notify(`Now using ${identifier}`, "info");
59
+ return;
60
+ } catch {
61
+ ctx.ui.notify(
62
+ `Stored auth for ${identifier} is no longer valid. Starting login again.`,
63
+ "warning",
64
+ );
65
+ }
66
+ }
67
+
68
+ await loginAndActivateAccount(pi, ctx, accountManager, identifier);
69
+ }
70
+
16
71
  export function registerCommands(
17
72
  pi: ExtensionAPI,
18
73
  accountManager: AccountManager,
19
74
  statusController: ReturnType<typeof createUsageStatusController>,
20
75
  ): void {
21
- pi.registerCommand("multicodex-login", {
22
- description: "Login to an OpenAI Codex account for the rotation pool",
76
+ pi.registerCommand("multicodex-use", {
77
+ description:
78
+ "Use an existing Codex account, or log in when the identifier is missing",
23
79
  handler: async (
24
80
  args: string,
25
81
  ctx: ExtensionCommandContext,
26
82
  ): Promise<void> => {
27
- const email = args.trim();
28
- if (!email) {
29
- ctx.ui.notify(
30
- "Please provide an email/identifier: /multicodex-login my@email.com",
31
- "error",
32
- );
83
+ const identifier = args.trim();
84
+ if (identifier) {
85
+ await useOrLoginAccount(pi, ctx, accountManager, identifier);
86
+ await statusController.refreshFor(ctx);
33
87
  return;
34
88
  }
35
89
 
36
- try {
37
- ctx.ui.notify(
38
- `Starting login for ${email}... Check your browser.`,
39
- "info",
40
- );
41
-
42
- const creds = await loginOpenAICodex({
43
- onAuth: ({ url }) => {
44
- void openLoginInBrowser(pi, ctx, url);
45
- ctx.ui.notify(`Please open this URL to login: ${url}`, "info");
46
- console.log(`[multicodex] Login URL: ${url}`);
47
- },
48
- onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
49
- });
50
-
51
- accountManager.addOrUpdateAccount(email, creds);
52
- ctx.ui.notify(`Successfully logged in as ${email}`, "info");
53
- } catch (error) {
54
- ctx.ui.notify(`Login failed: ${getErrorMessage(error)}`, "error");
55
- }
56
- },
57
- });
58
-
59
- pi.registerCommand("multicodex-use", {
60
- description: "Switch active Codex account for this session",
61
- handler: async (
62
- _args: string,
63
- ctx: ExtensionCommandContext,
64
- ): Promise<void> => {
90
+ await accountManager.syncImportedOpenAICodexAuth();
65
91
  const accounts = accountManager.getAccounts();
66
92
  if (accounts.length === 0) {
67
93
  ctx.ui.notify(
68
- "No accounts logged in. Use /multicodex-login first.",
94
+ "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
69
95
  "warning",
70
96
  );
71
97
  return;
@@ -82,9 +108,10 @@ export function registerCommands(
82
108
  const selected = await ctx.ui.select("Select Account", options);
83
109
  if (!selected) return;
84
110
 
85
- const email = selected.split(" ")[0];
111
+ const email = selected.split(" (")[0] ?? selected;
86
112
  accountManager.setManualAccount(email);
87
- ctx.ui.notify(`Switched to ${email}`, "info");
113
+ ctx.ui.notify(`Now using ${email}`, "info");
114
+ await statusController.refreshFor(ctx);
88
115
  },
89
116
  });
90
117
 
@@ -94,11 +121,12 @@ export function registerCommands(
94
121
  _args: string,
95
122
  ctx: ExtensionCommandContext,
96
123
  ): Promise<void> => {
124
+ await accountManager.syncImportedOpenAICodexAuth();
97
125
  await accountManager.refreshUsageForAllAccounts();
98
126
  const accounts = accountManager.getAccounts();
99
127
  if (accounts.length === 0) {
100
128
  ctx.ui.notify(
101
- "No accounts logged in. Use /multicodex-login first.",
129
+ "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
102
130
  "warning",
103
131
  );
104
132
  return;
@@ -112,10 +140,12 @@ export function registerCommands(
112
140
  account.quotaExhaustedUntil &&
113
141
  account.quotaExhaustedUntil > Date.now();
114
142
  const untouched = isUsageUntouched(usage) ? "untouched" : null;
143
+ const imported = account.importSource ? "imported" : null;
115
144
  const tags = [
116
145
  isActive ? "active" : null,
117
146
  quotaHit ? "quota" : null,
118
147
  untouched,
148
+ imported,
119
149
  ]
120
150
  .filter(Boolean)
121
151
  .join(", ");
package/extension.ts CHANGED
@@ -54,7 +54,7 @@ export default function multicodexExtension(pi: ExtensionAPI) {
54
54
 
55
55
  pi.on("model_select", (_event: unknown, ctx: ExtensionContext) => {
56
56
  lastContext = ctx;
57
- void statusController.refreshFor(ctx);
57
+ statusController.scheduleModelSelectRefresh(ctx);
58
58
  });
59
59
 
60
60
  pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
package/hooks.ts CHANGED
@@ -3,6 +3,7 @@ import type { AccountManager } from "./account-manager";
3
3
  async function refreshAndActivateBestAccount(
4
4
  accountManager: AccountManager,
5
5
  ): Promise<void> {
6
+ await accountManager.syncImportedOpenAICodexAuth();
6
7
  await accountManager.refreshUsageForAllAccounts({ force: true });
7
8
  const manual = accountManager.getAvailableManualAccount();
8
9
  if (manual) return;
package/index.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { AccountManager } from "./account-manager";
2
+ export {
3
+ loadImportedOpenAICodexAuth,
4
+ parseImportedOpenAICodexAuth,
5
+ } from "./auth";
2
6
  export { default } from "./extension";
3
7
  export {
4
8
  buildMulticodexProviderConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "abort-utils.ts",
34
34
  "account-manager.ts",
35
+ "auth.ts",
35
36
  "browser.ts",
36
37
  "commands.ts",
37
38
  "extension.ts",
package/provider.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  import type { AccountManager } from "./account-manager";
11
11
  import { createStreamWrapper } from "./stream-wrapper";
12
12
 
13
- export const PROVIDER_ID = "multicodex";
13
+ export const PROVIDER_ID = "openai-codex";
14
14
 
15
15
  export interface ProviderModelDef {
16
16
  id: string;
package/status.ts CHANGED
@@ -21,6 +21,7 @@ const STATUS_KEY = "multicodex-usage";
21
21
  const SETTINGS_KEY = "pi-multicodex";
22
22
  const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
23
23
  const REFRESH_INTERVAL_MS = 60_000;
24
+ const MODEL_SELECT_REFRESH_DEBOUNCE_MS = 250;
24
25
  const UNKNOWN_PERCENT = "--";
25
26
  const FIVE_HOUR_LABEL = "5h:";
26
27
  const SEVEN_DAY_LABEL = "7d:";
@@ -172,6 +173,40 @@ function formatResetCountdown(resetAt: number | undefined): string | undefined {
172
173
  return `${seconds}s`;
173
174
  }
174
175
 
176
+ function shouldShowReset(
177
+ preferences: FooterPreferences,
178
+ window: Exclude<ResetWindowMode, "both">,
179
+ ): boolean {
180
+ if (!preferences.showReset) return false;
181
+ return (
182
+ preferences.resetWindow === "both" || preferences.resetWindow === window
183
+ );
184
+ }
185
+
186
+ function formatUsageSegment(
187
+ ctx: ExtensionContext,
188
+ label: string,
189
+ usedPercent: number | undefined,
190
+ resetAt: number | undefined,
191
+ showReset: boolean,
192
+ preferences: FooterPreferences,
193
+ ): string {
194
+ const parts = [
195
+ `${ctx.ui.theme.fg("dim", label)}${formatPercent(
196
+ ctx,
197
+ usedToDisplayPercent(usedPercent, preferences.usageMode),
198
+ preferences.usageMode,
199
+ )}`,
200
+ ];
201
+ if (showReset) {
202
+ const countdown = formatResetCountdown(resetAt);
203
+ if (countdown) {
204
+ parts.push(ctx.ui.theme.fg("dim", `(↺${countdown})`));
205
+ }
206
+ }
207
+ return parts.join(" ");
208
+ }
209
+
175
210
  export function isManagedModel(model: MaybeModel): boolean {
176
211
  return model?.provider === PROVIDER_ID;
177
212
  }
@@ -195,41 +230,22 @@ export function formatActiveAccountStatus(
195
230
  .join(" ");
196
231
  }
197
232
 
198
- const fiveHour = `${ctx.ui.theme.fg("dim", FIVE_HOUR_LABEL)}${formatPercent(
233
+ const fiveHour = formatUsageSegment(
199
234
  ctx,
200
- usedToDisplayPercent(usage.primary?.usedPercent, preferences.usageMode),
201
- preferences.usageMode,
202
- )}`;
203
- const sevenDay = `${ctx.ui.theme.fg("dim", SEVEN_DAY_LABEL)}${formatPercent(
235
+ FIVE_HOUR_LABEL,
236
+ usage.primary?.usedPercent,
237
+ usage.primary?.resetAt,
238
+ shouldShowReset(preferences, "5h"),
239
+ preferences,
240
+ );
241
+ const sevenDay = formatUsageSegment(
204
242
  ctx,
205
- usedToDisplayPercent(usage.secondary?.usedPercent, preferences.usageMode),
206
- preferences.usageMode,
207
- )}`;
208
- const fiveHourReset = preferences.showReset
209
- ? formatResetCountdown(usage.primary?.resetAt)
210
- : undefined;
211
- const sevenDayReset = preferences.showReset
212
- ? formatResetCountdown(usage.secondary?.resetAt)
213
- : undefined;
214
- const resetText =
215
- preferences.resetWindow === "5h"
216
- ? fiveHourReset
217
- ? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
218
- : undefined
219
- : preferences.resetWindow === "7d"
220
- ? sevenDayReset
221
- ? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
222
- : undefined
223
- : [
224
- fiveHourReset
225
- ? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
226
- : undefined,
227
- sevenDayReset
228
- ? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
229
- : undefined,
230
- ]
231
- .filter(Boolean)
232
- .join(" ") || undefined;
243
+ SEVEN_DAY_LABEL,
244
+ usage.secondary?.usedPercent,
245
+ usage.secondary?.resetAt,
246
+ shouldShowReset(preferences, "7d"),
247
+ preferences,
248
+ );
233
249
 
234
250
  const leading =
235
251
  preferences.order === "account-first"
@@ -238,7 +254,7 @@ export function formatActiveAccountStatus(
238
254
  const trailing =
239
255
  preferences.order === "account-first" ? [] : [accountText].filter(Boolean);
240
256
 
241
- return [...leading, fiveHour, sevenDay, resetText, ...trailing]
257
+ return [...leading, fiveHour, sevenDay, ...trailing]
242
258
  .filter(Boolean)
243
259
  .join(" ");
244
260
  }
@@ -315,10 +331,17 @@ function applyPreferenceChange(
315
331
 
316
332
  export function createUsageStatusController(accountManager: AccountManager) {
317
333
  let refreshTimer: ReturnType<typeof setInterval> | undefined;
334
+ let modelSelectTimer: ReturnType<typeof setTimeout> | undefined;
318
335
  let activeContext: ExtensionContext | undefined;
319
336
  let refreshInFlight = false;
320
337
  let queuedRefresh = false;
321
338
  let preferences: FooterPreferences = DEFAULT_PREFERENCES;
339
+ let livePreviewPreferences: FooterPreferences | undefined;
340
+
341
+ accountManager.onStateChange(() => {
342
+ if (!activeContext) return;
343
+ renderCachedStatus(activeContext, livePreviewPreferences ?? preferences);
344
+ });
322
345
 
323
346
  function clearStatus(ctx?: ExtensionContext): void {
324
347
  ctx?.ui.setStatus(STATUS_KEY, undefined);
@@ -328,6 +351,42 @@ export function createUsageStatusController(accountManager: AccountManager) {
328
351
  preferences = await loadFooterPreferences();
329
352
  }
330
353
 
354
+ function getStatusText(
355
+ ctx: ExtensionContext,
356
+ preferencesOverride?: FooterPreferences,
357
+ ): string | undefined {
358
+ if (!ctx.hasUI) return undefined;
359
+ if (!isManagedModel(ctx.model)) return undefined;
360
+
361
+ const activeAccount = accountManager.getActiveAccount();
362
+ if (!activeAccount) {
363
+ return ctx.ui.theme.fg("warning", "Multicodex no active account");
364
+ }
365
+
366
+ return formatActiveAccountStatus(
367
+ ctx,
368
+ activeAccount.email,
369
+ accountManager.getCachedUsage(activeAccount.email),
370
+ preferencesOverride ?? preferences,
371
+ );
372
+ }
373
+
374
+ function renderCachedStatus(
375
+ ctx: ExtensionContext,
376
+ preferencesOverride?: FooterPreferences,
377
+ ): void {
378
+ if (!ctx.hasUI) return;
379
+ if (!isManagedModel(ctx.model)) {
380
+ clearStatus(ctx);
381
+ return;
382
+ }
383
+
384
+ const text = getStatusText(ctx, preferencesOverride);
385
+ if (text) {
386
+ ctx.ui.setStatus(STATUS_KEY, text);
387
+ }
388
+ }
389
+
331
390
  async function updateStatus(ctx: ExtensionContext): Promise<void> {
332
391
  if (!ctx.hasUI) return;
333
392
  if (!isManagedModel(ctx.model)) {
@@ -335,7 +394,13 @@ export function createUsageStatusController(accountManager: AccountManager) {
335
394
  return;
336
395
  }
337
396
 
338
- const activeAccount = accountManager.getActiveAccount();
397
+ renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
398
+
399
+ let activeAccount = accountManager.getActiveAccount();
400
+ if (!activeAccount) {
401
+ await accountManager.syncImportedOpenAICodexAuth();
402
+ activeAccount = accountManager.getActiveAccount();
403
+ }
339
404
  if (!activeAccount) {
340
405
  ctx.ui.setStatus(
341
406
  STATUS_KEY,
@@ -350,7 +415,12 @@ export function createUsageStatusController(accountManager: AccountManager) {
350
415
  cachedUsage;
351
416
  ctx.ui.setStatus(
352
417
  STATUS_KEY,
353
- formatActiveAccountStatus(ctx, activeAccount.email, usage, preferences),
418
+ formatActiveAccountStatus(
419
+ ctx,
420
+ activeAccount.email,
421
+ usage,
422
+ livePreviewPreferences ?? preferences,
423
+ ),
354
424
  );
355
425
  }
356
426
 
@@ -373,6 +443,19 @@ export function createUsageStatusController(accountManager: AccountManager) {
373
443
  }
374
444
  }
375
445
 
446
+ function scheduleModelSelectRefresh(ctx: ExtensionContext): void {
447
+ activeContext = ctx;
448
+ renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
449
+ if (modelSelectTimer) {
450
+ clearTimeout(modelSelectTimer);
451
+ }
452
+ modelSelectTimer = setTimeout(() => {
453
+ modelSelectTimer = undefined;
454
+ void refreshFor(ctx);
455
+ }, MODEL_SELECT_REFRESH_DEBOUNCE_MS);
456
+ modelSelectTimer.unref?.();
457
+ }
458
+
376
459
  function startAutoRefresh(): void {
377
460
  if (refreshTimer) clearInterval(refreshTimer);
378
461
  refreshTimer = setInterval(() => {
@@ -387,6 +470,11 @@ export function createUsageStatusController(accountManager: AccountManager) {
387
470
  clearInterval(refreshTimer);
388
471
  refreshTimer = undefined;
389
472
  }
473
+ if (modelSelectTimer) {
474
+ clearTimeout(modelSelectTimer);
475
+ modelSelectTimer = undefined;
476
+ }
477
+ livePreviewPreferences = undefined;
390
478
  clearStatus(ctx ?? activeContext);
391
479
  activeContext = undefined;
392
480
  queuedRefresh = false;
@@ -404,11 +492,23 @@ export function createUsageStatusController(accountManager: AccountManager) {
404
492
  }
405
493
  }
406
494
 
495
+ function renderPreviewLabel(
496
+ ctx: ExtensionContext,
497
+ theme: ExtensionCommandContext["ui"]["theme"],
498
+ draft: FooterPreferences,
499
+ ): string {
500
+ const previewText =
501
+ getStatusText(ctx, draft) ?? ctx.ui.theme.fg("dim", "Codex loading...");
502
+ return `${theme.fg("dim", "Preview")}: ${previewText}`;
503
+ }
504
+
407
505
  async function openPreferencesPanel(
408
506
  ctx: ExtensionCommandContext,
409
507
  ): Promise<void> {
410
508
  await loadPreferences(ctx);
411
509
  let draft = preferences;
510
+ livePreviewPreferences = draft;
511
+ renderCachedStatus(ctx, livePreviewPreferences);
412
512
 
413
513
  await ctx.ui.custom((_tui, theme, _kb, done) => {
414
514
  const container = new Container();
@@ -425,14 +525,20 @@ export function createUsageStatusController(accountManager: AccountManager) {
425
525
  0,
426
526
  ),
427
527
  );
528
+ const previewText = new Text(renderPreviewLabel(ctx, theme, draft), 1, 0);
529
+ container.addChild(previewText);
428
530
 
429
531
  const settingsList = new SettingsList(
430
532
  createSettingsItems(draft),
431
- 7,
533
+ 9,
432
534
  getSettingsListTheme(),
433
535
  (id: string, newValue: string) => {
434
536
  draft = applyPreferenceChange(draft, id, newValue);
537
+ livePreviewPreferences = draft;
435
538
  settingsList.updateValue(id, newValue);
539
+ previewText.setText(renderPreviewLabel(ctx, theme, draft));
540
+ container.invalidate();
541
+ renderCachedStatus(ctx, draft);
436
542
  },
437
543
  () => done(undefined),
438
544
  { enableSearch: true },
@@ -447,6 +553,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
447
553
  });
448
554
 
449
555
  preferences = draft;
556
+ livePreviewPreferences = undefined;
450
557
  await persistFooterPreferences(preferences);
451
558
  await refreshFor(ctx);
452
559
  }
@@ -455,6 +562,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
455
562
  loadPreferences,
456
563
  openPreferencesPanel,
457
564
  refreshFor,
565
+ scheduleModelSelectRefresh,
458
566
  startAutoRefresh,
459
567
  stopAutoRefresh,
460
568
  getPreferences: () => preferences,
package/storage.ts CHANGED
@@ -10,6 +10,8 @@ export interface Account {
10
10
  accountId?: string;
11
11
  lastUsed?: number;
12
12
  quotaExhaustedUntil?: number;
13
+ importSource?: "pi-openai-codex";
14
+ importFingerprint?: string;
13
15
  }
14
16
 
15
17
  export interface StorageData {
package/stream-wrapper.ts CHANGED
@@ -80,6 +80,7 @@ export function createStreamWrapper(
80
80
 
81
81
  (async () => {
82
82
  try {
83
+ await accountManager.syncImportedOpenAICodexAuth();
83
84
  const excludedEmails = new Set<string>();
84
85
  for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
85
86
  const now = Date.now();
@@ -100,7 +101,7 @@ export function createStreamWrapper(
100
101
  }
101
102
  if (!account) {
102
103
  throw new Error(
103
- "No available Multicodex accounts. Please use /multicodex-login.",
104
+ "No available Multicodex accounts. Please use /multicodex-use <identifier>.",
104
105
  );
105
106
  }
106
107