@pi-unipi/fusion 2.17.0

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/src/picker.ts ADDED
@@ -0,0 +1,572 @@
1
+ /**
2
+ * @pi-unipi/fusion — Devin-style model picker component
3
+ *
4
+ * Layout (learned from Devin CLI `/model`, not copied):
5
+ *
6
+ * / type to search
7
+ * ──────────────────────────────────────────────────────────────
8
+ * ❭ Fusion ← ◼◼◼◼◻ → High Lead Opus… ▾ Sidekick GLM… ▾
9
+ * · GLM-5.3 Flash ✓ ◼◼◼◼◼ Max
10
+ * · Claude Opus 5 ◼◼◼ Medium
11
+ * ↓ more below
12
+ *
13
+ * Input Cached input Output Sidekick input Sidekick output
14
+ * $10 / 1M $0.25 / 1M $50 / 1M $0.2 / 1M $1.2 / 1M
15
+ * ↑/↓ select · ←/→ effort · tab lead · Enter confirm · esc cancel
16
+ *
17
+ * Row order: the active selection pinned first, then the Fusion row (when a
18
+ * pair is configured), then recent (≤5, MRU), then the preset models, then
19
+ * EVERY other available model — the catalogue is never hidden, the preset
20
+ * only controls ordering. Typing filters all rows except the pinned one.
21
+ *
22
+ * ←/→ steps the highlighted row's effort. Per-model effort is remembered for
23
+ * plain model rows; the Fusion row keeps its own lead/sidekick efforts so
24
+ * adjusting one never rewrites a model's standalone level.
25
+ *
26
+ * When a single model is selected, its row lights up with ✓ (plus accent
27
+ * styling). When Fusion is selected, the selection lives on the Fusion row
28
+ * only — plain model rows stay unmarked.
29
+ *
30
+ * On the Fusion row, Tab cycles effort → lead → sidekick (Shift+Tab
31
+ * reverses); the lead/sidekick focus opens an inline dropdown fed by the
32
+ * preset lists.
33
+ */
34
+
35
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
36
+ import { frameOverlay } from "@pi-unipi/core";
37
+ import {
38
+ effortLabel,
39
+ EFFORT_LEVELS,
40
+ stepEffort,
41
+ type ActiveSelection,
42
+ type EffortLevel,
43
+ type FusionBadge,
44
+ type ModelKey,
45
+ } from "./preset.js";
46
+ import { blendedPrice, renderSlider, sliderPosition } from "./slider.js";
47
+
48
+ // ── Data contracts ─────────────────────────────────────────────────────────
49
+
50
+ export interface PickerModel {
51
+ key: ModelKey;
52
+ name: string;
53
+ provider: string;
54
+ badge?: FusionBadge | undefined;
55
+ /** $/1M tokens; undefined when the catalogue has no price. */
56
+ cost?: { input: number; cachedInput: number; output: number } | undefined;
57
+ reasoning: boolean;
58
+ }
59
+
60
+ export interface PickerState {
61
+ models: readonly PickerModel[];
62
+ fusionLeads: readonly ModelKey[];
63
+ fusionSidekicks: readonly ModelKey[];
64
+ fusionDefault: { lead?: ModelKey | undefined; sidekick?: ModelKey | undefined };
65
+ recent: readonly ModelKey[];
66
+ active: ActiveSelection | undefined;
67
+ /** The session's current model — its row lights up with ✓. */
68
+ currentModelKey: ModelKey | undefined;
69
+ effort: Readonly<Record<ModelKey, EffortLevel>>;
70
+ /** Effort used for a model with no remembered level. */
71
+ fallbackEffort: EffortLevel;
72
+ }
73
+
74
+ export type PickerResult =
75
+ | {
76
+ type: "single";
77
+ model: ModelKey;
78
+ effort: EffortLevel;
79
+ effortMap: Record<ModelKey, EffortLevel>;
80
+ }
81
+ | {
82
+ type: "fusion";
83
+ lead: ModelKey;
84
+ sidekick: ModelKey;
85
+ leadEffort: EffortLevel;
86
+ sidekickEffort: EffortLevel;
87
+ effortMap: Record<ModelKey, EffortLevel>;
88
+ }
89
+ | { type: "cancelled" };
90
+
91
+ export interface PickerTheme {
92
+ fg: (color: string, text: string) => string;
93
+ bold: (text: string) => string;
94
+ }
95
+
96
+ export interface PickerOptions {
97
+ state: PickerState;
98
+ theme: PickerTheme;
99
+ onDone: (result: PickerResult) => void;
100
+ onRenderRequest?: (() => void) | undefined;
101
+ /** Rows visible in the list window. */
102
+ visibleRows?: number | undefined;
103
+ }
104
+
105
+ // ── Rows ───────────────────────────────────────────────────────────────────
106
+
107
+ type Row = { kind: "fusion" } | { kind: "model"; key: ModelKey };
108
+ type FusionFocus = "effort" | "lead" | "sidekick";
109
+
110
+ const DEFAULT_VISIBLE_ROWS = 10;
111
+ const NAME_COL = 24;
112
+ const MARKER_COL = 2;
113
+ const BAR_SEGMENTS = 5;
114
+
115
+ function printable(data: string): string | undefined {
116
+ if (data.length !== 1) return undefined;
117
+ const code = data.charCodeAt(0);
118
+ if (code < 32 || code === 127) return undefined;
119
+ return data;
120
+ }
121
+
122
+ function shortName(name: string, max: number): string {
123
+ return name.length > max ? `${name.slice(0, Math.max(1, max - 1))}…` : name;
124
+ }
125
+
126
+ function money(perMillion: number): string {
127
+ const rounded = perMillion >= 10 ? perMillion.toFixed(0) : perMillion >= 1 ? perMillion.toFixed(1) : perMillion.toFixed(2);
128
+ return `$${rounded.replace(/\.0+$/u, "").replace(/(\.\d)0$/u, "$1")} / 1M`;
129
+ }
130
+
131
+ function pad(text: string, width: number): string {
132
+ const w = visibleWidth(text);
133
+ return w >= width ? text : text + " ".repeat(width - w);
134
+ }
135
+
136
+ export class ModelPicker {
137
+ private readonly theme: PickerTheme;
138
+ private readonly onDone: (result: PickerResult) => void;
139
+ private readonly onRenderRequest: (() => void) | undefined;
140
+ private readonly visibleRows: number;
141
+ private readonly modelsByKey: Map<ModelKey, PickerModel>;
142
+ private readonly state: PickerState;
143
+ private readonly priceRange: { min: number; max: number };
144
+
145
+ private effort: Record<ModelKey, EffortLevel>;
146
+ /** Fusion-row efforts — deliberately NOT stored in the per-model map. */
147
+ private fusionLeadEffort: EffortLevel;
148
+ private fusionSidekickEffort: EffortLevel;
149
+ private lead: ModelKey | undefined;
150
+ private sidekick: ModelKey | undefined;
151
+ private search = "";
152
+ private selected = 0;
153
+ private focus: FusionFocus = "effort";
154
+ private dropdownIndex = 0;
155
+ private done = false;
156
+
157
+ constructor(options: PickerOptions) {
158
+ this.state = options.state;
159
+ this.theme = options.theme;
160
+ this.onDone = options.onDone;
161
+ this.onRenderRequest = options.onRenderRequest;
162
+ this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
163
+ this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
164
+ const prices = options.state.models.map((m) => (m.cost ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined);
165
+ this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
166
+ this.effort = { ...options.state.effort };
167
+ const active = options.state.active;
168
+ this.lead =
169
+ (active?.kind === "fusion" ? active.lead : undefined) ??
170
+ options.state.fusionDefault.lead ??
171
+ options.state.fusionLeads[0];
172
+ this.sidekick =
173
+ (active?.kind === "fusion" ? active.sidekick : undefined) ??
174
+ options.state.fusionDefault.sidekick ??
175
+ options.state.fusionSidekicks[0];
176
+ this.fusionLeadEffort =
177
+ (active?.kind === "fusion" ? active.leadEffort : undefined) ??
178
+ (this.lead !== undefined ? this.effort[this.lead] : undefined) ??
179
+ options.state.fallbackEffort;
180
+ this.fusionSidekickEffort =
181
+ (active?.kind === "fusion" ? active.sidekickEffort : undefined) ??
182
+ (this.sidekick !== undefined ? this.effort[this.sidekick] : undefined) ??
183
+ options.state.fallbackEffort;
184
+ this.selected = 0; // pinned active row
185
+ }
186
+
187
+ // ── Row model ────────────────────────────────────────────────────────────
188
+
189
+ private fusionAvailable(): boolean {
190
+ return this.lead !== undefined && this.sidekick !== undefined;
191
+ }
192
+
193
+ private matchesSearch(key: ModelKey): boolean {
194
+ if (this.search.length === 0) return true;
195
+ const m = this.modelsByKey.get(key);
196
+ const hay = `${key} ${m?.name ?? ""}`.toLowerCase();
197
+ const q = this.search.toLowerCase();
198
+ // subsequence match, cheap and forgiving
199
+ let i = 0;
200
+ for (const ch of hay) {
201
+ if (ch === q[i]) i++;
202
+ if (i === q.length) return true;
203
+ }
204
+ return q.length === 0;
205
+ }
206
+
207
+ rows(): Row[] {
208
+ const out: Row[] = [];
209
+ const seen = new Set<ModelKey>();
210
+ const active = this.state.active;
211
+ const pinnedFusion = active?.kind === "fusion" && this.fusionAvailable();
212
+
213
+ if (pinnedFusion) out.push({ kind: "fusion" });
214
+ else if (active?.kind === "single" && this.modelsByKey.has(active.model)) {
215
+ out.push({ kind: "model", key: active.model });
216
+ seen.add(active.model);
217
+ }
218
+ if (!pinnedFusion && this.fusionAvailable()) out.push({ kind: "fusion" });
219
+
220
+ const ordered: ModelKey[] = [
221
+ ...this.state.recent,
222
+ ...this.state.fusionLeads,
223
+ ...this.state.fusionSidekicks,
224
+ ];
225
+ // The whole catalogue always follows; the preset only controls ordering.
226
+ ordered.push(...this.state.models.map((m) => m.key));
227
+ for (const key of ordered) {
228
+ if (seen.has(key) || !this.modelsByKey.has(key)) continue;
229
+ if (!this.matchesSearch(key)) continue;
230
+ seen.add(key);
231
+ out.push({ kind: "model", key });
232
+ }
233
+ return out;
234
+ }
235
+
236
+ private effortFor(key: ModelKey | undefined): EffortLevel {
237
+ if (key === undefined) return this.state.fallbackEffort;
238
+ return this.effort[key] ?? this.state.fallbackEffort;
239
+ }
240
+
241
+ private selectedRow(): Row | undefined {
242
+ return this.rows()[this.selected];
243
+ }
244
+
245
+ private dropdownItems(): ModelKey[] {
246
+ const source = this.focus === "lead" ? this.state.fusionLeads : this.state.fusionSidekicks;
247
+ return source.filter((k) => this.modelsByKey.has(k));
248
+ }
249
+
250
+ // ── Input ────────────────────────────────────────────────────────────────
251
+
252
+ handleInput(data: string): void {
253
+ if (this.done) return;
254
+ const row = this.selectedRow();
255
+ const inDropdown = row?.kind === "fusion" && this.focus !== "effort";
256
+
257
+ if (matchesKey(data, Key.escape)) {
258
+ if (inDropdown) {
259
+ this.focus = "effort";
260
+ this.changed();
261
+ return;
262
+ }
263
+ this.finish({ type: "cancelled" });
264
+ return;
265
+ }
266
+
267
+ if (inDropdown) {
268
+ const items = this.dropdownItems();
269
+ if (matchesKey(data, Key.up)) {
270
+ this.dropdownIndex = Math.max(0, this.dropdownIndex - 1);
271
+ } else if (matchesKey(data, Key.down)) {
272
+ this.dropdownIndex = Math.min(Math.max(0, items.length - 1), this.dropdownIndex + 1);
273
+ } else if (matchesKey(data, Key.tab)) {
274
+ this.applyDropdown(items);
275
+ this.cycleFocus(matchesKey(data, "shift+tab"));
276
+ } else if (matchesKey(data, Key.enter) || data === "\r") {
277
+ this.applyDropdown(items);
278
+ this.focus = "effort";
279
+ } else {
280
+ return;
281
+ }
282
+ this.changed();
283
+ return;
284
+ }
285
+
286
+ if (matchesKey(data, Key.up)) {
287
+ const n = this.rows().length;
288
+ this.selected = n === 0 ? 0 : (this.selected - 1 + n) % n;
289
+ this.focus = "effort";
290
+ } else if (matchesKey(data, Key.down)) {
291
+ const n = this.rows().length;
292
+ this.selected = n === 0 ? 0 : (this.selected + 1) % n;
293
+ this.focus = "effort";
294
+ } else if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
295
+ const delta: -1 | 1 = matchesKey(data, Key.left) ? -1 : 1;
296
+ if (row?.kind === "fusion") {
297
+ // Fusion-row effort is its own state: never touches per-model memory.
298
+ if (this.focus === "effort") this.fusionLeadEffort = stepEffort(this.fusionLeadEffort, delta);
299
+ } else if (row?.key !== undefined) {
300
+ this.effort[row.key] = stepEffort(this.effortFor(row.key), delta);
301
+ }
302
+ } else if (matchesKey(data, Key.tab)) {
303
+ if (row?.kind === "fusion") this.cycleFocus(matchesKey(data, "shift+tab"));
304
+ } else if (matchesKey(data, Key.enter) || data === "\r") {
305
+ this.confirm(row);
306
+ return;
307
+ } else if (matchesKey(data, Key.backspace) || data === "\x7f") {
308
+ this.search = this.search.slice(0, -1);
309
+ this.selected = 0;
310
+ } else {
311
+ const ch = printable(data);
312
+ if (ch === undefined) return;
313
+ this.search += ch;
314
+ this.selected = 0;
315
+ }
316
+ this.changed();
317
+ }
318
+
319
+ private cycleFocus(reverse = false): void {
320
+ const order: FusionFocus[] = ["effort", "lead", "sidekick"];
321
+ const dir = reverse ? -1 : 1;
322
+ const next = order[(order.indexOf(this.focus) + dir + order.length) % order.length] ?? "effort";
323
+ this.focus = next;
324
+ if (this.focus !== "effort") {
325
+ const items = this.dropdownItems();
326
+ const current = this.focus === "lead" ? this.lead : this.sidekick;
327
+ const idx = current === undefined ? -1 : items.indexOf(current);
328
+ this.dropdownIndex = idx >= 0 ? idx : 0;
329
+ }
330
+ }
331
+
332
+ private applyDropdown(items: ModelKey[]): void {
333
+ const pick = items[this.dropdownIndex];
334
+ if (pick === undefined) return;
335
+ if (this.focus === "lead") this.lead = pick;
336
+ else this.sidekick = pick;
337
+ }
338
+
339
+ private confirm(row: Row | undefined): void {
340
+ if (row === undefined) return;
341
+ if (row.kind === "fusion") {
342
+ if (this.lead === undefined || this.sidekick === undefined) return;
343
+ this.finish({
344
+ type: "fusion",
345
+ lead: this.lead,
346
+ sidekick: this.sidekick,
347
+ leadEffort: this.fusionLeadEffort,
348
+ sidekickEffort: this.fusionSidekickEffort,
349
+ effortMap: { ...this.effort },
350
+ });
351
+ return;
352
+ }
353
+ this.finish({
354
+ type: "single",
355
+ model: row.key,
356
+ effort: this.effortFor(row.key),
357
+ effortMap: { ...this.effort },
358
+ });
359
+ }
360
+
361
+ private finish(result: PickerResult): void {
362
+ this.done = true;
363
+ this.onDone(result);
364
+ }
365
+
366
+ private changed(): void {
367
+ this.onRenderRequest?.();
368
+ }
369
+
370
+ invalidate(): void {
371
+ /* stateless render */
372
+ }
373
+
374
+ // ── Render ───────────────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * Marker for the working model. Only a SINGLE active selection gets a ✓ —
378
+ * when Fusion is selected, the selection lives on the Fusion row itself and
379
+ * plain model rows stay unmarked.
380
+ */
381
+ private markerFor(key: ModelKey | undefined): string {
382
+ if (key === undefined) return " ";
383
+ const active = this.state.active;
384
+ if (active?.kind === "single" && key === active.model) return this.theme.fg("success", "✓");
385
+ return " ";
386
+ }
387
+
388
+ private bar(level: EffortLevel, highlighted: boolean): string {
389
+ const index = Math.max(0, EFFORT_LEVELS.indexOf(level));
390
+ const filled = Math.ceil((index / (EFFORT_LEVELS.length - 1)) * BAR_SEGMENTS);
391
+ const on = this.theme.fg(highlighted ? "text" : "muted", "▰".repeat(filled));
392
+ const off = this.theme.fg("dim", "▱".repeat(BAR_SEGMENTS - filled));
393
+ return `${on}${off}`;
394
+ }
395
+
396
+ private nameOf(key: ModelKey | undefined, max: number): string {
397
+ if (key === undefined) return "—";
398
+ const m = this.modelsByKey.get(key);
399
+ return shortName(m?.name ?? key, max);
400
+ }
401
+
402
+ private renderRow(row: Row, highlighted: boolean, width: number): string {
403
+ const t = this.theme;
404
+ const pointer = highlighted ? t.fg("accent", "❭") : t.fg("dim", "·");
405
+ // The Fusion composite gets the check when it is the active selection —
406
+ // same affordance a single active model gets on its own row.
407
+ const marker =
408
+ row.kind === "fusion"
409
+ ? this.state.active?.kind === "fusion"
410
+ ? t.fg("success", "✓")
411
+ : " "
412
+ : this.markerFor(row.key);
413
+ const working = row.kind === "model" && row.key === this.state.currentModelKey;
414
+ const nameRaw = row.kind === "fusion" ? "Fusion" : this.nameOf(row.key, NAME_COL - 1);
415
+ const name =
416
+ row.kind === "fusion"
417
+ ? highlighted
418
+ ? t.fg("accent", t.bold(nameRaw))
419
+ : t.fg("text", nameRaw)
420
+ : working
421
+ ? t.fg("accent", t.bold(nameRaw))
422
+ : highlighted
423
+ ? t.fg("accent", nameRaw)
424
+ : t.fg("text", nameRaw);
425
+ const model = row.kind === "model" ? this.modelsByKey.get(row.key) : undefined;
426
+ const badge = model?.badge;
427
+ const badgeGlyph = badge === undefined ? "" : ` ${t.fg(badge === "new" ? "success" : badge === "promotion" ? "accent" : "warning", "✱")}`;
428
+
429
+ const level = row.kind === "fusion" ? this.fusionLeadEffort : this.effortFor(row.key);
430
+ const arrowsOn = highlighted && this.focus === "effort";
431
+ const left = arrowsOn ? t.fg("accent", "←") : " ";
432
+ const right = arrowsOn ? t.fg("accent", "→") : " ";
433
+ const label = highlighted ? t.fg("accent", effortLabel(level)) : t.fg("muted", effortLabel(level));
434
+
435
+ let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, highlighted)} ${right} ${pad(label, 8)}`;
436
+
437
+ if (row.kind === "fusion") {
438
+ const leadName = this.nameOf(this.lead, 14);
439
+ const sideName = this.nameOf(this.sidekick, 14);
440
+ const leadFocused = highlighted && this.focus === "lead";
441
+ const sideFocused = highlighted && this.focus === "sidekick";
442
+ const leadText = leadFocused
443
+ ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "▾")}`
444
+ : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "▾")}`;
445
+ const sideText = sideFocused
446
+ ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
447
+ : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
448
+ line += ` ${leadText} ${sideText}`;
449
+ }
450
+ return truncateToWidth(line, Math.max(1, width - 1));
451
+ }
452
+
453
+ private renderDropdown(width: number): string[] {
454
+ const t = this.theme;
455
+ const items = this.dropdownItems();
456
+ const indent = " ".repeat(MARKER_COL + NAME_COL + 5);
457
+ if (items.length === 0) {
458
+ return [`${indent}${t.fg("warning", `no ${this.focus} models in preset — run /unipi:fusion-preset`)}`];
459
+ }
460
+ const win = 6;
461
+ const start = Math.max(0, Math.min(this.dropdownIndex - Math.floor(win / 2), items.length - win));
462
+ const slice = items.slice(start, start + win);
463
+ return slice.map((key, i) => {
464
+ const idx = start + i;
465
+ const isCur = idx === this.dropdownIndex;
466
+ const isSet = key === (this.focus === "lead" ? this.lead : this.sidekick);
467
+ const glyph = isCur ? t.fg("accent", "▸") : " ";
468
+ const label = isCur ? t.fg("accent", t.bold(this.nameOf(key, 28))) : t.fg("text", this.nameOf(key, 28));
469
+ const star = isSet ? t.fg("dim", " *") : "";
470
+ return truncateToWidth(`${indent}${glyph} ${label}${star}`, Math.max(1, width - 1));
471
+ });
472
+ }
473
+
474
+ private renderPricePanel(row: Row | undefined, width: number): string[] {
475
+ const t = this.theme;
476
+ if (row === undefined) return [];
477
+ const primaryKey = row.kind === "fusion" ? this.lead : row.key;
478
+ const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
479
+ const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
480
+ const cols: Array<[string, string]> = [];
481
+ if (primary?.cost) {
482
+ cols.push(["Input", money(primary.cost.input)]);
483
+ cols.push(["Cached input", money(primary.cost.cachedInput)]);
484
+ cols.push(["Output", money(primary.cost.output)]);
485
+ } else {
486
+ cols.push(["Input", "—"], ["Cached input", "—"], ["Output", "—"]);
487
+ }
488
+ if (row.kind === "fusion") {
489
+ if (side?.cost) {
490
+ cols.push(["Sidekick input", money(side.cost.input)]);
491
+ cols.push(["Sidekick cached input", money(side.cost.cachedInput)]);
492
+ cols.push(["Sidekick output", money(side.cost.output)]);
493
+ } else {
494
+ cols.push(["Sidekick input", "—"], ["Sidekick cached input", "—"], ["Sidekick output", "—"]);
495
+ }
496
+ }
497
+ const colWidth = Math.max(10, Math.min(18, Math.floor((width - 4) / cols.length)));
498
+ const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
499
+ const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
500
+ const desc =
501
+ row.kind === "fusion"
502
+ ? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution")
503
+ : primary?.reasoning
504
+ ? t.fg("dim", "Reasoning model · ←/→ adjusts thinking effort")
505
+ : t.fg("dim", "Non-reasoning model · effort is ignored by the provider");
506
+ const badges = this.state.models.some((m) => m.badge !== undefined)
507
+ ? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
508
+ : "";
509
+ const description = `${badges}${badges.length > 0 ? " " : ""}${desc}`;
510
+ return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
511
+ }
512
+
513
+ private hintLine(row: Row | undefined): string {
514
+ const t = this.theme;
515
+ const parts: string[] = [];
516
+ if (row?.kind === "fusion" && this.focus !== "effort") {
517
+ parts.push("↑↓ select", `tab ${this.focus === "lead" ? "sidekick" : "effort"}`, "↵ apply", "esc collapse");
518
+ } else {
519
+ parts.push("↑↓ select");
520
+ if (row?.kind === "fusion") parts.push("tab lead");
521
+ parts.push("←→ effort", "↵ confirm", "esc cancel");
522
+ }
523
+ return t.fg("dim", parts.join(" · "));
524
+ }
525
+
526
+ render(width: number): string[] {
527
+ return frameOverlay(this.renderBody(Math.max(4, width - 2)), width, { title: "Model" });
528
+ }
529
+
530
+ private renderBody(width: number): string[] {
531
+ const t = this.theme;
532
+ const rows = this.rows();
533
+ if (this.selected >= rows.length) this.selected = Math.max(0, rows.length - 1);
534
+ const row = rows[this.selected];
535
+ const lines: string[] = [];
536
+
537
+ const searchText = this.search.length > 0 ? t.fg("text", this.search) : t.fg("dim", "Type to search");
538
+ lines.push(truncateToWidth(`${t.fg("accent", "/")} ${searchText}`, width - 1));
539
+ lines.push(t.fg("dim", "─".repeat(Math.max(1, width - 2))));
540
+
541
+ if (rows.length === 0) {
542
+ lines.push(t.fg("warning", " No matching models."));
543
+ } else {
544
+ const win = this.visibleRows;
545
+ const start = Math.max(0, Math.min(this.selected - Math.floor(win / 2), rows.length - win));
546
+ const end = Math.min(rows.length, start + win);
547
+ if (start > 0) lines.push(t.fg("dim", " ↑ more above"));
548
+ for (let i = start; i < end; i++) {
549
+ const r = rows[i];
550
+ if (r === undefined) continue;
551
+ const highlighted = i === this.selected;
552
+ lines.push(this.renderRow(r, highlighted, width));
553
+ if (highlighted && r.kind === "fusion" && this.focus !== "effort") {
554
+ lines.push(...this.renderDropdown(width));
555
+ }
556
+ }
557
+ if (end < rows.length) lines.push(t.fg("dim", ` ↓ more below (${String(rows.length - end)})`));
558
+ }
559
+
560
+ lines.push("");
561
+ const sliderCells = Math.min(48, Math.max(1, width - 6));
562
+ const sliderKey = row?.kind === "fusion" ? this.lead : row?.key;
563
+ const sliderModel = sliderKey === undefined ? undefined : this.modelsByKey.get(sliderKey);
564
+ const sliderPrice = sliderModel?.cost === undefined ? undefined : blendedPrice(sliderModel.cost);
565
+ const marker = sliderPrice === undefined ? undefined : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
566
+ lines.push(truncateToWidth(` ${renderSlider(sliderCells, marker)}`, width - 1));
567
+ lines.push(...this.renderPricePanel(row, width));
568
+ lines.push("");
569
+ lines.push(this.hintLine(row));
570
+ return lines;
571
+ }
572
+ }