@victor-software-house/pi-multicodex 2.1.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -313,6 +313,42 @@ export class AccountManager {
313
313
  );
314
314
  }
315
315
 
316
+ private getLinkedImportedAccount(): Account | undefined {
317
+ return this.data.accounts.find(
318
+ (account) =>
319
+ account.importSource === "pi-openai-codex" &&
320
+ account.importMode !== "synthetic",
321
+ );
322
+ }
323
+
324
+ private getSyntheticImportedAccount(): Account | undefined {
325
+ return this.data.accounts.find(
326
+ (account) =>
327
+ account.importSource === "pi-openai-codex" &&
328
+ account.importMode === "synthetic",
329
+ );
330
+ }
331
+
332
+ private findManagedImportedTarget(imported: {
333
+ identifier: string;
334
+ credentials: OAuthCredentials;
335
+ }): Account | undefined {
336
+ const byRefreshToken = this.data.accounts.find(
337
+ (account) =>
338
+ account.importMode !== "synthetic" &&
339
+ account.refreshToken === imported.credentials.refresh,
340
+ );
341
+ if (byRefreshToken) {
342
+ return byRefreshToken;
343
+ }
344
+
345
+ return this.data.accounts.find(
346
+ (account) =>
347
+ account.importMode !== "synthetic" &&
348
+ account.email === imported.identifier,
349
+ );
350
+ }
351
+
316
352
  private clearImportedLink(account: Account): boolean {
317
353
  let changed = false;
318
354
  if (account.importSource) {
@@ -334,19 +370,18 @@ export class AccountManager {
334
370
  const imported = await loadImportedOpenAICodexAuth();
335
371
  if (!imported) return false;
336
372
 
337
- const existingImported = this.getImportedAccount();
373
+ const linkedImported = this.getLinkedImportedAccount();
374
+ const syntheticImported = this.getSyntheticImportedAccount();
375
+ const currentImported = linkedImported ?? syntheticImported;
338
376
  if (
339
- existingImported?.importFingerprint === imported.fingerprint &&
340
- (existingImported.importMode !== "synthetic" ||
341
- existingImported.email === imported.identifier)
377
+ currentImported?.importFingerprint === imported.fingerprint &&
378
+ (currentImported.importMode !== "synthetic" ||
379
+ currentImported.email === imported.identifier)
342
380
  ) {
343
381
  return false;
344
382
  }
345
383
 
346
- const matchingAccount = this.findAccountByRefreshToken(
347
- imported.credentials.refresh,
348
- existingImported?.email,
349
- );
384
+ const matchingAccount = this.findManagedImportedTarget(imported);
350
385
  if (matchingAccount) {
351
386
  let changed = this.applyCredentials(
352
387
  matchingAccount,
@@ -357,12 +392,11 @@ export class AccountManager {
357
392
  importFingerprint: imported.fingerprint,
358
393
  },
359
394
  );
360
- if (existingImported && existingImported !== matchingAccount) {
361
- if (existingImported.importMode === "synthetic") {
362
- changed = this.removeAccountRecord(existingImported) || changed;
363
- } else {
364
- changed = this.clearImportedLink(existingImported) || changed;
365
- }
395
+ if (linkedImported && linkedImported !== matchingAccount) {
396
+ changed = this.clearImportedLink(linkedImported) || changed;
397
+ }
398
+ if (syntheticImported) {
399
+ changed = this.removeAccountRecord(syntheticImported) || changed;
366
400
  }
367
401
  if (changed) {
368
402
  this.save();
@@ -371,17 +405,24 @@ export class AccountManager {
371
405
  return changed;
372
406
  }
373
407
 
374
- if (existingImported?.importMode === "synthetic") {
375
- const target = this.getAccount(imported.identifier);
408
+ if (linkedImported) {
409
+ const changed = this.clearImportedLink(linkedImported);
410
+ if (changed) {
411
+ this.save();
412
+ this.notifyStateChanged();
413
+ }
414
+ }
415
+
416
+ if (syntheticImported) {
376
417
  let changed = false;
377
- if (!target && existingImported.email !== imported.identifier) {
418
+ if (syntheticImported.email !== imported.identifier) {
378
419
  changed = this.updateAccountEmail(
379
- existingImported,
420
+ syntheticImported,
380
421
  imported.identifier,
381
422
  );
382
423
  }
383
424
  changed =
384
- this.applyCredentials(existingImported, imported.credentials, {
425
+ this.applyCredentials(syntheticImported, imported.credentials, {
385
426
  importSource: "pi-openai-codex",
386
427
  importMode: "synthetic",
387
428
  importFingerprint: imported.fingerprint,
@@ -393,14 +434,6 @@ export class AccountManager {
393
434
  return changed;
394
435
  }
395
436
 
396
- if (existingImported) {
397
- const changed = this.clearImportedLink(existingImported);
398
- if (changed) {
399
- this.save();
400
- this.notifyStateChanged();
401
- }
402
- }
403
-
404
437
  this.addOrUpdateAccount(imported.identifier, imported.credentials, {
405
438
  importSource: "pi-openai-codex",
406
439
  importMode: "synthetic",
package/commands.ts CHANGED
@@ -5,14 +5,15 @@ import type {
5
5
  ExtensionAPI,
6
6
  ExtensionCommandContext,
7
7
  } from "@mariozechner/pi-coding-agent";
8
- import { getSelectListTheme } from "@mariozechner/pi-coding-agent";
8
+ import { DynamicBorder, rawKeyHint } from "@mariozechner/pi-coding-agent";
9
9
  import {
10
10
  type AutocompleteItem,
11
11
  Container,
12
- Key,
12
+ getKeybindings,
13
13
  matchesKey,
14
- SelectList,
15
- Text,
14
+ Spacer,
15
+ truncateToWidth,
16
+ visibleWidth,
16
17
  } from "@mariozechner/pi-tui";
17
18
  import { getAgentSettingsPath } from "pi-provider-utils/agent-paths";
18
19
  import { normalizeUnknownError } from "pi-provider-utils/streams";
@@ -20,7 +21,7 @@ import type { AccountManager } from "./account-manager";
20
21
  import { writeActiveTokenToAuthJson } from "./auth";
21
22
  import { openLoginInBrowser } from "./browser";
22
23
  import type { createUsageStatusController } from "./status";
23
- import { STORAGE_FILE } from "./storage";
24
+ import { type Account, STORAGE_FILE } from "./storage";
24
25
  import { formatResetAt, isUsageUntouched } from "./usage";
25
26
 
26
27
  const SETTINGS_FILE = getAgentSettingsPath();
@@ -87,31 +88,42 @@ function parseResetTarget(value: string): ResetTarget | undefined {
87
88
  return undefined;
88
89
  }
89
90
 
90
- function formatAccountStatusLine(
91
+ function isPlaceholderAccount(account: Account): boolean {
92
+ return (
93
+ !account.accessToken || !account.refreshToken || account.expiresAt <= 0
94
+ );
95
+ }
96
+
97
+ function getAccountTags(
91
98
  accountManager: AccountManager,
92
- email: string,
93
- ): string {
94
- const account = accountManager.getAccount(email);
95
- if (!account) return email;
99
+ account: Account,
100
+ ): string[] {
96
101
  const usage = accountManager.getCachedUsage(account.email);
97
102
  const active = accountManager.getActiveAccount();
98
103
  const manual = accountManager.getManualAccount();
99
104
  const quotaHit =
100
105
  account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
101
- const untouched = isUsageUntouched(usage) ? "untouched" : null;
102
- const imported = account.importSource ? "pi auth" : null;
103
- const reauth = account.needsReauth ? "needs reauth" : null;
104
- const tags = [
106
+ const imported = account.importSource
107
+ ? account.importMode === "synthetic"
108
+ ? "pi auth only"
109
+ : "pi auth"
110
+ : null;
111
+ return [
105
112
  active?.email === account.email ? "active" : null,
106
113
  manual?.email === account.email ? "manual" : null,
107
- reauth,
114
+ account.needsReauth ? "needs reauth" : null,
115
+ isPlaceholderAccount(account) ? "placeholder" : null,
108
116
  quotaHit ? "quota" : null,
109
- untouched,
117
+ isUsageUntouched(usage) ? "untouched" : null,
110
118
  imported,
111
- ]
112
- .filter(Boolean)
113
- .join(", ");
114
- const suffix = tags ? ` (${tags})` : "";
119
+ ].filter((value): value is string => Boolean(value));
120
+ }
121
+
122
+ function formatUsageSummary(
123
+ accountManager: AccountManager,
124
+ account: Account,
125
+ ): string {
126
+ const usage = accountManager.getCachedUsage(account.email);
115
127
  const primaryUsed = usage?.primary?.usedPercent;
116
128
  const secondaryUsed = usage?.secondary?.usedPercent;
117
129
  const primaryReset = usage?.primary?.resetAt;
@@ -120,8 +132,18 @@ function formatAccountStatusLine(
120
132
  primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
121
133
  const secondaryLabel =
122
134
  secondaryUsed === undefined ? "unknown" : `${Math.round(secondaryUsed)}%`;
123
- const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
124
- return `${account.email}${suffix} - ${usageSummary}`;
135
+ return `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
136
+ }
137
+
138
+ function formatAccountStatusLine(
139
+ accountManager: AccountManager,
140
+ email: string,
141
+ ): string {
142
+ const account = accountManager.getAccount(email);
143
+ if (!account) return email;
144
+ const tags = getAccountTags(accountManager, account).join(", ");
145
+ const suffix = tags ? ` (${tags})` : "";
146
+ return `${account.email}${suffix} - ${formatUsageSummary(accountManager, account)}`;
125
147
  }
126
148
 
127
149
  function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
@@ -340,58 +362,234 @@ async function openAccountManagementPanel(
340
362
  accountManager: AccountManager,
341
363
  ): Promise<AccountPanelResult> {
342
364
  const accounts = accountManager.getAccounts();
343
- const items = accounts.map((account) => ({
344
- value: account.email,
345
- label: formatAccountStatusLine(accountManager, account.email),
346
- }));
347
365
 
348
- return ctx.ui.custom<AccountPanelResult>((_tui, theme, _kb, done) => {
349
- const container = new Container();
350
- container.addChild(
351
- new Text(theme.fg("accent", theme.bold("MultiCodex Accounts")), 1, 0),
352
- );
353
- container.addChild(
354
- new Text(
355
- theme.fg(
356
- "dim",
357
- "enter: use u: refresh r: re-auth n: add backspace: remove esc: cancel",
358
- ),
359
- 1,
360
- 0,
361
- ),
362
- );
366
+ return ctx.ui.custom<AccountPanelResult>((tui, theme, _kb, done) => {
367
+ const kb = getKeybindings();
368
+ let selectedIndex = 0;
369
+ const maxVisible = 12;
370
+
371
+ function getSelectedAccount(): Account | undefined {
372
+ return accounts[selectedIndex];
373
+ }
374
+
375
+ function findNextIndex(from: number, direction: number): number {
376
+ if (accounts.length === 0) return 0;
377
+ return Math.max(0, Math.min(accounts.length - 1, from + direction));
378
+ }
379
+
380
+ function renderTag(text: string): string {
381
+ if (text === "active") {
382
+ return theme.fg("accent", `[${text}]`);
383
+ }
384
+ if (text === "manual") {
385
+ return theme.fg("warning", `[${text}]`);
386
+ }
387
+ if (text === "needs reauth") {
388
+ return theme.fg("error", `[${text}]`);
389
+ }
390
+ if (text === "placeholder") {
391
+ return theme.fg("warning", `[${text}]`);
392
+ }
393
+ if (text === "quota") {
394
+ return theme.fg("warning", `[${text}]`);
395
+ }
396
+ if (text === "pi auth" || text === "pi auth only") {
397
+ return theme.fg("success", `[${text}]`);
398
+ }
399
+ return theme.fg("muted", `[${text}]`);
400
+ }
363
401
 
364
- const selectList = new SelectList(items, 12, getSelectListTheme());
365
- selectList.onSelect = (item) => {
366
- done({ action: "select", email: item.value });
402
+ function renderRow(
403
+ account: Account,
404
+ selected: boolean,
405
+ width: number,
406
+ ): string[] {
407
+ const cursor = selected ? theme.fg("accent", ">") : theme.fg("dim", " ");
408
+ const name = selected ? theme.bold(account.email) : account.email;
409
+ const tags = getAccountTags(accountManager, account)
410
+ .map((tag) => renderTag(tag))
411
+ .join(" ");
412
+ const primary = truncateToWidth(
413
+ `${cursor} ${name}${tags ? ` ${tags}` : ""}`,
414
+ width,
415
+ "",
416
+ );
417
+ const summaryColor = account.needsReauth
418
+ ? "warning"
419
+ : isPlaceholderAccount(account)
420
+ ? "muted"
421
+ : "dim";
422
+ const secondary = theme.fg(
423
+ summaryColor,
424
+ formatUsageSummary(accountManager, account),
425
+ );
426
+ return [primary, truncateToWidth(` ${secondary}`, width, "")];
427
+ }
428
+
429
+ const header = {
430
+ invalidate() {},
431
+ render(width: number): string[] {
432
+ const title = theme.bold("MultiCodex Accounts");
433
+ const sep = theme.fg("muted", " · ");
434
+ const hints = [
435
+ rawKeyHint("enter", "use"),
436
+ rawKeyHint("u", "refresh"),
437
+ rawKeyHint("r", "reauth"),
438
+ rawKeyHint("n", "add"),
439
+ rawKeyHint("backspace", "remove"),
440
+ rawKeyHint("esc", "close"),
441
+ ].join(sep);
442
+ const spacing = Math.max(
443
+ 1,
444
+ width - visibleWidth(title) - visibleWidth(hints),
445
+ );
446
+ const reauthCount = accountManager.getAccountsNeedingReauth().length;
447
+ const placeholderCount = accounts.filter((account) =>
448
+ isPlaceholderAccount(account),
449
+ ).length;
450
+ const status = [
451
+ `${accounts.length} account${accounts.length === 1 ? "" : "s"}`,
452
+ reauthCount > 0 ? `${reauthCount} need reauth` : undefined,
453
+ placeholderCount > 0
454
+ ? `${placeholderCount} placeholder${placeholderCount === 1 ? "" : "s"}`
455
+ : undefined,
456
+ ]
457
+ .filter(Boolean)
458
+ .join(" · ");
459
+ return [
460
+ truncateToWidth(`${title}${" ".repeat(spacing)}${hints}`, width, ""),
461
+ theme.fg("muted", status),
462
+ ];
463
+ },
367
464
  };
368
- selectList.onCancel = () => done(undefined);
369
- container.addChild(selectList);
465
+
466
+ const list = {
467
+ invalidate() {},
468
+ render(width: number): string[] {
469
+ const lines: string[] = [];
470
+ if (accounts.length === 0) {
471
+ return [theme.fg("muted", " No managed accounts")];
472
+ }
473
+
474
+ const visibleRows = Math.max(1, Math.floor(maxVisible / 2));
475
+ const startIndex = Math.max(
476
+ 0,
477
+ Math.min(
478
+ selectedIndex - Math.floor(visibleRows / 2),
479
+ Math.max(0, accounts.length - visibleRows),
480
+ ),
481
+ );
482
+ const endIndex = Math.min(accounts.length, startIndex + visibleRows);
483
+
484
+ for (let index = startIndex; index < endIndex; index++) {
485
+ const account = accounts[index];
486
+ if (!account) continue;
487
+ lines.push(...renderRow(account, index === selectedIndex, width));
488
+ if (index < endIndex - 1) {
489
+ lines.push("");
490
+ }
491
+ }
492
+
493
+ const selected = getSelectedAccount();
494
+ if (selected) {
495
+ lines.push("");
496
+ const detail = isPlaceholderAccount(selected)
497
+ ? `selected: ${selected.email} · restored placeholder, re-auth required`
498
+ : `selected: ${selected.email}`;
499
+ lines.push(truncateToWidth(theme.fg("dim", detail), width, ""));
500
+ }
501
+
502
+ const current = selectedIndex + 1;
503
+ lines.push(
504
+ theme.fg(
505
+ "dim",
506
+ ` ${current}/${accounts.length} visible account rows`,
507
+ ),
508
+ );
509
+ return lines;
510
+ },
511
+ };
512
+
513
+ const container = new Container();
514
+ container.addChild(new Spacer(1));
515
+ container.addChild(new DynamicBorder());
516
+ container.addChild(new Spacer(1));
517
+ container.addChild(header);
518
+ container.addChild(new Spacer(1));
519
+ container.addChild(list);
520
+ container.addChild(new Spacer(1));
521
+ container.addChild(new DynamicBorder());
370
522
 
371
523
  return {
372
- render: (width: number) => container.render(width),
373
- invalidate: () => container.invalidate(),
374
- handleInput: (data: string) => {
524
+ render(width: number) {
525
+ return container.render(width);
526
+ },
527
+ invalidate() {
528
+ container.invalidate();
529
+ },
530
+ handleInput(data: string) {
531
+ if (kb.matches(data, "tui.select.up")) {
532
+ selectedIndex = findNextIndex(selectedIndex, -1);
533
+ tui.requestRender();
534
+ return;
535
+ }
536
+ if (kb.matches(data, "tui.select.down")) {
537
+ selectedIndex = findNextIndex(selectedIndex, 1);
538
+ tui.requestRender();
539
+ return;
540
+ }
541
+ if (kb.matches(data, "tui.select.pageUp")) {
542
+ selectedIndex = findNextIndex(selectedIndex, -5);
543
+ tui.requestRender();
544
+ return;
545
+ }
546
+ if (kb.matches(data, "tui.select.pageDown")) {
547
+ selectedIndex = findNextIndex(selectedIndex, 5);
548
+ tui.requestRender();
549
+ return;
550
+ }
551
+ if (
552
+ kb.matches(data, "tui.select.cancel") ||
553
+ matchesKey(data, "ctrl+c")
554
+ ) {
555
+ done(undefined);
556
+ return;
557
+ }
558
+ if (
559
+ data === "\r" ||
560
+ data === "\n" ||
561
+ kb.matches(data, "tui.select.confirm")
562
+ ) {
563
+ const selected = getSelectedAccount();
564
+ if (selected) {
565
+ done({ action: "select", email: selected.email });
566
+ }
567
+ return;
568
+ }
375
569
  if (data.toLowerCase() === "n") {
376
570
  done({ action: "add" });
377
571
  return;
378
572
  }
379
- const selected = selectList.getSelectedItem();
380
- if (selected && data.toLowerCase() === "u") {
381
- done({ action: "refresh", email: selected.value });
573
+ if (data.toLowerCase() === "u") {
574
+ const selected = getSelectedAccount();
575
+ if (selected) {
576
+ done({ action: "refresh", email: selected.email });
577
+ }
382
578
  return;
383
579
  }
384
- if (selected && data.toLowerCase() === "r") {
385
- done({ action: "reauth", email: selected.value });
580
+ if (data.toLowerCase() === "r") {
581
+ const selected = getSelectedAccount();
582
+ if (selected) {
583
+ done({ action: "reauth", email: selected.email });
584
+ }
386
585
  return;
387
586
  }
388
- if (matchesKey(data, Key.backspace)) {
587
+ if (matchesKey(data, "backspace")) {
588
+ const selected = getSelectedAccount();
389
589
  if (selected) {
390
- done({ action: "remove", email: selected.value });
590
+ done({ action: "remove", email: selected.email });
391
591
  }
392
- return;
393
592
  }
394
- selectList.handleInput(data);
395
593
  },
396
594
  };
397
595
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",