@oh-my-pi/pi-coding-agent 14.0.4 → 14.1.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 (61) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/package.json +11 -8
  3. package/src/async/index.ts +1 -0
  4. package/src/async/support.ts +5 -0
  5. package/src/cli/list-models.ts +96 -57
  6. package/src/commit/model-selection.ts +16 -13
  7. package/src/config/model-equivalence.ts +674 -0
  8. package/src/config/model-registry.ts +182 -13
  9. package/src/config/model-resolver.ts +203 -74
  10. package/src/config/settings-schema.ts +23 -0
  11. package/src/config/settings.ts +9 -2
  12. package/src/dap/session.ts +31 -39
  13. package/src/debug/log-formatting.ts +2 -2
  14. package/src/edit/modes/chunk.ts +8 -3
  15. package/src/export/html/template.css +82 -0
  16. package/src/export/html/template.generated.ts +1 -1
  17. package/src/export/html/template.js +612 -97
  18. package/src/internal-urls/docs-index.generated.ts +1 -1
  19. package/src/internal-urls/jobs-protocol.ts +2 -1
  20. package/src/lsp/client.ts +5 -3
  21. package/src/lsp/index.ts +4 -9
  22. package/src/lsp/utils.ts +26 -0
  23. package/src/main.ts +6 -1
  24. package/src/memories/index.ts +7 -6
  25. package/src/modes/components/diff.ts +1 -1
  26. package/src/modes/components/model-selector.ts +221 -64
  27. package/src/modes/controllers/command-controller.ts +18 -0
  28. package/src/modes/controllers/event-controller.ts +438 -426
  29. package/src/modes/controllers/selector-controller.ts +13 -5
  30. package/src/modes/theme/mermaid-cache.ts +5 -7
  31. package/src/priority.json +8 -0
  32. package/src/prompts/agents/designer.md +1 -2
  33. package/src/prompts/system/system-prompt.md +5 -1
  34. package/src/prompts/tools/bash.md +15 -0
  35. package/src/prompts/tools/cancel-job.md +1 -1
  36. package/src/prompts/tools/chunk-edit.md +39 -40
  37. package/src/prompts/tools/read-chunk.md +13 -1
  38. package/src/prompts/tools/read.md +9 -0
  39. package/src/prompts/tools/write.md +1 -0
  40. package/src/sdk.ts +7 -4
  41. package/src/session/agent-session.ts +33 -6
  42. package/src/session/compaction/compaction.ts +1 -1
  43. package/src/task/executor.ts +5 -1
  44. package/src/tools/await-tool.ts +2 -1
  45. package/src/tools/bash.ts +221 -56
  46. package/src/tools/browser.ts +84 -21
  47. package/src/tools/cancel-job.ts +2 -1
  48. package/src/tools/fetch.ts +1 -1
  49. package/src/tools/find.ts +40 -94
  50. package/src/tools/gemini-image.ts +1 -0
  51. package/src/tools/inspect-image.ts +1 -1
  52. package/src/tools/read.ts +218 -1
  53. package/src/tools/render-utils.ts +1 -1
  54. package/src/tools/sqlite-reader.ts +623 -0
  55. package/src/tools/write.ts +187 -1
  56. package/src/utils/commit-message-generator.ts +1 -0
  57. package/src/utils/git.ts +24 -1
  58. package/src/utils/image-resize.ts +73 -37
  59. package/src/utils/title-generator.ts +1 -1
  60. package/src/web/scrapers/types.ts +50 -32
  61. package/src/web/search/providers/codex.ts +21 -2
@@ -28,10 +28,38 @@ function makeInvertedBadge(label: string, color: ThemeColor): string {
28
28
  return `${bgAnsi}\x1b[30m ${label} \x1b[39m\x1b[49m`;
29
29
  }
30
30
 
