@oh-my-pi/pi-coding-agent 16.4.4 → 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 +62 -0
- package/dist/cli.js +3799 -3729
- package/dist/types/async/job-manager.d.ts +8 -0
- 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 +19 -9
- package/dist/types/config/settings.d.ts +3 -2
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +36 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
- package/dist/types/modes/components/ask-dialog.d.ts +27 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -8
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/model-browser.d.ts +100 -0
- package/dist/types/modes/components/model-hub.d.ts +52 -0
- package/dist/types/modes/components/pause-screen.d.ts +43 -0
- package/dist/types/modes/components/session-selector.d.ts +13 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/queue-input.d.ts +8 -0
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/agent-storage.d.ts +57 -0
- package/dist/types/session/session-context.d.ts +9 -0
- package/dist/types/task/executor.d.ts +26 -13
- package/dist/types/task/index.d.ts +12 -11
- package/dist/types/task/label.d.ts +4 -0
- package/dist/types/task/repair-args.d.ts +8 -8
- package/dist/types/task/types.d.ts +31 -56
- package/dist/types/tools/ask.d.ts +12 -0
- package/dist/types/tools/conflict-detect.d.ts +17 -1
- package/dist/types/tools/job.d.ts +16 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +0 -1
- package/scripts/compile-binary.ts +4 -3
- package/src/async/job-manager.ts +9 -0
- 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/commit/agentic/tools/analyze-file.ts +2 -3
- package/src/config/settings-schema.ts +18 -7
- package/src/config/settings.ts +13 -4
- package/src/discovery/helpers.ts +3 -4
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/extensions/types.ts +46 -0
- package/src/irc/bus.ts +61 -20
- package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
- package/src/modes/components/advisor-config.ts +32 -22
- package/src/modes/components/ask-dialog.ts +888 -0
- package/src/modes/components/custom-editor.test.ts +58 -1
- package/src/modes/components/custom-editor.ts +42 -11
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/model-browser.ts +769 -0
- package/src/modes/components/model-hub.ts +2002 -0
- package/src/modes/components/pause-screen.ts +208 -0
- package/src/modes/components/session-selector.ts +299 -42
- package/src/modes/components/tool-execution.ts +2 -0
- package/src/modes/components/usage-row.ts +5 -6
- package/src/modes/controllers/event-controller.ts +8 -2
- package/src/modes/controllers/extension-ui-controller.ts +252 -5
- package/src/modes/controllers/input-controller.ts +140 -6
- package/src/modes/controllers/selector-controller.ts +160 -97
- package/src/modes/controllers/tan-command-controller.ts +1 -1
- package/src/modes/controllers/todo-command-controller.ts +1 -2
- package/src/modes/interactive-mode.ts +8 -0
- package/src/modes/queue-input.ts +132 -0
- package/src/modes/shared.ts +1 -1
- package/src/modes/theme/theme.ts +3 -3
- package/src/modes/types.ts +4 -0
- package/src/modes/utils/ui-helpers.ts +50 -24
- package/src/prompts/agents/scout.md +0 -1
- package/src/prompts/agents/task.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +1 -5
- package/src/prompts/system/subagent-yield-reminder.md +10 -0
- package/src/prompts/system/task-label.md +23 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/prompts/tools/task-summary.md +3 -0
- package/src/prompts/tools/task.md +17 -18
- package/src/session/agent-session.ts +186 -49
- package/src/session/agent-storage.ts +330 -3
- package/src/session/history-storage.ts +1 -34
- package/src/session/session-context.test.ts +73 -0
- package/src/session/session-context.ts +43 -26
- package/src/slash-commands/builtin-registry.ts +18 -0
- package/src/task/agents.ts +2 -0
- package/src/task/executor.ts +159 -46
- package/src/task/index.ts +377 -239
- package/src/task/label.ts +38 -0
- package/src/task/render.ts +74 -22
- package/src/task/repair-args.ts +20 -31
- package/src/task/spawn-policy.test.ts +4 -4
- package/src/task/types.ts +46 -66
- package/src/tools/ask.ts +233 -40
- package/src/tools/conflict-detect.ts +102 -5
- package/src/tools/index.ts +1 -0
- package/src/tools/irc.ts +20 -11
- package/src/tools/job.ts +158 -18
- package/src/tools/write.ts +70 -6
- package/src/vibe/runtime.ts +1 -1
- package/src/web/search/providers/browser-headers.ts +30 -13
- package/dist/types/modes/components/model-selector.d.ts +0 -37
- package/dist/types/tools/bash-command-fixup.d.ts +0 -3
- package/src/modes/components/model-selector.ts +0 -1291
- package/src/tools/bash-command-fixup.ts +0 -4
|
@@ -0,0 +1,2002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fullscreen /models hub, shown on the alternate screen like /settings.
|
|
3
|
+
*
|
|
4
|
+
* Layout: a sidebar of scopes (recently used, role management, all models,
|
|
5
|
+
* one entry per provider — locked providers included, dimmed) beside a
|
|
6
|
+
* {@link ModelBrowser} body. The Roles view manages assignments directly:
|
|
7
|
+
* pick a role, pick a model, adjust thinking in an inline strip, or clear the
|
|
8
|
+
* role back to auto-selection. Locked providers forward to the /login flow.
|
|
9
|
+
* Fully mouse-navigable (hover, wheel, click).
|
|
10
|
+
*/
|
|
11
|
+
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
12
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
13
|
+
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
14
|
+
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
15
|
+
import { getCatalogProviderEntry } from "@oh-my-pi/pi-catalog/provider-models";
|
|
16
|
+
import {
|
|
17
|
+
type Component,
|
|
18
|
+
extractPrintableText,
|
|
19
|
+
fuzzyFilter,
|
|
20
|
+
getKeybindings,
|
|
21
|
+
Input,
|
|
22
|
+
matchesKey,
|
|
23
|
+
routeSgrMouseInput,
|
|
24
|
+
type SgrMouseEvent,
|
|
25
|
+
type TUI,
|
|
26
|
+
truncateToWidth,
|
|
27
|
+
visibleWidth,
|
|
28
|
+
} from "@oh-my-pi/pi-tui";
|
|
29
|
+
import type { ModelRegistry } from "../../config/model-registry";
|
|
30
|
+
import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
|
|
31
|
+
import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles";
|
|
32
|
+
import type { Settings } from "../../config/settings";
|
|
33
|
+
import {
|
|
34
|
+
AUTO_THINKING,
|
|
35
|
+
type ConfiguredThinkingLevel,
|
|
36
|
+
getConfiguredThinkingLevelMetadata,
|
|
37
|
+
parseConfiguredThinkingLevel,
|
|
38
|
+
} from "../../thinking";
|
|
39
|
+
import { theme } from "../theme/theme";
|
|
40
|
+
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
|
41
|
+
import {
|
|
42
|
+
buildBrowserItems,
|
|
43
|
+
ModelBrowser,
|
|
44
|
+
type ModelBrowserItem,
|
|
45
|
+
type RoleAssignments,
|
|
46
|
+
sortModelItems,
|
|
47
|
+
thinkingLevelGlyph,
|
|
48
|
+
} from "./model-browser";
|
|
49
|
+
import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box";
|
|
50
|
+
import { renderSegmentTrack } from "./segment-track";
|
|
51
|
+
|
|
52
|
+
/** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
|
|
53
|
+
export type ModelHubMode = "roles" | "pick";
|
|
54
|
+
|
|
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" };
|
|
78
|
+
|
|
79
|
+
/** A `--models` scope entry (mirrors the session's scoped model list). */
|
|
80
|
+
export interface ScopedModelItem {
|
|
81
|
+
model: Model;
|
|
82
|
+
thinkingLevel?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ModelHubCallbacks {
|
|
86
|
+
/** Persist a role assignment. */
|
|
87
|
+
onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void;
|
|
88
|
+
/** Clear a configured role back to auto-selection. */
|
|
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;
|
|
92
|
+
/** Pick-mode activation: session-only switch or embedded pick. */
|
|
93
|
+
onPick?: (model: Model, selector: string) => void;
|
|
94
|
+
/** Locked provider activation: forward to the /login flow. */
|
|
95
|
+
onLoginRequest?: (providerId: string) => void;
|
|
96
|
+
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
|
|
97
|
+
onCycleOrderChange?: (order: string[]) => void;
|
|
98
|
+
onCancel: () => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ModelHubOptions {
|
|
102
|
+
mode?: ModelHubMode;
|
|
103
|
+
/** Session token count; in pick mode, models with smaller context windows are disabled. */
|
|
104
|
+
currentContextTokens?: number;
|
|
105
|
+
/** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
|
|
106
|
+
initialProviderId?: string;
|
|
107
|
+
/** Status-row hint shown in pick mode. */
|
|
108
|
+
pickerHint?: string;
|
|
109
|
+
initialQuery?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface SidebarEntry {
|
|
113
|
+
id: string;
|
|
114
|
+
kind: "recent" | "roles" | "all" | "separator" | "provider";
|
|
115
|
+
label: string;
|
|
116
|
+
providerId?: string;
|
|
117
|
+
locked?: boolean;
|
|
118
|
+
/** Right-aligned annotation: model count, `assigned/total`, or `login`. */
|
|
119
|
+
annotation?: string;
|
|
120
|
+
oauth?: boolean;
|
|
121
|
+
catalogCount?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface StripChip {
|
|
125
|
+
label: string;
|
|
126
|
+
/** Pre-styled label body (without selection decoration). */
|
|
127
|
+
styled: string;
|
|
128
|
+
role?: string;
|
|
129
|
+
action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking";
|
|
130
|
+
thinkingLevel?: ConfiguredThinkingLevel;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type StripState =
|
|
134
|
+
| {
|
|
135
|
+
kind: "role" | "thinking";
|
|
136
|
+
item: ModelBrowserItem;
|
|
137
|
+
role?: string;
|
|
138
|
+
chips: StripChip[];
|
|
139
|
+
index: number;
|
|
140
|
+
/** Where to land when a thinking strip closes. */
|
|
141
|
+
returnToRoles: boolean;
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
/** Footer text input naming a new custom role. */
|
|
145
|
+
kind: "roleName";
|
|
146
|
+
input: Input;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Recorded chip hit-range on the footer row (columns relative to frame col 0). */
|
|
150
|
+
interface ChipRange {
|
|
151
|
+
start: number;
|
|
152
|
+
end: number;
|
|
153
|
+
index: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
|
|
157
|
+
const RECENT_LIMIT = 15;
|
|
158
|
+
const SIDEBAR_MIN_WIDTH = 18;
|
|
159
|
+
const SIDEBAR_MAX_WIDTH = 26;
|
|
160
|
+
|
|
161
|
+
const PICK_MODE_HINT = "Session-only switch — role models stay unchanged";
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Providers already auto-refreshed this process. Selecting a provider fetches
|
|
165
|
+
* its live model list at most once per application lifetime (surviving hub
|
|
166
|
+
* close/reopen); F5 re-fetches on demand.
|
|
167
|
+
*/
|
|
168
|
+
const autoRefreshedProviders = new Set<string>();
|
|
169
|
+
|
|
170
|
+
/** Test hook: forget which providers were auto-refreshed this process. */
|
|
171
|
+
export function resetProviderAutoRefreshGuard(): void {
|
|
172
|
+
autoRefreshedProviders.clear();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The fullscreen model hub component. Hosted via `ui.showOverlay(..., { fullscreen: true })`;
|
|
177
|
+
* the host must call {@link ModelHubComponent.dispose} when the overlay closes.
|
|
178
|
+
*/
|
|
179
|
+
export class ModelHubComponent implements Component {
|
|
180
|
+
#tui: TUI;
|
|
181
|
+
#settings: Settings;
|
|
182
|
+
#registry: ModelRegistry;
|
|
183
|
+
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
|
184
|
+
#callbacks: ModelHubCallbacks;
|
|
185
|
+
#mode: ModelHubMode;
|
|
186
|
+
#pickerHint: string;
|
|
187
|
+
|
|
188
|
+
#browser: ModelBrowser;
|
|
189
|
+
#roles: RoleAssignments = {};
|
|
190
|
+
#availableItems: ModelBrowserItem[] = [];
|
|
191
|
+
#recentItems: ModelBrowserItem[] = [];
|
|
192
|
+
#configError: string | undefined;
|
|
193
|
+
|
|
194
|
+
#entries: SidebarEntry[] = [];
|
|
195
|
+
// Sidebar sections from the last registry sync; #composeEntries assembles
|
|
196
|
+
// #entries from these (reordered while searching).
|
|
197
|
+
#fixedEntries: SidebarEntry[] = [];
|
|
198
|
+
#unlockedProviderEntries: SidebarEntry[] = [];
|
|
199
|
+
#lockedProviderEntries: SidebarEntry[] = [];
|
|
200
|
+
/** Fuzzy match totals while searching: recent-scope hits and overall hits. */
|
|
201
|
+
#recentSearchCount = 0;
|
|
202
|
+
#searchTotal = 0;
|
|
203
|
+
#activeEntryId = "all";
|
|
204
|
+
#sidebarScroll = 0;
|
|
205
|
+
/** Snap the sidebar viewport to the active entry on the next render; wheel panning leaves it free. */
|
|
206
|
+
#sidebarFollowActive = true;
|
|
207
|
+
#sidebarHover: number | null = null;
|
|
208
|
+
/**
|
|
209
|
+
* Arrow-key ownership: `scope` (default) hops the sidebar even while the
|
|
210
|
+
* search bar holds the caret; `list` navigates rows (browser models or
|
|
211
|
+
* role rows). Tab toggles.
|
|
212
|
+
*/
|
|
213
|
+
#focus: "scope" | "list" = "scope";
|
|
214
|
+
|
|
215
|
+
#rolesRows: RolesRow[] = [];
|
|
216
|
+
#roleIndex = 0;
|
|
217
|
+
#roleHover: number | null = null;
|
|
218
|
+
|
|
219
|
+
#assigning: AssignTarget | null = null;
|
|
220
|
+
#strip: StripState | null = null;
|
|
221
|
+
/** Per-provider fuzzy match counts while a query is active; null when not searching. */
|
|
222
|
+
#searchCounts: Map<string, number> | null = null;
|
|
223
|
+
|
|
224
|
+
// Provider discovery refresh (debounced per sidebar selection, with spinner).
|
|
225
|
+
#refreshingProviders = new Set<string>();
|
|
226
|
+
#scheduledProviderRefreshes = new Map<string, Timer>();
|
|
227
|
+
#refreshSpinnerFrame = 0;
|
|
228
|
+
#refreshSpinnerInterval?: Timer;
|
|
229
|
+
|
|
230
|
+
// Frame geometry from the last render, for mouse hit-testing (the
|
|
231
|
+
// fullscreen overlay paints from screen row 0, so mouse rows map 1:1).
|
|
232
|
+
#contentRowStart = 1;
|
|
233
|
+
#contentRowCount = 0;
|
|
234
|
+
#sidebarWidthLast = SIDEBAR_MIN_WIDTH;
|
|
235
|
+
#footerRow = 0;
|
|
236
|
+
#chipRanges: ChipRange[] = [];
|
|
237
|
+
#lockedLoginLine: number | null = null;
|
|
238
|
+
#rolesRowStart = 1;
|
|
239
|
+
|
|
240
|
+
constructor(
|
|
241
|
+
tui: TUI,
|
|
242
|
+
settings: Settings,
|
|
243
|
+
registry: ModelRegistry,
|
|
244
|
+
scopedModels: ReadonlyArray<ScopedModelItem>,
|
|
245
|
+
callbacks: ModelHubCallbacks,
|
|
246
|
+
options: ModelHubOptions = {},
|
|
247
|
+
) {
|
|
248
|
+
this.#tui = tui;
|
|
249
|
+
this.#settings = settings;
|
|
250
|
+
this.#registry = registry;
|
|
251
|
+
this.#scopedModels = scopedModels;
|
|
252
|
+
this.#callbacks = callbacks;
|
|
253
|
+
this.#mode = options.mode ?? "roles";
|
|
254
|
+
this.#pickerHint = options.pickerHint ?? PICK_MODE_HINT;
|
|
255
|
+
|
|
256
|
+
this.#browser = new ModelBrowser(settings, {
|
|
257
|
+
currentContextTokens: options.currentContextTokens,
|
|
258
|
+
disableOverContext: this.#mode === "pick",
|
|
259
|
+
emptyText: () => this.#emptyStateMessage(),
|
|
260
|
+
initialQuery: options.initialQuery,
|
|
261
|
+
});
|
|
262
|
+
this.#browser.onActivate = item => this.#activateItem(item);
|
|
263
|
+
this.#browser.onCancel = () => this.#callbacks.onCancel();
|
|
264
|
+
this.#browser.onQueryChange = query => this.#onQueryChanged(query);
|
|
265
|
+
|
|
266
|
+
// Hydrate synchronously from the current registry snapshot so the first
|
|
267
|
+
// Enter after opening acts on cached models instead of being dropped
|
|
268
|
+
// while the offline refresh promise is still pending.
|
|
269
|
+
this.#syncFromRegistryState();
|
|
270
|
+
|
|
271
|
+
const initialProvider = options.initialProviderId;
|
|
272
|
+
if (initialProvider && this.#entries.some(entry => entry.providerId === initialProvider)) {
|
|
273
|
+
this.#setActiveEntry(`provider:${initialProvider}`);
|
|
274
|
+
} else {
|
|
275
|
+
this.#setActiveEntry("all");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (this.#mode === "pick") {
|
|
279
|
+
this.#focus = "list";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Reconcile with cached discovery state in the background. A --models
|
|
283
|
+
// scope is registry-independent, so the offline reload would only repeat
|
|
284
|
+
// the synchronous hydration above.
|
|
285
|
+
if (this.#scopedModels.length === 0) {
|
|
286
|
+
this.#registry
|
|
287
|
+
.refresh("offline")
|
|
288
|
+
.then(() => this.#syncFromRegistryState())
|
|
289
|
+
.catch(error => {
|
|
290
|
+
this.#configError = error instanceof Error ? error.message : String(error);
|
|
291
|
+
})
|
|
292
|
+
.finally(() => this.#tui.requestRender());
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Cancel pending provider refresh timers and the spinner. Host calls this on overlay close. */
|
|
297
|
+
dispose(): void {
|
|
298
|
+
for (const [, timer] of this.#scheduledProviderRefreshes) clearTimeout(timer);
|
|
299
|
+
this.#scheduledProviderRefreshes.clear();
|
|
300
|
+
this.#refreshingProviders.clear();
|
|
301
|
+
if (this.#refreshSpinnerInterval) {
|
|
302
|
+
clearInterval(this.#refreshSpinnerInterval);
|
|
303
|
+
this.#refreshSpinnerInterval = undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
invalidate(): void {}
|
|
308
|
+
|
|
309
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
310
|
+
// Data pipeline
|
|
311
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
312
|
+
|
|
313
|
+
#visibleRoleIds(): string[] {
|
|
314
|
+
return getKnownRoleIds(this.#settings).filter(role => !getRoleInfo(role, this.#settings).hidden);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#getResolvedRoleThinkingLevel(
|
|
318
|
+
role: string,
|
|
319
|
+
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
|
|
320
|
+
): ConfiguredThinkingLevel {
|
|
321
|
+
if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
|
|
322
|
+
return resolved.thinkingLevel;
|
|
323
|
+
}
|
|
324
|
+
if (role === "default") {
|
|
325
|
+
return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
|
|
326
|
+
}
|
|
327
|
+
return ThinkingLevel.Inherit;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Resolve every known role: configured values first, auto-selection for the rest. */
|
|
331
|
+
#reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
|
|
332
|
+
const nextRoles: RoleAssignments = {};
|
|
333
|
+
const allModels = this.#scopedModels.length > 0 ? [...autoCandidates] : this.#registry.getAll();
|
|
334
|
+
const matchPreferences = getModelMatchPreferences(this.#settings);
|
|
335
|
+
const knownRoles = getKnownRoleIds(this.#settings);
|
|
336
|
+
const configuredRoles = new Set<string>();
|
|
337
|
+
|
|
338
|
+
for (const role of knownRoles) {
|
|
339
|
+
const roleValue = this.#settings.getModelRole(role);
|
|
340
|
+
if (!roleValue) continue;
|
|
341
|
+
configuredRoles.add(role);
|
|
342
|
+
const resolved = resolveModelRoleValue(roleValue, allModels, {
|
|
343
|
+
settings: this.#settings,
|
|
344
|
+
matchPreferences,
|
|
345
|
+
});
|
|
346
|
+
if (resolved.model) {
|
|
347
|
+
nextRoles[role] = {
|
|
348
|
+
model: resolved.model,
|
|
349
|
+
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
|
|
350
|
+
autoSelected: false,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (autoCandidates.length > 0) {
|
|
356
|
+
const candidates = [...autoCandidates];
|
|
357
|
+
for (const role of knownRoles) {
|
|
358
|
+
if (configuredRoles.has(role)) continue;
|
|
359
|
+
const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
|
|
360
|
+
settings: this.#settings,
|
|
361
|
+
matchPreferences,
|
|
362
|
+
});
|
|
363
|
+
if (!resolved.model) continue;
|
|
364
|
+
nextRoles[role] = {
|
|
365
|
+
model: resolved.model,
|
|
366
|
+
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
|
|
367
|
+
autoSelected: true,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
this.#roles = nextRoles;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
|
|
376
|
+
#syncFromRegistryState(): void {
|
|
377
|
+
let allModels: ReadonlyArray<Model>;
|
|
378
|
+
let availableModels: ReadonlyArray<Model>;
|
|
379
|
+
if (this.#scopedModels.length > 0) {
|
|
380
|
+
allModels = this.#scopedModels.map(scoped => scoped.model);
|
|
381
|
+
availableModels = allModels;
|
|
382
|
+
this.#configError = undefined;
|
|
383
|
+
} else {
|
|
384
|
+
const loadError = this.#registry.getError();
|
|
385
|
+
this.#configError = loadError ? String(loadError) : undefined;
|
|
386
|
+
allModels = this.#registry.getAll();
|
|
387
|
+
try {
|
|
388
|
+
availableModels = this.#registry.getAvailable();
|
|
389
|
+
} catch (error) {
|
|
390
|
+
this.#configError = error instanceof Error ? error.message : String(error);
|
|
391
|
+
availableModels = [];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.#reloadRoles(availableModels);
|
|
396
|
+
this.#buildRolesRows();
|
|
397
|
+
|
|
398
|
+
const storage = this.#settings.getStorage();
|
|
399
|
+
const mruOrder = storage?.getModelUsageOrder() ?? [];
|
|
400
|
+
this.#availableItems = buildBrowserItems(availableModels);
|
|
401
|
+
sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder });
|
|
402
|
+
this.#browser.setRoles(this.#roles);
|
|
403
|
+
this.#browser.setMruOrder(mruOrder);
|
|
404
|
+
this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
|
|
405
|
+
|
|
406
|
+
const bySelector = new Map(this.#availableItems.map(item => [item.selector, item]));
|
|
407
|
+
this.#recentItems = [];
|
|
408
|
+
for (const key of mruOrder) {
|
|
409
|
+
const item = bySelector.get(key);
|
|
410
|
+
if (item) this.#recentItems.push(item);
|
|
411
|
+
if (this.#recentItems.length >= RECENT_LIMIT) break;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this.#buildSidebar(allModels, availableModels);
|
|
415
|
+
this.#applyScope();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#buildSidebar(allModels: ReadonlyArray<Model>, availableModels: ReadonlyArray<Model>): void {
|
|
419
|
+
const scoped = this.#scopedModels.length > 0;
|
|
420
|
+
let disabledProviders: ReadonlySet<string>;
|
|
421
|
+
try {
|
|
422
|
+
disabledProviders = new Set(this.#settings.get("disabledProviders"));
|
|
423
|
+
} catch {
|
|
424
|
+
disabledProviders = new Set();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const availableCounts = new Map<string, number>();
|
|
428
|
+
for (const model of availableModels) {
|
|
429
|
+
availableCounts.set(model.provider, (availableCounts.get(model.provider) ?? 0) + 1);
|
|
430
|
+
}
|
|
431
|
+
const catalogCounts = new Map<string, number>();
|
|
432
|
+
for (const model of allModels) {
|
|
433
|
+
catalogCounts.set(model.provider, (catalogCounts.get(model.provider) ?? 0) + 1);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const unlocked = new Set<string>(availableCounts.keys());
|
|
437
|
+
const locked = new Set<string>();
|
|
438
|
+
if (!scoped) {
|
|
439
|
+
const authStorage = this.#registry.authStorage;
|
|
440
|
+
for (const provider of catalogCounts.keys()) {
|
|
441
|
+
if (!unlocked.has(provider) && !disabledProviders.has(provider)) {
|
|
442
|
+
locked.add(provider);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
for (const provider of this.#registry.getDiscoverableProviders()) {
|
|
446
|
+
if (unlocked.has(provider) || disabledProviders.has(provider)) continue;
|
|
447
|
+
// Discoverable without stored auth: catalog-backed providers stay
|
|
448
|
+
// locked; keyless/custom endpoints (ollama, vllm, …) surface as
|
|
449
|
+
// selectable so discovery can populate them.
|
|
450
|
+
if (authStorage.hasAuth(provider) || !locked.has(provider)) {
|
|
451
|
+
locked.delete(provider);
|
|
452
|
+
unlocked.add(provider);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const oauthIds = new Set(getOAuthProviders().map(provider => provider.id));
|
|
458
|
+
const providerEntry = (providerId: string, isLocked: boolean): SidebarEntry => ({
|
|
459
|
+
id: `provider:${providerId}`,
|
|
460
|
+
kind: "provider",
|
|
461
|
+
label: providerId,
|
|
462
|
+
providerId,
|
|
463
|
+
locked: isLocked,
|
|
464
|
+
annotation: isLocked ? "login" : String(availableCounts.get(providerId) ?? 0),
|
|
465
|
+
oauth: oauthIds.has(providerId),
|
|
466
|
+
catalogCount: catalogCounts.get(providerId) ?? 0,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const visibleRoles = this.#visibleRoleIds();
|
|
470
|
+
let assignedCount = 0;
|
|
471
|
+
for (const role of visibleRoles) {
|
|
472
|
+
const assignment = this.#roles[role];
|
|
473
|
+
if (assignment && !assignment.autoSelected) assignedCount++;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Roles leads the fixed section so downward hops from Recent head into
|
|
477
|
+
// model scopes instead of being captured by the roles view.
|
|
478
|
+
const fixed: SidebarEntry[] = [];
|
|
479
|
+
if (this.#mode === "roles") {
|
|
480
|
+
fixed.push({
|
|
481
|
+
id: "roles",
|
|
482
|
+
kind: "roles",
|
|
483
|
+
label: "Roles",
|
|
484
|
+
annotation: `${assignedCount}/${visibleRoles.length}`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
|
|
488
|
+
|
|
489
|
+
this.#fixedEntries = fixed;
|
|
490
|
+
this.#unlockedProviderEntries = [...unlocked]
|
|
491
|
+
.sort((a, b) => a.localeCompare(b))
|
|
492
|
+
.map(provider => providerEntry(provider, false));
|
|
493
|
+
this.#lockedProviderEntries = [...locked]
|
|
494
|
+
.sort((a, b) => a.localeCompare(b))
|
|
495
|
+
.map(provider => providerEntry(provider, true));
|
|
496
|
+
this.#composeEntries();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Assemble `#entries` from the stored sections. While a search is active,
|
|
501
|
+
* providers with matches float to the top of the provider section (each
|
|
502
|
+
* group stays alphabetical) so the hop order, mouse hit-testing, and the
|
|
503
|
+
* paint all agree.
|
|
504
|
+
*/
|
|
505
|
+
#composeEntries(): void {
|
|
506
|
+
const counts = this.#searchCounts;
|
|
507
|
+
let providers = this.#unlockedProviderEntries;
|
|
508
|
+
if (counts) {
|
|
509
|
+
providers = [...providers].sort((a, b) => {
|
|
510
|
+
const aMatched = (counts.get(a.providerId ?? "") ?? 0) > 0;
|
|
511
|
+
const bMatched = (counts.get(b.providerId ?? "") ?? 0) > 0;
|
|
512
|
+
if (aMatched !== bMatched) return aMatched ? -1 : 1;
|
|
513
|
+
return a.label.localeCompare(b.label);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const entries: SidebarEntry[] = [...this.#fixedEntries];
|
|
518
|
+
if (providers.length > 0) {
|
|
519
|
+
entries.push({ id: "sep:providers", kind: "separator", label: "" }, ...providers);
|
|
520
|
+
}
|
|
521
|
+
if (this.#lockedProviderEntries.length > 0) {
|
|
522
|
+
entries.push({ id: "sep:locked", kind: "separator", label: "" }, ...this.#lockedProviderEntries);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
this.#entries = entries;
|
|
526
|
+
if (!entries.some(entry => entry.id === this.#activeEntryId)) {
|
|
527
|
+
this.#activeEntryId = "all";
|
|
528
|
+
this.#sidebarFollowActive = true;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#activeEntry(): SidebarEntry {
|
|
533
|
+
return this.#entries.find(entry => entry.id === this.#activeEntryId) ?? this.#entries[0];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#setActiveEntry(id: string): void {
|
|
537
|
+
if (!this.#entries.some(entry => entry.id === id)) return;
|
|
538
|
+
this.#activeEntryId = id;
|
|
539
|
+
this.#sidebarFollowActive = true;
|
|
540
|
+
this.#applyScope();
|
|
541
|
+
const entry = this.#activeEntry();
|
|
542
|
+
// Hops must never steal arrow focus: landing on a scope keeps provider
|
|
543
|
+
// navigation active. Diving into the roles rows is explicit (Enter, →,
|
|
544
|
+
// or a click on the Roles entry).
|
|
545
|
+
this.#focus = "scope";
|
|
546
|
+
if (entry.kind === "provider" && !entry.locked) {
|
|
547
|
+
this.#scheduleProviderRefresh(entry.providerId ?? "");
|
|
548
|
+
}
|
|
549
|
+
this.#cancelScheduledRefreshesExcept(entry.kind === "provider" ? entry.providerId : undefined);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Push the active scope's items into the browser. */
|
|
553
|
+
#applyScope(): void {
|
|
554
|
+
const entry = this.#activeEntry();
|
|
555
|
+
switch (entry.kind) {
|
|
556
|
+
case "recent":
|
|
557
|
+
this.#browser.setShowProvider(true);
|
|
558
|
+
this.#browser.setItems([...this.#recentItems]);
|
|
559
|
+
break;
|
|
560
|
+
case "provider": {
|
|
561
|
+
if (entry.locked) {
|
|
562
|
+
// Assign-mode renders the browser regardless of scope; a locked
|
|
563
|
+
// provider contributes nothing selectable.
|
|
564
|
+
this.#browser.setItems([]);
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
const providerId = entry.providerId;
|
|
568
|
+
this.#browser.setShowProvider(false);
|
|
569
|
+
this.#browser.setItems(this.#availableItems.filter(item => item.provider === providerId));
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case "roles":
|
|
573
|
+
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
|
|
574
|
+
break;
|
|
575
|
+
default:
|
|
576
|
+
this.#browser.setShowProvider(true);
|
|
577
|
+
this.#browser.setItems([...this.#availableItems]);
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
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
|
+
|
|
634
|
+
/** Refresh roles + dependent state after a settings mutation (assign/unassign). */
|
|
635
|
+
#refreshAfterMutation(): void {
|
|
636
|
+
this.#syncFromRegistryState();
|
|
637
|
+
this.#tui.requestRender();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Recompute per-provider match counts for the active query. Providers
|
|
642
|
+
* without matches gray out and the scope hop skips them; a provider scope
|
|
643
|
+
* that just lost its last match falls back to All models so the results
|
|
644
|
+
* never silently vanish.
|
|
645
|
+
*/
|
|
646
|
+
#onQueryChanged(query: string): void {
|
|
647
|
+
if (!query.trim()) {
|
|
648
|
+
this.#searchCounts = null;
|
|
649
|
+
this.#composeEntries();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const matches = fuzzyFilter(this.#availableItems, query, ({ provider, id }) => `${provider}/${id}`);
|
|
653
|
+
const counts = new Map<string, number>();
|
|
654
|
+
for (const item of matches) {
|
|
655
|
+
counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
|
|
656
|
+
}
|
|
657
|
+
const recentSelectors = new Set(this.#recentItems.map(item => item.selector));
|
|
658
|
+
this.#recentSearchCount = matches.reduce(
|
|
659
|
+
(total, item) => total + (recentSelectors.has(item.selector) ? 1 : 0),
|
|
660
|
+
0,
|
|
661
|
+
);
|
|
662
|
+
this.#searchTotal = matches.length;
|
|
663
|
+
this.#searchCounts = counts;
|
|
664
|
+
this.#composeEntries();
|
|
665
|
+
const entry = this.#activeEntry();
|
|
666
|
+
if (
|
|
667
|
+
this.#assigning === null &&
|
|
668
|
+
entry.kind === "provider" &&
|
|
669
|
+
(entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0)
|
|
670
|
+
) {
|
|
671
|
+
this.#setActiveEntry("all");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Entries the scope hop skips: separators always; while searching, also
|
|
677
|
+
* the Roles view (not a model scope), an empty Recent, locked providers,
|
|
678
|
+
* and providers without matches.
|
|
679
|
+
*/
|
|
680
|
+
#isHopSkipped(entry: SidebarEntry): boolean {
|
|
681
|
+
if (entry.kind === "separator") return true;
|
|
682
|
+
if (!this.#searchCounts) return false;
|
|
683
|
+
if (entry.kind === "roles") return true;
|
|
684
|
+
if (entry.kind === "recent") return this.#recentSearchCount === 0;
|
|
685
|
+
if (entry.kind === "provider") {
|
|
686
|
+
if (entry.locked) return true;
|
|
687
|
+
return (this.#searchCounts.get(entry.providerId ?? "") ?? 0) === 0;
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
693
|
+
// Provider discovery refresh
|
|
694
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
695
|
+
|
|
696
|
+
#startRefreshSpinner(): void {
|
|
697
|
+
if (this.#refreshSpinnerInterval) return;
|
|
698
|
+
this.#refreshSpinnerInterval = setInterval(() => {
|
|
699
|
+
const frameCount = theme.spinnerFrames.length;
|
|
700
|
+
if (frameCount > 0) {
|
|
701
|
+
this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
|
|
702
|
+
}
|
|
703
|
+
this.#tui.requestRender();
|
|
704
|
+
}, 80);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
#stopRefreshSpinnerIfIdle(): void {
|
|
708
|
+
if (this.#refreshingProviders.size > 0) return;
|
|
709
|
+
if (this.#refreshSpinnerInterval) {
|
|
710
|
+
clearInterval(this.#refreshSpinnerInterval);
|
|
711
|
+
this.#refreshSpinnerInterval = undefined;
|
|
712
|
+
}
|
|
713
|
+
this.#refreshSpinnerFrame = 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
#setProviderRefreshing(providerId: string, refreshing: boolean): void {
|
|
717
|
+
if (refreshing) {
|
|
718
|
+
this.#refreshingProviders.add(providerId);
|
|
719
|
+
this.#startRefreshSpinner();
|
|
720
|
+
} else {
|
|
721
|
+
this.#refreshingProviders.delete(providerId);
|
|
722
|
+
this.#stopRefreshSpinnerIfIdle();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#cancelScheduledRefreshesExcept(keepProviderId?: string): void {
|
|
727
|
+
for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
|
|
728
|
+
if (providerId === keepProviderId) continue;
|
|
729
|
+
clearTimeout(timer);
|
|
730
|
+
this.#scheduledProviderRefreshes.delete(providerId);
|
|
731
|
+
this.#setProviderRefreshing(providerId, false);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
#scheduleProviderRefresh(providerId: string, options?: { force?: boolean }): void {
|
|
736
|
+
if (this.#scopedModels.length > 0 || !providerId) return;
|
|
737
|
+
if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) return;
|
|
738
|
+
// Hovering a provider must not re-fetch on every visit: auto-refresh runs
|
|
739
|
+
// at most once per provider for the process lifetime. F5 forces a re-fetch.
|
|
740
|
+
if (!options?.force && autoRefreshedProviders.has(providerId)) return;
|
|
741
|
+
this.#setProviderRefreshing(providerId, true);
|
|
742
|
+
const timer = setTimeout(() => {
|
|
743
|
+
// Consume the once-guard only when the fetch actually starts: hopping
|
|
744
|
+
// through a provider cancels the debounce and must not burn its slot.
|
|
745
|
+
autoRefreshedProviders.add(providerId);
|
|
746
|
+
this.#scheduledProviderRefreshes.delete(providerId);
|
|
747
|
+
void this.#refreshProviderInBackground(providerId);
|
|
748
|
+
}, PROVIDER_REFRESH_DEBOUNCE_MS);
|
|
749
|
+
this.#scheduledProviderRefreshes.set(providerId, timer);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async #refreshProviderInBackground(providerId: string): Promise<void> {
|
|
753
|
+
try {
|
|
754
|
+
await this.#registry.refreshProvider(providerId, "online");
|
|
755
|
+
// The provider refresh already updated the registry snapshot;
|
|
756
|
+
// re-reading it here stays purely in-memory.
|
|
757
|
+
this.#syncFromRegistryState();
|
|
758
|
+
} catch (error) {
|
|
759
|
+
this.#configError = error instanceof Error ? error.message : String(error);
|
|
760
|
+
} finally {
|
|
761
|
+
this.#setProviderRefreshing(providerId, false);
|
|
762
|
+
this.#tui.requestRender();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#formatDiscoveryAge(fetchedAt: number | undefined): string | undefined {
|
|
767
|
+
if (!fetchedAt) return undefined;
|
|
768
|
+
const ageMs = Math.max(0, Date.now() - fetchedAt);
|
|
769
|
+
if (ageMs < 60_000) return "less than a minute ago";
|
|
770
|
+
return `${Math.round(ageMs / 60_000)}m ago`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
#emptyStateMessage(): string | undefined {
|
|
774
|
+
if (this.#configError) return ` ${this.#configError}`;
|
|
775
|
+
const entry = this.#activeEntry();
|
|
776
|
+
if (entry.kind === "recent") return " No recently used models yet";
|
|
777
|
+
if (entry.kind !== "provider" || entry.locked) return undefined;
|
|
778
|
+
if (this.#browser.query.trim()) {
|
|
779
|
+
return ` No matching models in ${entry.label}. Switch to All models to search every provider.`;
|
|
780
|
+
}
|
|
781
|
+
const providerId = entry.providerId ?? "";
|
|
782
|
+
const state = this.#registry.getProviderDiscoveryState(providerId);
|
|
783
|
+
if (!state) return undefined;
|
|
784
|
+
const age = this.#formatDiscoveryAge(state.fetchedAt);
|
|
785
|
+
switch (state.status) {
|
|
786
|
+
case "cached":
|
|
787
|
+
return age
|
|
788
|
+
? ` Using cached model list from ${age}. Live refresh is still pending.`
|
|
789
|
+
: " Using cached model list. Live refresh is still pending.";
|
|
790
|
+
case "unavailable": {
|
|
791
|
+
const httpMatch = state.error?.match(/^HTTP (\d+) from (.+)$/);
|
|
792
|
+
if (httpMatch?.[1] === "404") {
|
|
793
|
+
return ` Discovery endpoint ${httpMatch[2]} returned 404. Point baseUrl at the host that serves /models (usually .../v1).`;
|
|
794
|
+
}
|
|
795
|
+
if (state.error) return ` Discovery failed: ${state.error}`;
|
|
796
|
+
return age ? ` Provider unavailable. Using cached model list from ${age}.` : " Provider unavailable.";
|
|
797
|
+
}
|
|
798
|
+
case "unauthenticated":
|
|
799
|
+
return " Provider requires authentication before models can be discovered.";
|
|
800
|
+
case "idle":
|
|
801
|
+
return " Provider has not been refreshed yet.";
|
|
802
|
+
case "empty":
|
|
803
|
+
return " Discovery succeeded but returned 0 models. Check that /models returns { data: [{ id }] }.";
|
|
804
|
+
case "ok":
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
810
|
+
// Assignment flow
|
|
811
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
812
|
+
|
|
813
|
+
#activateItem(item: ModelBrowserItem): void {
|
|
814
|
+
if (this.#mode === "pick") {
|
|
815
|
+
this.#callbacks.onPick?.(item.model, item.selector);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
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
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
this.#openRoleStrip(item);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
|
|
834
|
+
#assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
|
|
835
|
+
const current = this.#roles[role];
|
|
836
|
+
let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit;
|
|
837
|
+
if (current && !current.autoSelected) {
|
|
838
|
+
const supported = this.#thinkingOptionsFor(item.model);
|
|
839
|
+
level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit;
|
|
840
|
+
}
|
|
841
|
+
this.#callbacks.onAssign(item.model, role, level, item.selector);
|
|
842
|
+
this.#refreshAfterMutation();
|
|
843
|
+
this.#openThinkingStrip(item, role, returnToRoles);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#unassignRole(role: string): void {
|
|
847
|
+
const assignment = this.#roles[role];
|
|
848
|
+
if (!assignment || assignment.autoSelected) return;
|
|
849
|
+
this.#callbacks.onUnassign(role);
|
|
850
|
+
this.#refreshAfterMutation();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
#thinkingOptionsFor(model: Model): ConfiguredThinkingLevel[] {
|
|
854
|
+
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#openRoleStrip(item: ModelBrowserItem): void {
|
|
858
|
+
const chips: StripChip[] = [];
|
|
859
|
+
for (const role of this.#visibleRoleIds()) {
|
|
860
|
+
const info = getRoleInfo(role, this.#settings);
|
|
861
|
+
const assignment = this.#roles[role];
|
|
862
|
+
const assignedHere =
|
|
863
|
+
!!assignment &&
|
|
864
|
+
!assignment.autoSelected &&
|
|
865
|
+
assignment.model.provider === item.model.provider &&
|
|
866
|
+
assignment.model.id === item.model.id;
|
|
867
|
+
const label = (info.tag ?? info.name ?? role).toLowerCase();
|
|
868
|
+
chips.push({
|
|
869
|
+
label,
|
|
870
|
+
styled: assignedHere
|
|
871
|
+
? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
|
|
872
|
+
theme.fg("dim", ` ${theme.status.success}`)
|
|
873
|
+
: theme.fg(info.color ?? "muted", label),
|
|
874
|
+
role,
|
|
875
|
+
action: assignedHere ? "unassign" : "assign",
|
|
876
|
+
});
|
|
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
|
+
});
|
|
888
|
+
chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
|
|
889
|
+
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
#openThinkingStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
|
|
893
|
+
const options = this.#thinkingOptionsFor(item.model);
|
|
894
|
+
const current = this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
|
|
895
|
+
const chips: StripChip[] = options.map(level => {
|
|
896
|
+
const label = getConfiguredThinkingLevelMetadata(level).label;
|
|
897
|
+
const glyph = thinkingLevelGlyph(level);
|
|
898
|
+
return {
|
|
899
|
+
label,
|
|
900
|
+
styled: glyph ? `${theme.fg("accent", glyph)} ${label}` : label,
|
|
901
|
+
action: "thinking",
|
|
902
|
+
thinkingLevel: level,
|
|
903
|
+
};
|
|
904
|
+
});
|
|
905
|
+
const preselect = options.indexOf(current);
|
|
906
|
+
this.#strip = {
|
|
907
|
+
kind: "thinking",
|
|
908
|
+
item,
|
|
909
|
+
role,
|
|
910
|
+
chips,
|
|
911
|
+
index: preselect >= 0 ? preselect : 0,
|
|
912
|
+
returnToRoles,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
#closeStrip(): void {
|
|
917
|
+
const strip = this.#strip;
|
|
918
|
+
this.#strip = null;
|
|
919
|
+
this.#chipRanges = [];
|
|
920
|
+
if (strip?.kind === "thinking" && strip.returnToRoles && this.#mode === "roles") {
|
|
921
|
+
this.#setActiveEntry("roles");
|
|
922
|
+
this.#focus = "list";
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
#activateStripChip(): void {
|
|
927
|
+
const strip = this.#strip;
|
|
928
|
+
if (!strip || strip.kind === "roleName") return;
|
|
929
|
+
const chip = strip.chips[strip.index];
|
|
930
|
+
if (!chip) return;
|
|
931
|
+
switch (chip.action) {
|
|
932
|
+
case "assign":
|
|
933
|
+
if (chip.role) {
|
|
934
|
+
this.#strip = null;
|
|
935
|
+
this.#assignRole(strip.item, chip.role, false);
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
case "unassign":
|
|
939
|
+
if (chip.role) {
|
|
940
|
+
this.#callbacks.onUnassign(chip.role);
|
|
941
|
+
this.#refreshAfterMutation();
|
|
942
|
+
}
|
|
943
|
+
this.#closeStrip();
|
|
944
|
+
return;
|
|
945
|
+
case "fallback":
|
|
946
|
+
this.#appendFallback(strip.item, "default");
|
|
947
|
+
this.#closeStrip();
|
|
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;
|
|
957
|
+
case "thinking":
|
|
958
|
+
if (strip.role && chip.thinkingLevel !== undefined) {
|
|
959
|
+
this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector);
|
|
960
|
+
this.#refreshAfterMutation();
|
|
961
|
+
}
|
|
962
|
+
this.#closeStrip();
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
|
|
968
|
+
#startAssign(role: string): void {
|
|
969
|
+
this.#assigning = { kind: "role", role };
|
|
970
|
+
this.#focus = "scope";
|
|
971
|
+
this.#browser.setShowProvider(true);
|
|
972
|
+
this.#browser.setItems([...this.#availableItems]);
|
|
973
|
+
this.#browser.setQuery("");
|
|
974
|
+
const current = this.#roles[role];
|
|
975
|
+
if (current) {
|
|
976
|
+
this.#browser.selectSelector(`${current.model.provider}/${current.model.id}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
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
|
+
|
|
1076
|
+
#cancelAssign(): void {
|
|
1077
|
+
this.#assigning = null;
|
|
1078
|
+
this.#browser.setQuery("");
|
|
1079
|
+
if (this.#mode === "roles") {
|
|
1080
|
+
this.#setActiveEntry("roles");
|
|
1081
|
+
this.#focus = "list";
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1086
|
+
// Quick-switch cycle (ctrl+p) editing
|
|
1087
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1088
|
+
|
|
1089
|
+
#cycleOrder(): string[] {
|
|
1090
|
+
try {
|
|
1091
|
+
return [...this.#settings.get("cycleOrder")];
|
|
1092
|
+
} catch {
|
|
1093
|
+
return [];
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Toggle `role`'s membership in the quick-switch cycle (appended at the end). */
|
|
1098
|
+
#toggleCycleMembership(role: string): void {
|
|
1099
|
+
const order = this.#cycleOrder();
|
|
1100
|
+
const index = order.indexOf(role);
|
|
1101
|
+
if (index >= 0) {
|
|
1102
|
+
order.splice(index, 1);
|
|
1103
|
+
} else {
|
|
1104
|
+
order.push(role);
|
|
1105
|
+
}
|
|
1106
|
+
this.#callbacks.onCycleOrderChange?.(order);
|
|
1107
|
+
this.#refreshAfterMutation();
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Move `role` one slot earlier/later within the cycle order. */
|
|
1111
|
+
#moveCycleMembership(role: string, delta: -1 | 1): void {
|
|
1112
|
+
const order = this.#cycleOrder();
|
|
1113
|
+
const index = order.indexOf(role);
|
|
1114
|
+
const target = index + delta;
|
|
1115
|
+
if (index < 0 || target < 0 || target >= order.length) return;
|
|
1116
|
+
[order[index], order[target]] = [order[target], order[index]];
|
|
1117
|
+
this.#callbacks.onCycleOrderChange?.(order);
|
|
1118
|
+
this.#refreshAfterMutation();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Open the footer name input that creates a new custom role. */
|
|
1122
|
+
#openRoleNameStrip(): void {
|
|
1123
|
+
this.#strip = { kind: "roleName", input: new Input() };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Validate and commit the new-role name: jump straight into assigning it. */
|
|
1127
|
+
#submitRoleName(): void {
|
|
1128
|
+
const strip = this.#strip;
|
|
1129
|
+
if (strip?.kind !== "roleName") return;
|
|
1130
|
+
const name = strip.input.getValue().trim();
|
|
1131
|
+
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
|
|
1132
|
+
if (this.#visibleRoleIds().includes(name)) return;
|
|
1133
|
+
this.#strip = null;
|
|
1134
|
+
this.#chipRanges = [];
|
|
1135
|
+
this.#startAssign(name);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1139
|
+
// Input
|
|
1140
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1141
|
+
|
|
1142
|
+
handleInput(data: string): void {
|
|
1143
|
+
if (data.startsWith("\x1b[<")) {
|
|
1144
|
+
routeSgrMouseInput(data, event => this.#routeMouseEvent(event));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (this.#strip) {
|
|
1149
|
+
this.#handleStripInput(data);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (matchesSelectCancel(data)) {
|
|
1154
|
+
if (this.#assigning !== null) {
|
|
1155
|
+
this.#cancelAssign();
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const entry = this.#activeEntry();
|
|
1159
|
+
if (this.#isBrowserView(entry) && this.#browser.query.length > 0) {
|
|
1160
|
+
this.#browser.handleCancel();
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
this.#callbacks.onCancel();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const entry = this.#activeEntry();
|
|
1168
|
+
const rolesView = entry.kind === "roles" && this.#assigning === null;
|
|
1169
|
+
const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null;
|
|
1170
|
+
|
|
1171
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
|
|
1172
|
+
this.#focus = this.#focus === "scope" ? "list" : "scope";
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (matchesKey(data, "f5")) {
|
|
1176
|
+
if (entry.kind === "provider" && !entry.locked) {
|
|
1177
|
+
this.#scheduleProviderRefresh(entry.providerId ?? "", { force: true });
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ←/→ are spatial pane switches: the sidebar sits left of the rows.
|
|
1183
|
+
// They never reach the search caret — fuzzy queries don't need one.
|
|
1184
|
+
if (matchesKey(data, "left")) {
|
|
1185
|
+
this.#focus = "scope";
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (matchesKey(data, "right")) {
|
|
1189
|
+
// Only views with rows can take list focus (not the locked pane).
|
|
1190
|
+
if (rolesView || this.#isBrowserView(entry)) {
|
|
1191
|
+
this.#focus = "list";
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Arrow ownership: scope mode hops the sidebar even while the search
|
|
1197
|
+
// bar holds the caret; list mode navigates rows.
|
|
1198
|
+
if (this.#focus === "scope") {
|
|
1199
|
+
if (matchesSelectUp(data)) {
|
|
1200
|
+
this.#moveSidebar(-1);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (matchesSelectDown(data)) {
|
|
1204
|
+
this.#moveSidebar(1);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (rolesView) {
|
|
1210
|
+
this.#handleRolesViewInput(data);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (lockedView) {
|
|
1214
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1215
|
+
this.#requestLogin(entry);
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
this.#browser.handleInput(data);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
#isBrowserView(entry: SidebarEntry): boolean {
|
|
1223
|
+
if (this.#assigning !== null) return true;
|
|
1224
|
+
return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
#handleStripInput(data: string): void {
|
|
1228
|
+
const strip = this.#strip;
|
|
1229
|
+
if (!strip) return;
|
|
1230
|
+
if (matchesSelectCancel(data)) {
|
|
1231
|
+
this.#closeStrip();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (strip.kind === "roleName") {
|
|
1235
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1236
|
+
this.#submitRoleName();
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
strip.input.handleInput(data);
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab")) {
|
|
1243
|
+
strip.index = (strip.index - 1 + strip.chips.length) % strip.chips.length;
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (matchesKey(data, "right") || matchesKey(data, "down") || matchesKey(data, "tab")) {
|
|
1247
|
+
strip.index = (strip.index + 1) % strip.chips.length;
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1251
|
+
this.#activateStripChip();
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
#moveSidebar(delta: number): void {
|
|
1257
|
+
const count = this.#entries.length;
|
|
1258
|
+
if (count === 0) return;
|
|
1259
|
+
let index = this.#entries.findIndex(entry => entry.id === this.#activeEntryId);
|
|
1260
|
+
if (index < 0) index = 0;
|
|
1261
|
+
for (let step = 0; step < count; step++) {
|
|
1262
|
+
index = (index + delta + count) % count;
|
|
1263
|
+
const entry = this.#entries[index];
|
|
1264
|
+
if (entry && !this.#isHopSkipped(entry)) {
|
|
1265
|
+
// Scope changes keep an active assignment (scoping helps find the
|
|
1266
|
+
// model); landing on the Roles view cancels it.
|
|
1267
|
+
if (entry.kind === "roles") this.#assigning = null;
|
|
1268
|
+
this.#setActiveEntry(entry.id);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */
|
|
1275
|
+
get #rolesRowCount(): number {
|
|
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;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
#handleRolesViewInput(data: string): void {
|
|
1322
|
+
// Scope focus treats the roles view as a preview: Enter/Space dives
|
|
1323
|
+
// into the rows, everything else is inert (arrows already hop).
|
|
1324
|
+
if (this.#focus === "scope") {
|
|
1325
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || matchesKey(data, "space")) {
|
|
1326
|
+
this.#focus = "list";
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (matchesSelectUp(data)) {
|
|
1331
|
+
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (matchesSelectDown(data)) {
|
|
1335
|
+
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const row = this.#rolesRows[this.#roleIndex];
|
|
1339
|
+
const role = row?.kind === "role" ? row.role : undefined;
|
|
1340
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1341
|
+
if (row) this.#activateRolesRow(row);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
|
|
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, []);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
// Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later —
|
|
1351
|
+
// cycle order on a role row, chain order on a fallback row.
|
|
1352
|
+
if (matchesKey(data, "shift+up")) {
|
|
1353
|
+
if (role) this.#moveCycleMembership(role, -1);
|
|
1354
|
+
else if (row?.kind === "fallback") this.#moveFallback(row, -1);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (matchesKey(data, "shift+down")) {
|
|
1358
|
+
if (role) this.#moveCycleMembership(role, 1);
|
|
1359
|
+
else if (row?.kind === "fallback") this.#moveFallback(row, 1);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const printable = extractPrintableText(data);
|
|
1363
|
+
if (printable === "x") {
|
|
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
|
+
}
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (printable === "c") {
|
|
1377
|
+
if (role) this.#toggleCycleMembership(role);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (printable === "[") {
|
|
1381
|
+
if (role) this.#moveCycleMembership(role, -1);
|
|
1382
|
+
else if (row?.kind === "fallback") this.#moveFallback(row, -1);
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (printable === "]") {
|
|
1386
|
+
if (role) this.#moveCycleMembership(role, 1);
|
|
1387
|
+
else if (row?.kind === "fallback") this.#moveFallback(row, 1);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (printable === "n") {
|
|
1391
|
+
this.#openRoleNameStrip();
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (printable === "t") {
|
|
1395
|
+
const assignment = role ? this.#roles[role] : undefined;
|
|
1396
|
+
if (role && assignment) {
|
|
1397
|
+
const item: ModelBrowserItem = {
|
|
1398
|
+
provider: assignment.model.provider,
|
|
1399
|
+
id: assignment.model.id,
|
|
1400
|
+
model: assignment.model,
|
|
1401
|
+
selector: `${assignment.model.provider}/${assignment.model.id}`,
|
|
1402
|
+
};
|
|
1403
|
+
this.#openThinkingStrip(item, role, true);
|
|
1404
|
+
}
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
#requestLogin(entry: SidebarEntry): void {
|
|
1410
|
+
if (!entry.providerId) return;
|
|
1411
|
+
if (entry.oauth) {
|
|
1412
|
+
this.#callbacks.onLoginRequest?.(entry.providerId);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1417
|
+
// Mouse
|
|
1418
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1419
|
+
|
|
1420
|
+
#routeMouseEvent(event: SgrMouseEvent): boolean {
|
|
1421
|
+
const contentLine = event.row - this.#contentRowStart;
|
|
1422
|
+
const overContent = contentLine >= 0 && contentLine < this.#contentRowCount;
|
|
1423
|
+
const sidebarColStart = 2;
|
|
1424
|
+
const sidebarColEnd = sidebarColStart + this.#sidebarWidthLast;
|
|
1425
|
+
const bodyColStart = this.#sidebarWidthLast + 5;
|
|
1426
|
+
const overSidebar = overContent && event.col >= 0 && event.col < sidebarColEnd;
|
|
1427
|
+
const overBody = overContent && event.col >= bodyColStart;
|
|
1428
|
+
const bodyLine = contentLine - 1; // body row 0 is the status row
|
|
1429
|
+
const entry = this.#activeEntry();
|
|
1430
|
+
|
|
1431
|
+
// Footer strip chips.
|
|
1432
|
+
if (event.row === this.#footerRow && this.#strip) {
|
|
1433
|
+
const strip = this.#strip;
|
|
1434
|
+
if (event.leftClick && strip.kind !== "roleName") {
|
|
1435
|
+
for (const range of this.#chipRanges) {
|
|
1436
|
+
if (event.col >= range.start && event.col < range.end) {
|
|
1437
|
+
strip.index = range.index;
|
|
1438
|
+
this.#activateStripChip();
|
|
1439
|
+
return true;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return true;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (event.wheel !== null) {
|
|
1447
|
+
if (overSidebar) {
|
|
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);
|
|
1452
|
+
} else if (overBody) {
|
|
1453
|
+
if (entry.kind === "roles" && this.#assigning === null) {
|
|
1454
|
+
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false });
|
|
1455
|
+
} else if (this.#isBrowserView(entry)) {
|
|
1456
|
+
this.#browser.routeMouse(event, bodyLine);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return true;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (event.motion) {
|
|
1463
|
+
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
|
|
1464
|
+
if (overBody && entry.kind === "roles" && this.#assigning === null) {
|
|
1465
|
+
const roleLine = bodyLine - this.#rolesRowStart;
|
|
1466
|
+
this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
|
|
1467
|
+
} else {
|
|
1468
|
+
this.#roleHover = null;
|
|
1469
|
+
if (overBody && this.#isBrowserView(entry)) {
|
|
1470
|
+
this.#browser.routeMouse(event, bodyLine);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (!event.leftClick) return true;
|
|
1477
|
+
|
|
1478
|
+
if (overSidebar) {
|
|
1479
|
+
const index = this.#sidebarEntryIndexAt(contentLine);
|
|
1480
|
+
const clicked = index !== null ? this.#entries[index] : undefined;
|
|
1481
|
+
if (clicked && clicked.kind !== "separator") {
|
|
1482
|
+
const already = clicked.id === this.#activeEntryId;
|
|
1483
|
+
if (clicked.kind === "roles") this.#assigning = null;
|
|
1484
|
+
this.#setActiveEntry(clicked.id);
|
|
1485
|
+
// A click on Roles is a deliberate dive into the rows.
|
|
1486
|
+
if (clicked.kind === "roles") this.#focus = "list";
|
|
1487
|
+
if (already && clicked.kind === "provider" && clicked.locked) {
|
|
1488
|
+
this.#requestLogin(clicked);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
if (overBody) {
|
|
1495
|
+
if (entry.kind === "roles" && this.#assigning === null) {
|
|
1496
|
+
this.#focus = "list";
|
|
1497
|
+
const roleLine = bodyLine - this.#rolesRowStart;
|
|
1498
|
+
if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
|
|
1499
|
+
const rowDef = this.#rolesRows[roleLine];
|
|
1500
|
+
if (rowDef && rowDef.kind !== "separator") {
|
|
1501
|
+
if (roleLine === this.#roleIndex) {
|
|
1502
|
+
this.#activateRolesRow(rowDef);
|
|
1503
|
+
} else {
|
|
1504
|
+
this.#roleIndex = roleLine;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
|
|
1509
|
+
if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
|
|
1510
|
+
this.#requestLogin(entry);
|
|
1511
|
+
}
|
|
1512
|
+
} else if (this.#isBrowserView(entry)) {
|
|
1513
|
+
this.#browser.routeMouse(event, bodyLine);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** Map a content-line index to a sidebar entry index (accounting for scroll). */
|
|
1520
|
+
#sidebarEntryIndexAt(contentLine: number): number | null {
|
|
1521
|
+
const index = this.#sidebarScroll + contentLine;
|
|
1522
|
+
if (index < 0 || index >= this.#entries.length) return null;
|
|
1523
|
+
return index;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1527
|
+
// Rendering
|
|
1528
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1529
|
+
|
|
1530
|
+
#sidebarWidth(): number {
|
|
1531
|
+
let longest = 0;
|
|
1532
|
+
for (const entry of this.#entries) {
|
|
1533
|
+
const annotation = entry.annotation ?? "";
|
|
1534
|
+
longest = Math.max(longest, visibleWidth(entry.label) + visibleWidth(annotation) + 5);
|
|
1535
|
+
}
|
|
1536
|
+
return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, longest));
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
#renderSidebar(width: number, rows: number): string[] {
|
|
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;
|
|
1554
|
+
}
|
|
1555
|
+
this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll, Math.max(0, this.#entries.length - rows)));
|
|
1556
|
+
|
|
1557
|
+
const lines: string[] = [];
|
|
1558
|
+
for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) {
|
|
1559
|
+
const entry = this.#entries[i];
|
|
1560
|
+
if (!entry) continue;
|
|
1561
|
+
if (entry.kind === "separator") {
|
|
1562
|
+
lines.push(theme.fg("border", "─".repeat(width)));
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
const active = entry.id === this.#activeEntryId;
|
|
1566
|
+
const hovered = i === this.#sidebarHover;
|
|
1567
|
+
const searching = this.#searchCounts !== null;
|
|
1568
|
+
let matchCount: number | undefined;
|
|
1569
|
+
if (searching) {
|
|
1570
|
+
if (entry.kind === "provider" && !entry.locked) {
|
|
1571
|
+
matchCount = this.#searchCounts?.get(entry.providerId ?? "") ?? 0;
|
|
1572
|
+
} else if (entry.kind === "recent") {
|
|
1573
|
+
matchCount = this.#recentSearchCount;
|
|
1574
|
+
} else if (entry.kind === "all") {
|
|
1575
|
+
matchCount = this.#searchTotal;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
// While searching, entries the hop skips gray out: locked and
|
|
1579
|
+
// zero-match providers, an empty Recent, and the Roles view.
|
|
1580
|
+
const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
|
|
1581
|
+
const cursor = active
|
|
1582
|
+
? this.#focus === "scope"
|
|
1583
|
+
? theme.fg("accent", theme.nav.cursor)
|
|
1584
|
+
: theme.fg("dim", theme.nav.cursor)
|
|
1585
|
+
: " ";
|
|
1586
|
+
|
|
1587
|
+
let icon: string;
|
|
1588
|
+
if (entry.kind === "recent") {
|
|
1589
|
+
icon = theme.icon.time;
|
|
1590
|
+
} else if (entry.kind === "roles") {
|
|
1591
|
+
icon = theme.icon.extensionSkill;
|
|
1592
|
+
} else if (entry.kind === "all") {
|
|
1593
|
+
icon = theme.icon.model;
|
|
1594
|
+
} else {
|
|
1595
|
+
icon = muted ? theme.status.shadowed : theme.status.enabled;
|
|
1596
|
+
}
|
|
1597
|
+
const labelStyled = muted
|
|
1598
|
+
? theme.fg("dim", entry.label)
|
|
1599
|
+
: active
|
|
1600
|
+
? theme.fg("accent", entry.label)
|
|
1601
|
+
: entry.label;
|
|
1602
|
+
|
|
1603
|
+
const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false;
|
|
1604
|
+
const annotationText = matchCount !== undefined ? String(matchCount) : (entry.annotation ?? "");
|
|
1605
|
+
const annotationStyled = refreshing
|
|
1606
|
+
? theme.fg("warning", theme.spinnerFrames[this.#refreshSpinnerFrame % theme.spinnerFrames.length] ?? "")
|
|
1607
|
+
: theme.fg("dim", annotationText);
|
|
1608
|
+
|
|
1609
|
+
const left = `${cursor} ${muted ? theme.fg("dim", icon) : theme.fg(entry.kind === "provider" ? "success" : "accent", icon)} ${labelStyled}`;
|
|
1610
|
+
const leftWidth = visibleWidth(left);
|
|
1611
|
+
const annWidth = visibleWidth(annotationStyled);
|
|
1612
|
+
let line: string;
|
|
1613
|
+
if (leftWidth + annWidth + 1 <= width) {
|
|
1614
|
+
line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`;
|
|
1615
|
+
} else {
|
|
1616
|
+
line = truncateToWidth(left, width);
|
|
1617
|
+
}
|
|
1618
|
+
if (hovered && !active) {
|
|
1619
|
+
line = theme.bg("selectedBg", line);
|
|
1620
|
+
}
|
|
1621
|
+
lines.push(line);
|
|
1622
|
+
}
|
|
1623
|
+
return lines;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
#statusRow(width: number): string {
|
|
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
|
+
}
|
|
1643
|
+
return truncateToWidth(
|
|
1644
|
+
theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
|
|
1645
|
+
width,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
const entry = this.#activeEntry();
|
|
1649
|
+
const scopedSuffix = this.#scopedModels.length > 0 ? " · --models scope" : "";
|
|
1650
|
+
let text: string;
|
|
1651
|
+
switch (entry.kind) {
|
|
1652
|
+
case "recent":
|
|
1653
|
+
text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
|
|
1654
|
+
break;
|
|
1655
|
+
case "roles":
|
|
1656
|
+
text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
|
|
1657
|
+
break;
|
|
1658
|
+
case "provider":
|
|
1659
|
+
if (entry.locked) {
|
|
1660
|
+
text = `${entry.label} · not configured`;
|
|
1661
|
+
} else if (entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
|
|
1662
|
+
text = `${entry.label} · refreshing model list…`;
|
|
1663
|
+
} else {
|
|
1664
|
+
text = `${entry.label} · ${entry.annotation ?? "0"} models${scopedSuffix}`;
|
|
1665
|
+
}
|
|
1666
|
+
break;
|
|
1667
|
+
default:
|
|
1668
|
+
text = this.#mode === "pick" ? this.#pickerHint : `All available models${scopedSuffix}`;
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
if (this.#configError && entry.kind !== "provider") {
|
|
1672
|
+
text = this.#configError;
|
|
1673
|
+
return truncateToWidth(theme.fg("error", ` ${text}`), width);
|
|
1674
|
+
}
|
|
1675
|
+
return truncateToWidth(theme.fg("muted", ` ${text}`), width);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
#renderRolesView(width: number, rows: number): string[] {
|
|
1679
|
+
const lines: string[] = [];
|
|
1680
|
+
lines.push("");
|
|
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;
|
|
1685
|
+
|
|
1686
|
+
let tagWidth = 0;
|
|
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));
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
const cycleOrder = this.#cycleOrder();
|
|
1694
|
+
for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
|
|
1695
|
+
const rowDef = this.#rolesRows[i];
|
|
1696
|
+
if (!rowDef) continue;
|
|
1697
|
+
const selected = i === this.#roleIndex;
|
|
1698
|
+
const hovered = i === this.#roleHover;
|
|
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];
|
|
1746
|
+
const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
|
|
1747
|
+
|
|
1748
|
+
let dot: string;
|
|
1749
|
+
let tagStyled: string;
|
|
1750
|
+
let value: string;
|
|
1751
|
+
let levelStyled = "";
|
|
1752
|
+
if (assignment && !assignment.autoSelected) {
|
|
1753
|
+
dot = theme.fg(info.color ?? "muted", theme.status.enabled);
|
|
1754
|
+
tagStyled = theme.fg(info.color ?? "muted", tag);
|
|
1755
|
+
value = `${theme.fg("dim", `${assignment.model.provider}/`)}${selected ? theme.fg("accent", assignment.model.id) : assignment.model.id}`;
|
|
1756
|
+
const glyph = thinkingLevelGlyph(assignment.thinkingLevel);
|
|
1757
|
+
const label = getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label;
|
|
1758
|
+
if (assignment.thinkingLevel !== ThinkingLevel.Inherit) {
|
|
1759
|
+
levelStyled = theme.fg("dim", glyph ? `${glyph} ${label}` : label);
|
|
1760
|
+
}
|
|
1761
|
+
} else if (assignment) {
|
|
1762
|
+
dot = theme.fg("dim", theme.status.shadowed);
|
|
1763
|
+
tagStyled = theme.fg("dim", tag);
|
|
1764
|
+
value = theme.fg("dim", `auto → ${assignment.model.provider}/${assignment.model.id}`);
|
|
1765
|
+
} else {
|
|
1766
|
+
dot = theme.fg("dim", theme.status.shadowed);
|
|
1767
|
+
tagStyled = theme.fg("dim", tag);
|
|
1768
|
+
value = theme.fg("dim", "—");
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Quick-cycle membership badge (`⟳2` = second stop of the ctrl+p cycle).
|
|
1772
|
+
const cycleIndex = cycleOrder.indexOf(role);
|
|
1773
|
+
const cycleStyled = cycleIndex >= 0 ? theme.fg("accent", `${theme.icon.loop}${cycleIndex + 1}`) : "";
|
|
1774
|
+
|
|
1775
|
+
let line = ` ${cursor} ${dot} ${tagStyled} ${value}`;
|
|
1776
|
+
const right = [levelStyled, cycleStyled].filter(part => part.length > 0).join(" ");
|
|
1777
|
+
const rightWidth = visibleWidth(right);
|
|
1778
|
+
const lineWidth = visibleWidth(line);
|
|
1779
|
+
if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
|
|
1780
|
+
line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
|
|
1781
|
+
} else {
|
|
1782
|
+
line = truncateToWidth(line, width);
|
|
1783
|
+
}
|
|
1784
|
+
if (hovered && !selected) {
|
|
1785
|
+
line = theme.bg("selectedBg", line);
|
|
1786
|
+
}
|
|
1787
|
+
lines.push(line);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Live preview of the quick-switch cycle, rendered with the exact
|
|
1791
|
+
// segment track the ctrl+p status uses; the selected role's chip fills.
|
|
1792
|
+
while (lines.length < rows - 1) lines.push("");
|
|
1793
|
+
if (rows >= 2) {
|
|
1794
|
+
const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
|
|
1795
|
+
if (cycleOrder.length > 0) {
|
|
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);
|
|
1800
|
+
const track = renderSegmentTrack(
|
|
1801
|
+
cycleOrder.map(role => ({ label: role })),
|
|
1802
|
+
activeIndex,
|
|
1803
|
+
);
|
|
1804
|
+
lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
|
|
1805
|
+
} else {
|
|
1806
|
+
lines[rows - 1] = truncateToWidth(
|
|
1807
|
+
theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
|
|
1808
|
+
width,
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
return lines;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
#renderLockedView(entry: SidebarEntry, width: number, rows: number): string[] {
|
|
1816
|
+
const lines: string[] = [];
|
|
1817
|
+
this.#lockedLoginLine = null;
|
|
1818
|
+
lines.push("");
|
|
1819
|
+
lines.push(truncateToWidth(theme.fg("warning", ` ${entry.label} has no credentials configured`), width));
|
|
1820
|
+
lines.push("");
|
|
1821
|
+
const envVars = entry.providerId ? (getCatalogProviderEntry(entry.providerId)?.envVars ?? []) : [];
|
|
1822
|
+
if (envVars.length > 0) {
|
|
1823
|
+
lines.push(
|
|
1824
|
+
truncateToWidth(
|
|
1825
|
+
theme.fg("muted", ` Set ${envVars.join(" or ")} in your environment, or add a key in config.`),
|
|
1826
|
+
width,
|
|
1827
|
+
),
|
|
1828
|
+
);
|
|
1829
|
+
} else {
|
|
1830
|
+
lines.push(truncateToWidth(theme.fg("muted", " Add an API key for this provider in config."), width));
|
|
1831
|
+
}
|
|
1832
|
+
if (entry.oauth) {
|
|
1833
|
+
this.#lockedLoginLine = lines.length + 1; // +1 for the status row offset handled by caller
|
|
1834
|
+
lines.push(truncateToWidth(theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (Enter)`), width));
|
|
1835
|
+
}
|
|
1836
|
+
lines.push("");
|
|
1837
|
+
const catalogCount = entry.catalogCount ?? 0;
|
|
1838
|
+
if (catalogCount > 0) {
|
|
1839
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${catalogCount} models in catalog:`), width));
|
|
1840
|
+
const preview = this.#scopedModels.length > 0 ? [] : this.#registry.getAll();
|
|
1841
|
+
for (const model of preview) {
|
|
1842
|
+
if (model.provider !== entry.providerId) continue;
|
|
1843
|
+
if (lines.length >= rows) break;
|
|
1844
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${model.id}`), width));
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
while (lines.length < rows) lines.push("");
|
|
1848
|
+
return lines.slice(0, rows);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
#footerHint(): string {
|
|
1852
|
+
const strip = this.#strip;
|
|
1853
|
+
if (strip) {
|
|
1854
|
+
if (strip.kind === "roleName") {
|
|
1855
|
+
return "Enter create + pick model · Esc cancel";
|
|
1856
|
+
}
|
|
1857
|
+
return strip.kind === "role"
|
|
1858
|
+
? "←/→ choose · Enter assign/clear · Esc cancel"
|
|
1859
|
+
: "←/→ thinking level · Enter apply · Esc keep";
|
|
1860
|
+
}
|
|
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
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const entry = this.#activeEntry();
|
|
1872
|
+
if (entry.kind === "roles") {
|
|
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";
|
|
1887
|
+
}
|
|
1888
|
+
if (entry.kind === "provider" && entry.locked) {
|
|
1889
|
+
return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
|
|
1890
|
+
}
|
|
1891
|
+
const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
|
|
1892
|
+
const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
|
|
1893
|
+
if (this.#mode === "pick") {
|
|
1894
|
+
return `Enter use for this session · ${arrows} · type to search${refresh} · Esc close`;
|
|
1895
|
+
}
|
|
1896
|
+
return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/** Footer row: active strip (chips) or the contextual hint line. */
|
|
1900
|
+
#renderFooter(width: number): string {
|
|
1901
|
+
this.#chipRanges = [];
|
|
1902
|
+
const strip = this.#strip;
|
|
1903
|
+
if (!strip) {
|
|
1904
|
+
return truncateToWidth(theme.fg("dim", this.#footerHint()), width);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
if (strip.kind === "roleName") {
|
|
1908
|
+
const label = theme.fg("accent", "New role name:");
|
|
1909
|
+
const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
|
|
1910
|
+
const inputLine = strip.input.render(inputWidth)[0] ?? "";
|
|
1911
|
+
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const prefix =
|
|
1915
|
+
strip.kind === "role"
|
|
1916
|
+
? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
|
|
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} →`)} `;
|
|
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
|
+
|
|
1943
|
+
let line = prefix;
|
|
1944
|
+
// Columns are relative to the frame: row() insets content by 2.
|
|
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++) {
|
|
1951
|
+
const chip = strip.chips[i];
|
|
1952
|
+
if (!chip) continue;
|
|
1953
|
+
const selected = i === strip.index;
|
|
1954
|
+
const body = ` ${chip.styled} `;
|
|
1955
|
+
const rendered = selected
|
|
1956
|
+
? theme.bg("selectedBg", `${theme.fg("accent", "[")}${body}${theme.fg("accent", "]")}`)
|
|
1957
|
+
: body;
|
|
1958
|
+
const w = visibleWidth(body) + (selected ? 2 : 0);
|
|
1959
|
+
this.#chipRanges.push({ start: col, end: col + w, index: i });
|
|
1960
|
+
line += rendered;
|
|
1961
|
+
col += w;
|
|
1962
|
+
line += " ";
|
|
1963
|
+
col += 1;
|
|
1964
|
+
}
|
|
1965
|
+
return truncateToWidth(line, width);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
render(width: number): string[] {
|
|
1969
|
+
const height = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
|
|
1970
|
+
const sidebarWidth = this.#sidebarWidth();
|
|
1971
|
+
this.#sidebarWidthLast = sidebarWidth;
|
|
1972
|
+
const bodyWidth = splitBodyWidth(width, sidebarWidth);
|
|
1973
|
+
const contentRows = Math.max(10, height - 4);
|
|
1974
|
+
this.#contentRowCount = contentRows;
|
|
1975
|
+
|
|
1976
|
+
const entry = this.#activeEntry();
|
|
1977
|
+
const bodyLines: string[] = [this.#statusRow(bodyWidth)];
|
|
1978
|
+
if (entry.kind === "roles" && this.#assigning === null) {
|
|
1979
|
+
bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1));
|
|
1980
|
+
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
|
|
1981
|
+
bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
|
|
1982
|
+
} else {
|
|
1983
|
+
this.#browser.setMaxVisible(contentRows - 1 - 5);
|
|
1984
|
+
bodyLines.push(...this.#browser.render(bodyWidth));
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows);
|
|
1988
|
+
|
|
1989
|
+
const title = this.#mode === "pick" ? "Switch Model" : "Models";
|
|
1990
|
+
const out: string[] = [];
|
|
1991
|
+
out.push(topBorderSplit(width, title, sidebarWidth));
|
|
1992
|
+
this.#contentRowStart = out.length;
|
|
1993
|
+
for (let i = 0; i < contentRows; i++) {
|
|
1994
|
+
out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth));
|
|
1995
|
+
}
|
|
1996
|
+
out.push(dividerSplit(width, sidebarWidth));
|
|
1997
|
+
this.#footerRow = out.length;
|
|
1998
|
+
out.push(row(this.#renderFooter(width - 4), width));
|
|
1999
|
+
out.push(bottomBorder(width));
|
|
2000
|
+
return out;
|
|
2001
|
+
}
|
|
2002
|
+
}
|