@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8

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 (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3271 -3224
  3. package/dist/types/cli/bench-cli.d.ts +1 -7
  4. package/dist/types/cli/usage-cli.d.ts +1 -0
  5. package/dist/types/commands/usage.d.ts +7 -0
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  8. package/dist/types/modes/components/model-browser.d.ts +14 -1
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/modes/components/welcome.d.ts +4 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  13. package/dist/types/modes/interactive-mode.d.ts +2 -0
  14. package/dist/types/modes/queue-input.d.ts +8 -0
  15. package/dist/types/modes/types.d.ts +2 -0
  16. package/dist/types/session/agent-storage.d.ts +57 -0
  17. package/package.json +12 -12
  18. package/scripts/build-binary.ts +0 -1
  19. package/scripts/compile-binary.ts +4 -3
  20. package/src/cli/bench-cli.ts +7 -26
  21. package/src/cli/usage-cli.ts +11 -0
  22. package/src/commands/usage.ts +13 -2
  23. package/src/config/settings-schema.ts +1 -1
  24. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  25. package/src/modes/components/advisor-config.ts +3 -1
  26. package/src/modes/components/custom-editor.test.ts +58 -1
  27. package/src/modes/components/custom-editor.ts +42 -11
  28. package/src/modes/components/model-browser.ts +154 -60
  29. package/src/modes/components/model-hub.ts +475 -122
  30. package/src/modes/components/plan-review-overlay.ts +7 -0
  31. package/src/modes/components/tips.txt +2 -1
  32. package/src/modes/components/usage-row.ts +5 -6
  33. package/src/modes/components/welcome.ts +13 -14
  34. package/src/modes/controllers/input-controller.ts +140 -6
  35. package/src/modes/controllers/selector-controller.ts +20 -13
  36. package/src/modes/controllers/todo-command-controller.ts +1 -2
  37. package/src/modes/interactive-mode.ts +18 -0
  38. package/src/modes/queue-input.ts +132 -0
  39. package/src/modes/types.ts +2 -0
  40. package/src/modes/utils/ui-helpers.ts +19 -20
  41. package/src/session/agent-session.ts +184 -48
  42. package/src/session/agent-storage.ts +330 -3
  43. package/src/session/history-storage.ts +1 -34
  44. package/src/slash-commands/builtin-registry.ts +9 -0
  45. package/src/web/search/providers/perplexity.ts +18 -2
@@ -52,7 +52,29 @@ import { renderSegmentTrack } from "./segment-track";
52
52
  /** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
53
53
  export type ModelHubMode = "roles" | "pick";
54
54
 
55
- export type ModelHubAction = "modelRole" | "retryFallback";
55
+ /**
56
+ * A row of the Roles view: a role, a model/wildcard chain-key header, one of a
57
+ * chain's fallback entries, or the trailing "+ New role…". Fallback rows under
58
+ * a chain-key header carry the key in `role` — `retry.fallbackChains` treats
59
+ * roles, `provider/model-id`, and `provider/*` keys uniformly.
60
+ */
61
+ type RolesRow =
62
+ | { kind: "role"; role: string }
63
+ | { kind: "chainKey"; role: string }
64
+ | { kind: "fallback"; role: string; chainIndex: number; selector: string }
65
+ | { kind: "separator" }
66
+ | { kind: "newFallback" }
67
+ | { kind: "newRole" };
68
+
69
+ /**
70
+ * What the model browser is currently picking for: a role's model, a slot in
71
+ * a fallback chain (`role` may be a role name, model selector, or `provider/*`
72
+ * key), or the primary model a brand-new fallback chain protects.
73
+ */
74
+ type AssignTarget =
75
+ | { kind: "role"; role: string }
76
+ | { kind: "fallback"; role: string; index: number | null }
77
+ | { kind: "fallbackKey" };
56
78
 
57
79
  /** A `--models` scope entry (mirrors the session's scoped model list). */
58
80
  export interface ScopedModelItem {
@@ -61,16 +83,12 @@ export interface ScopedModelItem {
61
83
  }
62
84
 
63
85
  export interface ModelHubCallbacks {
64
- /** Persist a role assignment (or a retry-fallback registration). */
65
- onAssign: (
66
- model: Model,
67
- role: string,
68
- thinkingLevel: ConfiguredThinkingLevel | undefined,
69
- selector: string,
70
- action: ModelHubAction,
71
- ) => void;
86
+ /** Persist a role assignment. */
87
+ onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void;
72
88
  /** Clear a configured role back to auto-selection. */
73
89
  onUnassign: (role: string) => void;
90
+ /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
91
+ onFallbackChainChange?: (role: string, chain: string[]) => void;
74
92
  /** Pick-mode activation: session-only switch or embedded pick. */
75
93
  onPick?: (model: Model, selector: string) => void;
76
94
  /** Locked provider activation: forward to the /login flow. */
@@ -108,7 +126,7 @@ interface StripChip {
108
126
  /** Pre-styled label body (without selection decoration). */
109
127
  styled: string;
110
128
  role?: string;
111
- action: "assign" | "unassign" | "fallback" | "thinking";
129
+ action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking";
112
130
  thinkingLevel?: ConfiguredThinkingLevel;
113
131
  }
114
132
 
@@ -184,6 +202,8 @@ export class ModelHubComponent implements Component {
184
202
  #searchTotal = 0;
185
203
  #activeEntryId = "all";
186
204
  #sidebarScroll = 0;
205
+ /** Snap the sidebar viewport to the active entry on the next render; wheel panning leaves it free. */
206
+ #sidebarFollowActive = true;
187
207
  #sidebarHover: number | null = null;
188
208
  /**
189
209
  * Arrow-key ownership: `scope` (default) hops the sidebar even while the
@@ -192,11 +212,11 @@ export class ModelHubComponent implements Component {
192
212
  */
193
213
  #focus: "scope" | "list" = "scope";
194
214
 
195
- #roleIds: string[] = [];
215
+ #rolesRows: RolesRow[] = [];
196
216
  #roleIndex = 0;
197
217
  #roleHover: number | null = null;
198
218
 
199
- #assigningRole: string | null = null;
219
+ #assigning: AssignTarget | null = null;
200
220
  #strip: StripState | null = null;
201
221
  /** Per-provider fuzzy match counts while a query is active; null when not searching. */
202
222
  #searchCounts: Map<string, number> | null = null;
@@ -215,7 +235,7 @@ export class ModelHubComponent implements Component {
215
235
  #footerRow = 0;
216
236
  #chipRanges: ChipRange[] = [];
217
237
  #lockedLoginLine: number | null = null;
218
- #rolesRowStart = 2;
238
+ #rolesRowStart = 1;
219
239
 
220
240
  constructor(
221
241
  tui: TUI,
@@ -373,12 +393,15 @@ export class ModelHubComponent implements Component {
373
393
  }
374
394
 
375
395
  this.#reloadRoles(availableModels);
396
+ this.#buildRolesRows();
376
397
 
377
- const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
398
+ const storage = this.#settings.getStorage();
399
+ const mruOrder = storage?.getModelUsageOrder() ?? [];
378
400
  this.#availableItems = buildBrowserItems(availableModels);
379
401
  sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder });
380
402
  this.#browser.setRoles(this.#roles);
381
403
  this.#browser.setMruOrder(mruOrder);
404
+ this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
382
405
 
383
406
  const bySelector = new Map(this.#availableItems.map(item => [item.selector, item]));
384
407
  this.#recentItems = [];
@@ -438,7 +461,7 @@ export class ModelHubComponent implements Component {
438
461
  label: providerId,
439
462
  providerId,
440
463
  locked: isLocked,
441
- annotation: isLocked ? "login" : String(availableCounts.get(providerId) ?? 0),
464
+ annotation: isLocked ? undefined : String(availableCounts.get(providerId) ?? 0),
442
465
  oauth: oauthIds.has(providerId),
443
466
  catalogCount: catalogCounts.get(providerId) ?? 0,
444
467
  });
@@ -502,6 +525,7 @@ export class ModelHubComponent implements Component {
502
525
  this.#entries = entries;
503
526
  if (!entries.some(entry => entry.id === this.#activeEntryId)) {
504
527
  this.#activeEntryId = "all";
528
+ this.#sidebarFollowActive = true;
505
529
  }
506
530
  }
507
531
 
@@ -512,6 +536,7 @@ export class ModelHubComponent implements Component {
512
536
  #setActiveEntry(id: string): void {
513
537
  if (!this.#entries.some(entry => entry.id === id)) return;
514
538
  this.#activeEntryId = id;
539
+ this.#sidebarFollowActive = true;
515
540
  this.#applyScope();
516
541
  const entry = this.#activeEntry();
517
542
  // Hops must never steal arrow focus: landing on a scope keeps provider
@@ -545,7 +570,6 @@ export class ModelHubComponent implements Component {
545
570
  break;
546
571
  }
547
572
  case "roles":
548
- this.#roleIds = this.#visibleRoleIds();
549
573
  this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
550
574
  break;
551
575
  default:
@@ -555,6 +579,58 @@ export class ModelHubComponent implements Component {
555
579
  }
556
580
  }
557
581
 
582
+ /**
583
+ * The configured `retry.fallbackChains` record with malformed keys/entries
584
+ * dropped: non-array chains and non-string selectors never reach the rows
585
+ * or chain editors, so an edit through the hub replaces them wholesale.
586
+ */
587
+ #fallbackChains(): Record<string, string[]> {
588
+ try {
589
+ const chains = this.#settings.get("retry.fallbackChains");
590
+ if (!chains || typeof chains !== "object" || Array.isArray(chains)) return {};
591
+ const sanitized: Record<string, string[]> = {};
592
+ for (const key in chains) {
593
+ const chain = (chains as Record<string, unknown>)[key];
594
+ if (!Array.isArray(chain)) continue;
595
+ sanitized[key] = chain.filter((entry): entry is string => typeof entry === "string");
596
+ }
597
+ return sanitized;
598
+ } catch {
599
+ return {};
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Rebuild the Roles view rows: each visible role followed by its
605
+ * fallback-chain entries, then model-oriented chains (`provider/model-id`
606
+ * and `provider/*` keys) as headed groups.
607
+ */
608
+ #buildRolesRows(): void {
609
+ const rows: RolesRow[] = [];
610
+ const chains = this.#fallbackChains();
611
+ for (const role of this.#visibleRoleIds()) {
612
+ rows.push({ kind: "role", role });
613
+ const chain = chains[role] ?? [];
614
+ for (let i = 0; i < chain.length; i++) {
615
+ rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] });
616
+ }
617
+ }
618
+ rows.push({ kind: "newRole" });
619
+ rows.push({ kind: "separator" });
620
+ const modelKeys = Object.keys(chains)
621
+ .filter(key => key.includes("/"))
622
+ .sort();
623
+ for (const key of modelKeys) {
624
+ const chain = chains[key] ?? [];
625
+ rows.push({ kind: "chainKey", role: key });
626
+ for (let i = 0; i < chain.length; i++) {
627
+ rows.push({ kind: "fallback", role: key, chainIndex: i, selector: chain[i] });
628
+ }
629
+ }
630
+ rows.push({ kind: "newFallback" });
631
+ this.#rolesRows = rows;
632
+ }
633
+
558
634
  /** Refresh roles + dependent state after a settings mutation (assign/unassign). */
559
635
  #refreshAfterMutation(): void {
560
636
  this.#syncFromRegistryState();
@@ -588,7 +664,7 @@ export class ModelHubComponent implements Component {
588
664
  this.#composeEntries();
589
665
  const entry = this.#activeEntry();
590
666
  if (
591
- this.#assigningRole === null &&
667
+ this.#assigning === null &&
592
668
  entry.kind === "provider" &&
593
669
  (entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0)
594
670
  ) {
@@ -739,10 +815,16 @@ export class ModelHubComponent implements Component {
739
815
  this.#callbacks.onPick?.(item.model, item.selector);
740
816
  return;
741
817
  }
742
- if (this.#assigningRole) {
743
- const role = this.#assigningRole;
744
- this.#assigningRole = null;
745
- this.#assignRole(item, role, true);
818
+ if (this.#assigning) {
819
+ const target = this.#assigning;
820
+ this.#assigning = null;
821
+ if (target.kind === "role") {
822
+ this.#assignRole(item, target.role, true);
823
+ } else if (target.kind === "fallbackKey") {
824
+ this.#openFallbackKeyStrip(item);
825
+ } else {
826
+ this.#commitFallback(item, target);
827
+ }
746
828
  return;
747
829
  }
748
830
  this.#openRoleStrip(item);
@@ -756,7 +838,7 @@ export class ModelHubComponent implements Component {
756
838
  const supported = this.#thinkingOptionsFor(item.model);
757
839
  level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit;
758
840
  }
759
- this.#callbacks.onAssign(item.model, role, level, item.selector, "modelRole");
841
+ this.#callbacks.onAssign(item.model, role, level, item.selector);
760
842
  this.#refreshAfterMutation();
761
843
  this.#openThinkingStrip(item, role, returnToRoles);
762
844
  }
@@ -793,6 +875,16 @@ export class ModelHubComponent implements Component {
793
875
  action: assignedHere ? "unassign" : "assign",
794
876
  });
795
877
  }
878
+ chips.push({
879
+ label: `fallbacks:${item.model.id}`,
880
+ styled: theme.fg("muted", `fallbacks:${item.model.id}`),
881
+ action: "fallbackModel",
882
+ });
883
+ chips.push({
884
+ label: `fallbacks:${item.model.provider}/*`,
885
+ styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`),
886
+ action: "fallbackProvider",
887
+ });
796
888
  chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
797
889
  this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
798
890
  }
@@ -851,18 +943,20 @@ export class ModelHubComponent implements Component {
851
943
  this.#closeStrip();
852
944
  return;
853
945
  case "fallback":
854
- this.#callbacks.onAssign(strip.item.model, "default", undefined, strip.item.selector, "retryFallback");
946
+ this.#appendFallback(strip.item, "default");
855
947
  this.#closeStrip();
856
948
  return;
949
+ case "fallbackModel":
950
+ this.#closeStrip();
951
+ this.#startAssignFallback(strip.item.selector, null);
952
+ return;
953
+ case "fallbackProvider":
954
+ this.#closeStrip();
955
+ this.#startAssignFallback(`${strip.item.model.provider}/*`, null);
956
+ return;
857
957
  case "thinking":
858
958
  if (strip.role && chip.thinkingLevel !== undefined) {
859
- this.#callbacks.onAssign(
860
- strip.item.model,
861
- strip.role,
862
- chip.thinkingLevel,
863
- strip.item.selector,
864
- "modelRole",
865
- );
959
+ this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector);
866
960
  this.#refreshAfterMutation();
867
961
  }
868
962
  this.#closeStrip();
@@ -872,7 +966,7 @@ export class ModelHubComponent implements Component {
872
966
 
873
967
  /** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
874
968
  #startAssign(role: string): void {
875
- this.#assigningRole = role;
969
+ this.#assigning = { kind: "role", role };
876
970
  this.#focus = "scope";
877
971
  this.#browser.setShowProvider(true);
878
972
  this.#browser.setItems([...this.#availableItems]);
@@ -883,8 +977,104 @@ export class ModelHubComponent implements Component {
883
977
  }
884
978
  }
885
979
 
980
+ /** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */
981
+ #startAssignFallback(role: string, index: number | null): void {
982
+ this.#assigning = { kind: "fallback", role, index };
983
+ this.#focus = "scope";
984
+ this.#browser.setShowProvider(true);
985
+ this.#browser.setItems([...this.#availableItems]);
986
+ this.#browser.setQuery("");
987
+ if (index !== null) {
988
+ const selector = this.#fallbackChains()[role]?.[index];
989
+ if (selector) this.#browser.selectSelector(selector);
990
+ }
991
+ }
992
+
993
+ /** Browse the catalog for the primary model a brand-new fallback chain protects. */
994
+ #startAssignFallbackKey(): void {
995
+ this.#assigning = { kind: "fallbackKey" };
996
+ this.#focus = "scope";
997
+ this.#browser.setShowProvider(true);
998
+ this.#browser.setItems([...this.#availableItems]);
999
+ this.#browser.setQuery("");
1000
+ }
1001
+
1002
+ /** Second step of "+ New fallback…": key the chain by the picked model or its whole provider. */
1003
+ #openFallbackKeyStrip(item: ModelBrowserItem): void {
1004
+ const chips: StripChip[] = [
1005
+ {
1006
+ label: `for ${item.selector}`,
1007
+ styled: theme.fg("muted", `for ${item.selector}`),
1008
+ action: "fallbackModel",
1009
+ },
1010
+ {
1011
+ label: `for ${item.model.provider}/*`,
1012
+ styled: theme.fg("muted", `for ${item.model.provider}/*`),
1013
+ action: "fallbackProvider",
1014
+ },
1015
+ ];
1016
+ this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
1017
+ }
1018
+
1019
+ /** Write the picked model into the target chain slot, dedupe, and land back on its Roles row. */
1020
+ #commitFallback(item: ModelBrowserItem, target: { role: string; index: number | null }): void {
1021
+ const chain = [...(this.#fallbackChains()[target.role] ?? [])];
1022
+ const selector = item.selector;
1023
+ if (target.index !== null && target.index < chain.length) {
1024
+ chain[target.index] = selector;
1025
+ for (let i = chain.length - 1; i >= 0; i--) {
1026
+ if (i !== target.index && chain[i] === selector) chain.splice(i, 1);
1027
+ }
1028
+ } else if (!chain.includes(selector)) {
1029
+ chain.push(selector);
1030
+ }
1031
+ this.#setFallbackChain(target.role, chain);
1032
+ this.#browser.setQuery("");
1033
+ if (this.#mode === "roles") {
1034
+ this.#setActiveEntry("roles");
1035
+ this.#focus = "list";
1036
+ const rowIndex = this.#rolesRows.findIndex(
1037
+ row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
1038
+ );
1039
+ if (rowIndex >= 0) this.#roleIndex = rowIndex;
1040
+ }
1041
+ }
1042
+
1043
+ /** Persist `role`'s chain through the host callback and rebuild dependent state. */
1044
+ #setFallbackChain(role: string, chain: string[]): void {
1045
+ this.#callbacks.onFallbackChainChange?.(role, chain);
1046
+ this.#refreshAfterMutation();
1047
+ }
1048
+
1049
+ /** Append `item` to `role`'s fallback chain (no-op when already present). */
1050
+ #appendFallback(item: ModelBrowserItem, role: string): void {
1051
+ const chain = [...(this.#fallbackChains()[role] ?? [])];
1052
+ if (chain.includes(item.selector)) return;
1053
+ chain.push(item.selector);
1054
+ this.#setFallbackChain(role, chain);
1055
+ }
1056
+
1057
+ /** Remove one chain entry; the cursor stays on the nearest surviving row. */
1058
+ #removeFallback(row: { role: string; chainIndex: number }): void {
1059
+ const chain = [...(this.#fallbackChains()[row.role] ?? [])];
1060
+ if (row.chainIndex >= chain.length) return;
1061
+ chain.splice(row.chainIndex, 1);
1062
+ this.#setFallbackChain(row.role, chain);
1063
+ this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRows.length - 1));
1064
+ }
1065
+
1066
+ /** Move a chain entry one slot earlier/later; the cursor follows the moved entry. */
1067
+ #moveFallback(row: { role: string; chainIndex: number }, delta: -1 | 1): void {
1068
+ const chain = [...(this.#fallbackChains()[row.role] ?? [])];
1069
+ const target = row.chainIndex + delta;
1070
+ if (row.chainIndex >= chain.length || target < 0 || target >= chain.length) return;
1071
+ [chain[row.chainIndex], chain[target]] = [chain[target], chain[row.chainIndex]];
1072
+ this.#setFallbackChain(row.role, chain);
1073
+ this.#roleIndex += delta;
1074
+ }
1075
+
886
1076
  #cancelAssign(): void {
887
- this.#assigningRole = null;
1077
+ this.#assigning = null;
888
1078
  this.#browser.setQuery("");
889
1079
  if (this.#mode === "roles") {
890
1080
  this.#setActiveEntry("roles");
@@ -961,7 +1151,7 @@ export class ModelHubComponent implements Component {
961
1151
  }
962
1152
 
963
1153
  if (matchesSelectCancel(data)) {
964
- if (this.#assigningRole !== null) {
1154
+ if (this.#assigning !== null) {
965
1155
  this.#cancelAssign();
966
1156
  return;
967
1157
  }
@@ -975,8 +1165,8 @@ export class ModelHubComponent implements Component {
975
1165
  }
976
1166
 
977
1167
  const entry = this.#activeEntry();
978
- const rolesView = entry.kind === "roles" && this.#assigningRole === null;
979
- const lockedView = entry.kind === "provider" && entry.locked && this.#assigningRole === null;
1168
+ const rolesView = entry.kind === "roles" && this.#assigning === null;
1169
+ const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null;
980
1170
 
981
1171
  if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
982
1172
  this.#focus = this.#focus === "scope" ? "list" : "scope";
@@ -1030,7 +1220,7 @@ export class ModelHubComponent implements Component {
1030
1220
  }
1031
1221
 
1032
1222
  #isBrowserView(entry: SidebarEntry): boolean {
1033
- if (this.#assigningRole !== null) return true;
1223
+ if (this.#assigning !== null) return true;
1034
1224
  return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
1035
1225
  }
1036
1226
 
@@ -1074,16 +1264,58 @@ export class ModelHubComponent implements Component {
1074
1264
  if (entry && !this.#isHopSkipped(entry)) {
1075
1265
  // Scope changes keep an active assignment (scoping helps find the
1076
1266
  // model); landing on the Roles view cancels it.
1077
- if (entry.kind === "roles") this.#assigningRole = null;
1267
+ if (entry.kind === "roles") this.#assigning = null;
1078
1268
  this.#setActiveEntry(entry.id);
1079
1269
  return;
1080
1270
  }
1081
1271
  }
1082
1272
  }
1083
1273
 
1084
- /** Row count of the roles view: every visible role plus the trailing "+ New role…" row. */
1274
+ /** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */
1085
1275
  get #rolesRowCount(): number {
1086
- return this.#roleIds.length + 1;
1276
+ return this.#rolesRows.length;
1277
+ }
1278
+
1279
+ /** Enter/click activation for a Roles-view row. */
1280
+ #activateRolesRow(row: RolesRow): void {
1281
+ switch (row.kind) {
1282
+ case "role":
1283
+ this.#startAssign(row.role);
1284
+ return;
1285
+ case "chainKey":
1286
+ this.#startAssignFallback(row.role, null);
1287
+ return;
1288
+ case "fallback":
1289
+ this.#startAssignFallback(row.role, row.chainIndex);
1290
+ return;
1291
+ case "newFallback":
1292
+ this.#startAssignFallbackKey();
1293
+ return;
1294
+ case "newRole":
1295
+ this.#openRoleNameStrip();
1296
+ return;
1297
+ case "separator":
1298
+ return;
1299
+ }
1300
+ }
1301
+
1302
+ /** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
1303
+ #stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
1304
+ const wrap = options.wrap ?? true;
1305
+ const count = this.#rolesRows.length;
1306
+ if (count === 0) return 0;
1307
+ let index = from;
1308
+ for (let i = 0; i < count; i++) {
1309
+ const next = index + delta;
1310
+ if (next < 0 || next >= count) {
1311
+ if (!wrap) return from;
1312
+ index = (next + count) % count;
1313
+ } else {
1314
+ index = next;
1315
+ }
1316
+ if (this.#rolesRows[index]?.kind !== "separator") return index;
1317
+ }
1318
+ return from;
1087
1319
  }
1088
1320
 
1089
1321
  #handleRolesViewInput(data: string): void {
@@ -1095,41 +1327,50 @@ export class ModelHubComponent implements Component {
1095
1327
  }
1096
1328
  return;
1097
1329
  }
1098
- const rowCount = Math.max(1, this.#rolesRowCount);
1099
1330
  if (matchesSelectUp(data)) {
1100
- this.#roleIndex = (this.#roleIndex - 1 + rowCount) % rowCount;
1331
+ this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1);
1101
1332
  return;
1102
1333
  }
1103
1334
  if (matchesSelectDown(data)) {
1104
- this.#roleIndex = (this.#roleIndex + 1) % rowCount;
1335
+ this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
1105
1336
  return;
1106
1337
  }
1107
- const role = this.#roleIds[this.#roleIndex];
1338
+ const row = this.#rolesRows[this.#roleIndex];
1339
+ const role = row?.kind === "role" ? row.role : undefined;
1108
1340
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1109
- if (role) {
1110
- this.#startAssign(role);
1111
- } else {
1112
- // The virtual "+ New role…" row.
1113
- this.#openRoleNameStrip();
1114
- }
1341
+ if (row) this.#activateRolesRow(row);
1115
1342
  return;
1116
1343
  }
1117
1344
  if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
1118
1345
  if (role) this.#unassignRole(role);
1346
+ else if (row?.kind === "fallback") this.#removeFallback(row);
1347
+ else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1119
1348
  return;
1120
1349
  }
1121
- // Cycle reordering: [ / shift+↑ moves the role earlier, ] / shift+↓ later.
1350
+ // Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later
1351
+ // cycle order on a role row, chain order on a fallback row.
1122
1352
  if (matchesKey(data, "shift+up")) {
1123
1353
  if (role) this.#moveCycleMembership(role, -1);
1354
+ else if (row?.kind === "fallback") this.#moveFallback(row, -1);
1124
1355
  return;
1125
1356
  }
1126
1357
  if (matchesKey(data, "shift+down")) {
1127
1358
  if (role) this.#moveCycleMembership(role, 1);
1359
+ else if (row?.kind === "fallback") this.#moveFallback(row, 1);
1128
1360
  return;
1129
1361
  }
1130
1362
  const printable = extractPrintableText(data);
1131
1363
  if (printable === "x") {
1132
1364
  if (role) this.#unassignRole(role);
1365
+ else if (row?.kind === "fallback") this.#removeFallback(row);
1366
+ else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1367
+ return;
1368
+ }
1369
+ if (printable === "f") {
1370
+ if (row?.kind === "newFallback") this.#startAssignFallbackKey();
1371
+ else if (row && row.kind !== "newRole" && row.kind !== "separator") {
1372
+ this.#startAssignFallback(row.role, null);
1373
+ }
1133
1374
  return;
1134
1375
  }
1135
1376
  if (printable === "c") {
@@ -1138,10 +1379,12 @@ export class ModelHubComponent implements Component {
1138
1379
  }
1139
1380
  if (printable === "[") {
1140
1381
  if (role) this.#moveCycleMembership(role, -1);
1382
+ else if (row?.kind === "fallback") this.#moveFallback(row, -1);
1141
1383
  return;
1142
1384
  }
1143
1385
  if (printable === "]") {
1144
1386
  if (role) this.#moveCycleMembership(role, 1);
1387
+ else if (row?.kind === "fallback") this.#moveFallback(row, 1);
1145
1388
  return;
1146
1389
  }
1147
1390
  if (printable === "n") {
@@ -1202,11 +1445,13 @@ export class ModelHubComponent implements Component {
1202
1445
 
1203
1446
  if (event.wheel !== null) {
1204
1447
  if (overSidebar) {
1205
- this.#moveSidebar(event.wheel);
1448
+ // Wheel pans the sidebar viewport; picking a scope is click/keys only.
1449
+ const maxScroll = Math.max(0, this.#entries.length - this.#contentRowCount);
1450
+ this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll + event.wheel, maxScroll));
1451
+ this.#sidebarHover = this.#sidebarEntryIndexAt(contentLine);
1206
1452
  } else if (overBody) {
1207
- if (entry.kind === "roles" && this.#assigningRole === null) {
1208
- const count = Math.max(1, this.#rolesRowCount);
1209
- this.#roleIndex = (this.#roleIndex + event.wheel + count) % count;
1453
+ if (entry.kind === "roles" && this.#assigning === null) {
1454
+ this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false });
1210
1455
  } else if (this.#isBrowserView(entry)) {
1211
1456
  this.#browser.routeMouse(event, bodyLine);
1212
1457
  }
@@ -1216,13 +1461,17 @@ export class ModelHubComponent implements Component {
1216
1461
 
1217
1462
  if (event.motion) {
1218
1463
  this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
1219
- if (overBody && entry.kind === "roles" && this.#assigningRole === null) {
1464
+ if (overBody && entry.kind === "roles" && this.#assigning === null) {
1220
1465
  const roleLine = bodyLine - this.#rolesRowStart;
1221
1466
  this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
1222
1467
  } else {
1223
1468
  this.#roleHover = null;
1224
1469
  if (overBody && this.#isBrowserView(entry)) {
1225
1470
  this.#browser.routeMouse(event, bodyLine);
1471
+ } else {
1472
+ // Pointer left the browser pane: without this, the last
1473
+ // hovered row keeps its band while the sidebar hovers too.
1474
+ this.#browser.clearHover();
1226
1475
  }
1227
1476
  }
1228
1477
  return true;
@@ -1235,7 +1484,7 @@ export class ModelHubComponent implements Component {
1235
1484
  const clicked = index !== null ? this.#entries[index] : undefined;
1236
1485
  if (clicked && clicked.kind !== "separator") {
1237
1486
  const already = clicked.id === this.#activeEntryId;
1238
- if (clicked.kind === "roles") this.#assigningRole = null;
1487
+ if (clicked.kind === "roles") this.#assigning = null;
1239
1488
  this.#setActiveEntry(clicked.id);
1240
1489
  // A click on Roles is a deliberate dive into the rows.
1241
1490
  if (clicked.kind === "roles") this.#focus = "list";
@@ -1247,22 +1496,20 @@ export class ModelHubComponent implements Component {
1247
1496
  }
1248
1497
 
1249
1498
  if (overBody) {
1250
- if (entry.kind === "roles" && this.#assigningRole === null) {
1499
+ if (entry.kind === "roles" && this.#assigning === null) {
1251
1500
  this.#focus = "list";
1252
1501
  const roleLine = bodyLine - this.#rolesRowStart;
1253
1502
  if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
1254
- if (roleLine === this.#roleIndex) {
1255
- const role = this.#roleIds[roleLine];
1256
- if (role) {
1257
- this.#startAssign(role);
1503
+ const rowDef = this.#rolesRows[roleLine];
1504
+ if (rowDef && rowDef.kind !== "separator") {
1505
+ if (roleLine === this.#roleIndex) {
1506
+ this.#activateRolesRow(rowDef);
1258
1507
  } else {
1259
- this.#openRoleNameStrip();
1508
+ this.#roleIndex = roleLine;
1260
1509
  }
1261
- } else {
1262
- this.#roleIndex = roleLine;
1263
1510
  }
1264
1511
  }
1265
- } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
1512
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
1266
1513
  if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
1267
1514
  this.#requestLogin(entry);
1268
1515
  }
@@ -1294,15 +1541,22 @@ export class ModelHubComponent implements Component {
1294
1541
  }
1295
1542
 
1296
1543
  #renderSidebar(width: number, rows: number): string[] {
1297
- const activeIndex = Math.max(
1298
- 0,
1299
- this.#entries.findIndex(entry => entry.id === this.#activeEntryId),
1300
- );
1301
- if (this.#entries.length > rows) {
1302
- this.#sidebarScroll = Math.max(0, Math.min(activeIndex - Math.floor(rows / 2), this.#entries.length - rows));
1303
- } else {
1304
- this.#sidebarScroll = 0;
1544
+ // The scroll offset is persistent: the wheel pans it freely. Only an
1545
+ // activation (keys, click, programmatic) snaps the viewport to the
1546
+ // active entry, and only far enough to reveal it.
1547
+ if (this.#sidebarFollowActive) {
1548
+ const activeIndex = Math.max(
1549
+ 0,
1550
+ this.#entries.findIndex(entry => entry.id === this.#activeEntryId),
1551
+ );
1552
+ if (activeIndex < this.#sidebarScroll) {
1553
+ this.#sidebarScroll = activeIndex;
1554
+ } else if (activeIndex >= this.#sidebarScroll + rows) {
1555
+ this.#sidebarScroll = activeIndex - rows + 1;
1556
+ }
1557
+ this.#sidebarFollowActive = false;
1305
1558
  }
1559
+ this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll, Math.max(0, this.#entries.length - rows)));
1306
1560
 
1307
1561
  const lines: string[] = [];
1308
1562
  for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) {
@@ -1328,11 +1582,10 @@ export class ModelHubComponent implements Component {
1328
1582
  // While searching, entries the hop skips gray out: locked and
1329
1583
  // zero-match providers, an empty Recent, and the Roles view.
1330
1584
  const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
1331
- const cursor = active
1332
- ? this.#focus === "scope"
1333
- ? theme.fg("accent", theme.nav.cursor)
1334
- : theme.fg("dim", theme.nav.cursor)
1335
- : " ";
1585
+ // The sidebar's active entry is state, not a cursor: accent label
1586
+ // plus a cursor glyph while the sidebar owns the arrows. The band
1587
+ // stays in the body pane so the two never look alike.
1588
+ const cursor = active && this.#focus === "scope" ? theme.fg("accent", theme.nav.cursor) : " ";
1336
1589
 
1337
1590
  let icon: string;
1338
1591
  if (entry.kind === "recent") {
@@ -1347,7 +1600,7 @@ export class ModelHubComponent implements Component {
1347
1600
  const labelStyled = muted
1348
1601
  ? theme.fg("dim", entry.label)
1349
1602
  : active
1350
- ? theme.fg("accent", entry.label)
1603
+ ? theme.bold(theme.fg("accent", entry.label))
1351
1604
  : entry.label;
1352
1605
 
1353
1606
  const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false;
@@ -1364,8 +1617,10 @@ export class ModelHubComponent implements Component {
1364
1617
  line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`;
1365
1618
  } else {
1366
1619
  line = truncateToWidth(left, width);
1620
+ const lineWidth = visibleWidth(line);
1621
+ if (lineWidth < width) line += " ".repeat(width - lineWidth);
1367
1622
  }
1368
- if (hovered && !active) {
1623
+ if (hovered) {
1369
1624
  line = theme.bg("selectedBg", line);
1370
1625
  }
1371
1626
  lines.push(line);
@@ -1374,9 +1629,22 @@ export class ModelHubComponent implements Component {
1374
1629
  }
1375
1630
 
1376
1631
  #statusRow(width: number): string {
1377
- if (this.#assigningRole !== null) {
1378
- const info = getRoleInfo(this.#assigningRole, this.#settings);
1379
- const label = info.tag ?? info.name ?? this.#assigningRole;
1632
+ if (this.#assigning !== null) {
1633
+ if (this.#assigning.kind === "fallbackKey") {
1634
+ return truncateToWidth(
1635
+ theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"),
1636
+ width,
1637
+ );
1638
+ }
1639
+ const info = getRoleInfo(this.#assigning.role, this.#settings);
1640
+ const label = info.tag ?? info.name ?? this.#assigning.role;
1641
+ if (this.#assigning.kind === "fallback") {
1642
+ const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
1643
+ return truncateToWidth(
1644
+ theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`),
1645
+ width,
1646
+ );
1647
+ }
1380
1648
  return truncateToWidth(
1381
1649
  theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
1382
1650
  width,
@@ -1390,7 +1658,7 @@ export class ModelHubComponent implements Component {
1390
1658
  text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
1391
1659
  break;
1392
1660
  case "roles":
1393
- text = "Model roles — assignments fall back to auto-selection when cleared";
1661
+ text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
1394
1662
  break;
1395
1663
  case "provider":
1396
1664
  if (entry.locked) {
@@ -1412,25 +1680,78 @@ export class ModelHubComponent implements Component {
1412
1680
  return truncateToWidth(theme.fg("muted", ` ${text}`), width);
1413
1681
  }
1414
1682
 
1683
+ /** Clamp a roles row to `width`; the bg band is reserved for mouse hover. */
1684
+ #finishRolesRow(line: string, width: number, hovered: boolean): string {
1685
+ let out = truncateToWidth(line, width);
1686
+ if (hovered) {
1687
+ const w = visibleWidth(out);
1688
+ if (w < width) out += " ".repeat(width - w);
1689
+ return theme.bg("selectedBg", out);
1690
+ }
1691
+ return out;
1692
+ }
1693
+
1415
1694
  #renderRolesView(width: number, rows: number): string[] {
1416
1695
  const lines: string[] = [];
1417
1696
  lines.push("");
1418
- this.#rolesRowStart = lines.length + 1; // +1 for the status row offset handled by caller
1697
+ // First row's offset in bodyLine coordinates: the mouse router's
1698
+ // `bodyLine` has already dropped the status row, so this is just the
1699
+ // leading blank line — no extra status-row offset here.
1700
+ this.#rolesRowStart = lines.length;
1419
1701
 
1420
1702
  let tagWidth = 0;
1421
- for (const role of this.#roleIds) {
1422
- const info = getRoleInfo(role, this.#settings);
1423
- tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? role));
1703
+ for (const rowDef of this.#rolesRows) {
1704
+ if (rowDef.kind !== "role") continue;
1705
+ const info = getRoleInfo(rowDef.role, this.#settings);
1706
+ tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role));
1424
1707
  }
1425
1708
 
1426
1709
  const cycleOrder = this.#cycleOrder();
1427
- for (let i = 0; i < this.#roleIds.length && lines.length < rows - 3; i++) {
1428
- const role = this.#roleIds[i];
1429
- const info = getRoleInfo(role, this.#settings);
1430
- const assignment = this.#roles[role];
1710
+ const listFocused = this.#focus === "list";
1711
+ for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
1712
+ const rowDef = this.#rolesRows[i];
1713
+ if (!rowDef) continue;
1431
1714
  const selected = i === this.#roleIndex;
1432
1715
  const hovered = i === this.#roleHover;
1433
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
1716
+ // The unfocused pane draws no cursor; accent text still marks the row.
1717
+ const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " ";
1718
+
1719
+ if (rowDef.kind === "separator") {
1720
+ lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`);
1721
+ continue;
1722
+ }
1723
+
1724
+ if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") {
1725
+ const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…";
1726
+ let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`;
1727
+ line = this.#finishRolesRow(line, width, hovered);
1728
+ lines.push(line);
1729
+ continue;
1730
+ }
1731
+
1732
+ if (rowDef.kind === "chainKey") {
1733
+ const key = rowDef.role;
1734
+ const slash = key.lastIndexOf("/");
1735
+ const tail = key.slice(slash + 1);
1736
+ const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail);
1737
+ let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`;
1738
+ line = this.#finishRolesRow(line, width, hovered);
1739
+ lines.push(line);
1740
+ continue;
1741
+ }
1742
+
1743
+ if (rowDef.kind === "fallback") {
1744
+ const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`);
1745
+ const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector);
1746
+ let line = ` ${cursor} ${branch} ${selector}`;
1747
+ line = this.#finishRolesRow(line, width, hovered);
1748
+ lines.push(line);
1749
+ continue;
1750
+ }
1751
+
1752
+ const role = rowDef.role;
1753
+ const info = getRoleInfo(role, this.#settings);
1754
+ const assignment = this.#roles[role];
1434
1755
  const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
1435
1756
 
1436
1757
  let dot: string;
@@ -1466,26 +1787,8 @@ export class ModelHubComponent implements Component {
1466
1787
  const lineWidth = visibleWidth(line);
1467
1788
  if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
1468
1789
  line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
1469
- } else {
1470
- line = truncateToWidth(line, width);
1471
- }
1472
- if (hovered && !selected) {
1473
- line = theme.bg("selectedBg", line);
1474
- }
1475
- lines.push(line);
1476
- }
1477
-
1478
- // Trailing virtual row: create a custom role.
1479
- if (lines.length < rows - 2) {
1480
- const newRoleIndex = this.#roleIds.length;
1481
- const selected = this.#roleIndex === newRoleIndex;
1482
- const hovered = this.#roleHover === newRoleIndex;
1483
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
1484
- let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New role…")}`;
1485
- line = truncateToWidth(line, width);
1486
- if (hovered && !selected) {
1487
- line = theme.bg("selectedBg", line);
1488
1790
  }
1791
+ line = this.#finishRolesRow(line, width, hovered);
1489
1792
  lines.push(line);
1490
1793
  }
