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

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 (38) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli.js +3052 -3005
  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 +3 -0
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  11. package/dist/types/modes/interactive-mode.d.ts +2 -0
  12. package/dist/types/modes/queue-input.d.ts +8 -0
  13. package/dist/types/modes/types.d.ts +2 -0
  14. package/dist/types/session/agent-storage.d.ts +57 -0
  15. package/package.json +12 -12
  16. package/scripts/build-binary.ts +0 -1
  17. package/scripts/compile-binary.ts +4 -3
  18. package/src/cli/bench-cli.ts +7 -26
  19. package/src/cli/usage-cli.ts +11 -0
  20. package/src/commands/usage.ts +13 -2
  21. package/src/config/settings-schema.ts +1 -1
  22. package/src/modes/components/advisor-config.ts +3 -1
  23. package/src/modes/components/custom-editor.test.ts +58 -1
  24. package/src/modes/components/custom-editor.ts +42 -11
  25. package/src/modes/components/model-browser.ts +114 -33
  26. package/src/modes/components/model-hub.ts +455 -108
  27. package/src/modes/components/usage-row.ts +5 -6
  28. package/src/modes/controllers/input-controller.ts +140 -6
  29. package/src/modes/controllers/selector-controller.ts +20 -13
  30. package/src/modes/controllers/todo-command-controller.ts +1 -2
  31. package/src/modes/interactive-mode.ts +5 -0
  32. package/src/modes/queue-input.ts +132 -0
  33. package/src/modes/types.ts +2 -0
  34. package/src/modes/utils/ui-helpers.ts +19 -20
  35. package/src/session/agent-session.ts +184 -48
  36. package/src/session/agent-storage.ts +330 -3
  37. package/src/session/history-storage.ts +1 -34
  38. package/src/slash-commands/builtin-registry.ts +9 -0
