@victor-software-house/pi-multicodex 1.0.8 → 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
@@ -69,11 +69,10 @@ Current direction:
69
69
 
70
70
  Current next step:
71
71
 
72
- - make MultiCodex own the normal `openai-codex` path directly
73
- - auto-import pi's existing `openai-codex` auth when it is new or changed
74
72
  - mirror the existing codex usage footer style, including support for displaying both reset countdowns
75
- - show the active account identifier beside the 5h and 7d usage metrics
76
- - keep footer configuration in an interactive panel
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
77
76
  - tighten footer updates so account switches and quota rotation are reflected immediately
78
77
 
79
78
  ## Release validation
@@ -18,6 +18,7 @@ const USAGE_REQUEST_TIMEOUT_MS = 10 * 1000;
18
18
  const QUOTA_COOLDOWN_MS = 60 * 60 * 1000;
19
19
 
20
20
  type WarningHandler = (message: string) => void;
21
+ type StateChangeHandler = () => void;
21
22
 
22
23
  function getErrorMessage(error: unknown): string {
23
24
  if (error instanceof Error) return error.message;
@@ -29,6 +30,7 @@ export class AccountManager {
29
30
  private usageCache = new Map<string, CodexUsageSnapshot>();
30
31
  private warningHandler?: WarningHandler;
31
32
  private manualEmail?: string;
33
+ private stateChangeHandlers = new Set<StateChangeHandler>();
32
34
 
33
35
  constructor() {
34
36
  this.data = loadStorage();
@@ -38,6 +40,19 @@ export class AccountManager {
38
40
  saveStorage(this.data);
39
41
  }
40
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
+
41
56
  getAccounts(): Account[] {
42
57
  return this.data.accounts;
43
58
  }
@@ -82,7 +97,6 @@ export class AccountManager {
82
97
  });
83
98
  }
84
99
  this.setActiveAccount(email);
85
- this.save();
86
100
  }
87
101
 
