@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.
- package/CHANGELOG.md +29 -0
- package/dist/cli.js +3052 -3005
- package/dist/types/cli/bench-cli.d.ts +1 -7
- package/dist/types/cli/usage-cli.d.ts +1 -0
- package/dist/types/commands/usage.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +3 -8
- package/dist/types/modes/components/model-browser.d.ts +3 -0
- package/dist/types/modes/components/model-hub.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/queue-input.d.ts +8 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-storage.d.ts +57 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +0 -1
- package/scripts/compile-binary.ts +4 -3
- package/src/cli/bench-cli.ts +7 -26
- package/src/cli/usage-cli.ts +11 -0
- package/src/commands/usage.ts +13 -2
- package/src/config/settings-schema.ts +1 -1
- package/src/modes/components/advisor-config.ts +3 -1
- package/src/modes/components/custom-editor.test.ts +58 -1
- package/src/modes/components/custom-editor.ts +42 -11
- package/src/modes/components/model-browser.ts +114 -33
- package/src/modes/components/model-hub.ts +455 -108
- package/src/modes/components/usage-row.ts +5 -6
- package/src/modes/controllers/input-controller.ts +140 -6
- package/src/modes/controllers/selector-controller.ts +20 -13
- package/src/modes/controllers/todo-command-controller.ts +1 -2
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/queue-input.ts +132 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +19 -20
- package/src/session/agent-session.ts +184 -48
- package/src/session/agent-storage.ts +330 -3
- package/src/session/history-storage.ts +1 -34
- 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
|
-
|
|
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
|
|
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
|
-
#
|
|
215
|
+
#rolesRows: RolesRow[] = [];
|
|
196
216
|
#roleIndex = 0;
|
|
197
217
|
#roleHover: number | null = null;
|
|
198
218
|
|
|
199
|
-
#
|
|
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 =
|
|
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
|
|
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.#
|
|
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.#
|
|
743
|
-
const
|
|
744
|
-
this.#
|
|
745
|
-
|
|
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
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
979
|
-
const lockedView = entry.kind === "provider" && entry.locked && this.#
|
|
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.#
|
|
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.#
|
|
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
|
|
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.#
|
|
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 -
|
|
1331
|
+
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1);
|
|
1101
1332
|
return;
|
|
1102
1333
|
}
|
|
1103
1334
|
if (matchesSelectDown(data)) {
|
|
1104
|
-
this.#roleIndex = (this.#roleIndex
|
|
1335
|
+
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
|
|
1105
1336
|
return;
|
|
1106
1337
|
}
|
|
1107
|
-
const
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
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.#
|
|
1208
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
if (
|
|
1257
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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.#
|
|
1378
|
-
|
|
1379
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
|
1422
|
-
|
|
1423
|
-
|
|
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.#
|
|
1428
|
-
const
|
|
1429
|
-
|
|
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
|
|
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.#
|
|
1561
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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 +
|
|
1603
|
-
|
|
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.#
|
|
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.#
|
|
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);
|