@@ -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 = [];
@@ -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,7 +1461,7 @@ 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 {
@@ -1235,7 +1480,7 @@ export class ModelHubComponent implements Component {
1235
1480
  const clicked = index !== null ? this.#entries[index] : undefined;
1236
1481
  if (clicked && clicked.kind !== "separator") {
1237
1482
  const already = clicked.id === this.#activeEntryId;
1238
- if (clicked.kind === "roles") this.#assigningRole = null;
1483
+ if (clicked.kind === "roles") this.#assigning = null;
1239
1484
  this.#setActiveEntry(clicked.id);
1240
1485
  // A click on Roles is a deliberate dive into the rows.
1241
1486
  if (clicked.kind === "roles") this.#focus = "list";
@@ -1247,22 +1492,20 @@ export class ModelHubComponent implements Component {
1247
1492
  }
1248
1493
 
1249
1494
  if (overBody) {
1250
- if (entry.kind === "roles" && this.#assigningRole === null) {
1495
+ if (entry.kind === "roles" && this.#assigning === null) {
1251
1496
  this.#focus = "list";
1252
1497
  const roleLine = bodyLine - this.#rolesRowStart;
1253
1498
  if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
1254
- if (roleLine === this.#roleIndex) {
1255
- const role = this.#roleIds[roleLine];
1256
- if (role) {
1257
- this.#startAssign(role);
1499
+ const rowDef = this.#rolesRows[roleLine];
1500
+ if (rowDef && rowDef.kind !== "separator") {
1501
+ if (roleLine === this.#roleIndex) {
1502
+ this.#activateRolesRow(rowDef);
1258
1503
  } else {
1259
- this.#openRoleNameStrip();
1504
+ this.#roleIndex = roleLine;
1260
1505
  }
1261
- } else {
1262
- this.#roleIndex = roleLine;
1263
1506
  }
1264
1507
  }
1265
- } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
1508
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
1266
1509
  if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
1267
1510
  this.#requestLogin(entry);
1268
1511
  }
@@ -1294,15 +1537,22 @@ export class ModelHubComponent implements Component {
1294
1537
  }
1295
1538
 
1296
1539
  #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;
1540
+ // The scroll offset is persistent: the wheel pans it freely. Only an
1541
+ // activation (keys, click, programmatic) snaps the viewport to the
1542
+ // active entry, and only far enough to reveal it.
1543
+ if (this.#sidebarFollowActive) {
1544
+ const activeIndex = Math.max(
1545
+ 0,
1546
+ this.#entries.findIndex(entry => entry.id === this.#activeEntryId),
1547
+ );
1548
+ if (activeIndex < this.#sidebarScroll) {
1549
+ this.#sidebarScroll = activeIndex;
1550
+ } else if (activeIndex >= this.#sidebarScroll + rows) {
1551
+ this.#sidebarScroll = activeIndex - rows + 1;
1552
+ }
1553
+ this.#sidebarFollowActive = false;
1305
1554
  }
1555
+ this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll, Math.max(0, this.#entries.length - rows)));
1306
1556
 
1307
1557
  const lines: string[] = [];
1308
1558
  for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) {
@@ -1374,9 +1624,22 @@ export class ModelHubComponent implements Component {
1374
1624
  }
1375
1625
 
1376
1626
  #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;
1627
+ if (this.#assigning !== null) {
1628
+ if (this.#assigning.kind === "fallbackKey") {
1629
+ return truncateToWidth(
1630
+ theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"),
1631
+ width,
1632
+ );
1633
+ }
1634
+ const info = getRoleInfo(this.#assigning.role, this.#settings);
1635
+ const label = info.tag ?? info.name ?? this.#assigning.role;
1636
+ if (this.#assigning.kind === "fallback") {
1637
+ const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
1638
+ return truncateToWidth(
1639
+ theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`),
1640
+ width,
1641
+ );
1642
+ }
1380
1643
  return truncateToWidth(
1381
1644
  theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
1382
1645
  width,
@@ -1390,7 +1653,7 @@ export class ModelHubComponent implements Component {
1390
1653
  text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
1391
1654
  break;
1392
1655
  case "roles":
1393
- text = "Model roles — assignments fall back to auto-selection when cleared";
1656
+ text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
1394
1657
  break;
1395
1658
  case "provider":
1396
1659
  if (entry.locked) {
@@ -1415,22 +1678,71 @@ export class ModelHubComponent implements Component {
1415
1678
  #renderRolesView(width: number, rows: number): string[] {
1416
1679
  const lines: string[] = [];
1417
1680
  lines.push("");
1418
- this.#rolesRowStart = lines.length + 1; // +1 for the status row offset handled by caller
1681
+ // First row's offset in bodyLine coordinates: the mouse router's
1682
+ // `bodyLine` has already dropped the status row, so this is just the
1683
+ // leading blank line — no extra status-row offset here.
1684
+ this.#rolesRowStart = lines.length;
1419
1685
 
1420
1686
  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));
1687
+ for (const rowDef of this.#rolesRows) {
1688
+ if (rowDef.kind !== "role") continue;
1689
+ const info = getRoleInfo(rowDef.role, this.#settings);
1690
+ tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role));
1424
1691
  }
1425
1692
 
1426
1693
  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];
1694
+ for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
1695
+ const rowDef = this.#rolesRows[i];
1696
+ if (!rowDef) continue;
1431
1697
  const selected = i === this.#roleIndex;
1432
1698
  const hovered = i === this.#roleHover;
1433
1699
  const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
1700
+
1701
+ if (rowDef.kind === "separator") {
1702
+ lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`);
1703
+ continue;
1704
+ }
1705
+
1706
+ if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") {
1707
+ const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…";
1708
+ let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`;
1709
+ line = truncateToWidth(line, width);
1710
+ if (hovered && !selected) {
1711
+ line = theme.bg("selectedBg", line);
1712
+ }
1713
+ lines.push(line);
1714
+ continue;
1715
+ }
1716
+
1717
+ if (rowDef.kind === "chainKey") {
1718
+ const key = rowDef.role;
1719
+ const slash = key.lastIndexOf("/");
1720
+ const tail = key.slice(slash + 1);
1721
+ const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail);
1722
+ let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`;
1723
+ line = truncateToWidth(line, width);
1724
+ if (hovered && !selected) {
1725
+ line = theme.bg("selectedBg", line);
1726
+ }
1727
+ lines.push(line);
1728
+ continue;
1729
+ }
1730
+
1731
+ if (rowDef.kind === "fallback") {
1732
+ const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`);
1733
+ const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector);
1734
+ let line = ` ${cursor} ${branch} ${selector}`;
1735
+ line = truncateToWidth(line, width);
1736
+ if (hovered && !selected) {
1737
+ line = theme.bg("selectedBg", line);
1738
+ }
1739
+ lines.push(line);
1740
+ continue;
1741
+ }
1742
+
1743
+ const role = rowDef.role;
1744
+ const info = getRoleInfo(role, this.#settings);
1745
+ const assignment = this.#roles[role];
1434
1746
  const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
1435
1747
 
1436
1748
  let dot: string;
@@ -1475,27 +1787,16 @@ export class ModelHubComponent implements Component {
1475
1787
  lines.push(line);
1476
1788
  }
1477
1789
 
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
- }
1489
- lines.push(line);
1490
- }
1491
-
1492
1790
  // Live preview of the quick-switch cycle, rendered with the exact
1493
1791
  // segment track the ctrl+p status uses; the selected role's chip fills.
1494
1792
  while (lines.length < rows - 1) lines.push("");
1495
1793
  if (rows >= 2) {
1496
1794
  const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
1497
1795
  if (cycleOrder.length > 0) {
1498
- const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? "");
1796
+ const selectedRow = this.#rolesRows[this.#roleIndex];
1797
+ const selectedRole =
1798
+ selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : "";
1799
+ const activeIndex = cycleOrder.indexOf(selectedRole);
1499
1800
  const track = renderSegmentTrack(
1500
1801
  cycleOrder.map(role => ({ label: role })),
1501
1802
  activeIndex,
@@ -1557,14 +1858,32 @@ export class ModelHubComponent implements Component {
1557
1858
  ? "←/→ choose · Enter assign/clear · Esc cancel"
1558
1859
  : "←/→ thinking level · Enter apply · Esc keep";
1559
1860
  }
1560
- if (this.#assigningRole !== null) {
1561
- return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
1861
+ if (this.#assigning !== null) {
1862
+ switch (this.#assigning.kind) {
1863
+ case "fallback":
1864
+ return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel";
1865
+ case "fallbackKey":
1866
+ return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel";
1867
+ default:
1868
+ return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
1869
+ }
1562
1870
  }
1563
1871
  const entry = this.#activeEntry();
1564
1872
  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";
1873
+ if (this.#focus !== "list") {
1874
+ return "↑/↓ providers · roles · Esc close";
1875
+ }
1876
+ const row = this.#rolesRows[this.#roleIndex];
1877
+ if (row?.kind === "fallback") {
1878
+ return "↑/↓ rows · Enter replace · f add another · x remove · [/] reorder · ← providers";
1879
+ }
1880
+ if (row?.kind === "chainKey") {
1881
+ return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers";
1882
+ }
1883
+ if (row?.kind === "newFallback") {
1884
+ return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
1885
+ }
1886
+ return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
1568
1887
  }
1569
1888
  if (entry.kind === "provider" && entry.locked) {
1570
1889
  return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
@@ -1597,10 +1916,38 @@ export class ModelHubComponent implements Component {
1597
1916
  ? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
1598
1917
  : `${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
1918
 
1919
+ // Horizontal window: once the strip overflows, drop leading chips behind
1920
+ // a dim ellipsis so the selected chip (plus one chip of lookahead when it
1921
+ // fits) stays visible while cycling right.
1922
+ const prefixWidth = visibleWidth(prefix);
1923
+ const available = Math.max(1, width - prefixWidth);
1924
+ const chipWidths = strip.chips.map(
1925
+ (chip, i) => visibleWidth(` ${chip.styled} `) + (i === strip.index ? 2 : 0) + 1,
1926
+ );
1927
+ // Smallest start index whose window [start..target] (with its "… " lead-in
1928
+ // when start > 0) fits in the available width; `target` itself may still
1929
+ // overflow when a single chip is wider than the row.
1930
+ const startFor = (target: number): number => {
1931
+ let start = 0;
1932
+ while (start < target) {
1933
+ let sum = start > 0 ? 2 : 0;
1934
+ for (let i = start; i <= target; i++) sum += chipWidths[i] ?? 0;
1935
+ if (sum <= available) break;
1936
+ start++;
1937
+ }
1938
+ return start;
1939
+ };
1940
+ let start = startFor(Math.min(strip.index + 1, strip.chips.length - 1));
1941
+ if (start > strip.index) start = startFor(strip.index);
1942
+
1600
1943
  let line = prefix;
1601
1944
  // 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++) {
1945
+ let col = 2 + prefixWidth;
1946
+ if (start > 0) {
1947
+ line += theme.fg("dim", "… ");
1948
+ col += 2;
1949
+ }
1950
+ for (let i = start; i < strip.chips.length; i++) {
1604
1951
  const chip = strip.chips[i];
1605
1952
  if (!chip) continue;
1606
1953
  const selected = i === strip.index;
@@ -1628,9 +1975,9 @@ export class ModelHubComponent implements Component {
1628
1975
 
1629
1976
  const entry = this.#activeEntry();
1630
1977
  const bodyLines: string[] = [this.#statusRow(bodyWidth)];
1631
- if (entry.kind === "roles" && this.#assigningRole === null) {
1978
+ if (entry.kind === "roles" && this.#assigning === null) {
1632
1979
  bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1));
1633
- } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
1980
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
1634
1981
  bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
1635
1982
  } else {
1636
1983
  this.#browser.setMaxVisible(contentRows - 1 - 5);