@wuyaos/pi-model-config 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/command.ts +1053 -1053
  3. package/src/selector.ts +433 -433
package/src/selector.ts CHANGED
@@ -1,433 +1,433 @@
1
- import type { AnyModel } from "@earendil-works/pi-ai";
2
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
3
- import {
4
- Input,
5
- Key,
6
- matchesKey,
7
- truncateToWidth,
8
- visibleWidth,
9
- wrapTextWithAnsi,
10
- type Component,
11
- type Focusable,
12
- type TUI,
13
- } from "@earendil-works/pi-tui";
14
- import { t, type UiLanguage } from "./i18n.ts";
15
- import { COMPAT_CONTEXT_WINDOW, COMPAT_MAX_TOKENS } from "./discovery.ts";
16
- import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, type FooterAction } from "./panel.ts";
17
- import type { CandidateModel, ManagedModel, ManagedModelType, ModelMetadataEntry, ProviderDraft, SelectionResult, ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
18
- import { identityOf } from "./types.ts";
19
- import { protocolThinkingMap } from "./thinking-defaults.ts";
20
- import { describeThinking } from "./model-editor.ts";
21
-
22
- function modelTypeLabel(language: UiLanguage, type: ManagedModelType): string {
23
- return type === "classifier" ? t(language, "selectorTypeClassifier")
24
- : type === "image" ? t(language, "selectorTypeImage")
25
- : t(language, "selectorTypeChat");
26
- }
27
-
28
- function pad(line: string, width: number): string {
29
- const clipped = truncateToWidth(line, Math.max(1, width), "");
30
- return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
31
- }
32
-
33
- function joinColumns(left: string[], right: string[], leftWidth: number, rightWidth: number): string[] {
34
- const height = Math.max(left.length, right.length);
35
- return Array.from({ length: height }, (_, index) => `${pad(left[index] ?? "", leftWidth)} ${pad(right[index] ?? "", rightWidth)}`);
36
- }
37
-
38
- function cloneManaged(model: ManagedModel): ManagedModel {
39
- return structuredClone(model);
40
- }
41
-
42
- function stripCandidate(model: CandidateModel): ManagedModel {
43
- const { source: _source, mutableType: _mutable, readOnly: _readOnly, readOnlyReason: _reason, contextSource: _contextSource, maxTokensSource: _maxTokensSource, thinkingSource: _thinkingSource, ...managed } = model;
44
- return managed as ManagedModel;
45
- }
46
-
47
- export function candidateFromInstalled(model: AnyModel): CandidateModel {
48
- const common = {
49
- id: model.id,
50
- name: model.name,
51
- api: model.api,
52
- baseUrl: model.baseUrl,
53
- input: [...model.input],
54
- cost: { ...model.cost },
55
- ...(model.headers ? { headers: { ...model.headers } } : {}),
56
- source: "installed" as const,
57
- mutableType: false,
58
- contextSource: model.type === "image" ? undefined : "catalog" as const,
59
- maxTokensSource: model.type === "chat" ? "catalog" as const : undefined,
60
- readOnly: true,
61
- readOnlyReason: "runtime-catalog" as const,
62
- };
63
- if (model.type === "classifier") return { ...common, type: "classifier", contextWindow: model.contextWindow };
64
- if (model.type === "image") return { ...common, type: "image", output: [...model.output] };
65
- return {
66
- ...common,
67
- type: "chat",
68
- reasoning: model.reasoning,
69
- ...(model.thinkingLevelMap ? { thinkingLevelMap: { ...model.thinkingLevelMap }, thinkingSource: "catalog" as const } : {}),
70
- contextWindow: model.contextWindow,
71
- maxTokens: model.maxTokens,
72
- };
73
- }
74
-
75
- export function candidateFromManaged(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
76
- return { ...cloneManaged(model), source: "managed", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined) } as CandidateModel;
77
- }
78
-
79
- export function candidateFromExternal(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
80
- return { ...cloneManaged(model), source: "external", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined), readOnly: true, readOnlyReason: "external-config" } as CandidateModel;
81
- }
82
-
83
- export function mergeCandidates(provider: string, installed: CandidateModel[], managed: CandidateModel[], remote: CandidateModel[]): CandidateModel[] {
84
- const rows = new Map<string, CandidateModel>();
85
- for (const model of managed) rows.set(identityOf(model, provider), model);
86
- for (const model of installed) {
87
- const key = identityOf(model, provider);
88
- if (!rows.has(key)) rows.set(key, model);
89
- }
90
- for (const model of remote) {
91
- const key = identityOf(model, provider);
92
- // Discovery may fill only a subset of metadata. Existing installed/managed/external
93
- // entries remain authoritative unless a future explicit field editor changes them.
94
- if (!rows.has(key)) rows.set(key, model);
95
- }
96
- return [...rows.values()].sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id, undefined, { numeric: true }));
97
- }
98
-
99
- export interface ModelPanelState {
100
- search: string;
101
- selectedId?: string;
102
- focus: "search" | "list" | "details" | "actions";
103
- actionIndex: number;
104
- detailScroll: number;
105
- }
106
-
107
- interface CycleMetadata {
108
- contextWindow?: number;
109
- maxTokens?: number;
110
- reasoning?: boolean;
111
- thinkingLevelMap?: ThinkingLevelMap;
112
- thinkingSource?: ThinkingMetadataSource;
113
- }
114
-
115
- class ModelSelector implements Component, Focusable {
116
- private search: Input;
117
- private cycleMetadata = new WeakMap<object, CycleMetadata>();
118
- private selected = new Set<string>();
119
- private filtered: CandidateModel[] = [];
120
- private index = 0;
121
- private status = "";
122
- private detailMaxScroll = 0;
123
- private _focused = false;
124
- private finished = false;
125
-
126
- constructor(
127
- private readonly tui: TUI,
128
- private readonly theme: Theme,
129
- private readonly language: UiLanguage,
130
- private readonly provider: string,
131
- private readonly draft: ProviderDraft,
132
- private readonly rows: CandidateModel[],
133
- private readonly initialSelected: ManagedModel[] | undefined,
134
- private readonly state: ModelPanelState,
135
- private readonly done: (result: SelectionResult) => void,
136
- private readonly initialMetadata: ModelMetadataEntry[] | undefined,
137
- ) {
138
- this.search = new Input({ prompt: t(language, "selectorSearchPrompt"), placeholder: t(language, "selectorSearchPlaceholder") });
139
- this.search.setValue(state.search);
140
- if (initialSelected) {
141
- const selectedModels = new Map(initialSelected.map((model) => [identityOf(model, provider), model]));
142
- const selectedMetadata = new Map((initialMetadata ?? []).map((entry) => [identityOf(entry, provider), entry]));
143
- for (const row of rows) {
144
- const key = identityOf(row, provider);
145
- const selected = selectedModels.get(key);
146
- const metadata = selectedMetadata.get(key);
147
- if (selected && !row.readOnly) Object.assign(row, cloneManaged(selected));
148
- if (metadata && !row.readOnly) Object.assign(row, {
149
- contextSource: metadata.contextSource,
150
- maxTokensSource: metadata.maxTokensSource,
151
- thinkingSource: metadata.thinkingSource,
152
- });
153
- }
154
- }
155
- for (const row of rows) {
156
- this.cycleMetadata.set(row, {
157
- ...(row.type !== "image" ? { contextWindow: row.contextWindow } : {}),
158
- ...(row.type === "chat" ? { maxTokens: row.maxTokens, reasoning: row.reasoning, thinkingLevelMap: row.thinkingLevelMap ? { ...row.thinkingLevelMap } : undefined, thinkingSource: row.thinkingSource } : {}),
159
- });
160
- if (row.source === "managed" || row.source === "external") this.selected.add(identityOf(row, provider));
161
- }
162
- if (initialSelected) {
163
- for (const row of rows) if (!row.readOnly) this.selected.delete(identityOf(row, provider));
164
- for (const model of initialSelected) this.selected.add(identityOf(model, provider));
165
- }
166
- this.search.onSubmit = () => this.toggleCurrent();
167
- this.refresh();
168
- const remembered = state.selectedId ? this.filtered.findIndex((row) => row.id === state.selectedId) : -1;
169
- if (remembered >= 0) this.index = remembered;
170
- this.syncCurrent();
171
- }
172
-
173
- get focused(): boolean { return this._focused; }
174
- set focused(value: boolean) {
175
- this._focused = value;
176
- this.search.focused = value && this.state.focus === "search";
177
- }
178
-
179
- private request(): void {
180
- this.search.focused = this._focused && this.state.focus === "search";
181
- this.tui.requestRender();
182
- }
183
-
184
- private current(): CandidateModel | undefined { return this.filtered[this.index]; }
185
- private syncCurrent(): void { this.state.selectedId = this.current()?.id; }
186
-
187
- private selectedRows(): CandidateModel[] { return this.rows.filter((row) => !row.readOnly && this.selected.has(this.key(row))); }
188
- private selectedModels(): ManagedModel[] { return this.selectedRows().map(stripCandidate); }
189
- private selectedMetadata(): ModelMetadataEntry[] { return this.selectedRows().map((row) => ({ type: row.type, id: row.id, contextSource: row.contextSource, maxTokensSource: row.maxTokensSource, thinkingSource: row.type === "chat" ? row.thinkingSource : undefined })); }
190
-
191
- private refresh(): void {
192
- const query = this.search.getValue().trim().toLowerCase();
193
- this.state.search = this.search.getValue();
194
- this.filtered = query
195
- ? this.rows.filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query))
196
- : [...this.rows];
197
- this.index = Math.min(this.index, Math.max(0, this.filtered.length - 1));
198
- this.syncCurrent();
199
- this.invalidate();
200
- this.request();
201
- }
202
-
203
- private key(model: CandidateModel): string { return identityOf(model, this.provider); }
204
-
205
- private toggleCurrent(): void {
206
- const row = this.filtered[this.index];
207
- if (!row) return;
208
- if (row.readOnly) {
209
- this.status = t(this.language, row.readOnlyReason === "external-config" ? "selectorReadonlyExternal" : row.readOnlyReason === "runtime-catalog" ? "selectorReadonlyRuntime" : "selectorReadonlyStatus");
210
- this.tui.requestRender();
211
- return;
212
- }
213
- this.status = "";
214
- const key = this.key(row);
215
- if (this.selected.has(key)) this.selected.delete(key); else this.selected.add(key);
216
- this.tui.requestRender();
217
- }
218
-
219
- private cycleType(): void {
220
- const row = this.filtered[this.index];
221
- if (!row?.mutableType) return;
222
- const types: ManagedModelType[] = ["chat", "classifier", "image"];
223
- const nextType = types[(types.indexOf(row.type) + 1) % types.length]!;
224
- const oldKey = this.key(row);
225
- const metadata = this.cycleMetadata.get(row) ?? {};
226
- const nextKey = identityOf({ id: row.id, type: nextType }, this.provider);
227
- if (this.rows.some((candidate) => candidate !== row && this.key(candidate) === nextKey)) {
228
- this.status = t(this.language, "selectorTypeConflict", { type: modelTypeLabel(this.language, nextType), identity: `${modelTypeLabel(this.language, nextType)}/${row.id}` });
229
- this.tui.requestRender();
230
- return;
231
- }
232
- this.status = "";
233
- const wasSelected = this.selected.delete(oldKey);
234
- const common = {
235
- id: row.id,
236
- name: row.name,
237
- baseUrl: row.baseUrl,
238
- input: row.input,
239
- cost: row.cost,
240
- source: row.source,
241
- mutableType: true,
242
- };
243
- let next: CandidateModel;
244
- if (nextType === "classifier") {
245
- next = { ...common, type: nextType, api: this.draft.classifierApi, contextWindow: metadata.contextWindow ?? COMPAT_CONTEXT_WINDOW };
246
- } else if (nextType === "image") {
247
- next = { ...common, type: nextType, api: this.draft.imageApi, output: ["image"] };
248
- } else {
249
- const protocolMap = metadata.thinkingLevelMap ? undefined : protocolThinkingMap(this.draft.chatApi);
250
- next = {
251
- ...common,
252
- type: nextType,
253
- api: this.draft.chatApi,
254
- reasoning: metadata.reasoning ?? true,
255
- ...(metadata.thinkingLevelMap ? { thinkingLevelMap: { ...metadata.thinkingLevelMap }, thinkingSource: metadata.thinkingSource }
256
- : protocolMap ? { thinkingLevelMap: protocolMap, thinkingSource: "protocol" as const } : {}),
257
- contextWindow: metadata.contextWindow ?? COMPAT_CONTEXT_WINDOW,
258
- maxTokens: metadata.maxTokens ?? COMPAT_MAX_TOKENS,
259
- };
260
- }
261
- this.cycleMetadata.set(next, metadata);
262
- const rowIndex = this.rows.indexOf(row);
263
- if (rowIndex >= 0) this.rows[rowIndex] = next;
264
- if (wasSelected) this.selected.add(this.key(next));
265
- this.refresh();
266
- }
267
-
268
- private actions(): FooterAction[] {
269
- return [
270
- { id: "search", label: t(this.language, "modelSearch"), key: "/" },
271
- { id: "selectFiltered", label: t(this.language, "modelSelectFiltered"), key: Key.ctrl("a") },
272
- { id: "deselectFiltered", label: t(this.language, "modelDeselectFiltered"), key: Key.ctrl("d") },
273
- { id: "cycleType", label: t(this.language, "modelCycleType"), key: Key.ctrl("t") },
274
- { id: "edit", label: t(this.language, "modelEditParameters"), key: Key.ctrl("e") },
275
- { id: "probe", label: t(this.language, "modelProbeContext"), key: Key.ctrl("k") },
276
- { id: "probeThinking", label: t(this.language, "modelProbeThinking"), key: Key.ctrl("l") },
277
- { id: "fetch", label: t(this.language, "modelFetch"), key: Key.ctrl("r") },
278
- { id: "preview", label: t(this.language, "modelPreview"), key: Key.ctrl("s") },
279
- { id: "back", label: t(this.language, "modelBack"), key: Key.escape },
280
- ];
281
- }
282
-
283
- private finish(action: SelectionResult["action"]): void {
284
- if (this.finished) return;
285
- this.finished = true;
286
- const current = this.current();
287
- this.done({
288
- action,
289
- cancelled: action === "back",
290
- selected: this.selectedModels(),
291
- metadata: this.selectedMetadata(),
292
- ...(current ? { target: { id: current.id, type: current.type, source: current.source, readOnlyReason: current.readOnlyReason } } : {}),
293
- });
294
- }
295
-
296
- private activate(action: FooterAction | undefined): void {
297
- if (!action) return;
298
- if (action.id === "search") { this.state.focus = "search"; this.request(); return; }
299
- if (action.id === "selectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row)); this.request(); return; }
300
- if (action.id === "deselectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row)); this.request(); return; }
301
- if (action.id === "cycleType") { this.cycleType(); return; }
302
- this.finish(action.id as SelectionResult["action"]);
303
- }
304
-
305
- handleInput(data: string): void {
306
- if (data === "/") { this.state.focus = "search"; this.request(); return; }
307
- if (matchesKey(data, Key.ctrl("r"))) { this.finish("fetch"); return; }
308
- if (matchesKey(data, Key.ctrl("e"))) { this.finish("edit"); return; }
309
- if (matchesKey(data, Key.ctrl("k"))) { this.finish("probe"); return; }
310
- if (matchesKey(data, Key.ctrl("l"))) { this.finish("probeThinking"); return; }
311
- if (matchesKey(data, Key.ctrl("s"))) { this.finish("preview"); return; }
312
- if (matchesKey(data, Key.escape)) { this.finish("back"); return; }
313
- if (matchesKey(data, Key.ctrl("t"))) { this.cycleType(); return; }
314
- if (matchesKey(data, Key.ctrl("a"))) {
315
- for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row));
316
- this.tui.requestRender();
317
- return;
318
- }
319
- if (matchesKey(data, Key.ctrl("d"))) {
320
- for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row));
321
- this.tui.requestRender();
322
- return;
323
- }
324
- if (matchesKey(data, Key.tab) || matchesKey(data, "shift+tab")) {
325
- const order: Array<ModelPanelState["focus"]> = ["search", "list", "details", "actions"];
326
- const current = Math.max(0, order.indexOf(this.state.focus));
327
- const delta = matchesKey(data, "shift+tab") ? -1 : 1;
328
- this.state.focus = order[(current + delta + order.length) % order.length]!;
329
- this.request();
330
- return;
331
- }
332
- if (matchesKey(data, Key.left)) { if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex - 1 + actions.length) % actions.length; } else if (this.state.focus === "details") this.state.focus = "list"; this.request(); return; }
333
- if (matchesKey(data, Key.right)) { if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex + 1) % actions.length; } else if (this.state.focus === "search" || this.state.focus === "list") this.state.focus = "details"; else if (this.state.focus === "details") this.state.focus = "actions"; this.request(); return; }
334
- if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
335
- const delta = matchesKey(data, Key.up) ? -1 : 1;
336
- if (this.state.focus === "actions") { this.request(); return; } else if ((this.state.focus === "search" || this.state.focus === "list" || this.state.focus === "details") && this.filtered.length) {
337
- this.index = Math.max(0, Math.min(this.filtered.length - 1, this.index + delta));
338
- this.state.detailScroll = 0;
339
- this.syncCurrent();
340
- }
341
- this.request();
342
- return;
343
- }
344
- if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
345
- this.state.detailScroll = Math.min(this.detailMaxScroll, Math.max(0, this.state.detailScroll + (matchesKey(data, Key.pageUp) ? -5 : 5)));
346
- this.request();
347
- return;
348
- }
349
- if (matchesKey(data, Key.enter)) {
350
- if (this.state.focus === "actions") this.activate(this.actions()[this.state.actionIndex]);
351
- else this.toggleCurrent();
352
- return;
353
- }
354
- if (matchesKey(data, Key.space) && this.state.focus === "actions") {
355
- this.activate(this.actions()[this.state.actionIndex]);
356
- return;
357
- }
358
- if (this.state.focus !== "search") return;
359
- this.search.handleInput(data);
360
- this.refresh();
361
- }
362
-
363
- render(width: number): string[] {
364
- const safeWidth = Math.max(4, width);
365
- if (safeWidth < 24) return renderPanelBox([truncateToWidth(this.theme.fg("warning", t(this.language, "selectorNarrow")), Math.max(1, safeWidth - 2), "")], safeWidth, t(this.language, "selectorTitle", { provider: this.provider }), this.theme);
366
- const innerWidth = Math.max(1, safeWidth - 2);
367
- const wide = innerWidth >= 68;
368
- const contentBudget = Math.max(2, overlayViewportRows(this.tui) - 2);
369
- const actions = this.actions();
370
- this.state.actionIndex = Math.min(this.state.actionIndex, actions.length - 1);
371
- const actionFooter = renderActionFooter(actions, this.state.actionIndex, this.state.focus === "actions", innerWidth, this.theme);
372
- const searchLines = this.search.render(innerWidth);
373
- const statusLines = this.status ? [this.theme.fg("warning", this.status)] : [];
374
- const externalCount = this.rows.filter((row) => row.readOnly).length;
375
- const countLine = this.theme.fg("muted", t(this.language, "selectorFooter", { filtered: this.filtered.length, total: this.rows.length, selected: this.selected.size, readonly: externalCount }));
376
- const bodyBudget = Math.max(0, contentBudget - searchLines.length - statusLines.length - actionFooter.length - 1);
377
- const leftWidth = wide ? Math.max(28, Math.floor((innerWidth - 2) * 0.5)) : innerWidth;
378
- const rightWidth = wide ? innerWidth - leftWidth - 2 : innerWidth;
379
- const left: string[] = [];
380
- if (!this.filtered.length) left.push(this.theme.fg("muted", t(this.language, "selectorNoMatches")));
381
- const visible = Math.max(0, bodyBudget);
382
- const start = Math.max(0, Math.min(this.index - Math.floor(visible / 2), Math.max(0, this.filtered.length - visible)));
383
- for (let i = start; i < Math.min(start + visible, this.filtered.length); i++) {
384
- const row = this.filtered[i]!;
385
- const checked = this.selected.has(this.key(row));
386
- const currentRow = i === this.index;
387
- const indicator = currentRow ? "→ " : " ";
388
- const check = checked ? this.theme.fg("success", "✓") : " ";
389
- const source = row.readOnly ? t(this.language, "selectorReadonlyTag") : "";
390
- const prefix = `${indicator}[${check}] [${modelTypeLabel(this.language, row.type)}] `;
391
- const suffix = ` · ${row.name}${source ? ` · ${source}` : ""}`;
392
- const available = Math.max(1, leftWidth - visibleWidth(prefix) - visibleWidth(suffix));
393
- const content = `${prefix}${truncateToWidth(row.id, available, "…")}${suffix}`;
394
- left.push(truncateToWidth(currentRow ? this.theme.fg("accent", this.theme.bold(content)) : content, leftWidth, ""));
395
- }
396
- const current = this.current();
397
- const detailText = current ? [
398
- t(this.language, "panelModelName", { value: current.name }),
399
- t(this.language, "panelModelId", { value: current.id }),
400
- t(this.language, "panelModelType", { value: modelTypeLabel(this.language, current.type) }),
401
- t(this.language, "panelModelApi", { value: current.api }),
402
- ...(current.type !== "image" ? [current.contextSource === "compat" ? t(this.language, "compatContextDisplay") : t(this.language, "panelModelContext", { value: current.contextWindow })] : []),
403
- ...(current.type === "chat" ? [current.maxTokensSource === "compat" ? t(this.language, "compatMaxDisplay") : t(this.language, "panelModelMaxTokens", { value: current.maxTokens }), describeThinking(this.language, current, { thinkingSource: current.thinkingSource })] : []),
404
- t(this.language, "panelModelInput", { value: current.input.join(", ") || t(this.language, "valueUnset") }),
405
- ...(current.type === "image" ? [t(this.language, "panelModelOutput", { value: current.output.join(", ") || t(this.language, "valueUnset") })] : []),
406
- t(this.language, "panelModelCost", { input: current.cost.input, output: current.cost.output, cacheRead: current.cost.cacheRead, cacheWrite: current.cost.cacheWrite }),
407
- t(this.language, "panelModelBaseUrl", { value: current.baseUrl ?? t(this.language, "valueDefault") }),
408
- ...(current.readOnly ? [t(this.language, current.readOnlyReason === "external-config" ? "panelModelReadonlyExternal" : current.readOnlyReason === "runtime-catalog" ? "panelModelReadonlyRuntime" : "panelModelReadonly")] : []),
409
- ] : [];
410
- const rightAll = detailText.flatMap((line) => wrapTextWithAnsi(this.theme.fg("muted", line), rightWidth));
411
- this.detailMaxScroll = Math.max(0, rightAll.length - bodyBudget);
412
- this.state.detailScroll = Math.min(this.state.detailScroll, this.detailMaxScroll);
413
- const right = rightAll.slice(this.state.detailScroll, this.state.detailScroll + bodyBudget);
414
- const body = wide ? joinColumns(left.slice(0, bodyBudget), right, leftWidth, rightWidth) : this.state.focus === "details" ? right : left.slice(0, bodyBudget);
415
- const content = [...searchLines, ...statusLines, ...body.slice(0, bodyBudget), countLine, ...actionFooter].slice(0, contentBudget);
416
- return renderPanelBox(content, safeWidth, t(this.language, "selectorTitle", { provider: this.provider }), this.theme);
417
- }
418
-
419
- invalidate(): void { this.search.invalidate(); }
420
- }
421
-
422
- export async function selectModels(
423
- ctx: ExtensionCommandContext,
424
- language: UiLanguage,
425
- provider: string,
426
- draft: ProviderDraft,
427
- rows: CandidateModel[],
428
- initialSelected?: ManagedModel[],
429
- state: ModelPanelState = { search: "", focus: "list", actionIndex: 0, detailScroll: 0 },
430
- initialMetadata?: ModelMetadataEntry[],
431
- ): Promise<SelectionResult> {
432
- return ctx.ui.custom<SelectionResult>((tui, theme, _keybindings, done) => new ModelSelector(tui, theme, language, provider, draft, rows, initialSelected, state, done, initialMetadata), MODEL_CONFIG_OVERLAY_OPTIONS);
433
- }
1
+ import type { AnyModel } from "@earendil-works/pi-ai";
2
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ Input,
5
+ Key,
6
+ matchesKey,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ wrapTextWithAnsi,
10
+ type Component,
11
+ type Focusable,
12
+ type TUI,
13
+ } from "@earendil-works/pi-tui";
14
+ import { t, type UiLanguage } from "./i18n.ts";
15
+ import { COMPAT_CONTEXT_WINDOW, COMPAT_MAX_TOKENS } from "./discovery.ts";
16
+ import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, type FooterAction } from "./panel.ts";
17
+ import type { CandidateModel, ManagedModel, ManagedModelType, ModelMetadataEntry, ProviderDraft, SelectionResult, ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
18
+ import { identityOf } from "./types.ts";
19
+ import { protocolThinkingMap } from "./thinking-defaults.ts";
20
+ import { describeThinking } from "./model-editor.ts";
21
+
22
+ function modelTypeLabel(language: UiLanguage, type: ManagedModelType): string {
23
+ return type === "classifier" ? t(language, "selectorTypeClassifier")
24
+ : type === "image" ? t(language, "selectorTypeImage")
25
+ : t(language, "selectorTypeChat");
26
+ }
27
+
28
+ function pad(line: string, width: number): string {
29
+ const clipped = truncateToWidth(line, Math.max(1, width), "");
30
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
31
+ }
32
+
33
+ function joinColumns(left: string[], right: string[], leftWidth: number, rightWidth: number): string[] {
34
+ const height = Math.max(left.length, right.length);
35
+ return Array.from({ length: height }, (_, index) => `${pad(left[index] ?? "", leftWidth)} ${pad(right[index] ?? "", rightWidth)}`);
36
+ }
37
+
38
+ function cloneManaged(model: ManagedModel): ManagedModel {
39
+ return structuredClone(model);
40
+ }
41
+
42
+ function stripCandidate(model: CandidateModel): ManagedModel {
43
+ const { source: _source, mutableType: _mutable, readOnly: _readOnly, readOnlyReason: _reason, contextSource: _contextSource, maxTokensSource: _maxTokensSource, thinkingSource: _thinkingSource, ...managed } = model;
44
+ return managed as ManagedModel;
45
+ }
46
+
47
+ export function candidateFromInstalled(model: AnyModel): CandidateModel {
48
+ const common = {
49
+ id: model.id,
50
+ name: model.name,
51
+ api: model.api,
52
+ baseUrl: model.baseUrl,
53
+ input: [...model.input],
54
+ cost: { ...model.cost },
55
+ ...(model.headers ? { headers: { ...model.headers } } : {}),
56
+ source: "installed" as const,
57
+ mutableType: false,
58
+ contextSource: model.type === "image" ? undefined : "catalog" as const,
59
+ maxTokensSource: model.type === "chat" ? "catalog" as const : undefined,
60
+ readOnly: true,
61
+ readOnlyReason: "runtime-catalog" as const,
62
+ };
63
+ if (model.type === "classifier") return { ...common, type: "classifier", contextWindow: model.contextWindow };
64
+ if (model.type === "image") return { ...common, type: "image", output: [...model.output] };
65
+ return {
66
+ ...common,
67
+ type: "chat",
68
+ reasoning: model.reasoning,
69
+ ...(model.thinkingLevelMap ? { thinkingLevelMap: { ...model.thinkingLevelMap }, thinkingSource: "catalog" as const } : {}),
70
+ contextWindow: model.contextWindow,
71
+ maxTokens: model.maxTokens,
72
+ };
73
+ }
74
+
75
+ export function candidateFromManaged(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
76
+ return { ...cloneManaged(model), source: "managed", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined) } as CandidateModel;
77
+ }
78
+
79
+ export function candidateFromExternal(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
80
+ return { ...cloneManaged(model), source: "external", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined), readOnly: true, readOnlyReason: "external-config" } as CandidateModel;
81
+ }
82
+
83
+ export function mergeCandidates(provider: string, installed: CandidateModel[], managed: CandidateModel[], remote: CandidateModel[]): CandidateModel[] {
84
+ const rows = new Map<string, CandidateModel>();
85
+ for (const model of managed) rows.set(identityOf(model, provider), model);
86
+ for (const model of installed) {
87
+ const key = identityOf(model, provider);
88
+ if (!rows.has(key)) rows.set(key, model);
89
+ }
90
+ for (const model of remote) {
91
+ const key = identityOf(model, provider);
92
+ // Discovery may fill only a subset of metadata. Existing installed/managed/external
93
+ // entries remain authoritative unless a future explicit field editor changes them.
94
+ if (!rows.has(key)) rows.set(key, model);
95
+ }
96
+ return [...rows.values()].sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id, undefined, { numeric: true }));
97
+ }
98
+
99
+ export interface ModelPanelState {
100
+ search: string;
101
+ selectedId?: string;
102
+ focus: "search" | "list" | "details" | "actions";
103
+ actionIndex: number;
104
+ detailScroll: number;
105
+ }
106
+
107
+ interface CycleMetadata {
108
+ contextWindow?: number;
109
+ maxTokens?: number;
110
+ reasoning?: boolean;
111
+ thinkingLevelMap?: ThinkingLevelMap;
112
+ thinkingSource?: ThinkingMetadataSource;
113
+ }
114
+
115
+ class ModelSelector implements Component, Focusable {
116
+ private search: Input;
117
+ private cycleMetadata = new WeakMap<object, CycleMetadata>();
118
+ private selected = new Set<string>();
119
+ private filtered: CandidateModel[] = [];
120
+ private index = 0;
121
+ private status = "";
122
+ private detailMaxScroll = 0;
123
+ private _focused = false;
124
+ private finished = false;
125
+
126
+ constructor(
127
+ private readonly tui: TUI,
128
+ private readonly theme: Theme,
129
+ private readonly language: UiLanguage,
130
+ private readonly provider: string,
131
+ private readonly draft: ProviderDraft,
132
+ private readonly rows: CandidateModel[],
133
+ private readonly initialSelected: ManagedModel[] | undefined,
134
+ private readonly state: ModelPanelState,
135
+ private readonly done: (result: SelectionResult) => void,
136
+ private readonly initialMetadata: ModelMetadataEntry[] | undefined,
137
+ ) {
138
+ this.search = new Input({ prompt: t(language, "selectorSearchPrompt"), placeholder: t(language, "selectorSearchPlaceholder") });
139
+ this.search.setValue(state.search);
140
+ if (initialSelected) {
141
+ const selectedModels = new Map(initialSelected.map((model) => [identityOf(model, provider), model]));
142
+ const selectedMetadata = new Map((initialMetadata ?? []).map((entry) => [identityOf(entry, provider), entry]));
143
+ for (const row of rows) {
144
+ const key = identityOf(row, provider);
145
+ const selected = selectedModels.get(key);
146
+ const metadata = selectedMetadata.get(key);
147
+ if (selected && !row.readOnly) Object.assign(row, cloneManaged(selected));
148
+ if (metadata && !row.readOnly) Object.assign(row, {
149
+ contextSource: metadata.contextSource,
150
+ maxTokensSource: metadata.maxTokensSource,
151
+ thinkingSource: metadata.thinkingSource,
152
+ });
153
+ }
154
+ }
155
+ for (const row of rows) {
156
+ this.cycleMetadata.set(row, {
157
+ ...(row.type !== "image" ? { contextWindow: row.contextWindow } : {}),
158
+ ...(row.type === "chat" ? { maxTokens: row.maxTokens, reasoning: row.reasoning, thinkingLevelMap: row.thinkingLevelMap ? { ...row.thinkingLevelMap } : undefined, thinkingSource: row.thinkingSource } : {}),
159
+ });
160
+ if (row.source === "managed" || row.source === "external") this.selected.add(identityOf(row, provider));
161
+ }
162
+ if (initialSelected) {
163
+ for (const row of rows) if (!row.readOnly) this.selected.delete(identityOf(row, provider));
164
+ for (const model of initialSelected) this.selected.add(identityOf(model, provider));
165
+ }
166
+ this.search.onSubmit = () => this.toggleCurrent();
167
+ this.refresh();
168
+ const remembered = state.selectedId ? this.filtered.findIndex((row) => row.id === state.selectedId) : -1;
169
+ if (remembered >= 0) this.index = remembered;
170
+ this.syncCurrent();
171
+ }
172
+
173
+ get focused(): boolean { return this._focused; }
174
+ set focused(value: boolean) {
175
+ this._focused = value;
176
+ this.search.focused = value && this.state.focus === "search";
177
+ }
178
+
179
+ private request(): void {
180
+ this.search.focused = this._focused && this.state.focus === "search";
181
+ this.tui.requestRender();
182
+ }
183
+
184
+ private current(): CandidateModel | undefined { return this.filtered[this.index]; }
185
+ private syncCurrent(): void { this.state.selectedId = this.current()?.id; }
186
+
187
+ private selectedRows(): CandidateModel[] { return this.rows.filter((row) => !row.readOnly && this.selected.has(this.key(row))); }
188
+ private selectedModels(): ManagedModel[] { return this.selectedRows().map(stripCandidate); }
189
+ private selectedMetadata(): ModelMetadataEntry[] { return this.selectedRows().map((row) => ({ type: row.type, id: row.id, contextSource: row.contextSource, maxTokensSource: row.maxTokensSource, thinkingSource: row.type === "chat" ? row.thinkingSource : undefined })); }
190
+
191
+ private refresh(): void {
192
+ const query = this.search.getValue().trim().toLowerCase();
193
+ this.state.search = this.search.getValue();
194
+ this.filtered = query
195
+ ? this.rows.filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query))
196
+ : [...this.rows];
197
+ this.index = Math.min(this.index, Math.max(0, this.filtered.length - 1));
198
+ this.syncCurrent();
199
+ this.invalidate();
200
+ this.request();
201
+ }
202
+
203
+ private key(model: CandidateModel): string { return identityOf(model, this.provider); }
204
+
205
+ private toggleCurrent(): void {
206
+ const row = this.filtered[this.index];
207
+ if (!row) return;
208
+ if (row.readOnly) {
209
+ this.status = t(this.language, row.readOnlyReason === "external-config" ? "selectorReadonlyExternal" : row.readOnlyReason === "runtime-catalog" ? "selectorReadonlyRuntime" : "selectorReadonlyStatus");
210
+ this.tui.requestRender();
211
+ return;
212
+ }
213
+ this.status = "";
214
+ const key = this.key(row);
215
+ if (this.selected.has(key)) this.selected.delete(key); else this.selected.add(key);
216
+ this.tui.requestRender();
217
+ }
218
+
219
+ private cycleType(): void {
220
+ const row = this.filtered[this.index];
221
+ if (!row?.mutableType) return;
222
+ const types: ManagedModelType[] = ["chat", "classifier", "image"];
223
+ const nextType = types[(types.indexOf(row.type) + 1) % types.length]!;
224
+ const oldKey = this.key(row);
225
+ const metadata = this.cycleMetadata.get(row) ?? {};
226
+ const nextKey = identityOf({ id: row.id, type: nextType }, this.provider);
227
+ if (this.rows.some((candidate) => candidate !== row && this.key(candidate) === nextKey)) {
228
+ this.status = t(this.language, "selectorTypeConflict", { type: modelTypeLabel(this.language, nextType), identity: `${modelTypeLabel(this.language, nextType)}/${row.id}` });
229
+ this.tui.requestRender();
230
+ return;
231
+ }
232
+ this.status = "";
233
+ const wasSelected = this.selected.delete(oldKey);
234
+ const common = {
235
+ id: row.id,
236
+ name: row.name,
237
+ baseUrl: row.baseUrl,
238
+ input: row.input,
239
+ cost: row.cost,
240
+ source: row.source,
241
+ mutableType: true,
242
+ };
243
+ let next: CandidateModel;
244
+ if (nextType === "classifier") {
245
+ next = { ...common, type: nextType, api: this.draft.classifierApi, contextWindow: metadata.contextWindow ?? COMPAT_CONTEXT_WINDOW };
246
+ } else if (nextType === "image") {
247
+ next = { ...common, type: nextType, api: this.draft.imageApi, output: ["image"] };
248
+ } else {
249
+ const protocolMap = metadata.thinkingLevelMap ? undefined : protocolThinkingMap(this.draft.chatApi);
250
+ next = {
251
+ ...common,
252
+ type: nextType,
253
+ api: this.draft.chatApi,
254
+ reasoning: metadata.reasoning ?? true,
255
+ ...(metadata.thinkingLevelMap ? { thinkingLevelMap: { ...metadata.thinkingLevelMap }, thinkingSource: metadata.thinkingSource }
256
+ : protocolMap ? { thinkingLevelMap: protocolMap, thinkingSource: "protocol" as const } : {}),
257
+ contextWindow: metadata.contextWindow ?? COMPAT_CONTEXT_WINDOW,
258
+ maxTokens: metadata.maxTokens ?? COMPAT_MAX_TOKENS,
259
+ };
260
+ }
261
+ this.cycleMetadata.set(next, metadata);
262
+ const rowIndex = this.rows.indexOf(row);
263
+ if (rowIndex >= 0) this.rows[rowIndex] = next;
264
+ if (wasSelected) this.selected.add(this.key(next));
265
+ this.refresh();
266
+ }
267
+
268
+ private actions(): FooterAction[] {
269
+ return [
270
+ { id: "search", label: t(this.language, "modelSearch"), key: "/" },
271
+ { id: "selectFiltered", label: t(this.language, "modelSelectFiltered"), key: Key.ctrl("a") },
272
+ { id: "deselectFiltered", label: t(this.language, "modelDeselectFiltered"), key: Key.ctrl("d") },
273
+ { id: "cycleType", label: t(this.language, "modelCycleType"), key: Key.ctrl("t") },
274
+ { id: "edit", label: t(this.language, "modelEditParameters"), key: Key.ctrl("e") },
275
+ { id: "probe", label: t(this.language, "modelProbeContext"), key: Key.ctrl("k") },
276
+ { id: "probeThinking", label: t(this.language, "modelProbeThinking"), key: Key.ctrl("l") },
277
+ { id: "fetch", label: t(this.language, "modelFetch"), key: Key.ctrl("r") },
278
+ { id: "preview", label: t(this.language, "modelPreview"), key: Key.ctrl("s") },
279
+ { id: "back", label: t(this.language, "modelBack"), key: Key.escape },
280
+ ];
281
+ }
282
+
283
+ private finish(action: SelectionResult["action"]): void {
284
+ if (this.finished) return;
285
+ this.finished = true;
286
+ const current = this.current();
287
+ this.done({
288
+ action,
289
+ cancelled: action === "back",
290
+ selected: this.selectedModels(),
291
+ metadata: this.selectedMetadata(),
292
+ ...(current ? { target: { id: current.id, type: current.type, source: current.source, readOnlyReason: current.readOnlyReason } } : {}),
293
+ });
294
+ }
295
+
296
+ private activate(action: FooterAction | undefined): void {
297
+ if (!action) return;
298
+ if (action.id === "search") { this.state.focus = "search"; this.request(); return; }
299
+ if (action.id === "selectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row)); this.request(); return; }
300
+ if (action.id === "deselectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row)); this.request(); return; }
301
+ if (action.id === "cycleType") { this.cycleType(); return; }
302
+ this.finish(action.id as SelectionResult["action"]);
303
+ }
304
+
305
+ handleInput(data: string): void {
306
+ if (data === "/") { this.state.focus = "search"; this.request(); return; }
307
+ if (matchesKey(data, Key.ctrl("r"))) { this.finish("fetch"); return; }
308
+ if (matchesKey(data, Key.ctrl("e"))) { this.finish("edit"); return; }
309
+ if (matchesKey(data, Key.ctrl("k"))) { this.finish("probe"); return; }
310
+ if (matchesKey(data, Key.ctrl("l"))) { this.finish("probeThinking"); return; }
311
+ if (matchesKey(data, Key.ctrl("s"))) { this.finish("preview"); return; }
312
+ if (matchesKey(data, Key.escape)) { this.finish("back"); return; }
313
+ if (matchesKey(data, Key.ctrl("t"))) { this.cycleType(); return; }
314
+ if (matchesKey(data, Key.ctrl("a"))) {
315
+ for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row));
316
+ this.tui.requestRender();
317
+ return;
318
+ }
319
+ if (matchesKey(data, Key.ctrl("d"))) {
320
+ for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row));
321
+ this.tui.requestRender();
322
+ return;
323
+ }
324
+ if (matchesKey(data, Key.tab) || matchesKey(data, "shift+tab")) {
325
+ const order: Array<ModelPanelState["focus"]> = ["search", "list", "details", "actions"];
326
+ const current = Math.max(0, order.indexOf(this.state.focus));
327
+ const delta = matchesKey(data, "shift+tab") ? -1 : 1;
328
+ this.state.focus = order[(current + delta + order.length) % order.length]!;
329
+ this.request();
330
+ return;
331
+ }
332
+ if (matchesKey(data, Key.left)) { if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex - 1 + actions.length) % actions.length; } else if (this.state.focus === "details") this.state.focus = "list"; this.request(); return; }
333
+ if (matchesKey(data, Key.right)) { if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex + 1) % actions.length; } else if (this.state.focus === "search" || this.state.focus === "list") this.state.focus = "details"; else if (this.state.focus === "details") this.state.focus = "actions"; this.request(); return; }
334
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
335
+ const delta = matchesKey(data, Key.up) ? -1 : 1;
336
+ if (this.state.focus === "actions") { this.request(); return; } else if ((this.state.focus === "search" || this.state.focus === "list" || this.state.focus === "details") && this.filtered.length) {
337
+ this.index = Math.max(0, Math.min(this.filtered.length - 1, this.index + delta));
338
+ this.state.detailScroll = 0;
339
+ this.syncCurrent();
340
+ }
341
+ this.request();
342
+ return;
343
+ }
344
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
345
+ this.state.detailScroll = Math.min(this.detailMaxScroll, Math.max(0, this.state.detailScroll + (matchesKey(data, Key.pageUp) ? -5 : 5)));
346
+ this.request();
347
+ return;
348
+ }
349
+ if (matchesKey(data, Key.enter)) {
350
+ if (this.state.focus === "actions") this.activate(this.actions()[this.state.actionIndex]);
351
+ else this.toggleCurrent();
352
+ return;
353
+ }
354
+ if (matchesKey(data, Key.space) && this.state.focus === "actions") {
355
+ this.activate(this.actions()[this.state.actionIndex]);
356
+ return;
357
+ }
358
+ if (this.state.focus !== "search") return;
359
+ this.search.handleInput(data);
360
+ this.refresh();
361
+ }
362
+
363
+ render(width: number): string[] {
364
+ const safeWidth = Math.max(4, width);
365
+ if (safeWidth < 24) return renderPanelBox([truncateToWidth(this.theme.fg("warning", t(this.language, "selectorNarrow")), Math.max(1, safeWidth - 2), "")], safeWidth, t(this.language, "selectorTitle", { provider: this.provider }), this.theme);
366
+ const innerWidth = Math.max(1, safeWidth - 2);
367
+ const wide = innerWidth >= 68;
368
+ const contentBudget = Math.max(2, overlayViewportRows(this.tui) - 2);
369
+ const actions = this.actions();
370
+ this.state.actionIndex = Math.min(this.state.actionIndex, actions.length - 1);
371
+ const actionFooter = renderActionFooter(actions, this.state.actionIndex, this.state.focus === "actions", innerWidth, this.theme);
372
+ const searchLines = this.search.render(innerWidth);
373
+ const statusLines = this.status ? [this.theme.fg("warning", this.status)] : [];
374
+ const externalCount = this.rows.filter((row) => row.readOnly).length;
375
+ const countLine = this.theme.fg("muted", t(this.language, "selectorFooter", { filtered: this.filtered.length, total: this.rows.length, selected: this.selected.size, readonly: externalCount }));
376
+ const bodyBudget = Math.max(0, contentBudget - searchLines.length - statusLines.length - actionFooter.length - 1);
377
+ const leftWidth = wide ? Math.max(28, Math.floor((innerWidth - 2) * 0.5)) : innerWidth;
378
+ const rightWidth = wide ? innerWidth - leftWidth - 2 : innerWidth;
379
+ const left: string[] = [];
380
+ if (!this.filtered.length) left.push(this.theme.fg("muted", t(this.language, "selectorNoMatches")));
381
+ const visible = Math.max(0, bodyBudget);
382
+ const start = Math.max(0, Math.min(this.index - Math.floor(visible / 2), Math.max(0, this.filtered.length - visible)));
383
+ for (let i = start; i < Math.min(start + visible, this.filtered.length); i++) {
384
+ const row = this.filtered[i]!;
385
+ const checked = this.selected.has(this.key(row));
386
+ const currentRow = i === this.index;
387
+ const indicator = currentRow ? "→ " : " ";
388
+ const check = checked ? this.theme.fg("success", "✓") : " ";
389
+ const source = row.readOnly ? t(this.language, "selectorReadonlyTag") : "";
390
+ const prefix = `${indicator}[${check}] [${modelTypeLabel(this.language, row.type)}] `;
391
+ const suffix = ` · ${row.name}${source ? ` · ${source}` : ""}`;
392
+ const available = Math.max(1, leftWidth - visibleWidth(prefix) - visibleWidth(suffix));
393
+ const content = `${prefix}${truncateToWidth(row.id, available, "…")}${suffix}`;
394
+ left.push(truncateToWidth(currentRow ? this.theme.fg("accent", this.theme.bold(content)) : content, leftWidth, ""));
395
+ }
396
+ const current = this.current();
397
+ const detailText = current ? [
398
+ t(this.language, "panelModelName", { value: current.name }),
399
+ t(this.language, "panelModelId", { value: current.id }),
400
+ t(this.language, "panelModelType", { value: modelTypeLabel(this.language, current.type) }),
401
+ t(this.language, "panelModelApi", { value: current.api }),
402
+ ...(current.type !== "image" ? [current.contextSource === "compat" ? t(this.language, "compatContextDisplay") : t(this.language, "panelModelContext", { value: current.contextWindow })] : []),
403
+ ...(current.type === "chat" ? [current.maxTokensSource === "compat" ? t(this.language, "compatMaxDisplay") : t(this.language, "panelModelMaxTokens", { value: current.maxTokens }), describeThinking(this.language, current, { thinkingSource: current.thinkingSource })] : []),
404
+ t(this.language, "panelModelInput", { value: current.input.join(", ") || t(this.language, "valueUnset") }),
405
+ ...(current.type === "image" ? [t(this.language, "panelModelOutput", { value: current.output.join(", ") || t(this.language, "valueUnset") })] : []),
406
+ t(this.language, "panelModelCost", { input: current.cost.input, output: current.cost.output, cacheRead: current.cost.cacheRead, cacheWrite: current.cost.cacheWrite }),
407
+ t(this.language, "panelModelBaseUrl", { value: current.baseUrl ?? t(this.language, "valueDefault") }),
408
+ ...(current.readOnly ? [t(this.language, current.readOnlyReason === "external-config" ? "panelModelReadonlyExternal" : current.readOnlyReason === "runtime-catalog" ? "panelModelReadonlyRuntime" : "panelModelReadonly")] : []),
409
+ ] : [];
410
+ const rightAll = detailText.flatMap((line) => wrapTextWithAnsi(this.theme.fg("muted", line), rightWidth));
411
+ this.detailMaxScroll = Math.max(0, rightAll.length - bodyBudget);
412
+ this.state.detailScroll = Math.min(this.state.detailScroll, this.detailMaxScroll);
413
+ const right = rightAll.slice(this.state.detailScroll, this.state.detailScroll + bodyBudget);
414
+ const body = wide ? joinColumns(left.slice(0, bodyBudget), right, leftWidth, rightWidth) : this.state.focus === "details" ? right : left.slice(0, bodyBudget);
415
+ const content = [...searchLines, ...statusLines, ...body.slice(0, bodyBudget), countLine, ...actionFooter].slice(0, contentBudget);
416
+ return renderPanelBox(content, safeWidth, t(this.language, "selectorTitle", { provider: this.provider }), this.theme);
417
+ }
418
+
419
+ invalidate(): void { this.search.invalidate(); }
420
+ }
421
+
422
+ export async function selectModels(
423
+ ctx: ExtensionCommandContext,
424
+ language: UiLanguage,
425
+ provider: string,
426
+ draft: ProviderDraft,
427
+ rows: CandidateModel[],
428
+ initialSelected?: ManagedModel[],
429
+ state: ModelPanelState = { search: "", focus: "list", actionIndex: 0, detailScroll: 0 },
430
+ initialMetadata?: ModelMetadataEntry[],
431
+ ): Promise<SelectionResult> {
432
+ return ctx.ui.custom<SelectionResult>((tui, theme, _keybindings, done) => new ModelSelector(tui, theme, language, provider, draft, rows, initialSelected, state, done, initialMetadata), MODEL_CONFIG_OVERLAY_OPTIONS);
433
+ }