31
+ function normalizeSearchText(value: string): string {
32
+ return value
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, " ")
35
+ .trim();
36
+ }
37
+
38
+ function compactSearchText(value: string): string {
39
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
40
+ }
41
+
42
+ function getAlphaSearchTokens(query: string): string[] {
43
+ return [...normalizeSearchText(query).matchAll(/[a-z]+/g)].map(match => match[0]).filter(token => token.length > 0);
44
+ }
45
+
31
46
  interface ModelItem {
47
+ kind: "provider";
32
48
  provider: string;
33
49
  id: string;
34
50
  model: Model;
51
+ selector: string;
52
+ }
53
+
54
+ interface CanonicalModelItem {
55
+ kind: "canonical";
56
+ id: string;
57
+ model: Model;
58
+ selector: string;
59
+ variantCount: number;
60
+ searchText: string;
61
+ normalizedSearchText: string;
62
+ compactSearchText: string;
35
63
  }
36
64
 
37
65
  interface ScopedModelItem {
@@ -44,7 +72,7 @@ interface RoleAssignment {
44
72
  thinkingLevel: ThinkingLevel;
45
73
  }
46
74
 
47
- type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ThinkingLevel) => void;
75
+ type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ThinkingLevel, selector?: string) => void;
48
76
  type CancelCallback = () => void;
