@victor-software-house/pi-multicodex 2.1.5 → 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.
Files changed (2) hide show
  1. package/commands.ts +251 -57
  2. package/package.json +1 -1
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,35 +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
106
  const imported = account.importSource
103
107
  ? account.importMode === "synthetic"
104
108
  ? "pi auth only"
105
109
  : "pi auth"
106
110
  : null;
107
- const reauth = account.needsReauth ? "needs reauth" : null;
108
- const tags = [
111
+ return [
109
112
  active?.email === account.email ? "active" : null,
110
113
  manual?.email === account.email ? "manual" : null,
111
- reauth,
114
+ account.needsReauth ? "needs reauth" : null,
115
+ isPlaceholderAccount(account) ? "placeholder" : null,
112
116
  quotaHit ? "quota" : null,
113
- untouched,
117
+ isUsageUntouched(usage) ? "untouched" : null,
114
118
  imported,
115
- ]
116
- .filter(Boolean)
117
- .join(", ");
118
- 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);
119
127
  const primaryUsed = usage?.primary?.usedPercent;
120
128
  const secondaryUsed = usage?.secondary?.usedPercent;
121
129
  const primaryReset = usage?.primary?.resetAt;
@@ -124,8 +132,18 @@ function formatAccountStatusLine(
124
132
  primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
125
133
  const secondaryLabel =
126
134
  secondaryUsed === undefined ? "unknown" : `${Math.round(secondaryUsed)}%`;
127
- const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
128
- 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)}`;
129
147
  }
130
148
 
131
149
  function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
@@ -344,58 +362,234 @@ async function openAccountManagementPanel(
344
362
  accountManager: AccountManager,
345
363
  ): Promise<AccountPanelResult> {
346
364
  const accounts = accountManager.getAccounts();
347
- const items = accounts.map((account) => ({
348
- value: account.email,
349
- label: formatAccountStatusLine(accountManager, account.email),
350
- }));
351
365
 
352
- return ctx.ui.custom<AccountPanelResult>((_tui, theme, _kb, done) => {
353
- const container = new Container();
354
- container.addChild(
355
- new Text(theme.fg("accent", theme.bold("MultiCodex Accounts")), 1, 0),
356
- );
357
- container.addChild(
358
- new Text(
359
- theme.fg(
360
- "dim",
361
- "enter: use u: refresh r: re-auth n: add backspace: remove esc: cancel",
362
- ),
363
- 1,
364
- 0,
365
- ),
366
- );
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
+ }
367
401
 
368
- const selectList = new SelectList(items, 12, getSelectListTheme());
369
- selectList.onSelect = (item) => {
370
- 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
+ },
371
464
  };
372
- selectList.onCancel = () => done(undefined);
373
- 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());
374
522
 
375
523
  return {
376
- render: (width: number) => container.render(width),
377
- invalidate: () => container.invalidate(),
378
- 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
+ }
379
569
  if (data.toLowerCase() === "n") {
380
570
  done({ action: "add" });
381
571
  return;
382
572
  }
383
- const selected = selectList.getSelectedItem();
384
- if (selected && data.toLowerCase() === "u") {
385
- 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
+ }
386
578
  return;
387
579
  }
388
- if (selected && data.toLowerCase() === "r") {
389
- 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
+ }
390
585
  return;
391
586
  }
392
- if (matchesKey(data, Key.backspace)) {
587
+ if (matchesKey(data, "backspace")) {
588
+ const selected = getSelectedAccount();
393
589
  if (selected) {
394
- done({ action: "remove", email: selected.value });
590
+ done({ action: "remove", email: selected.email });
395
591
  }
396
- return;
397
592
  }
398
- selectList.handleInput(data);
399
593
  },
400
594
  };
401
595
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",