@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.5
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 +33 -0
- package/dist/cli.js +3563 -3540
- package/dist/types/async/job-manager.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +18 -8
- 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/index.d.ts +2 -1
- package/dist/types/modes/components/model-browser.d.ts +97 -0
- package/dist/types/modes/components/model-hub.d.ts +51 -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/interactive-mode.d.ts +1 -0
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- 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/src/async/job-manager.ts +9 -0
- package/src/commit/agentic/tools/analyze-file.ts +2 -3
- package/src/config/settings-schema.ts +17 -6
- 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 +30 -22
- package/src/modes/components/ask-dialog.ts +888 -0
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/model-browser.ts +688 -0
- package/src/modes/components/model-hub.ts +1655 -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/controllers/event-controller.ts +8 -2
- package/src/modes/controllers/extension-ui-controller.ts +252 -5
- package/src/modes/controllers/selector-controller.ts +143 -87
- package/src/modes/controllers/tan-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/shared.ts +1 -1
- package/src/modes/theme/theme.ts +3 -3
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +31 -4
- 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 +2 -1
- package/src/session/session-context.test.ts +73 -0
- package/src/session/session-context.ts +43 -26
- package/src/slash-commands/builtin-registry.ts +9 -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,1655 @@
|
|
|
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
|
+
export type ModelHubAction = "modelRole" | "retryFallback";
|
|
56
|
+
|
|
57
|
+
/** A `--models` scope entry (mirrors the session's scoped model list). */
|
|
58
|
+
export interface ScopedModelItem {
|
|
59
|
+
model: Model;
|
|
60
|
+
thinkingLevel?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ModelHubCallbacks {
|
|
64
|
+
/** Persist a role assignment (or a retry-fallback registration). */
|
|
65
|
+
onAssign: (
|
|
66
|
+
model: Model,
|
|
67
|
+
role: string,
|
|
68
|
+
thinkingLevel: ConfiguredThinkingLevel | undefined,
|
|
69
|
+
selector: string,
|
|
70
|
+
action: ModelHubAction,
|
|
71
|
+
) => void;
|
|
72
|
+
/** Clear a configured role back to auto-selection. */
|
|
73
|
+
onUnassign: (role: string) => void;
|
|
74
|
+
/** Pick-mode activation: session-only switch or embedded pick. */
|
|
75
|
+
onPick?: (model: Model, selector: string) => void;
|
|
76
|
+
/** Locked provider activation: forward to the /login flow. */
|
|
77
|
+
onLoginRequest?: (providerId: string) => void;
|
|
78
|
+
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
|
|
79
|
+
onCycleOrderChange?: (order: string[]) => void;
|
|
80
|
+
onCancel: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ModelHubOptions {
|
|
84
|
+
mode?: ModelHubMode;
|
|
85
|
+
/** Session token count; in pick mode, models with smaller context windows are disabled. */
|
|
86
|
+
currentContextTokens?: number;
|
|
87
|
+
/** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
|
|
88
|
+
initialProviderId?: string;
|
|
89
|
+
/** Status-row hint shown in pick mode. */
|
|
90
|
+
pickerHint?: string;
|
|
91
|
+
initialQuery?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface SidebarEntry {
|
|
95
|
+
id: string;
|
|
96
|
+
kind: "recent" | "roles" | "all" | "separator" | "provider";
|
|
97
|
+
label: string;
|
|
98
|
+
providerId?: string;
|
|
99
|
+
locked?: boolean;
|
|
100
|
+
/** Right-aligned annotation: model count, `assigned/total`, or `login`. */
|
|
101
|
+
annotation?: string;
|
|
102
|
+
oauth?: boolean;
|
|
103
|
+
catalogCount?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface StripChip {
|
|
107
|
+
label: string;
|
|
108
|
+
/** Pre-styled label body (without selection decoration). */
|
|
109
|
+
styled: string;
|
|
110
|
+
role?: string;
|
|
111
|
+
action: "assign" | "unassign" | "fallback" | "thinking";
|
|
112
|
+
thinkingLevel?: ConfiguredThinkingLevel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type StripState =
|
|
116
|
+
| {
|
|
117
|
+
kind: "role" | "thinking";
|
|
118
|
+
item: ModelBrowserItem;
|
|
119
|
+
role?: string;
|
|
120
|
+
chips: StripChip[];
|
|
121
|
+
index: number;
|
|
122
|
+
/** Where to land when a thinking strip closes. */
|
|
123
|
+
returnToRoles: boolean;
|
|
124
|
+
}
|
|
125
|
+
| {
|
|
126
|
+
/** Footer text input naming a new custom role. */
|
|
127
|
+
kind: "roleName";
|
|
128
|
+
input: Input;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Recorded chip hit-range on the footer row (columns relative to frame col 0). */
|
|
132
|
+
interface ChipRange {
|
|
133
|
+
start: number;
|
|
134
|
+
end: number;
|
|
135
|
+
index: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
|
|
139
|
+
const RECENT_LIMIT = 15;
|
|
140
|
+
const SIDEBAR_MIN_WIDTH = 18;
|
|
141
|
+
const SIDEBAR_MAX_WIDTH = 26;
|
|
142
|
+
|
|
143
|
+
const PICK_MODE_HINT = "Session-only switch — role models stay unchanged";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Providers already auto-refreshed this process. Selecting a provider fetches
|
|
147
|
+
* its live model list at most once per application lifetime (surviving hub
|
|
148
|
+
* close/reopen); F5 re-fetches on demand.
|
|
149
|
+
*/
|
|
150
|
+
const autoRefreshedProviders = new Set<string>();
|
|
151
|
+
|
|
152
|
+
/** Test hook: forget which providers were auto-refreshed this process. */
|
|
153
|
+
export function resetProviderAutoRefreshGuard(): void {
|
|
154
|
+
autoRefreshedProviders.clear();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The fullscreen model hub component. Hosted via `ui.showOverlay(..., { fullscreen: true })`;
|
|
159
|
+
* the host must call {@link ModelHubComponent.dispose} when the overlay closes.
|
|
160
|
+
*/
|
|
161
|
+
export class ModelHubComponent implements Component {
|
|
162
|
+
#tui: TUI;
|
|
163
|
+
#settings: Settings;
|
|
164
|
+
#registry: ModelRegistry;
|
|
165
|
+
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
|
166
|
+
#callbacks: ModelHubCallbacks;
|
|
167
|
+
#mode: ModelHubMode;
|
|
168
|
+
#pickerHint: string;
|
|
169
|
+
|
|
170
|
+
#browser: ModelBrowser;
|
|
171
|
+
#roles: RoleAssignments = {};
|
|
172
|
+
#availableItems: ModelBrowserItem[] = [];
|
|
173
|
+
#recentItems: ModelBrowserItem[] = [];
|
|
174
|
+
#configError: string | undefined;
|
|
175
|
+
|
|
176
|
+
#entries: SidebarEntry[] = [];
|
|
177
|
+
// Sidebar sections from the last registry sync; #composeEntries assembles
|
|
178
|
+
// #entries from these (reordered while searching).
|
|
179
|
+
#fixedEntries: SidebarEntry[] = [];
|
|
180
|
+
#unlockedProviderEntries: SidebarEntry[] = [];
|
|
181
|
+
#lockedProviderEntries: SidebarEntry[] = [];
|
|
182
|
+
/** Fuzzy match totals while searching: recent-scope hits and overall hits. */
|
|
183
|
+
#recentSearchCount = 0;
|
|
184
|
+
#searchTotal = 0;
|
|
185
|
+
#activeEntryId = "all";
|
|
186
|
+
#sidebarScroll = 0;
|
|
187
|
+
#sidebarHover: number | null = null;
|
|
188
|
+
/**
|
|
189
|
+
* Arrow-key ownership: `scope` (default) hops the sidebar even while the
|
|
190
|
+
* search bar holds the caret; `list` navigates rows (browser models or
|
|
191
|
+
* role rows). Tab toggles.
|
|
192
|
+
*/
|
|
193
|
+
#focus: "scope" | "list" = "scope";
|
|
194
|
+
|
|
195
|
+
#roleIds: string[] = [];
|
|
196
|
+
#roleIndex = 0;
|
|
197
|
+
#roleHover: number | null = null;
|
|
198
|
+
|
|
199
|
+
#assigningRole: string | null = null;
|
|
200
|
+
#strip: StripState | null = null;
|
|
201
|
+
/** Per-provider fuzzy match counts while a query is active; null when not searching. */
|
|
202
|
+
#searchCounts: Map<string, number> | null = null;
|
|
203
|
+
|
|
204
|
+
// Provider discovery refresh (debounced per sidebar selection, with spinner).
|
|
205
|
+
#refreshingProviders = new Set<string>();
|
|
206
|
+
#scheduledProviderRefreshes = new Map<string, Timer>();
|
|
207
|
+
#refreshSpinnerFrame = 0;
|
|
208
|
+
#refreshSpinnerInterval?: Timer;
|
|
209
|
+
|
|
210
|
+
// Frame geometry from the last render, for mouse hit-testing (the
|
|
211
|
+
// fullscreen overlay paints from screen row 0, so mouse rows map 1:1).
|
|
212
|
+
#contentRowStart = 1;
|
|
213
|
+
#contentRowCount = 0;
|
|
214
|
+
#sidebarWidthLast = SIDEBAR_MIN_WIDTH;
|
|
215
|
+
#footerRow = 0;
|
|
216
|
+
#chipRanges: ChipRange[] = [];
|
|
217
|
+
#lockedLoginLine: number | null = null;
|
|
218
|
+
#rolesRowStart = 2;
|
|
219
|
+
|
|
220
|
+
constructor(
|
|
221
|
+
tui: TUI,
|
|
222
|
+
settings: Settings,
|
|
223
|
+
registry: ModelRegistry,
|
|
224
|
+
scopedModels: ReadonlyArray<ScopedModelItem>,
|
|
225
|
+
callbacks: ModelHubCallbacks,
|
|
226
|
+
options: ModelHubOptions = {},
|
|
227
|
+
) {
|
|
228
|
+
this.#tui = tui;
|
|
229
|
+
this.#settings = settings;
|
|
230
|
+
this.#registry = registry;
|
|
231
|
+
this.#scopedModels = scopedModels;
|
|
232
|
+
this.#callbacks = callbacks;
|
|
233
|
+
this.#mode = options.mode ?? "roles";
|
|
234
|
+
this.#pickerHint = options.pickerHint ?? PICK_MODE_HINT;
|
|
235
|
+
|
|
236
|
+
this.#browser = new ModelBrowser(settings, {
|
|
237
|
+
currentContextTokens: options.currentContextTokens,
|
|
238
|
+
disableOverContext: this.#mode === "pick",
|
|
239
|
+
emptyText: () => this.#emptyStateMessage(),
|
|
240
|
+
initialQuery: options.initialQuery,
|
|
241
|
+
});
|
|
242
|
+
this.#browser.onActivate = item => this.#activateItem(item);
|
|
243
|
+
this.#browser.onCancel = () => this.#callbacks.onCancel();
|
|
244
|
+
this.#browser.onQueryChange = query => this.#onQueryChanged(query);
|
|
245
|
+
|
|
246
|
+
// Hydrate synchronously from the current registry snapshot so the first
|
|
247
|
+
// Enter after opening acts on cached models instead of being dropped
|
|
248
|
+
// while the offline refresh promise is still pending.
|
|
249
|
+
this.#syncFromRegistryState();
|
|
250
|
+
|
|
251
|
+
const initialProvider = options.initialProviderId;
|
|
252
|
+
if (initialProvider && this.#entries.some(entry => entry.providerId === initialProvider)) {
|
|
253
|
+
this.#setActiveEntry(`provider:${initialProvider}`);
|
|
254
|
+
} else {
|
|
255
|
+
this.#setActiveEntry("all");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (this.#mode === "pick") {
|
|
259
|
+
this.#focus = "list";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reconcile with cached discovery state in the background. A --models
|
|
263
|
+
// scope is registry-independent, so the offline reload would only repeat
|
|
264
|
+
// the synchronous hydration above.
|
|
265
|
+
if (this.#scopedModels.length === 0) {
|
|
266
|
+
this.#registry
|
|
267
|
+
.refresh("offline")
|
|
268
|
+
.then(() => this.#syncFromRegistryState())
|
|
269
|
+
.catch(error => {
|
|
270
|
+
this.#configError = error instanceof Error ? error.message : String(error);
|
|
271
|
+
})
|
|
272
|
+
.finally(() => this.#tui.requestRender());
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Cancel pending provider refresh timers and the spinner. Host calls this on overlay close. */
|
|
277
|
+
dispose(): void {
|
|
278
|
+
for (const [, timer] of this.#scheduledProviderRefreshes) clearTimeout(timer);
|
|
279
|
+
this.#scheduledProviderRefreshes.clear();
|
|
280
|
+
this.#refreshingProviders.clear();
|
|
281
|
+
if (this.#refreshSpinnerInterval) {
|
|
282
|
+
clearInterval(this.#refreshSpinnerInterval);
|
|
283
|
+
this.#refreshSpinnerInterval = undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
invalidate(): void {}
|
|
288
|
+
|
|
289
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
290
|
+
// Data pipeline
|
|
291
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
292
|
+
|
|
293
|
+
#visibleRoleIds(): string[] {
|
|
294
|
+
return getKnownRoleIds(this.#settings).filter(role => !getRoleInfo(role, this.#settings).hidden);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#getResolvedRoleThinkingLevel(
|
|
298
|
+
role: string,
|
|
299
|
+
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
|
|
300
|
+
): ConfiguredThinkingLevel {
|
|
301
|
+
if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
|
|
302
|
+
return resolved.thinkingLevel;
|
|
303
|
+
}
|
|
304
|
+
if (role === "default") {
|
|
305
|
+
return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
|
|
306
|
+
}
|
|
307
|
+
return ThinkingLevel.Inherit;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Resolve every known role: configured values first, auto-selection for the rest. */
|
|
311
|
+
#reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
|
|
312
|
+
const nextRoles: RoleAssignments = {};
|
|
313
|
+
const allModels = this.#scopedModels.length > 0 ? [...autoCandidates] : this.#registry.getAll();
|
|
314
|
+
const matchPreferences = getModelMatchPreferences(this.#settings);
|
|
315
|
+
const knownRoles = getKnownRoleIds(this.#settings);
|
|
316
|
+
const configuredRoles = new Set<string>();
|
|
317
|
+
|
|
318
|
+
for (const role of knownRoles) {
|
|
319
|
+
const roleValue = this.#settings.getModelRole(role);
|
|
320
|
+
if (!roleValue) continue;
|
|
321
|
+
configuredRoles.add(role);
|
|
322
|
+
const resolved = resolveModelRoleValue(roleValue, allModels, {
|
|
323
|
+
settings: this.#settings,
|
|
324
|
+
matchPreferences,
|
|
325
|
+
});
|
|
326
|
+
if (resolved.model) {
|
|
327
|
+
nextRoles[role] = {
|
|
328
|
+
model: resolved.model,
|
|
329
|
+
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
|
|
330
|
+
autoSelected: false,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (autoCandidates.length > 0) {
|
|
336
|
+
const candidates = [...autoCandidates];
|
|
337
|
+
for (const role of knownRoles) {
|
|
338
|
+
if (configuredRoles.has(role)) continue;
|
|
339
|
+
const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
|
|
340
|
+
settings: this.#settings,
|
|
341
|
+
matchPreferences,
|
|
342
|
+
});
|
|
343
|
+
if (!resolved.model) continue;
|
|
344
|
+
nextRoles[role] = {
|
|
345
|
+
model: resolved.model,
|
|
346
|
+
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
|
|
347
|
+
autoSelected: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.#roles = nextRoles;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
|
|
356
|
+
#syncFromRegistryState(): void {
|
|
357
|
+
let allModels: ReadonlyArray<Model>;
|
|
358
|
+
let availableModels: ReadonlyArray<Model>;
|
|
359
|
+
if (this.#scopedModels.length > 0) {
|
|
360
|
+
allModels = this.#scopedModels.map(scoped => scoped.model);
|
|
361
|
+
availableModels = allModels;
|
|
362
|
+
this.#configError = undefined;
|
|
363
|
+
} else {
|
|
364
|
+
const loadError = this.#registry.getError();
|
|
365
|
+
this.#configError = loadError ? String(loadError) : undefined;
|
|
366
|
+
allModels = this.#registry.getAll();
|
|
367
|
+
try {
|
|
368
|
+
availableModels = this.#registry.getAvailable();
|
|
369
|
+
} catch (error) {
|
|
370
|
+
this.#configError = error instanceof Error ? error.message : String(error);
|
|
371
|
+
availableModels = [];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.#reloadRoles(availableModels);
|
|
376
|
+
|
|
377
|
+
const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
|
|
378
|
+
this.#availableItems = buildBrowserItems(availableModels);
|
|
379
|
+
sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder });
|
|
380
|
+
this.#browser.setRoles(this.#roles);
|
|
381
|
+
this.#browser.setMruOrder(mruOrder);
|
|
382
|
+
|
|
383
|
+
const bySelector = new Map(this.#availableItems.map(item => [item.selector, item]));
|
|
384
|
+
this.#recentItems = [];
|
|
385
|
+
for (const key of mruOrder) {
|
|
386
|
+
const item = bySelector.get(key);
|
|
387
|
+
if (item) this.#recentItems.push(item);
|
|
388
|
+
if (this.#recentItems.length >= RECENT_LIMIT) break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.#buildSidebar(allModels, availableModels);
|
|
392
|
+
this.#applyScope();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#buildSidebar(allModels: ReadonlyArray<Model>, availableModels: ReadonlyArray<Model>): void {
|
|
396
|
+
const scoped = this.#scopedModels.length > 0;
|
|
397
|
+
let disabledProviders: ReadonlySet<string>;
|
|
398
|
+
try {
|
|
399
|
+
disabledProviders = new Set(this.#settings.get("disabledProviders"));
|
|
400
|
+
} catch {
|
|
401
|
+
disabledProviders = new Set();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const availableCounts = new Map<string, number>();
|
|
405
|
+
for (const model of availableModels) {
|
|
406
|
+
availableCounts.set(model.provider, (availableCounts.get(model.provider) ?? 0) + 1);
|
|
407
|
+
}
|
|
408
|
+
const catalogCounts = new Map<string, number>();
|
|
409
|
+
for (const model of allModels) {
|
|
410
|
+
catalogCounts.set(model.provider, (catalogCounts.get(model.provider) ?? 0) + 1);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const unlocked = new Set<string>(availableCounts.keys());
|
|
414
|
+
const locked = new Set<string>();
|
|
415
|
+
if (!scoped) {
|
|
416
|
+
const authStorage = this.#registry.authStorage;
|
|
417
|
+
for (const provider of catalogCounts.keys()) {
|
|
418
|
+
if (!unlocked.has(provider) && !disabledProviders.has(provider)) {
|
|
419
|
+
locked.add(provider);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const provider of this.#registry.getDiscoverableProviders()) {
|
|
423
|
+
if (unlocked.has(provider) || disabledProviders.has(provider)) continue;
|
|
424
|
+
// Discoverable without stored auth: catalog-backed providers stay
|
|
425
|
+
// locked; keyless/custom endpoints (ollama, vllm, …) surface as
|
|
426
|
+
// selectable so discovery can populate them.
|
|
427
|
+
if (authStorage.hasAuth(provider) || !locked.has(provider)) {
|
|
428
|
+
locked.delete(provider);
|
|
429
|
+
unlocked.add(provider);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const oauthIds = new Set(getOAuthProviders().map(provider => provider.id));
|
|
435
|
+
const providerEntry = (providerId: string, isLocked: boolean): SidebarEntry => ({
|
|
436
|
+
id: `provider:${providerId}`,
|
|
437
|
+
kind: "provider",
|
|
438
|
+
label: providerId,
|
|
439
|
+
providerId,
|
|
440
|
+
locked: isLocked,
|
|
441
|
+
annotation: isLocked ? "login" : String(availableCounts.get(providerId) ?? 0),
|
|
442
|
+
oauth: oauthIds.has(providerId),
|
|
443
|
+
catalogCount: catalogCounts.get(providerId) ?? 0,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const visibleRoles = this.#visibleRoleIds();
|
|
447
|
+
let assignedCount = 0;
|
|
448
|
+
for (const role of visibleRoles) {
|
|
449
|
+
const assignment = this.#roles[role];
|
|
450
|
+
if (assignment && !assignment.autoSelected) assignedCount++;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Roles leads the fixed section so downward hops from Recent head into
|
|
454
|
+
// model scopes instead of being captured by the roles view.
|
|
455
|
+
const fixed: SidebarEntry[] = [];
|
|
456
|
+
if (this.#mode === "roles") {
|
|
457
|
+
fixed.push({
|
|
458
|
+
id: "roles",
|
|
459
|
+
kind: "roles",
|
|
460
|
+
label: "Roles",
|
|
461
|
+
annotation: `${assignedCount}/${visibleRoles.length}`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
|
|
465
|
+
|
|
466
|
+
this.#fixedEntries = fixed;
|
|
467
|
+
this.#unlockedProviderEntries = [...unlocked]
|
|
468
|
+
.sort((a, b) => a.localeCompare(b))
|
|
469
|
+
.map(provider => providerEntry(provider, false));
|
|
470
|
+
this.#lockedProviderEntries = [...locked]
|
|
471
|
+
.sort((a, b) => a.localeCompare(b))
|
|
472
|
+
.map(provider => providerEntry(provider, true));
|
|
473
|
+
this.#composeEntries();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Assemble `#entries` from the stored sections. While a search is active,
|
|
478
|
+
* providers with matches float to the top of the provider section (each
|
|
479
|
+
* group stays alphabetical) so the hop order, mouse hit-testing, and the
|
|
480
|
+
* paint all agree.
|
|
481
|
+
*/
|
|
482
|
+
#composeEntries(): void {
|
|
483
|
+
const counts = this.#searchCounts;
|
|
484
|
+
let providers = this.#unlockedProviderEntries;
|
|
485
|
+
if (counts) {
|
|
486
|
+
providers = [...providers].sort((a, b) => {
|
|
487
|
+
const aMatched = (counts.get(a.providerId ?? "") ?? 0) > 0;
|
|
488
|
+
const bMatched = (counts.get(b.providerId ?? "") ?? 0) > 0;
|
|
489
|
+
if (aMatched !== bMatched) return aMatched ? -1 : 1;
|
|
490
|
+
return a.label.localeCompare(b.label);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const entries: SidebarEntry[] = [...this.#fixedEntries];
|
|
495
|
+
if (providers.length > 0) {
|
|
496
|
+
entries.push({ id: "sep:providers", kind: "separator", label: "" }, ...providers);
|
|
497
|
+
}
|
|
498
|
+
if (this.#lockedProviderEntries.length > 0) {
|
|
499
|
+
entries.push({ id: "sep:locked", kind: "separator", label: "" }, ...this.#lockedProviderEntries);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this.#entries = entries;
|
|
503
|
+
if (!entries.some(entry => entry.id === this.#activeEntryId)) {
|
|
504
|
+
this.#activeEntryId = "all";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#activeEntry(): SidebarEntry {
|
|
509
|
+
return this.#entries.find(entry => entry.id === this.#activeEntryId) ?? this.#entries[0];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#setActiveEntry(id: string): void {
|
|
513
|
+
if (!this.#entries.some(entry => entry.id === id)) return;
|
|
514
|
+
this.#activeEntryId = id;
|
|
515
|
+
this.#applyScope();
|
|
516
|
+
const entry = this.#activeEntry();
|
|
517
|
+
// Hops must never steal arrow focus: landing on a scope keeps provider
|
|
518
|
+
// navigation active. Diving into the roles rows is explicit (Enter, →,
|
|
519
|
+
// or a click on the Roles entry).
|
|
520
|
+
this.#focus = "scope";
|
|
521
|
+
if (entry.kind === "provider" && !entry.locked) {
|
|
522
|
+
this.#scheduleProviderRefresh(entry.providerId ?? "");
|
|
523
|
+
}
|
|
524
|
+
this.#cancelScheduledRefreshesExcept(entry.kind === "provider" ? entry.providerId : undefined);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Push the active scope's items into the browser. */
|
|
528
|
+
#applyScope(): void {
|
|
529
|
+
const entry = this.#activeEntry();
|
|
530
|
+
switch (entry.kind) {
|
|
531
|
+
case "recent":
|
|
532
|
+
this.#browser.setShowProvider(true);
|
|
533
|
+
this.#browser.setItems([...this.#recentItems]);
|
|
534
|
+
break;
|
|
535
|
+
case "provider": {
|
|
536
|
+
if (entry.locked) {
|
|
537
|
+
// Assign-mode renders the browser regardless of scope; a locked
|
|
538
|
+
// provider contributes nothing selectable.
|
|
539
|
+
this.#browser.setItems([]);
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
const providerId = entry.providerId;
|
|
543
|
+
this.#browser.setShowProvider(false);
|
|
544
|
+
this.#browser.setItems(this.#availableItems.filter(item => item.provider === providerId));
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case "roles":
|
|
548
|
+
this.#roleIds = this.#visibleRoleIds();
|
|
549
|
+
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
|
|
550
|
+
break;
|
|
551
|
+
default:
|
|
552
|
+
this.#browser.setShowProvider(true);
|
|
553
|
+
this.#browser.setItems([...this.#availableItems]);
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Refresh roles + dependent state after a settings mutation (assign/unassign). */
|
|
559
|
+
#refreshAfterMutation(): void {
|
|
560
|
+
this.#syncFromRegistryState();
|
|
561
|
+
this.#tui.requestRender();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Recompute per-provider match counts for the active query. Providers
|
|
566
|
+
* without matches gray out and the scope hop skips them; a provider scope
|
|
567
|
+
* that just lost its last match falls back to All models so the results
|
|
568
|
+
* never silently vanish.
|
|
569
|
+
*/
|
|
570
|
+
#onQueryChanged(query: string): void {
|
|
571
|
+
if (!query.trim()) {
|
|
572
|
+
this.#searchCounts = null;
|
|
573
|
+
this.#composeEntries();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const matches = fuzzyFilter(this.#availableItems, query, ({ provider, id }) => `${provider}/${id}`);
|
|
577
|
+
const counts = new Map<string, number>();
|
|
578
|
+
for (const item of matches) {
|
|
579
|
+
counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
|
|
580
|
+
}
|
|
581
|
+
const recentSelectors = new Set(this.#recentItems.map(item => item.selector));
|
|
582
|
+
this.#recentSearchCount = matches.reduce(
|
|
583
|
+
(total, item) => total + (recentSelectors.has(item.selector) ? 1 : 0),
|
|
584
|
+
0,
|
|
585
|
+
);
|
|
586
|
+
this.#searchTotal = matches.length;
|
|
587
|
+
this.#searchCounts = counts;
|
|
588
|
+
this.#composeEntries();
|
|
589
|
+
const entry = this.#activeEntry();
|
|
590
|
+
if (
|
|
591
|
+
this.#assigningRole === null &&
|
|
592
|
+
entry.kind === "provider" &&
|
|
593
|
+
(entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0)
|
|
594
|
+
) {
|
|
595
|
+
this.#setActiveEntry("all");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Entries the scope hop skips: separators always; while searching, also
|
|
601
|
+
* the Roles view (not a model scope), an empty Recent, locked providers,
|
|
602
|
+
* and providers without matches.
|
|
603
|
+
*/
|
|
604
|
+
#isHopSkipped(entry: SidebarEntry): boolean {
|
|
605
|
+
if (entry.kind === "separator") return true;
|
|
606
|
+
if (!this.#searchCounts) return false;
|
|
607
|
+
if (entry.kind === "roles") return true;
|
|
608
|
+
if (entry.kind === "recent") return this.#recentSearchCount === 0;
|
|
609
|
+
if (entry.kind === "provider") {
|
|
610
|
+
if (entry.locked) return true;
|
|
611
|
+
return (this.#searchCounts.get(entry.providerId ?? "") ?? 0) === 0;
|
|
612
|
+
}
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
617
|
+
// Provider discovery refresh
|
|
618
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
619
|
+
|
|
620
|
+
#startRefreshSpinner(): void {
|
|
621
|
+
if (this.#refreshSpinnerInterval) return;
|
|
622
|
+
this.#refreshSpinnerInterval = setInterval(() => {
|
|
623
|
+
const frameCount = theme.spinnerFrames.length;
|
|
624
|
+
if (frameCount > 0) {
|
|
625
|
+
this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
|
|
626
|
+
}
|
|
627
|
+
this.#tui.requestRender();
|
|
628
|
+
}, 80);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#stopRefreshSpinnerIfIdle(): void {
|
|
632
|
+
if (this.#refreshingProviders.size > 0) return;
|
|
633
|
+
if (this.#refreshSpinnerInterval) {
|
|
634
|
+
clearInterval(this.#refreshSpinnerInterval);
|
|
635
|
+
this.#refreshSpinnerInterval = undefined;
|
|
636
|
+
}
|
|
637
|
+
this.#refreshSpinnerFrame = 0;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#setProviderRefreshing(providerId: string, refreshing: boolean): void {
|
|
641
|
+
if (refreshing) {
|
|
642
|
+
this.#refreshingProviders.add(providerId);
|
|
643
|
+
this.#startRefreshSpinner();
|
|
644
|
+
} else {
|
|
645
|
+
this.#refreshingProviders.delete(providerId);
|
|
646
|
+
this.#stopRefreshSpinnerIfIdle();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#cancelScheduledRefreshesExcept(keepProviderId?: string): void {
|
|
651
|
+
for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
|
|
652
|
+
if (providerId === keepProviderId) continue;
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
this.#scheduledProviderRefreshes.delete(providerId);
|
|
655
|
+
this.#setProviderRefreshing(providerId, false);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
#scheduleProviderRefresh(providerId: string, options?: { force?: boolean }): void {
|
|
660
|
+
if (this.#scopedModels.length > 0 || !providerId) return;
|
|
661
|
+
if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) return;
|
|
662
|
+
// Hovering a provider must not re-fetch on every visit: auto-refresh runs
|
|
663
|
+
// at most once per provider for the process lifetime. F5 forces a re-fetch.
|
|
664
|
+
if (!options?.force && autoRefreshedProviders.has(providerId)) return;
|
|
665
|
+
this.#setProviderRefreshing(providerId, true);
|
|
666
|
+
const timer = setTimeout(() => {
|
|
667
|
+
// Consume the once-guard only when the fetch actually starts: hopping
|
|
668
|
+
// through a provider cancels the debounce and must not burn its slot.
|
|
669
|
+
autoRefreshedProviders.add(providerId);
|
|
670
|
+
this.#scheduledProviderRefreshes.delete(providerId);
|
|
671
|
+
void this.#refreshProviderInBackground(providerId);
|
|
672
|
+
}, PROVIDER_REFRESH_DEBOUNCE_MS);
|
|
673
|
+
this.#scheduledProviderRefreshes.set(providerId, timer);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async #refreshProviderInBackground(providerId: string): Promise<void> {
|
|
677
|
+
try {
|
|
678
|
+
await this.#registry.refreshProvider(providerId, "online");
|
|
679
|
+
// The provider refresh already updated the registry snapshot;
|
|
680
|
+
// re-reading it here stays purely in-memory.
|
|
681
|
+
this.#syncFromRegistryState();
|
|
682
|
+
} catch (error) {
|
|
683
|
+
this.#configError = error instanceof Error ? error.message : String(error);
|
|
684
|
+
} finally {
|
|
685
|
+
this.#setProviderRefreshing(providerId, false);
|
|
686
|
+
this.#tui.requestRender();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
#formatDiscoveryAge(fetchedAt: number | undefined): string | undefined {
|
|
691
|
+
if (!fetchedAt) return undefined;
|
|
692
|
+
const ageMs = Math.max(0, Date.now() - fetchedAt);
|
|
693
|
+
if (ageMs < 60_000) return "less than a minute ago";
|
|
694
|
+
return `${Math.round(ageMs / 60_000)}m ago`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#emptyStateMessage(): string | undefined {
|
|
698
|
+
if (this.#configError) return ` ${this.#configError}`;
|
|
699
|
+
const entry = this.#activeEntry();
|
|
700
|
+
if (entry.kind === "recent") return " No recently used models yet";
|
|
701
|
+
if (entry.kind !== "provider" || entry.locked) return undefined;
|
|
702
|
+
if (this.#browser.query.trim()) {
|
|
703
|
+
return ` No matching models in ${entry.label}. Switch to All models to search every provider.`;
|
|
704
|
+
}
|
|
705
|
+
const providerId = entry.providerId ?? "";
|
|
706
|
+
const state = this.#registry.getProviderDiscoveryState(providerId);
|
|
707
|
+
if (!state) return undefined;
|
|
708
|
+
const age = this.#formatDiscoveryAge(state.fetchedAt);
|
|
709
|
+
switch (state.status) {
|
|
710
|
+
case "cached":
|
|
711
|
+
return age
|
|
712
|
+
? ` Using cached model list from ${age}. Live refresh is still pending.`
|
|
713
|
+
: " Using cached model list. Live refresh is still pending.";
|
|
714
|
+
case "unavailable": {
|
|
715
|
+
const httpMatch = state.error?.match(/^HTTP (\d+) from (.+)$/);
|
|
716
|
+
if (httpMatch?.[1] === "404") {
|
|
717
|
+
return ` Discovery endpoint ${httpMatch[2]} returned 404. Point baseUrl at the host that serves /models (usually .../v1).`;
|
|
718
|
+
}
|
|
719
|
+
if (state.error) return ` Discovery failed: ${state.error}`;
|
|
720
|
+
return age ? ` Provider unavailable. Using cached model list from ${age}.` : " Provider unavailable.";
|
|
721
|
+
}
|
|
722
|
+
case "unauthenticated":
|
|
723
|
+
return " Provider requires authentication before models can be discovered.";
|
|
724
|
+
case "idle":
|
|
725
|
+
return " Provider has not been refreshed yet.";
|
|
726
|
+
case "empty":
|
|
727
|
+
return " Discovery succeeded but returned 0 models. Check that /models returns { data: [{ id }] }.";
|
|
728
|
+
case "ok":
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
734
|
+
// Assignment flow
|
|
735
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
736
|
+
|
|
737
|
+
#activateItem(item: ModelBrowserItem): void {
|
|
738
|
+
if (this.#mode === "pick") {
|
|
739
|
+
this.#callbacks.onPick?.(item.model, item.selector);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (this.#assigningRole) {
|
|
743
|
+
const role = this.#assigningRole;
|
|
744
|
+
this.#assigningRole = null;
|
|
745
|
+
this.#assignRole(item, role, true);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
this.#openRoleStrip(item);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
|
|
752
|
+
#assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
|
|
753
|
+
const current = this.#roles[role];
|
|
754
|
+
let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit;
|
|
755
|
+
if (current && !current.autoSelected) {
|
|
756
|
+
const supported = this.#thinkingOptionsFor(item.model);
|
|
757
|
+
level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit;
|
|
758
|
+
}
|
|
759
|
+
this.#callbacks.onAssign(item.model, role, level, item.selector, "modelRole");
|
|
760
|
+
this.#refreshAfterMutation();
|
|
761
|
+
this.#openThinkingStrip(item, role, returnToRoles);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
#unassignRole(role: string): void {
|
|
765
|
+
const assignment = this.#roles[role];
|
|
766
|
+
if (!assignment || assignment.autoSelected) return;
|
|
767
|
+
this.#callbacks.onUnassign(role);
|
|
768
|
+
this.#refreshAfterMutation();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#thinkingOptionsFor(model: Model): ConfiguredThinkingLevel[] {
|
|
772
|
+
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#openRoleStrip(item: ModelBrowserItem): void {
|
|
776
|
+
const chips: StripChip[] = [];
|
|
777
|
+
for (const role of this.#visibleRoleIds()) {
|
|
778
|
+
const info = getRoleInfo(role, this.#settings);
|
|
779
|
+
const assignment = this.#roles[role];
|
|
780
|
+
const assignedHere =
|
|
781
|
+
!!assignment &&
|
|
782
|
+
!assignment.autoSelected &&
|
|
783
|
+
assignment.model.provider === item.model.provider &&
|
|
784
|
+
assignment.model.id === item.model.id;
|
|
785
|
+
const label = (info.tag ?? info.name ?? role).toLowerCase();
|
|
786
|
+
chips.push({
|
|
787
|
+
label,
|
|
788
|
+
styled: assignedHere
|
|
789
|
+
? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
|
|
790
|
+
theme.fg("dim", ` ${theme.status.success}`)
|
|
791
|
+
: theme.fg(info.color ?? "muted", label),
|
|
792
|
+
role,
|
|
793
|
+
action: assignedHere ? "unassign" : "assign",
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
|
|
797
|
+
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
#openThinkingStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
|
|
801
|
+
const options = this.#thinkingOptionsFor(item.model);
|
|
802
|
+
const current = this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
|
|
803
|
+
const chips: StripChip[] = options.map(level => {
|
|
804
|
+
const label = getConfiguredThinkingLevelMetadata(level).label;
|
|
805
|
+
const glyph = thinkingLevelGlyph(level);
|
|
806
|
+
return {
|
|
807
|
+
label,
|
|
808
|
+
styled: glyph ? `${theme.fg("accent", glyph)} ${label}` : label,
|
|
809
|
+
action: "thinking",
|
|
810
|
+
thinkingLevel: level,
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
const preselect = options.indexOf(current);
|
|
814
|
+
this.#strip = {
|
|
815
|
+
kind: "thinking",
|
|
816
|
+
item,
|
|
817
|
+
role,
|
|
818
|
+
chips,
|
|
819
|
+
index: preselect >= 0 ? preselect : 0,
|
|
820
|
+
returnToRoles,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
#closeStrip(): void {
|
|
825
|
+
const strip = this.#strip;
|
|
826
|
+
this.#strip = null;
|
|
827
|
+
this.#chipRanges = [];
|
|
828
|
+
if (strip?.kind === "thinking" && strip.returnToRoles && this.#mode === "roles") {
|
|
829
|
+
this.#setActiveEntry("roles");
|
|
830
|
+
this.#focus = "list";
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
#activateStripChip(): void {
|
|
835
|
+
const strip = this.#strip;
|
|
836
|
+
if (!strip || strip.kind === "roleName") return;
|
|
837
|
+
const chip = strip.chips[strip.index];
|
|
838
|
+
if (!chip) return;
|
|
839
|
+
switch (chip.action) {
|
|
840
|
+
case "assign":
|
|
841
|
+
if (chip.role) {
|
|
842
|
+
this.#strip = null;
|
|
843
|
+
this.#assignRole(strip.item, chip.role, false);
|
|
844
|
+
}
|
|
845
|
+
return;
|
|
846
|
+
case "unassign":
|
|
847
|
+
if (chip.role) {
|
|
848
|
+
this.#callbacks.onUnassign(chip.role);
|
|
849
|
+
this.#refreshAfterMutation();
|
|
850
|
+
}
|
|
851
|
+
this.#closeStrip();
|
|
852
|
+
return;
|
|
853
|
+
case "fallback":
|
|
854
|
+
this.#callbacks.onAssign(strip.item.model, "default", undefined, strip.item.selector, "retryFallback");
|
|
855
|
+
this.#closeStrip();
|
|
856
|
+
return;
|
|
857
|
+
case "thinking":
|
|
858
|
+
if (strip.role && chip.thinkingLevel !== undefined) {
|
|
859
|
+
this.#callbacks.onAssign(
|
|
860
|
+
strip.item.model,
|
|
861
|
+
strip.role,
|
|
862
|
+
chip.thinkingLevel,
|
|
863
|
+
strip.item.selector,
|
|
864
|
+
"modelRole",
|
|
865
|
+
);
|
|
866
|
+
this.#refreshAfterMutation();
|
|
867
|
+
}
|
|
868
|
+
this.#closeStrip();
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
|
|
874
|
+
#startAssign(role: string): void {
|
|
875
|
+
this.#assigningRole = role;
|
|
876
|
+
this.#focus = "scope";
|
|
877
|
+
this.#browser.setShowProvider(true);
|
|
878
|
+
this.#browser.setItems([...this.#availableItems]);
|
|
879
|
+
this.#browser.setQuery("");
|
|
880
|
+
const current = this.#roles[role];
|
|
881
|
+
if (current) {
|
|
882
|
+
this.#browser.selectSelector(`${current.model.provider}/${current.model.id}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
#cancelAssign(): void {
|
|
887
|
+
this.#assigningRole = null;
|
|
888
|
+
this.#browser.setQuery("");
|
|
889
|
+
if (this.#mode === "roles") {
|
|
890
|
+
this.#setActiveEntry("roles");
|
|
891
|
+
this.#focus = "list";
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
896
|
+
// Quick-switch cycle (ctrl+p) editing
|
|
897
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
898
|
+
|
|
899
|
+
#cycleOrder(): string[] {
|
|
900
|
+
try {
|
|
901
|
+
return [...this.#settings.get("cycleOrder")];
|
|
902
|
+
} catch {
|
|
903
|
+
return [];
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Toggle `role`'s membership in the quick-switch cycle (appended at the end). */
|
|
908
|
+
#toggleCycleMembership(role: string): void {
|
|
909
|
+
const order = this.#cycleOrder();
|
|
910
|
+
const index = order.indexOf(role);
|
|
911
|
+
if (index >= 0) {
|
|
912
|
+
order.splice(index, 1);
|
|
913
|
+
} else {
|
|
914
|
+
order.push(role);
|
|
915
|
+
}
|
|
916
|
+
this.#callbacks.onCycleOrderChange?.(order);
|
|
917
|
+
this.#refreshAfterMutation();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Move `role` one slot earlier/later within the cycle order. */
|
|
921
|
+
#moveCycleMembership(role: string, delta: -1 | 1): void {
|
|
922
|
+
const order = this.#cycleOrder();
|
|
923
|
+
const index = order.indexOf(role);
|
|
924
|
+
const target = index + delta;
|
|
925
|
+
if (index < 0 || target < 0 || target >= order.length) return;
|
|
926
|
+
[order[index], order[target]] = [order[target], order[index]];
|
|
927
|
+
this.#callbacks.onCycleOrderChange?.(order);
|
|
928
|
+
this.#refreshAfterMutation();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Open the footer name input that creates a new custom role. */
|
|
932
|
+
#openRoleNameStrip(): void {
|
|
933
|
+
this.#strip = { kind: "roleName", input: new Input() };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Validate and commit the new-role name: jump straight into assigning it. */
|
|
937
|
+
#submitRoleName(): void {
|
|
938
|
+
const strip = this.#strip;
|
|
939
|
+
if (strip?.kind !== "roleName") return;
|
|
940
|
+
const name = strip.input.getValue().trim();
|
|
941
|
+
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
|
|
942
|
+
if (this.#visibleRoleIds().includes(name)) return;
|
|
943
|
+
this.#strip = null;
|
|
944
|
+
this.#chipRanges = [];
|
|
945
|
+
this.#startAssign(name);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
949
|
+
// Input
|
|
950
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
951
|
+
|
|
952
|
+
handleInput(data: string): void {
|
|
953
|
+
if (data.startsWith("\x1b[<")) {
|
|
954
|
+
routeSgrMouseInput(data, event => this.#routeMouseEvent(event));
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (this.#strip) {
|
|
959
|
+
this.#handleStripInput(data);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (matchesSelectCancel(data)) {
|
|
964
|
+
if (this.#assigningRole !== null) {
|
|
965
|
+
this.#cancelAssign();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const entry = this.#activeEntry();
|
|
969
|
+
if (this.#isBrowserView(entry) && this.#browser.query.length > 0) {
|
|
970
|
+
this.#browser.handleCancel();
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
this.#callbacks.onCancel();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const entry = this.#activeEntry();
|
|
978
|
+
const rolesView = entry.kind === "roles" && this.#assigningRole === null;
|
|
979
|
+
const lockedView = entry.kind === "provider" && entry.locked && this.#assigningRole === null;
|
|
980
|
+
|
|
981
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
|
|
982
|
+
this.#focus = this.#focus === "scope" ? "list" : "scope";
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (matchesKey(data, "f5")) {
|
|
986
|
+
if (entry.kind === "provider" && !entry.locked) {
|
|
987
|
+
this.#scheduleProviderRefresh(entry.providerId ?? "", { force: true });
|
|
988
|
+
}
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ←/→ are spatial pane switches: the sidebar sits left of the rows.
|
|
993
|
+
// They never reach the search caret — fuzzy queries don't need one.
|
|
994
|
+
if (matchesKey(data, "left")) {
|
|
995
|
+
this.#focus = "scope";
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (matchesKey(data, "right")) {
|
|
999
|
+
// Only views with rows can take list focus (not the locked pane).
|
|
1000
|
+
if (rolesView || this.#isBrowserView(entry)) {
|
|
1001
|
+
this.#focus = "list";
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Arrow ownership: scope mode hops the sidebar even while the search
|
|
1007
|
+
// bar holds the caret; list mode navigates rows.
|
|
1008
|
+
if (this.#focus === "scope") {
|
|
1009
|
+
if (matchesSelectUp(data)) {
|
|
1010
|
+
this.#moveSidebar(-1);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (matchesSelectDown(data)) {
|
|
1014
|
+
this.#moveSidebar(1);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (rolesView) {
|
|
1020
|
+
this.#handleRolesViewInput(data);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (lockedView) {
|
|
1024
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1025
|
+
this.#requestLogin(entry);
|
|
1026
|
+
}
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
this.#browser.handleInput(data);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
#isBrowserView(entry: SidebarEntry): boolean {
|
|
1033
|
+
if (this.#assigningRole !== null) return true;
|
|
1034
|
+
return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
#handleStripInput(data: string): void {
|
|
1038
|
+
const strip = this.#strip;
|
|
1039
|
+
if (!strip) return;
|
|
1040
|
+
if (matchesSelectCancel(data)) {
|
|
1041
|
+
this.#closeStrip();
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (strip.kind === "roleName") {
|
|
1045
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1046
|
+
this.#submitRoleName();
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
strip.input.handleInput(data);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab")) {
|
|
1053
|
+
strip.index = (strip.index - 1 + strip.chips.length) % strip.chips.length;
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
if (matchesKey(data, "right") || matchesKey(data, "down") || matchesKey(data, "tab")) {
|
|
1057
|
+
strip.index = (strip.index + 1) % strip.chips.length;
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1061
|
+
this.#activateStripChip();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
#moveSidebar(delta: number): void {
|
|
1067
|
+
const count = this.#entries.length;
|
|
1068
|
+
if (count === 0) return;
|
|
1069
|
+
let index = this.#entries.findIndex(entry => entry.id === this.#activeEntryId);
|
|
1070
|
+
if (index < 0) index = 0;
|
|
1071
|
+
for (let step = 0; step < count; step++) {
|
|
1072
|
+
index = (index + delta + count) % count;
|
|
1073
|
+
const entry = this.#entries[index];
|
|
1074
|
+
if (entry && !this.#isHopSkipped(entry)) {
|
|
1075
|
+
// Scope changes keep an active assignment (scoping helps find the
|
|
1076
|
+
// model); landing on the Roles view cancels it.
|
|
1077
|
+
if (entry.kind === "roles") this.#assigningRole = null;
|
|
1078
|
+
this.#setActiveEntry(entry.id);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Row count of the roles view: every visible role plus the trailing "+ New role…" row. */
|
|
1085
|
+
get #rolesRowCount(): number {
|
|
1086
|
+
return this.#roleIds.length + 1;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
#handleRolesViewInput(data: string): void {
|
|
1090
|
+
// Scope focus treats the roles view as a preview: Enter/Space dives
|
|
1091
|
+
// into the rows, everything else is inert (arrows already hop).
|
|
1092
|
+
if (this.#focus === "scope") {
|
|
1093
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || matchesKey(data, "space")) {
|
|
1094
|
+
this.#focus = "list";
|
|
1095
|
+
}
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const rowCount = Math.max(1, this.#rolesRowCount);
|
|
1099
|
+
if (matchesSelectUp(data)) {
|
|
1100
|
+
this.#roleIndex = (this.#roleIndex - 1 + rowCount) % rowCount;
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (matchesSelectDown(data)) {
|
|
1104
|
+
this.#roleIndex = (this.#roleIndex + 1) % rowCount;
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const role = this.#roleIds[this.#roleIndex];
|
|
1108
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1109
|
+
if (role) {
|
|
1110
|
+
this.#startAssign(role);
|
|
1111
|
+
} else {
|
|
1112
|
+
// The virtual "+ New role…" row.
|
|
1113
|
+
this.#openRoleNameStrip();
|
|
1114
|
+
}
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
|
|
1118
|
+
if (role) this.#unassignRole(role);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// Cycle reordering: [ / shift+↑ moves the role earlier, ] / shift+↓ later.
|
|
1122
|
+
if (matchesKey(data, "shift+up")) {
|
|
1123
|
+
if (role) this.#moveCycleMembership(role, -1);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (matchesKey(data, "shift+down")) {
|
|
1127
|
+
if (role) this.#moveCycleMembership(role, 1);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const printable = extractPrintableText(data);
|
|
1131
|
+
if (printable === "x") {
|
|
1132
|
+
if (role) this.#unassignRole(role);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (printable === "c") {
|
|
1136
|
+
if (role) this.#toggleCycleMembership(role);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (printable === "[") {
|
|
1140
|
+
if (role) this.#moveCycleMembership(role, -1);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (printable === "]") {
|
|
1144
|
+
if (role) this.#moveCycleMembership(role, 1);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (printable === "n") {
|
|
1148
|
+
this.#openRoleNameStrip();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (printable === "t") {
|
|
1152
|
+
const assignment = role ? this.#roles[role] : undefined;
|
|
1153
|
+
if (role && assignment) {
|
|
1154
|
+
const item: ModelBrowserItem = {
|
|
1155
|
+
provider: assignment.model.provider,
|
|
1156
|
+
id: assignment.model.id,
|
|
1157
|
+
model: assignment.model,
|
|
1158
|
+
selector: `${assignment.model.provider}/${assignment.model.id}`,
|
|
1159
|
+
};
|
|
1160
|
+
this.#openThinkingStrip(item, role, true);
|
|
1161
|
+
}
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
#requestLogin(entry: SidebarEntry): void {
|
|
1167
|
+
if (!entry.providerId) return;
|
|
1168
|
+
if (entry.oauth) {
|
|
1169
|
+
this.#callbacks.onLoginRequest?.(entry.providerId);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1174
|
+
// Mouse
|
|
1175
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1176
|
+
|
|
1177
|
+
#routeMouseEvent(event: SgrMouseEvent): boolean {
|
|
1178
|
+
const contentLine = event.row - this.#contentRowStart;
|
|
1179
|
+
const overContent = contentLine >= 0 && contentLine < this.#contentRowCount;
|
|
1180
|
+
const sidebarColStart = 2;
|
|
1181
|
+
const sidebarColEnd = sidebarColStart + this.#sidebarWidthLast;
|
|
1182
|
+
const bodyColStart = this.#sidebarWidthLast + 5;
|
|
1183
|
+
const overSidebar = overContent && event.col >= 0 && event.col < sidebarColEnd;
|
|
1184
|
+
const overBody = overContent && event.col >= bodyColStart;
|
|
1185
|
+
const bodyLine = contentLine - 1; // body row 0 is the status row
|
|
1186
|
+
const entry = this.#activeEntry();
|
|
1187
|
+
|
|
1188
|
+
// Footer strip chips.
|
|
1189
|
+
if (event.row === this.#footerRow && this.#strip) {
|
|
1190
|
+
const strip = this.#strip;
|
|
1191
|
+
if (event.leftClick && strip.kind !== "roleName") {
|
|
1192
|
+
for (const range of this.#chipRanges) {
|
|
1193
|
+
if (event.col >= range.start && event.col < range.end) {
|
|
1194
|
+
strip.index = range.index;
|
|
1195
|
+
this.#activateStripChip();
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (event.wheel !== null) {
|
|
1204
|
+
if (overSidebar) {
|
|
1205
|
+
this.#moveSidebar(event.wheel);
|
|
1206
|
+
} else if (overBody) {
|
|
1207
|
+
if (entry.kind === "roles" && this.#assigningRole === null) {
|
|
1208
|
+
const count = Math.max(1, this.#rolesRowCount);
|
|
1209
|
+
this.#roleIndex = (this.#roleIndex + event.wheel + count) % count;
|
|
1210
|
+
} else if (this.#isBrowserView(entry)) {
|
|
1211
|
+
this.#browser.routeMouse(event, bodyLine);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (event.motion) {
|
|
1218
|
+
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
|
|
1219
|
+
if (overBody && entry.kind === "roles" && this.#assigningRole === null) {
|
|
1220
|
+
const roleLine = bodyLine - this.#rolesRowStart;
|
|
1221
|
+
this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
|
|
1222
|
+
} else {
|
|
1223
|
+
this.#roleHover = null;
|
|
1224
|
+
if (overBody && this.#isBrowserView(entry)) {
|
|
1225
|
+
this.#browser.routeMouse(event, bodyLine);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (!event.leftClick) return true;
|
|
1232
|
+
|
|
1233
|
+
if (overSidebar) {
|
|
1234
|
+
const index = this.#sidebarEntryIndexAt(contentLine);
|
|
1235
|
+
const clicked = index !== null ? this.#entries[index] : undefined;
|
|
1236
|
+
if (clicked && clicked.kind !== "separator") {
|
|
1237
|
+
const already = clicked.id === this.#activeEntryId;
|
|
1238
|
+
if (clicked.kind === "roles") this.#assigningRole = null;
|
|
1239
|
+
this.#setActiveEntry(clicked.id);
|
|
1240
|
+
// A click on Roles is a deliberate dive into the rows.
|
|
1241
|
+
if (clicked.kind === "roles") this.#focus = "list";
|
|
1242
|
+
if (already && clicked.kind === "provider" && clicked.locked) {
|
|
1243
|
+
this.#requestLogin(clicked);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (overBody) {
|
|
1250
|
+
if (entry.kind === "roles" && this.#assigningRole === null) {
|
|
1251
|
+
this.#focus = "list";
|
|
1252
|
+
const roleLine = bodyLine - this.#rolesRowStart;
|
|
1253
|
+
if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
|
|
1254
|
+
if (roleLine === this.#roleIndex) {
|
|
1255
|
+
const role = this.#roleIds[roleLine];
|
|
1256
|
+
if (role) {
|
|
1257
|
+
this.#startAssign(role);
|
|
1258
|
+
} else {
|
|
1259
|
+
this.#openRoleNameStrip();
|
|
1260
|
+
}
|
|
1261
|
+
} else {
|
|
1262
|
+
this.#roleIndex = roleLine;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
} else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
|
|
1266
|
+
if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
|
|
1267
|
+
this.#requestLogin(entry);
|
|
1268
|
+
}
|
|
1269
|
+
} else if (this.#isBrowserView(entry)) {
|
|
1270
|
+
this.#browser.routeMouse(event, bodyLine);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return true;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Map a content-line index to a sidebar entry index (accounting for scroll). */
|
|
1277
|
+
#sidebarEntryIndexAt(contentLine: number): number | null {
|
|
1278
|
+
const index = this.#sidebarScroll + contentLine;
|
|
1279
|
+
if (index < 0 || index >= this.#entries.length) return null;
|
|
1280
|
+
return index;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1284
|
+
// Rendering
|
|
1285
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1286
|
+
|
|
1287
|
+
#sidebarWidth(): number {
|
|
1288
|
+
let longest = 0;
|
|
1289
|
+
for (const entry of this.#entries) {
|
|
1290
|
+
const annotation = entry.annotation ?? "";
|
|
1291
|
+
longest = Math.max(longest, visibleWidth(entry.label) + visibleWidth(annotation) + 5);
|
|
1292
|
+
}
|
|
1293
|
+
return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, longest));
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
#renderSidebar(width: number, rows: number): string[] {
|
|
1297
|
+
const activeIndex = Math.max(
|
|
1298
|
+
0,
|
|
1299
|
+
this.#entries.findIndex(entry => entry.id === this.#activeEntryId),
|
|
1300
|
+
);
|
|
1301
|
+
if (this.#entries.length > rows) {
|
|
1302
|
+
this.#sidebarScroll = Math.max(0, Math.min(activeIndex - Math.floor(rows / 2), this.#entries.length - rows));
|
|
1303
|
+
} else {
|
|
1304
|
+
this.#sidebarScroll = 0;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const lines: string[] = [];
|
|
1308
|
+
for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) {
|
|
1309
|
+
const entry = this.#entries[i];
|
|
1310
|
+
if (!entry) continue;
|
|
1311
|
+
if (entry.kind === "separator") {
|
|
1312
|
+
lines.push(theme.fg("border", "─".repeat(width)));
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const active = entry.id === this.#activeEntryId;
|
|
1316
|
+
const hovered = i === this.#sidebarHover;
|
|
1317
|
+
const searching = this.#searchCounts !== null;
|
|
1318
|
+
let matchCount: number | undefined;
|
|
1319
|
+
if (searching) {
|
|
1320
|
+
if (entry.kind === "provider" && !entry.locked) {
|
|
1321
|
+
matchCount = this.#searchCounts?.get(entry.providerId ?? "") ?? 0;
|
|
1322
|
+
} else if (entry.kind === "recent") {
|
|
1323
|
+
matchCount = this.#recentSearchCount;
|
|
1324
|
+
} else if (entry.kind === "all") {
|
|
1325
|
+
matchCount = this.#searchTotal;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
// While searching, entries the hop skips gray out: locked and
|
|
1329
|
+
// zero-match providers, an empty Recent, and the Roles view.
|
|
1330
|
+
const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
|
|
1331
|
+
const cursor = active
|
|
1332
|
+
? this.#focus === "scope"
|
|
1333
|
+
? theme.fg("accent", theme.nav.cursor)
|
|
1334
|
+
: theme.fg("dim", theme.nav.cursor)
|
|
1335
|
+
: " ";
|
|
1336
|
+
|
|
1337
|
+
let icon: string;
|
|
1338
|
+
if (entry.kind === "recent") {
|
|
1339
|
+
icon = theme.icon.time;
|
|
1340
|
+
} else if (entry.kind === "roles") {
|
|
1341
|
+
icon = theme.icon.extensionSkill;
|
|
1342
|
+
} else if (entry.kind === "all") {
|
|
1343
|
+
icon = theme.icon.model;
|
|
1344
|
+
} else {
|
|
1345
|
+
icon = muted ? theme.status.shadowed : theme.status.enabled;
|
|
1346
|
+
}
|
|
1347
|
+
const labelStyled = muted
|
|
1348
|
+
? theme.fg("dim", entry.label)
|
|
1349
|
+
: active
|
|
1350
|
+
? theme.fg("accent", entry.label)
|
|
1351
|
+
: entry.label;
|
|
1352
|
+
|
|
1353
|
+
const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false;
|
|
1354
|
+
const annotationText = matchCount !== undefined ? String(matchCount) : (entry.annotation ?? "");
|
|
1355
|
+
const annotationStyled = refreshing
|
|
1356
|
+
? theme.fg("warning", theme.spinnerFrames[this.#refreshSpinnerFrame % theme.spinnerFrames.length] ?? "")
|
|
1357
|
+
: theme.fg("dim", annotationText);
|
|
1358
|
+
|
|
1359
|
+
const left = `${cursor} ${muted ? theme.fg("dim", icon) : theme.fg(entry.kind === "provider" ? "success" : "accent", icon)} ${labelStyled}`;
|
|
1360
|
+
const leftWidth = visibleWidth(left);
|
|
1361
|
+
const annWidth = visibleWidth(annotationStyled);
|
|
1362
|
+
let line: string;
|
|
1363
|
+
if (leftWidth + annWidth + 1 <= width) {
|
|
1364
|
+
line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`;
|
|
1365
|
+
} else {
|
|
1366
|
+
line = truncateToWidth(left, width);
|
|
1367
|
+
}
|
|
1368
|
+
if (hovered && !active) {
|
|
1369
|
+
line = theme.bg("selectedBg", line);
|
|
1370
|
+
}
|
|
1371
|
+
lines.push(line);
|
|
1372
|
+
}
|
|
1373
|
+
return lines;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
#statusRow(width: number): string {
|
|
1377
|
+
if (this.#assigningRole !== null) {
|
|
1378
|
+
const info = getRoleInfo(this.#assigningRole, this.#settings);
|
|
1379
|
+
const label = info.tag ?? info.name ?? this.#assigningRole;
|
|
1380
|
+
return truncateToWidth(
|
|
1381
|
+
theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
|
|
1382
|
+
width,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const entry = this.#activeEntry();
|
|
1386
|
+
const scopedSuffix = this.#scopedModels.length > 0 ? " · --models scope" : "";
|
|
1387
|
+
let text: string;
|
|
1388
|
+
switch (entry.kind) {
|
|
1389
|
+
case "recent":
|
|
1390
|
+
text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
|
|
1391
|
+
break;
|
|
1392
|
+
case "roles":
|
|
1393
|
+
text = "Model roles — assignments fall back to auto-selection when cleared";
|
|
1394
|
+
break;
|
|
1395
|
+
case "provider":
|
|
1396
|
+
if (entry.locked) {
|
|
1397
|
+
text = `${entry.label} · not configured`;
|
|
1398
|
+
} else if (entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
|
|
1399
|
+
text = `${entry.label} · refreshing model list…`;
|
|
1400
|
+
} else {
|
|
1401
|
+
text = `${entry.label} · ${entry.annotation ?? "0"} models${scopedSuffix}`;
|
|
1402
|
+
}
|
|
1403
|
+
break;
|
|
1404
|
+
default:
|
|
1405
|
+
text = this.#mode === "pick" ? this.#pickerHint : `All available models${scopedSuffix}`;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
if (this.#configError && entry.kind !== "provider") {
|
|
1409
|
+
text = this.#configError;
|
|
1410
|
+
return truncateToWidth(theme.fg("error", ` ${text}`), width);
|
|
1411
|
+
}
|
|
1412
|
+
return truncateToWidth(theme.fg("muted", ` ${text}`), width);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
#renderRolesView(width: number, rows: number): string[] {
|
|
1416
|
+
const lines: string[] = [];
|
|
1417
|
+
lines.push("");
|
|
1418
|
+
this.#rolesRowStart = lines.length + 1; // +1 for the status row offset handled by caller
|
|
1419
|
+
|
|
1420
|
+
let tagWidth = 0;
|
|
1421
|
+
for (const role of this.#roleIds) {
|
|
1422
|
+
const info = getRoleInfo(role, this.#settings);
|
|
1423
|
+
tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? role));
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const cycleOrder = this.#cycleOrder();
|
|
1427
|
+
for (let i = 0; i < this.#roleIds.length && lines.length < rows - 3; i++) {
|
|
1428
|
+
const role = this.#roleIds[i];
|
|
1429
|
+
const info = getRoleInfo(role, this.#settings);
|
|
1430
|
+
const assignment = this.#roles[role];
|
|
1431
|
+
const selected = i === this.#roleIndex;
|
|
1432
|
+
const hovered = i === this.#roleHover;
|
|
1433
|
+
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
|
|
1434
|
+
const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
|
|
1435
|
+
|
|
1436
|
+
let dot: string;
|
|
1437
|
+
let tagStyled: string;
|
|
1438
|
+
let value: string;
|
|
1439
|
+
let levelStyled = "";
|
|
1440
|
+
if (assignment && !assignment.autoSelected) {
|
|
1441
|
+
dot = theme.fg(info.color ?? "muted", theme.status.enabled);
|
|
1442
|
+
tagStyled = theme.fg(info.color ?? "muted", tag);
|
|
1443
|
+
value = `${theme.fg("dim", `${assignment.model.provider}/`)}${selected ? theme.fg("accent", assignment.model.id) : assignment.model.id}`;
|
|
1444
|
+
const glyph = thinkingLevelGlyph(assignment.thinkingLevel);
|
|
1445
|
+
const label = getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label;
|
|
1446
|
+
if (assignment.thinkingLevel !== ThinkingLevel.Inherit) {
|
|
1447
|
+
levelStyled = theme.fg("dim", glyph ? `${glyph} ${label}` : label);
|
|
1448
|
+
}
|
|
1449
|
+
} else if (assignment) {
|
|
1450
|
+
dot = theme.fg("dim", theme.status.shadowed);
|
|
1451
|
+
tagStyled = theme.fg("dim", tag);
|
|
1452
|
+
value = theme.fg("dim", `auto → ${assignment.model.provider}/${assignment.model.id}`);
|
|
1453
|
+
} else {
|
|
1454
|
+
dot = theme.fg("dim", theme.status.shadowed);
|
|
1455
|
+
tagStyled = theme.fg("dim", tag);
|
|
1456
|
+
value = theme.fg("dim", "—");
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Quick-cycle membership badge (`⟳2` = second stop of the ctrl+p cycle).
|
|
1460
|
+
const cycleIndex = cycleOrder.indexOf(role);
|
|
1461
|
+
const cycleStyled = cycleIndex >= 0 ? theme.fg("accent", `${theme.icon.loop}${cycleIndex + 1}`) : "";
|
|
1462
|
+
|
|
1463
|
+
let line = ` ${cursor} ${dot} ${tagStyled} ${value}`;
|
|
1464
|
+
const right = [levelStyled, cycleStyled].filter(part => part.length > 0).join(" ");
|
|
1465
|
+
const rightWidth = visibleWidth(right);
|
|
1466
|
+
const lineWidth = visibleWidth(line);
|
|
1467
|
+
if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
|
|
1468
|
+
line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
|
|
1469
|
+
} else {
|
|
1470
|
+
line = truncateToWidth(line, width);
|
|
1471
|
+
}
|
|
1472
|
+
if (hovered && !selected) {
|
|
1473
|
+
line = theme.bg("selectedBg", line);
|
|
1474
|
+
}
|
|
1475
|
+
lines.push(line);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Trailing virtual row: create a custom role.
|
|
1479
|
+
if (lines.length < rows - 2) {
|
|
1480
|
+
const newRoleIndex = this.#roleIds.length;
|
|
1481
|
+
const selected = this.#roleIndex === newRoleIndex;
|
|
1482
|
+
const hovered = this.#roleHover === newRoleIndex;
|
|
1483
|
+
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
|
|
1484
|
+
let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New role…")}`;
|
|
1485
|
+
line = truncateToWidth(line, width);
|
|
1486
|
+
if (hovered && !selected) {
|
|
1487
|
+
line = theme.bg("selectedBg", line);
|
|
1488
|
+
}
|
|
1489
|
+
lines.push(line);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Live preview of the quick-switch cycle, rendered with the exact
|
|
1493
|
+
// segment track the ctrl+p status uses; the selected role's chip fills.
|
|
1494
|
+
while (lines.length < rows - 1) lines.push("");
|
|
1495
|
+
if (rows >= 2) {
|
|
1496
|
+
const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
|
|
1497
|
+
if (cycleOrder.length > 0) {
|
|
1498
|
+
const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? "");
|
|
1499
|
+
const track = renderSegmentTrack(
|
|
1500
|
+
cycleOrder.map(role => ({ label: role })),
|
|
1501
|
+
activeIndex,
|
|
1502
|
+
);
|
|
1503
|
+
lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
|
|
1504
|
+
} else {
|
|
1505
|
+
lines[rows - 1] = truncateToWidth(
|
|
1506
|
+
theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
|
|
1507
|
+
width,
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return lines;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
#renderLockedView(entry: SidebarEntry, width: number, rows: number): string[] {
|
|
1515
|
+
const lines: string[] = [];
|
|
1516
|
+
this.#lockedLoginLine = null;
|
|
1517
|
+
lines.push("");
|
|
1518
|
+
lines.push(truncateToWidth(theme.fg("warning", ` ${entry.label} has no credentials configured`), width));
|
|
1519
|
+
lines.push("");
|
|
1520
|
+
const envVars = entry.providerId ? (getCatalogProviderEntry(entry.providerId)?.envVars ?? []) : [];
|
|
1521
|
+
if (envVars.length > 0) {
|
|
1522
|
+
lines.push(
|
|
1523
|
+
truncateToWidth(
|
|
1524
|
+
theme.fg("muted", ` Set ${envVars.join(" or ")} in your environment, or add a key in config.`),
|
|
1525
|
+
width,
|
|
1526
|
+
),
|
|
1527
|
+
);
|
|
1528
|
+
} else {
|
|
1529
|
+
lines.push(truncateToWidth(theme.fg("muted", " Add an API key for this provider in config."), width));
|
|
1530
|
+
}
|
|
1531
|
+
if (entry.oauth) {
|
|
1532
|
+
this.#lockedLoginLine = lines.length + 1; // +1 for the status row offset handled by caller
|
|
1533
|
+
lines.push(truncateToWidth(theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (Enter)`), width));
|
|
1534
|
+
}
|
|
1535
|
+
lines.push("");
|
|
1536
|
+
const catalogCount = entry.catalogCount ?? 0;
|
|
1537
|
+
if (catalogCount > 0) {
|
|
1538
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${catalogCount} models in catalog:`), width));
|
|
1539
|
+
const preview = this.#scopedModels.length > 0 ? [] : this.#registry.getAll();
|
|
1540
|
+
for (const model of preview) {
|
|
1541
|
+
if (model.provider !== entry.providerId) continue;
|
|
1542
|
+
if (lines.length >= rows) break;
|
|
1543
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${model.id}`), width));
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
while (lines.length < rows) lines.push("");
|
|
1547
|
+
return lines.slice(0, rows);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
#footerHint(): string {
|
|
1551
|
+
const strip = this.#strip;
|
|
1552
|
+
if (strip) {
|
|
1553
|
+
if (strip.kind === "roleName") {
|
|
1554
|
+
return "Enter create + pick model · Esc cancel";
|
|
1555
|
+
}
|
|
1556
|
+
return strip.kind === "role"
|
|
1557
|
+
? "←/→ choose · Enter assign/clear · Esc cancel"
|
|
1558
|
+
: "←/→ thinking level · Enter apply · Esc keep";
|
|
1559
|
+
}
|
|
1560
|
+
if (this.#assigningRole !== null) {
|
|
1561
|
+
return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
|
|
1562
|
+
}
|
|
1563
|
+
const entry = this.#activeEntry();
|
|
1564
|
+
if (entry.kind === "roles") {
|
|
1565
|
+
return this.#focus === "list"
|
|
1566
|
+
? "↑/↓ roles · Enter pick · x clear · t thinking · c cycle · [/] reorder · n new · ← providers"
|
|
1567
|
+
: "↑/↓ providers · → roles · Esc close";
|
|
1568
|
+
}
|
|
1569
|
+
if (entry.kind === "provider" && entry.locked) {
|
|
1570
|
+
return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
|
|
1571
|
+
}
|
|
1572
|
+
const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
|
|
1573
|
+
const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
|
|
1574
|
+
if (this.#mode === "pick") {
|
|
1575
|
+
return `Enter use for this session · ${arrows} · type to search${refresh} · Esc close`;
|
|
1576
|
+
}
|
|
1577
|
+
return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** Footer row: active strip (chips) or the contextual hint line. */
|
|
1581
|
+
#renderFooter(width: number): string {
|
|
1582
|
+
this.#chipRanges = [];
|
|
1583
|
+
const strip = this.#strip;
|
|
1584
|
+
if (!strip) {
|
|
1585
|
+
return truncateToWidth(theme.fg("dim", this.#footerHint()), width);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if (strip.kind === "roleName") {
|
|
1589
|
+
const label = theme.fg("accent", "New role name:");
|
|
1590
|
+
const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
|
|
1591
|
+
const inputLine = strip.input.render(inputWidth)[0] ?? "";
|
|
1592
|
+
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const prefix =
|
|
1596
|
+
strip.kind === "role"
|
|
1597
|
+
? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
|
|
1598
|
+
: `${theme.fg(getRoleInfo(strip.role ?? "", this.#settings).color ?? "muted", (getRoleInfo(strip.role ?? "", this.#settings).tag ?? strip.role ?? "").toLowerCase())}${theme.fg("dim", ` · ${strip.item.id} →`)} `;
|
|
1599
|
+
|
|
1600
|
+
let line = prefix;
|
|
1601
|
+
// Columns are relative to the frame: row() insets content by 2.
|
|
1602
|
+
let col = 2 + visibleWidth(prefix);
|
|
1603
|
+
for (let i = 0; i < strip.chips.length; i++) {
|
|
1604
|
+
const chip = strip.chips[i];
|
|
1605
|
+
if (!chip) continue;
|
|
1606
|
+
const selected = i === strip.index;
|
|
1607
|
+
const body = ` ${chip.styled} `;
|
|
1608
|
+
const rendered = selected
|
|
1609
|
+
? theme.bg("selectedBg", `${theme.fg("accent", "[")}${body}${theme.fg("accent", "]")}`)
|
|
1610
|
+
: body;
|
|
1611
|
+
const w = visibleWidth(body) + (selected ? 2 : 0);
|
|
1612
|
+
this.#chipRanges.push({ start: col, end: col + w, index: i });
|
|
1613
|
+
line += rendered;
|
|
1614
|
+
col += w;
|
|
1615
|
+
line += " ";
|
|
1616
|
+
col += 1;
|
|
1617
|
+
}
|
|
1618
|
+
return truncateToWidth(line, width);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
render(width: number): string[] {
|
|
1622
|
+
const height = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
|
|
1623
|
+
const sidebarWidth = this.#sidebarWidth();
|
|
1624
|
+
this.#sidebarWidthLast = sidebarWidth;
|
|
1625
|
+
const bodyWidth = splitBodyWidth(width, sidebarWidth);
|
|
1626
|
+
const contentRows = Math.max(10, height - 4);
|
|
1627
|
+
this.#contentRowCount = contentRows;
|
|
1628
|
+
|
|
1629
|
+
const entry = this.#activeEntry();
|
|
1630
|
+
const bodyLines: string[] = [this.#statusRow(bodyWidth)];
|
|
1631
|
+
if (entry.kind === "roles" && this.#assigningRole === null) {
|
|
1632
|
+
bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1));
|
|
1633
|
+
} else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
|
|
1634
|
+
bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
|
|
1635
|
+
} else {
|
|
1636
|
+
this.#browser.setMaxVisible(contentRows - 1 - 5);
|
|
1637
|
+
bodyLines.push(...this.#browser.render(bodyWidth));
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows);
|
|
1641
|
+
|
|
1642
|
+
const title = this.#mode === "pick" ? "Switch Model" : "Models";
|
|
1643
|
+
const out: string[] = [];
|
|
1644
|
+
out.push(topBorderSplit(width, title, sidebarWidth));
|
|
1645
|
+
this.#contentRowStart = out.length;
|
|
1646
|
+
for (let i = 0; i < contentRows; i++) {
|
|
1647
|
+
out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth));
|
|
1648
|
+
}
|
|
1649
|
+
out.push(dividerSplit(width, sidebarWidth));
|
|
1650
|
+
this.#footerRow = out.length;
|
|
1651
|
+
out.push(row(this.#renderFooter(width - 4), width));
|
|
1652
|
+
out.push(bottomBorder(width));
|
|
1653
|
+
return out;
|
|
1654
|
+
}
|
|
1655
|
+
}
|