49
77
  interface MenuRoleAction {
50
78
  label: string;
@@ -52,6 +80,7 @@ interface MenuRoleAction {
52
80
  }
53
81
 
54
82
  const ALL_TAB = "ALL";
83
+ const CANONICAL_TAB = "CANONICAL";
55
84
 
56
85
  /**
57
86
  * Component that renders a model selector with provider tabs and context menu.
@@ -68,6 +97,8 @@ export class ModelSelectorComponent extends Container {
68
97
  #menuContainer: Container;
69
98
  #allModels: ModelItem[] = [];
70
99
  #filteredModels: ModelItem[] = [];
100
+ #canonicalModels: CanonicalModelItem[] = [];
101
+ #filteredCanonicalModels: CanonicalModelItem[] = [];
71
102
  #selectedIndex: number = 0;
72
103
  #roles = {} as Record<string, RoleAssignment | undefined>;
73
104
  #settings = null as unknown as Settings;
@@ -97,7 +128,7 @@ export class ModelSelectorComponent extends Container {
97
128
  settings: Settings,
98
129
  modelRegistry: ModelRegistry,
99
130
  scopedModels: ReadonlyArray<ScopedModelItem>,
100
- onSelect: (model: Model, role: string | null, thinkingLevel?: ThinkingLevel) => void,
131
+ onSelect: (model: Model, role: string | null, thinkingLevel?: ThinkingLevel, selector?: string) => void,
101
132
  onCancel: () => void,
102
133
  options?: { temporaryOnly?: boolean; initialSearchInput?: string },
103
134
  ) {
@@ -202,6 +233,7 @@ export class ModelSelectorComponent extends Container {
202
233
  const resolved = resolveModelRoleValue(roleValue, allModels, {
203
234
  settings: this.#settings,
204
235
  matchPreferences,
236
+ modelRegistry: this.#modelRegistry,
205
237
  });
206
238
  if (resolved.model) {
207
239
  this.#roles[role] = {
@@ -237,8 +269,8 @@ export class ModelSelectorComponent extends Container {
237
269
  const latestRe = /-latest$/;
238
270
 
239
271
  models.sort((a, b) => {
240
- const aKey = `${a.provider}/${a.id}`;
241
- const bKey = `${b.provider}/${b.id}`;
272
+ const aKey = a.selector;
273
+ const bKey = b.selector;
242
274
 
243
275
  const aRank = modelRank(a);
244
276
  const bRank = modelRank(b);
@@ -289,15 +321,50 @@ export class ModelSelectorComponent extends Container {
289
321
  });
290
322
  }
291
323
 
324
+ #sortCanonicalModels(models: CanonicalModelItem[]): void {
325
+ const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
326
+ const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
327
+
328
+ const modelRank = (model: CanonicalModelItem) => {
329
+ let i = 0;
330
+ while (i < MODEL_ROLE_IDS.length) {
331
+ const role = MODEL_ROLE_IDS[i];
332
+ const assigned = this.#roles[role];
333
+ if (assigned && modelsAreEqual(assigned.model, model.model)) {
334
+ break;
335
+ }
336
+ i++;
337
+ }
338
+ return i;
339
+ };
340
+
341
+ models.sort((a, b) => {
342
+ const aRank = modelRank(a);
343
+ const bRank = modelRank(b);
344
+ if (aRank !== bRank) return aRank - bRank;
345
+
346
+ const aMru = mruIndex.get(`${a.model.provider}/${a.model.id}`) ?? Number.MAX_SAFE_INTEGER;
347
+ const bMru = mruIndex.get(`${b.model.provider}/${b.model.id}`) ?? Number.MAX_SAFE_INTEGER;
348
+ if (aMru !== bMru) return aMru - bMru;
349
+
350
+ const providerCmp = a.model.provider.localeCompare(b.model.provider);
351
+ if (providerCmp !== 0) return providerCmp;
352
+
353
+ return a.id.localeCompare(b.id);
354
+ });
355
+ }
356
+
292
357
  async #loadModels(): Promise<void> {
293
358
  let models: ModelItem[];
294
359
 
295
360
  // Use scoped models if provided via --models flag
296
361
  if (this.#scopedModels.length > 0) {
297
362
  models = this.#scopedModels.map(scoped => ({
363
+ kind: "provider",
298
364
  provider: scoped.model.provider,
299
365
  id: scoped.model.id,
300
366
  model: scoped.model,
367
+ selector: `${scoped.model.provider}/${scoped.model.id}`,
301
368
  }));
302
369
  } else {
303
370
  // Reload config and cached discovery state without blocking on live provider refresh
@@ -315,22 +382,61 @@ export class ModelSelectorComponent extends Container {
315
382
  try {
316
383
  const availableModels = this.#modelRegistry.getAvailable();
317
384
  models = availableModels.map((model: Model) => ({
385
+ kind: "provider",
318
386
  provider: model.provider,
319
387
  id: model.id,
320
388
  model,
389
+ selector: `${model.provider}/${model.id}`,
321
390
  }));
322
391
  } catch (error) {
323
392
  this.#allModels = [];
324
393
  this.#filteredModels = [];
394
+ this.#canonicalModels = [];
395
+ this.#filteredCanonicalModels = [];
325
396
  this.#errorMessage = error instanceof Error ? error.message : String(error);
326
397
  return;
327
398
  }
328
399
  }
329
400
 
401
+ const canonicalRecords = this.#modelRegistry.getCanonicalModels({
402
+ availableOnly: this.#scopedModels.length === 0,
403
+ candidates: models.map(item => item.model),
404
+ });
405
+ const canonicalModels = canonicalRecords
406
+ .map(record => {
407
+ const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
408
+ availableOnly: this.#scopedModels.length === 0,
409
+ candidates: models.map(item => item.model),
410
+ });
411
+ if (!selectedModel) return undefined;
412
+ const searchText = [
413
+ record.id,
414
+ record.name,
415
+ selectedModel.provider,
416
+ selectedModel.id,
417
+ selectedModel.name,
418
+ ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
419
+ ].join(" ");
420
+ return {
421
+ kind: "canonical" as const,
422
+ id: record.id,
423
+ model: selectedModel,
424
+ selector: record.id,
425
+ variantCount: record.variants.length,
426
+ searchText,
427
+ normalizedSearchText: normalizeSearchText(searchText),
428
+ compactSearchText: compactSearchText(searchText),
429
+ };
430
+ })
431
+ .filter((item): item is CanonicalModelItem => item !== undefined);
432
+
330
433
  this.#sortModels(models);
434
+ this.#sortCanonicalModels(canonicalModels);
331
435
 
332
436
  this.#allModels = models;
333
437
  this.#filteredModels = models;
438
+ this.#canonicalModels = canonicalModels;
439
+ this.#filteredCanonicalModels = canonicalModels;
334
440
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
335
441
  }
336
442
 
@@ -343,12 +449,12 @@ export class ModelSelectorComponent extends Container {
343
449
  providerSet.add(provider.toUpperCase());
344
450
  }
345
451
  const sortedProviders = Array.from(providerSet).sort();
346
- this.#providers = [ALL_TAB, ...sortedProviders];
452
+ this.#providers = [ALL_TAB, CANONICAL_TAB, ...sortedProviders];
347
453
  }
348
454
 
349
455
  async #refreshSelectedProvider(): Promise<void> {
350
456
  const activeProvider = this.#getActiveProvider();
351
- if (this.#scopedModels.length > 0 || activeProvider === ALL_TAB) {
457
+ if (this.#scopedModels.length > 0 || activeProvider === ALL_TAB || activeProvider === CANONICAL_TAB) {
352
458
  return;
353
459
  }
354
460
  await this.#modelRegistry.refreshProvider(activeProvider.toLowerCase());
@@ -382,19 +488,25 @@ export class ModelSelectorComponent extends Container {
382
488
  return this.#providers[this.#activeTabIndex] ?? ALL_TAB;
383
489
  }
384
490
 
491
+ #isCanonicalTab(): boolean {
492
+ return this.#getActiveProvider() === CANONICAL_TAB;
493
+ }
494
+
385
495
  #filterModels(query: string): void {
386
496
  const activeProvider = this.#getActiveProvider();
497
+ const isCanonicalTab = activeProvider === CANONICAL_TAB;
387
498
 
388
- // Start with all models or filter by provider
499
+ // Start with all models or filter by provider/canonical view
389
500
  let baseModels = this.#allModels;
390
- if (activeProvider !== ALL_TAB) {
501
+ const baseCanonicalModels = this.#canonicalModels;
502
+ if (!isCanonicalTab && activeProvider !== ALL_TAB) {
391
503
  baseModels = this.#allModels.filter(m => m.provider.toUpperCase() === activeProvider);
392
504
  }
393
505
 
394
506
  // Apply fuzzy filter if query is present
395
507
  if (query.trim()) {
396
- // If user is searching, auto-switch to ALL tab to show global results
397
- if (activeProvider !== ALL_TAB) {
508
+ // If user is searching from a provider tab, auto-switch to ALL to show global provider results.
509
+ if (activeProvider !== ALL_TAB && !isCanonicalTab) {
398
510
  this.#activeTabIndex = 0;
399
511
  if (this.#tabBar && this.#tabBar.getActiveIndex() !== 0) {
400
512
  this.#tabBar.setActiveIndex(0);
@@ -403,14 +515,41 @@ export class ModelSelectorComponent extends Container {
403
515
  this.#updateTabBar();
404
516
  baseModels = this.#allModels;
405
517
  }
406
- const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${id} ${provider}`);
407
- this.#sortModels(fuzzyMatches);
408
- this.#filteredModels = fuzzyMatches;
518
+
519
+ if (isCanonicalTab) {
520
+ const alphaTokens = getAlphaSearchTokens(query);
521
+ const alphaFiltered =
522
+ alphaTokens.length === 0
523
+ ? baseCanonicalModels
524
+ : baseCanonicalModels.filter(item =>
525
+ alphaTokens.every(token => item.normalizedSearchText.includes(token)),
526
+ );
527
+ const compactQuery = compactSearchText(query);
528
+ const substringFiltered =
529
+ compactQuery.length === 0
530
+ ? alphaFiltered
531
+ : alphaFiltered.filter(item => item.compactSearchText.includes(compactQuery));
532
+ const fuzzySource =
533
+ substringFiltered.length > 0
534
+ ? substringFiltered
535
+ : alphaFiltered.length > 0
536
+ ? alphaFiltered
537
+ : baseCanonicalModels;
538
+ const fuzzyMatches = fuzzyFilter(fuzzySource, query, ({ searchText }) => searchText);
539
+ this.#sortCanonicalModels(fuzzyMatches);
540
+ this.#filteredCanonicalModels = fuzzyMatches;
541
+ } else {
542
+ const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${id} ${provider}`);
543
+ this.#sortModels(fuzzyMatches);
544
+ this.#filteredModels = fuzzyMatches;
545
+ }
409
546
  } else {
410
547
  this.#filteredModels = baseModels;
548
+ this.#filteredCanonicalModels = baseCanonicalModels;
411
549
  }
412
550
 
413
- this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredModels.length - 1));
551
+ const visibleCount = isCanonicalTab ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
552
+ this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, visibleCount - 1));
414
553
  this.#updateList();
415
554
  }
416
555
 
@@ -433,7 +572,7 @@ export class ModelSelectorComponent extends Container {
433
572
 
434
573
  #getProviderEmptyStateMessage(): string | undefined {
435
574
  const activeProvider = this.#getActiveProvider();
436
- if (activeProvider === ALL_TAB || this.#searchInput.getValue().trim()) {
575
+ if (activeProvider === ALL_TAB || activeProvider === CANONICAL_TAB || this.#searchInput.getValue().trim()) {
437
576
  return undefined;
438
577
  }
439
578
  const state = this.#modelRegistry.getProviderDiscoveryState(activeProvider.toLowerCase());
@@ -459,21 +598,25 @@ export class ModelSelectorComponent extends Container {
459
598
 
460
599
  #updateList(): void {
461
600
  this.#listContainer.clear();
601
+ const isCanonicalTab = this.#isCanonicalTab();
602
+ const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
462
603
 
463
604
  const maxVisible = 10;
464
605
  const startIndex = Math.max(
465
606
  0,
466
- Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), this.#filteredModels.length - maxVisible),
607
+ Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), visibleItems.length - maxVisible),
467
608
  );
468
- const endIndex = Math.min(startIndex + maxVisible, this.#filteredModels.length);
609
+ const endIndex = Math.min(startIndex + maxVisible, visibleItems.length);
469
610
 
470
611
  const activeProvider = this.#getActiveProvider();
471
612
  const showProvider = activeProvider === ALL_TAB;
472
613
 
473
614
  // Show visible slice of filtered models
474
615
  for (let i = startIndex; i < endIndex; i++) {
475
- const item = this.#filteredModels[i];
616
+ const item = visibleItems[i];
476
617
  if (!item) continue;
618
+ const canonicalItem = isCanonicalTab ? (item as CanonicalModelItem) : undefined;
619
+ const providerItem = isCanonicalTab ? undefined : (item as ModelItem);
477
620
 
478
621
  const isSelected = i === this.#selectedIndex;
479
622
 
@@ -502,17 +645,25 @@ export class ModelSelectorComponent extends Container {
502
645
  let line = "";
503
646
  if (isSelected) {
504
647
  const prefix = theme.fg("accent", `${theme.nav.cursor} `);
505
- if (showProvider) {
506
- const providerPrefix = theme.fg("dim", `${item.provider}/`);
507
- line = `${prefix}${providerPrefix}${theme.fg("accent", item.id)}${badgeText}`;
648
+ if (isCanonicalTab) {
649
+ const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
650
+ const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
651
+ line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}`;
652
+ } else if (showProvider) {
653
+ const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
654
+ line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}`;
508
655
  } else {
509
656
  line = `${prefix}${theme.fg("accent", item.id)}${badgeText}`;
510
657
  }
511
658
  } else {
512
659
  const prefix = " ";
513
- if (showProvider) {
514
- const providerPrefix = theme.fg("dim", `${item.provider}/`);
515
- line = `${prefix}${providerPrefix}${item.id}${badgeText}`;
660
+ if (isCanonicalTab) {
661
+ const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
662
+ const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
663
+ line = `${prefix}${item.id}${variants}${backing}${badgeText}`;
664
+ } else if (showProvider) {
665
+ const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
666
+ line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}`;
516
667
  } else {
517
668
  line = `${prefix}${item.id}${badgeText}`;
518
669
  }
@@ -522,8 +673,8 @@ export class ModelSelectorComponent extends Container {
522
673
  }
523
674
 
524
675
  // Add scroll indicator if needed
525
- if (startIndex > 0 || endIndex < this.#filteredModels.length) {
526
- const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredModels.length})`);
676
+ if (startIndex > 0 || endIndex < visibleItems.length) {
677
+ const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${visibleItems.length})`);
527
678
  this.#listContainer.addChild(new Text(scrollInfo, 0, 0));
528
679
  }
529
680
 
@@ -533,13 +684,21 @@ export class ModelSelectorComponent extends Container {
533
684
  for (const line of errorLines) {
534
685
  this.#listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
535
686
  }
536
- } else if (this.#filteredModels.length === 0) {
687
+ } else if (visibleItems.length === 0) {
537
688
  const statusMessage = this.#getProviderEmptyStateMessage();
538
689
  this.#listContainer.addChild(new Text(theme.fg("muted", statusMessage ?? " No matching models"), 0, 0));
539
690
  } else {
540
- const selected = this.#filteredModels[this.#selectedIndex];
691
+ const selected = visibleItems[this.#selectedIndex];
692
+ if (!selected) {
693
+ return;
694
+ }
541
695
  this.#listContainer.addChild(new Spacer(1));
542
- this.#listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
696
+ const suffix = isCanonicalTab
697
+ ? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
698
+ : "";
699
+ this.#listContainer.addChild(
700
+ new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0),
701
+ );
543
702
  }
544
703
  }
545
704
  #getThinkingLevelsForModel(model: Model): ReadonlyArray<ThinkingLevel> {
@@ -557,8 +716,14 @@ export class ModelSelectorComponent extends Container {
557
716
  return foundIndex >= 0 ? foundIndex : 0;
558
717
  }
559
718
 
719
+ #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
720
+ return this.#isCanonicalTab()
721
+ ? this.#filteredCanonicalModels[this.#selectedIndex]
722
+ : this.#filteredModels[this.#selectedIndex];
723
+ }
724
+
560
725
  #openMenu(): void {
561
- if (this.#filteredModels.length === 0) return;
726
+ if (!this.#getSelectedItem()) return;
562
727
 
563
728
  this.#isMenuOpen = true;
564
729
  this.#menuStep = "role";
@@ -577,11 +742,11 @@ export class ModelSelectorComponent extends Container {
577
742
  #updateMenu(): void {
578
743
  this.#menuContainer.clear();
579
744
 
580
- const selectedModel = this.#filteredModels[this.#selectedIndex];
581
- if (!selectedModel) return;
745
+ const selectedItem = this.#getSelectedItem();
746
+ if (!selectedItem) return;
582
747
 
583
748
  const showingThinking = this.#menuStep === "thinking" && this.#menuSelectedRole !== null;
584
- const thinkingOptions = showingThinking ? this.#getThinkingLevelsForModel(selectedModel.model) : [];
749
+ const thinkingOptions = showingThinking ? this.#getThinkingLevelsForModel(selectedItem.model) : [];
585
750
  const optionLines = showingThinking
586
751
  ? thinkingOptions.map((thinkingLevel, index) => {
587
752
  const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
@@ -596,8 +761,8 @@ export class ModelSelectorComponent extends Container {
596
761
  const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : "";
597
762
  const headerText =
598
763
  showingThinking && this.#menuSelectedRole
599
- ? ` Thinking for: ${selectedRoleName} (${selectedModel.id})`
600
- : ` Action for: ${selectedModel.id}`;
764
+ ? ` Thinking for: ${selectedRoleName} (${selectedItem.id})`
765
+ : ` Action for: ${selectedItem.id}`;
601
766
  const hintText = showingThinking ? " Enter: confirm Esc: back" : " Enter: continue Esc: cancel";
602
767
  const menuWidth = Math.max(
603
768
  visibleWidth(headerText),
@@ -610,15 +775,13 @@ export class ModelSelectorComponent extends Container {
610
775
  if (showingThinking && this.#menuSelectedRole) {
611
776
  this.#menuContainer.addChild(
612
777
  new Text(
613
- theme.fg("text", ` Thinking for: ${theme.bold(selectedRoleName)} (${theme.bold(selectedModel.id)})`),
778
+ theme.fg("text", ` Thinking for: ${theme.bold(selectedRoleName)} (${theme.bold(selectedItem.id)})`),
614
779
  0,
615
780
  0,
616
781
  ),
617
782
  );
618
783
  } else {
619
- this.#menuContainer.addChild(
620
- new Text(theme.fg("text", ` Action for: ${theme.bold(selectedModel.id)}`), 0, 0),
621
- );
784
+ this.#menuContainer.addChild(new Text(theme.fg("text", ` Action for: ${theme.bold(selectedItem.id)}`), 0, 0));
622
785
  }
623
786
  this.#menuContainer.addChild(new Spacer(1));
624
787
 
@@ -648,27 +811,29 @@ export class ModelSelectorComponent extends Container {
648
811
 
649
812
  // Up arrow - navigate list (wrap to bottom when at top)
650
813
  if (matchesKey(keyData, "up")) {
651
- if (this.#filteredModels.length === 0) return;
652
- this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredModels.length - 1 : this.#selectedIndex - 1;
814
+ const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
815
+ if (itemCount === 0) return;
816
+ this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1;
653
817
  this.#updateList();
654
818
  return;
655
819
  }
656
820
 
657
821
  // Down arrow - navigate list (wrap to top when at bottom)
658
822
  if (matchesKey(keyData, "down")) {
659
- if (this.#filteredModels.length === 0) return;
660
- this.#selectedIndex = this.#selectedIndex === this.#filteredModels.length - 1 ? 0 : this.#selectedIndex + 1;
823
+ const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
824
+ if (itemCount === 0) return;
825
+ this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1;
661
826
  this.#updateList();
662
827
  return;
663
828
  }
664
829
 
665
830
  // Enter - open context menu or select directly in temporary mode
666
831
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
667
- const selectedModel = this.#filteredModels[this.#selectedIndex];
668
- if (selectedModel) {
832
+ const selectedItem = this.#getSelectedItem();
833
+ if (selectedItem) {
669
834
  if (this.#temporaryOnly) {
670
835
  // In temporary mode, skip menu and select directly
671
- this.#handleSelect(selectedModel.model, null);
836
+ this.#handleSelect(selectedItem, null);
672
837
  } else {
673
838
  this.#openMenu();
674
839
  }
@@ -687,12 +852,12 @@ export class ModelSelectorComponent extends Container {
687
852
  this.#filterModels(this.#searchInput.getValue());
688
853
  }
689
854
  #handleMenuInput(keyData: string): void {
690
- const selectedModel = this.#filteredModels[this.#selectedIndex];
691
- if (!selectedModel) return;
855
+ const selectedItem = this.#getSelectedItem();
856
+ if (!selectedItem) return;
692
857
 
693
858
  const optionCount =
694
859
  this.#menuStep === "thinking" && this.#menuSelectedRole !== null
695
- ? this.#getThinkingLevelsForModel(selectedModel.model).length
860
+ ? this.#getThinkingLevelsForModel(selectedItem.model).length
696
861
  : this.#menuRoleActions.length;
697
862
  if (optionCount === 0) return;
698
863
 
@@ -714,16 +879,16 @@ export class ModelSelectorComponent extends Container {
714
879
  if (!action) return;
715
880
  this.#menuSelectedRole = action.role;
716
881
  this.#menuStep = "thinking";
717
- this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedModel.model);
882
+ this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
718
883
  this.#updateMenu();
719
884
  return;
720
885
  }
721
886
 
722
887
  if (!this.#menuSelectedRole) return;
723
- const thinkingOptions = this.#getThinkingLevelsForModel(selectedModel.model);
888
+ const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model);
724
889
  const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
725
890
  if (!thinkingLevel) return;
726
- this.#handleSelect(selectedModel.model, this.#menuSelectedRole, thinkingLevel);
891
+ this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel);
727
892
  this.#closeMenu();
728
893
  return;
729
894
  }
@@ -742,28 +907,20 @@ export class ModelSelectorComponent extends Container {
742
907
  }
743
908
  }
744
909
 
745
- #formatRoleModelValue(model: Model, thinkingLevel: ThinkingLevel): string {
746
- const modelKey = `${model.provider}/${model.id}`;
747
- if (thinkingLevel === ThinkingLevel.Inherit) return modelKey;
748
- return `${modelKey}:${thinkingLevel}`;
749
- }
750
- #handleSelect(model: Model, role: string | null, thinkingLevel?: ThinkingLevel): void {
910
+ #handleSelect(item: ModelItem | CanonicalModelItem, role: string | null, thinkingLevel?: ThinkingLevel): void {
751
911
  // For temporary role, don't save to settings - just notify caller
752
912
  if (role === null) {
753
- this.#onSelectCallback(model, null);
913
+ this.#onSelectCallback(item.model, null, undefined, item.selector);
754
914
  return;
755
915
  }
756
916
 
757
917
  const selectedThinkingLevel = thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
758
918
 
759
- // Save to settings
760
- this.#settings.setModelRole(role, this.#formatRoleModelValue(model, selectedThinkingLevel));
761
-
762
919
  // Update local state for UI
763
- this.#roles[role] = { model, thinkingLevel: selectedThinkingLevel };
920
+ this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel };
764
921
 
765
922
  // Notify caller (for updating agent state if needed)
766
- this.#onSelectCallback(model, role, selectedThinkingLevel);
923
+ this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
767
924
 
768
925
  // Update list to show new badges
769
926
  this.#updateList();
@@ -1002,6 +1002,14 @@ function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: numbe
1002
1002
  return `account ${index + 1}`;
1003
1003
  }
1004
1004
 
1005
+ function formatUnlimitedReportLabel(report: UsageReport, index: number): string {
1006
+ const email = report.metadata?.email as string | undefined;
1007
+ if (email) return email;
1008
+ const accountId = report.metadata?.accountId as string | undefined;
1009
+ if (accountId) return accountId;
1010
+ return `account ${index + 1}`;
1011
+ }
1012
+
1005
1013
  function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined {
1006
1014
  if (limit.window?.resetsAt !== undefined) {
1007
1015
  return formatDuration(limit.window.resetsAt - nowMs);
@@ -1188,6 +1196,16 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
1188
1196
  }
1189
1197
  }
1190
1198
 
1199
+ // Render accounts with no rate limits (e.g. business/enterprise plans).
1200
+ const unlimitedReports = providerReports.filter(report => report.limits.length === 0);
1201
+ for (const report of unlimitedReports) {
1202
+ const label = formatUnlimitedReportLabel(report, 0);
1203
+ const tier = report.metadata?.planType as string | undefined;
1204
+ const tierSuffix = tier ? ` ${uiTheme.fg("dim", `(${tier})`)}` : "";
1205
+ lines.push(
1206
+ `${uiTheme.fg("success", uiTheme.status.success)} ${label}${tierSuffix} ${uiTheme.fg("dim", "-- no limits")}`,
1207
+ );
1208
+ }
1191
1209
  // No per-provider footer; global header shows last check.
1192
1210
  }
1193
1211