88
102
  getActiveAccount(): Account | undefined {
@@ -111,6 +125,7 @@ export class AccountManager {
111
125
  setActiveAccount(email: string): void {
112
126
  this.data.activeEmail = email;
113
127
  this.save();
128
+ this.notifyStateChanged();
114
129
  }
115
130
 
116
131
  setManualAccount(email: string): void {
@@ -118,10 +133,13 @@ export class AccountManager {
118
133
  if (!account) return;
119
134
  this.manualEmail = email;
120
135
  account.lastUsed = Date.now();
136
+ this.notifyStateChanged();
121
137
  }
122
138
 
123
139
  clearManualAccount(): void {
140
+ if (!this.manualEmail) return;
124
141
  this.manualEmail = undefined;
142
+ this.notifyStateChanged();
125
143
  }
126
144
 
127
145
  getImportedAccount(): Account | undefined {
@@ -173,6 +191,7 @@ export class AccountManager {
173
191
  if (account) {
174
192
  account.quotaExhaustedUntil = until;
175
193
  this.save();
194
+ this.notifyStateChanged();
176
195
  }
177
196
  }
178
197
 
@@ -201,6 +220,7 @@ export class AccountManager {
201
220
  timeoutMs: USAGE_REQUEST_TIMEOUT_MS,
202
221
  });
203
222
  this.usageCache.set(account.email, usage);
223
+ this.notifyStateChanged();
204
224
  return usage;
205
225
  } catch (error) {
206
226
  this.warningHandler?.(
@@ -283,6 +303,7 @@ export class AccountManager {
283
303
  }
284
304
  if (changed) {
285
305
  this.save();
306
+ this.notifyStateChanged();
286
307
  }
287
308
  }
288
309
 
@@ -301,6 +322,7 @@ export class AccountManager {
301
322
  account.accountId = accountId;
302
323
  }
303
324
  this.save();
325
+ this.notifyStateChanged();
304
326
  return account.accessToken;
305
327
  }
306
328
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
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,6 +394,8 @@ export function createUsageStatusController(accountManager: AccountManager) {
335
394
  return;
336
395
  }
337
396
 
397
+ renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
398
+
338
399
  let activeAccount = accountManager.getActiveAccount();
339
400
  if (!activeAccount) {
340
401
  await accountManager.syncImportedOpenAICodexAuth();
@@ -354,7 +415,12 @@ export function createUsageStatusController(accountManager: AccountManager) {
354
415
  cachedUsage;
355
416
  ctx.ui.setStatus(
356
417
  STATUS_KEY,
357
- formatActiveAccountStatus(ctx, activeAccount.email, usage, preferences),
418
+ formatActiveAccountStatus(
419
+ ctx,
420
+ activeAccount.email,
421
+ usage,
422
+ livePreviewPreferences ?? preferences,
423
+ ),
358
424
  );
359
425
  }
360
426
 
@@ -377,6 +443,19 @@ export function createUsageStatusController(accountManager: AccountManager) {
377
443
  }
378
444
  }
379
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
+
380
459
  function startAutoRefresh(): void {
381
460
  if (refreshTimer) clearInterval(refreshTimer);
382
461
  refreshTimer = setInterval(() => {
@@ -391,6 +470,11 @@ export function createUsageStatusController(accountManager: AccountManager) {
391
470
  clearInterval(refreshTimer);
392
471
  refreshTimer = undefined;
393
472
  }
473
+ if (modelSelectTimer) {
474
+ clearTimeout(modelSelectTimer);
475
+ modelSelectTimer = undefined;
476
+ }
477
+ livePreviewPreferences = undefined;
394
478
  clearStatus(ctx ?? activeContext);
395
479
  activeContext = undefined;
396
480
  queuedRefresh = false;
@@ -408,11 +492,23 @@ export function createUsageStatusController(accountManager: AccountManager) {
408
492
  }
409
493
  }
410
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
+
411
505
  async function openPreferencesPanel(
412
506
  ctx: ExtensionCommandContext,
413
507
  ): Promise<void> {
414
508
  await loadPreferences(ctx);
415
509
  let draft = preferences;
510
+ livePreviewPreferences = draft;
511
+ renderCachedStatus(ctx, livePreviewPreferences);
416
512
 
417
513
  await ctx.ui.custom((_tui, theme, _kb, done) => {
418
514
  const container = new Container();
@@ -429,14 +525,20 @@ export function createUsageStatusController(accountManager: AccountManager) {
429
525
  0,
430
526
  ),
431
527
  );
528
+ const previewText = new Text(renderPreviewLabel(ctx, theme, draft), 1, 0);
529
+ container.addChild(previewText);
432
530
 
433
531
  const settingsList = new SettingsList(
434
532
  createSettingsItems(draft),
435
- 7,
533
+ 9,
436
534
  getSettingsListTheme(),
437
535
  (id: string, newValue: string) => {
438
536
  draft = applyPreferenceChange(draft, id, newValue);
537
+ livePreviewPreferences = draft;
439
538
  settingsList.updateValue(id, newValue);
539
+ previewText.setText(renderPreviewLabel(ctx, theme, draft));
540
+ container.invalidate();
541
+ renderCachedStatus(ctx, draft);
440
542
  },
441
543
  () => done(undefined),
442
544
  { enableSearch: true },
@@ -451,6 +553,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
451
553
  });
452
554
 
453
555
  preferences = draft;
556
+ livePreviewPreferences = undefined;
454
557
  await persistFooterPreferences(preferences);
455
558
  await refreshFor(ctx);
456
559
  }
@@ -459,6 +562,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
459
562
  loadPreferences,
460
563
  openPreferencesPanel,
461
564
  refreshFor,
565
+ scheduleModelSelectRefresh,
462
566
  startAutoRefresh,
463
567
  stopAutoRefresh,
464
568
  getPreferences: () => preferences,