1491
1794
 
@@ -1495,7 +1798,10 @@ export class ModelHubComponent implements Component {
1495
1798
  if (rows >= 2) {
1496
1799
  const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
1497
1800
  if (cycleOrder.length > 0) {
1498
- const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? "");
1801
+ const selectedRow = this.#rolesRows[this.#roleIndex];
1802
+ const selectedRole =
1803
+ selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : "";
1804
+ const activeIndex = cycleOrder.indexOf(selectedRole);
1499
1805
  const track = renderSegmentTrack(
1500
1806
  cycleOrder.map(role => ({ label: role })),
1501
1807
  activeIndex,
@@ -1557,14 +1863,32 @@ export class ModelHubComponent implements Component {
1557
1863
  ? "←/→ choose · Enter assign/clear · Esc cancel"
1558
1864
  : "←/→ thinking level · Enter apply · Esc keep";
1559
1865
  }
1560
- if (this.#assigningRole !== null) {
1561
- return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
1866
+ if (this.#assigning !== null) {
1867
+ switch (this.#assigning.kind) {
1868
+ case "fallback":
1869
+ return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel";
1870
+ case "fallbackKey":
1871
+ return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel";
1872
+ default:
1873
+ return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
1874
+ }
1562
1875
  }
1563
1876
  const entry = this.#activeEntry();
1564
1877
  if (entry.kind === "roles") {
1565
- return this.#focus === "list"
1566
- ? "↑/↓ roles · Enter pick · x clear · t thinking · c cycle · [/] reorder · n new · ← providers"
1567
- : "↑/↓ providers · → roles · Esc close";
1878
+ if (this.#focus !== "list") {
1879
+ return "↑/↓ providers · roles · Esc close";
1880
+ }
1881
+ const row = this.#rolesRows[this.#roleIndex];
1882
+ if (row?.kind === "fallback") {
1883
+ return "↑/↓ rows · Enter replace · f add another · x remove · [/] reorder · ← providers";
1884
+ }
1885
+ if (row?.kind === "chainKey") {
1886
+ return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers";
1887
+ }
1888
+ if (row?.kind === "newFallback") {
1889
+ return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
1890
+ }
1891
+ return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
1568
1892
  }
1569
1893
  if (entry.kind === "provider" && entry.locked) {
1570
1894
  return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
@@ -1597,10 +1921,38 @@ export class ModelHubComponent implements Component {
1597
1921
  ? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
1598
1922
  : `${theme.fg(getRoleInfo(strip.role ?? "", this.#settings).color ?? "muted", (getRoleInfo(strip.role ?? "", this.#settings).tag ?? strip.role ?? "").toLowerCase())}${theme.fg("dim", ` · ${strip.item.id} →`)} `;
1599
1923
 
1924
+ // Horizontal window: once the strip overflows, drop leading chips behind
1925
+ // a dim ellipsis so the selected chip (plus one chip of lookahead when it
1926
+ // fits) stays visible while cycling right.
1927
+ const prefixWidth = visibleWidth(prefix);
1928
+ const available = Math.max(1, width - prefixWidth);
1929
+ const chipWidths = strip.chips.map(
1930
+ (chip, i) => visibleWidth(` ${chip.styled} `) + (i === strip.index ? 2 : 0) + 1,
1931
+ );
1932
+ // Smallest start index whose window [start..target] (with its "… " lead-in
1933
+ // when start > 0) fits in the available width; `target` itself may still
1934
+ // overflow when a single chip is wider than the row.
1935
+ const startFor = (target: number): number => {
1936
+ let start = 0;
1937
+ while (start < target) {
1938
+ let sum = start > 0 ? 2 : 0;
1939
+ for (let i = start; i <= target; i++) sum += chipWidths[i] ?? 0;
1940
+ if (sum <= available) break;
1941
+ start++;
1942
+ }
1943
+ return start;
1944
+ };
1945
+ let start = startFor(Math.min(strip.index + 1, strip.chips.length - 1));
1946
+ if (start > strip.index) start = startFor(strip.index);
1947
+
1600
1948
  let line = prefix;
1601
1949
  // Columns are relative to the frame: row() insets content by 2.
1602
- let col = 2 + visibleWidth(prefix);
1603
- for (let i = 0; i < strip.chips.length; i++) {
1950
+ let col = 2 + prefixWidth;
1951
+ if (start > 0) {
1952
+ line += theme.fg("dim", "… ");
1953
+ col += 2;
1954
+ }
1955
+ for (let i = start; i < strip.chips.length; i++) {
1604
1956
  const chip = strip.chips[i];
1605
1957
  if (!chip) continue;
1606
1958
  const selected = i === strip.index;
@@ -1628,12 +1980,13 @@ export class ModelHubComponent implements Component {
1628
1980
 
1629
1981
  const entry = this.#activeEntry();
1630
1982
  const bodyLines: string[] = [this.#statusRow(bodyWidth)];
1631
- if (entry.kind === "roles" && this.#assigningRole === null) {
1983
+ if (entry.kind === "roles" && this.#assigning === null) {
1632
1984
  bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1));
1633
- } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
1985
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
1634
1986
  bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
1635
1987
  } else {
1636
1988
  this.#browser.setMaxVisible(contentRows - 1 - 5);
1989
+ this.#browser.setFocused(this.#focus === "list");
1637
1990
  bodyLines.push(...this.#browser.render(bodyWidth));
1638
1991
  }
1639
1992