@openeryc/pi-coding-agent 0.75.55 → 0.75.56

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +3 -0
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/fetch-openai-models.d.ts +25 -0
  6. package/dist/core/fetch-openai-models.d.ts.map +1 -0
  7. package/dist/core/fetch-openai-models.js +107 -0
  8. package/dist/core/fetch-openai-models.js.map +1 -0
  9. package/dist/core/model-registry.d.ts +112 -0
  10. package/dist/core/model-registry.d.ts.map +1 -1
  11. package/dist/core/model-registry.js +99 -2
  12. package/dist/core/model-registry.js.map +1 -1
  13. package/dist/core/settings-manager.d.ts +4 -0
  14. package/dist/core/settings-manager.d.ts.map +1 -1
  15. package/dist/core/settings-manager.js +12 -0
  16. package/dist/core/settings-manager.js.map +1 -1
  17. package/dist/core/slash-commands.d.ts.map +1 -1
  18. package/dist/core/slash-commands.js +2 -2
  19. package/dist/core/slash-commands.js.map +1 -1
  20. package/dist/modes/interactive/components/index.d.ts +1 -1
  21. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  22. package/dist/modes/interactive/components/index.js +1 -1
  23. package/dist/modes/interactive/components/index.js.map +1 -1
  24. package/dist/modes/interactive/components/model-hub.d.ts +115 -0
  25. package/dist/modes/interactive/components/model-hub.d.ts.map +1 -0
  26. package/dist/modes/interactive/components/model-hub.js +753 -0
  27. package/dist/modes/interactive/components/model-hub.js.map +1 -0
  28. package/dist/modes/interactive/components/model-selector.d.ts +2 -44
  29. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  30. package/dist/modes/interactive/components/model-selector.js +2 -275
  31. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  32. package/dist/modes/interactive/interactive-mode.d.ts +4 -2
  33. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  34. package/dist/modes/interactive/interactive-mode.js +308 -132
  35. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  36. package/docs/models.md +20 -0
  37. package/docs/providers.md +13 -0
  38. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  39. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  40. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  41. package/examples/extensions/sandbox/package-lock.json +2 -2
  42. package/examples/extensions/sandbox/package.json +1 -1
  43. package/examples/extensions/with-deps/package-lock.json +2 -2
  44. package/examples/extensions/with-deps/package.json +1 -1
  45. package/npm-shrinkwrap.json +12 -12
  46. package/package.json +4 -4
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Unified model hub: Select current model, manage cycle whitelist, manage custom providers.
3
+ */
4
+ import { modelsAreEqual } from "@openeryc/pi-ai";
5
+ import { Container, fuzzyFilter, getKeybindings, Input, Key, matchesKey, Spacer, Text, } from "@openeryc/pi-tui";
6
+ import { theme } from "../theme/theme.js";
7
+ import { DynamicBorder } from "./dynamic-border.js";
8
+ import { keyHint, keyText } from "./keybinding-hints.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Pure helpers (exported for tests)
11
+ // ---------------------------------------------------------------------------
12
+ export function formatTokenCount(count) {
13
+ if (count >= 1_000_000) {
14
+ const millions = count / 1_000_000;
15
+ return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
16
+ }
17
+ if (count >= 1_000) {
18
+ const thousands = count / 1_000;
19
+ return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
20
+ }
21
+ return count.toString();
22
+ }
23
+ function modelBadges(model) {
24
+ const parts = [];
25
+ if (model.reasoning)
26
+ parts.push("R");
27
+ parts.push(formatTokenCount(model.contextWindow));
28
+ return parts.join(" ");
29
+ }
30
+ function isEnabled(enabledIds, id) {
31
+ return enabledIds === null || enabledIds.includes(id);
32
+ }
33
+ function toggle(enabledIds, id) {
34
+ if (enabledIds === null)
35
+ return [id];
36
+ const index = enabledIds.indexOf(id);
37
+ if (index >= 0)
38
+ return [...enabledIds.slice(0, index), ...enabledIds.slice(index + 1)];
39
+ return [...enabledIds, id];
40
+ }
41
+ function enableAll(enabledIds, allIds, targetIds) {
42
+ if (enabledIds === null)
43
+ return null;
44
+ const targets = targetIds ?? allIds;
45
+ const result = [...enabledIds];
46
+ for (const id of targets) {
47
+ if (!result.includes(id))
48
+ result.push(id);
49
+ }
50
+ return result.length === allIds.length ? null : result;
51
+ }
52
+ function clearAll(enabledIds, allIds, targetIds) {
53
+ if (enabledIds === null) {
54
+ return targetIds ? allIds.filter((id) => !targetIds.includes(id)) : [];
55
+ }
56
+ const targets = new Set(targetIds ?? enabledIds);
57
+ return enabledIds.filter((id) => !targets.has(id));
58
+ }
59
+ function move(enabledIds, id, delta) {
60
+ if (enabledIds === null)
61
+ return null;
62
+ const list = [...enabledIds];
63
+ const index = list.indexOf(id);
64
+ if (index < 0)
65
+ return list;
66
+ const newIndex = index + delta;
67
+ if (newIndex < 0 || newIndex >= list.length)
68
+ return list;
69
+ const result = [...list];
70
+ [result[index], result[newIndex]] = [result[newIndex], result[index]];
71
+ return result;
72
+ }
73
+ function getSortedIds(enabledIds, allIds) {
74
+ if (enabledIds === null)
75
+ return allIds;
76
+ const enabledSet = new Set(enabledIds);
77
+ return [...enabledIds, ...allIds.filter((id) => !enabledSet.has(id))];
78
+ }
79
+ export function buildSelectRows(options) {
80
+ const { models, currentModel, recentKeys, query, expandedProviders, collapsedLimit = 12 } = options;
81
+ const rows = [];
82
+ if (query) {
83
+ const filtered = fuzzyFilter(models, query, ({ id, provider, model }) => `${id} ${provider} ${provider}/${id} ${model.name ?? ""} ${provider} ${id}`);
84
+ for (const item of filtered) {
85
+ rows.push({ kind: "model", item, badges: modelBadges(item.model) });
86
+ }
87
+ return rows;
88
+ }
89
+ const byKey = new Map(models.map((m) => [`${m.provider}/${m.id}`, m]));
90
+ const seen = new Set();
91
+ // Current first (if available)
92
+ if (currentModel) {
93
+ const key = `${currentModel.provider}/${currentModel.id}`;
94
+ const item = byKey.get(key);
95
+ if (item) {
96
+ rows.push({ kind: "header", label: "Current" });
97
+ rows.push({ kind: "model", item, badges: modelBadges(item.model) });
98
+ seen.add(key);
99
+ }
100
+ }
101
+ // Recent
102
+ const recentItems = [];
103
+ for (const key of recentKeys) {
104
+ if (seen.has(key))
105
+ continue;
106
+ const item = byKey.get(key);
107
+ if (item) {
108
+ recentItems.push(item);
109
+ seen.add(key);
110
+ }
111
+ }
112
+ if (recentItems.length > 0) {
113
+ rows.push({ kind: "header", label: "Recent" });
114
+ for (const item of recentItems.slice(0, 8)) {
115
+ rows.push({ kind: "model", item, badges: modelBadges(item.model) });
116
+ }
117
+ }
118
+ // Group remaining by provider
119
+ const byProvider = new Map();
120
+ for (const item of models) {
121
+ const key = `${item.provider}/${item.id}`;
122
+ if (seen.has(key))
123
+ continue;
124
+ const list = byProvider.get(item.provider) ?? [];
125
+ list.push(item);
126
+ byProvider.set(item.provider, list);
127
+ }
128
+ const providers = [...byProvider.keys()].sort((a, b) => a.localeCompare(b));
129
+ const currentProvider = currentModel?.provider;
130
+ for (const provider of providers) {
131
+ const list = byProvider.get(provider) ?? [];
132
+ list.sort((a, b) => a.id.localeCompare(b.id));
133
+ const expand = expandedProviders.has(provider) || provider === currentProvider || list.length <= collapsedLimit;
134
+ rows.push({ kind: "header", label: provider });
135
+ if (!expand) {
136
+ rows.push({ kind: "collapsed", provider, count: list.length });
137
+ continue;
138
+ }
139
+ for (const item of list) {
140
+ rows.push({ kind: "model", item, badges: modelBadges(item.model) });
141
+ }
142
+ }
143
+ return rows;
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // Component
147
+ // ---------------------------------------------------------------------------
148
+ export class ModelHubComponent extends Container {
149
+ searchInput;
150
+ listContainer;
151
+ headerText;
152
+ footerText;
153
+ hintText;
154
+ _focused = false;
155
+ get focused() {
156
+ return this._focused;
157
+ }
158
+ set focused(value) {
159
+ this._focused = value;
160
+ this.searchInput.focused = value;
161
+ }
162
+ tui;
163
+ currentModel;
164
+ settingsManager;
165
+ modelRegistry;
166
+ callbacks;
167
+ tab;
168
+ allModels = [];
169
+ scopedModels;
170
+ // Select state
171
+ selectRows = [];
172
+ selectIndex = 0;
173
+ expandedProviders = new Set();
174
+ // Cycle state
175
+ cycleAllIds = [];
176
+ cycleModelsById = new Map();
177
+ enabledIds = null;
178
+ cycleItems = [];
179
+ cycleIndex = 0;
180
+ cycleDirty = false;
181
+ // Manage state
182
+ manageRows = [];
183
+ manageIndex = 0;
184
+ errorMessage;
185
+ maxVisible = 10;
186
+ loadGeneration = 0;
187
+ constructor(tui, currentModel, settingsManager, modelRegistry, scopedModels, enabledModelIds, callbacks, options) {
188
+ super();
189
+ this.tui = tui;
190
+ this.currentModel = currentModel;
191
+ this.settingsManager = settingsManager;
192
+ this.modelRegistry = modelRegistry;
193
+ this.scopedModels = scopedModels;
194
+ this.callbacks = callbacks;
195
+ this.tab = options?.initialTab ?? "select";
196
+ this.enabledIds = enabledModelIds === null ? null : [...enabledModelIds];
197
+ this.addChild(new DynamicBorder());
198
+ this.addChild(new Spacer(1));
199
+ this.headerText = new Text(this.getHeaderText(), 0, 0);
200
+ this.addChild(this.headerText);
201
+ this.hintText = new Text(this.getHintText(), 0, 0);
202
+ this.addChild(this.hintText);
203
+ this.addChild(new Spacer(1));
204
+ this.searchInput = new Input();
205
+ if (options?.initialSearchInput) {
206
+ this.searchInput.setValue(options.initialSearchInput);
207
+ }
208
+ this.searchInput.onSubmit = () => this.handleConfirm();
209
+ this.addChild(this.searchInput);
210
+ this.addChild(new Spacer(1));
211
+ this.listContainer = new Container();
212
+ this.addChild(this.listContainer);
213
+ this.addChild(new Spacer(1));
214
+ this.footerText = new Text(this.getFooterText(), 0, 0);
215
+ this.addChild(this.footerText);
216
+ this.addChild(new DynamicBorder());
217
+ void this.reload().then(() => {
218
+ if (options?.initialSearchInput) {
219
+ this.applySearch();
220
+ }
221
+ this.tui.requestRender();
222
+ });
223
+ }
224
+ // ---- public helpers for tests ----
225
+ getTab() {
226
+ return this.tab;
227
+ }
228
+ getSearchInput() {
229
+ return this.searchInput;
230
+ }
231
+ /** Test helper: force reload completion */
232
+ async waitForLoad() {
233
+ await this.reload();
234
+ }
235
+ // ---- load ----
236
+ async reload() {
237
+ const gen = ++this.loadGeneration;
238
+ this.modelRegistry.refresh();
239
+ const loadError = this.modelRegistry.getError();
240
+ if (loadError) {
241
+ this.errorMessage = loadError;
242
+ }
243
+ else {
244
+ this.errorMessage = undefined;
245
+ }
246
+ try {
247
+ const available = this.modelRegistry.getAvailable();
248
+ if (gen !== this.loadGeneration)
249
+ return;
250
+ this.allModels = available.map((model) => ({
251
+ provider: model.provider,
252
+ id: model.id,
253
+ model,
254
+ }));
255
+ // Refresh scoped models against registry
256
+ this.scopedModels = this.scopedModels.map((scoped) => {
257
+ const refreshed = this.modelRegistry.find(scoped.model.provider, scoped.model.id);
258
+ return refreshed ? { ...scoped, model: refreshed } : scoped;
259
+ });
260
+ this.cycleModelsById.clear();
261
+ this.cycleAllIds = [];
262
+ for (const item of this.allModels) {
263
+ const fullId = `${item.provider}/${item.id}`;
264
+ this.cycleModelsById.set(fullId, item.model);
265
+ this.cycleAllIds.push(fullId);
266
+ }
267
+ this.rebuildManageRows();
268
+ this.applySearch();
269
+ }
270
+ catch (error) {
271
+ this.allModels = [];
272
+ this.errorMessage = error instanceof Error ? error.message : String(error);
273
+ this.applySearch();
274
+ }
275
+ }
276
+ rebuildManageRows() {
277
+ const rows = [];
278
+ rows.push({ kind: "action", action: "add", label: "+ Add custom provider (models.json)" });
279
+ for (const p of this.modelRegistry.listModelsJsonProviders()) {
280
+ const modelLabel = p.modelIds.length === 0
281
+ ? "override only"
282
+ : `${p.modelIds.length} model${p.modelIds.length === 1 ? "" : "s"}`;
283
+ rows.push({
284
+ kind: "provider",
285
+ source: "models_json",
286
+ id: p.name,
287
+ label: p.name,
288
+ detail: `${p.isBuiltIn ? "built-in override" : "custom"} · ${modelLabel}${p.baseUrl ? ` · ${p.baseUrl}` : ""}`,
289
+ });
290
+ }
291
+ const openaiCompat = this.modelRegistry.authStorage.getOpenAICompatibleProviders();
292
+ for (const [id, info] of Object.entries(openaiCompat)) {
293
+ rows.push({
294
+ kind: "provider",
295
+ source: "openai_compatible",
296
+ id,
297
+ label: id,
298
+ detail: `openai-compatible · ${info.models.length} model${info.models.length === 1 ? "" : "s"} · ${info.baseUrl}`,
299
+ });
300
+ }
301
+ this.manageRows = rows;
302
+ this.manageIndex = Math.min(this.manageIndex, Math.max(0, rows.length - 1));
303
+ }
304
+ // ---- header / footer ----
305
+ getHeaderText() {
306
+ const tabs = [
307
+ { id: "select", label: "Select" },
308
+ { id: "cycle", label: "Cycle" },
309
+ { id: "manage", label: "Manage" },
310
+ ];
311
+ const parts = tabs.map((t) => (t.id === this.tab ? theme.fg("accent", t.label) : theme.fg("muted", t.label)));
312
+ return `${theme.fg("muted", "Models: ")}${parts.join(theme.fg("muted", " | "))}`;
313
+ }
314
+ getHintText() {
315
+ if (this.tab === "select") {
316
+ return (keyHint("tui.input.tab", "tabs") +
317
+ theme.fg("muted", " · type to search · Enter select · collapsed providers: Enter expand"));
318
+ }
319
+ if (this.tab === "cycle") {
320
+ return (keyHint("tui.input.tab", "tabs") + theme.fg("muted", ` · session-only until ${keyText("app.models.save")}`));
321
+ }
322
+ return keyHint("tui.input.tab", "tabs") + theme.fg("muted", " · Enter edit/add · Backspace/Delete remove");
323
+ }
324
+ getFooterText() {
325
+ if (this.tab === "select") {
326
+ const modelCount = this.selectRows.filter((r) => r.kind === "model").length;
327
+ return theme.fg("dim", ` ${modelCount} model${modelCount === 1 ? "" : "s"} · Esc cancel`);
328
+ }
329
+ if (this.tab === "cycle") {
330
+ const enabledCount = this.enabledIds?.length ?? this.cycleAllIds.length;
331
+ const allEnabled = this.enabledIds === null;
332
+ const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.cycleAllIds.length} enabled`;
333
+ const parts = [
334
+ `${keyText("tui.select.confirm")} toggle`,
335
+ `${keyText("app.models.enableAll")} all`,
336
+ `${keyText("app.models.clearAll")} clear`,
337
+ `${keyText("app.models.toggleProvider")} provider`,
338
+ `${keyText("app.models.reorderUp")}/${keyText("app.models.reorderDown")} reorder`,
339
+ `${keyText("app.models.save")} save`,
340
+ countText,
341
+ ];
342
+ return this.cycleDirty
343
+ ? theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)")
344
+ : theme.fg("dim", ` ${parts.join(" · ")}`);
345
+ }
346
+ return theme.fg("dim", " Enter open · Backspace delete · writing models.json drops comments/trailing commas");
347
+ }
348
+ setTab(tab) {
349
+ if (this.tab === tab)
350
+ return;
351
+ this.tab = tab;
352
+ this.headerText.setText(this.getHeaderText());
353
+ this.hintText.setText(this.getHintText());
354
+ this.applySearch();
355
+ }
356
+ cycleTab() {
357
+ const order = ["select", "cycle", "manage"];
358
+ const idx = order.indexOf(this.tab);
359
+ this.setTab(order[(idx + 1) % order.length]);
360
+ }
361
+ // ---- list building ----
362
+ applySearch() {
363
+ const query = this.searchInput.getValue();
364
+ if (this.tab === "select") {
365
+ this.selectRows = buildSelectRows({
366
+ models: this.allModels,
367
+ currentModel: this.currentModel,
368
+ recentKeys: this.settingsManager.getRecentModels(),
369
+ query,
370
+ expandedProviders: this.expandedProviders,
371
+ });
372
+ // Prefer current model selection
373
+ const currentIdx = this.selectRows.findIndex((r) => r.kind === "model" && modelsAreEqual(this.currentModel, r.item.model));
374
+ this.selectIndex =
375
+ currentIdx >= 0 ? currentIdx : Math.min(this.selectIndex, Math.max(0, this.selectRows.length - 1));
376
+ // Snap to first selectable if landing on header
377
+ this.ensureSelectableIndex("select");
378
+ }
379
+ else if (this.tab === "cycle") {
380
+ const items = getSortedIds(this.enabledIds, this.cycleAllIds)
381
+ .filter((id) => this.cycleModelsById.has(id))
382
+ .map((id) => ({
383
+ fullId: id,
384
+ model: this.cycleModelsById.get(id),
385
+ enabled: isEnabled(this.enabledIds, id),
386
+ }));
387
+ this.cycleItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items;
388
+ this.cycleIndex = Math.min(this.cycleIndex, Math.max(0, this.cycleItems.length - 1));
389
+ }
390
+ else {
391
+ this.rebuildManageRows();
392
+ if (query) {
393
+ this.manageRows = fuzzyFilter(this.manageRows, query, (r) => r.kind === "action" ? r.label : `${r.label} ${r.detail} ${r.id}`);
394
+ }
395
+ this.manageIndex = Math.min(this.manageIndex, Math.max(0, this.manageRows.length - 1));
396
+ }
397
+ this.updateList();
398
+ this.footerText.setText(this.getFooterText());
399
+ }
400
+ ensureSelectableIndex(which) {
401
+ if (which !== "select" || this.selectRows.length === 0)
402
+ return;
403
+ if (this.selectRows[this.selectIndex]?.kind === "header") {
404
+ const next = this.selectRows.findIndex((r, i) => i >= this.selectIndex && r.kind !== "header");
405
+ if (next >= 0) {
406
+ this.selectIndex = next;
407
+ return;
408
+ }
409
+ const prev = [...this.selectRows]
410
+ .map((r, i) => ({ r, i }))
411
+ .reverse()
412
+ .find(({ r }) => r.kind !== "header");
413
+ if (prev)
414
+ this.selectIndex = prev.i;
415
+ }
416
+ }
417
+ updateList() {
418
+ this.listContainer.clear();
419
+ if (this.errorMessage && this.tab !== "manage") {
420
+ for (const line of this.errorMessage.split("\n")) {
421
+ this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
422
+ }
423
+ }
424
+ if (this.tab === "select") {
425
+ this.renderSelectList();
426
+ }
427
+ else if (this.tab === "cycle") {
428
+ this.renderCycleList();
429
+ }
430
+ else {
431
+ this.renderManageList();
432
+ }
433
+ }
434
+ renderSelectList() {
435
+ if (this.selectRows.length === 0) {
436
+ this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
437
+ return;
438
+ }
439
+ const startIndex = Math.max(0, Math.min(this.selectIndex - Math.floor(this.maxVisible / 2), this.selectRows.length - this.maxVisible));
440
+ const endIndex = Math.min(startIndex + this.maxVisible, this.selectRows.length);
441
+ for (let i = startIndex; i < endIndex; i++) {
442
+ const row = this.selectRows[i];
443
+ const isSelected = i === this.selectIndex;
444
+ if (row.kind === "header") {
445
+ this.listContainer.addChild(new Text(theme.fg("muted", ` ── ${row.label} ──`), 0, 0));
446
+ continue;
447
+ }
448
+ if (row.kind === "collapsed") {
449
+ const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
450
+ const text = isSelected
451
+ ? theme.fg("accent", `▸ ${row.provider} (${row.count})`)
452
+ : theme.fg("muted", `▸ ${row.provider} (${row.count})`);
453
+ this.listContainer.addChild(new Text(`${prefix}${text}`, 0, 0));
454
+ continue;
455
+ }
456
+ const isCurrent = modelsAreEqual(this.currentModel, row.item.model);
457
+ const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
458
+ const modelText = isSelected ? theme.fg("accent", row.item.id) : row.item.id;
459
+ const providerBadge = theme.fg("muted", ` [${row.item.provider}]`);
460
+ const badges = theme.fg("dim", ` ${row.badges}`);
461
+ const check = isCurrent ? theme.fg("success", " ✓") : "";
462
+ this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${badges}${check}`, 0, 0));
463
+ }
464
+ if (startIndex > 0 || endIndex < this.selectRows.length) {
465
+ this.listContainer.addChild(new Text(theme.fg("muted", ` (${this.selectIndex + 1}/${this.selectRows.length})`), 0, 0));
466
+ }
467
+ const selected = this.selectRows[this.selectIndex];
468
+ if (selected?.kind === "model") {
469
+ this.listContainer.addChild(new Spacer(1));
470
+ this.listContainer.addChild(new Text(theme.fg("muted", ` ${selected.item.model.name}`), 0, 0));
471
+ }
472
+ }
473
+ renderCycleList() {
474
+ if (this.cycleItems.length === 0) {
475
+ this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
476
+ return;
477
+ }
478
+ const startIndex = Math.max(0, Math.min(this.cycleIndex - Math.floor(this.maxVisible / 2), this.cycleItems.length - this.maxVisible));
479
+ const endIndex = Math.min(startIndex + this.maxVisible, this.cycleItems.length);
480
+ const allEnabled = this.enabledIds === null;
481
+ for (let i = startIndex; i < endIndex; i++) {
482
+ const item = this.cycleItems[i];
483
+ const isSelected = i === this.cycleIndex;
484
+ const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
485
+ const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
486
+ const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
487
+ const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
488
+ this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
489
+ }
490
+ if (startIndex > 0 || endIndex < this.cycleItems.length) {
491
+ this.listContainer.addChild(new Text(theme.fg("muted", ` (${this.cycleIndex + 1}/${this.cycleItems.length})`), 0, 0));
492
+ }
493
+ if (this.cycleItems.length > 0) {
494
+ const selected = this.cycleItems[this.cycleIndex];
495
+ this.listContainer.addChild(new Spacer(1));
496
+ this.listContainer.addChild(new Text(theme.fg("muted", ` ${selected?.model.name}`), 0, 0));
497
+ }
498
+ }
499
+ renderManageList() {
500
+ if (this.manageRows.length === 0) {
501
+ this.listContainer.addChild(new Text(theme.fg("muted", " No custom providers"), 0, 0));
502
+ return;
503
+ }
504
+ const startIndex = Math.max(0, Math.min(this.manageIndex - Math.floor(this.maxVisible / 2), this.manageRows.length - this.maxVisible));
505
+ const endIndex = Math.min(startIndex + this.maxVisible, this.manageRows.length);
506
+ for (let i = startIndex; i < endIndex; i++) {
507
+ const row = this.manageRows[i];
508
+ const isSelected = i === this.manageIndex;
509
+ const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
510
+ if (row.kind === "action") {
511
+ const text = isSelected ? theme.fg("accent", row.label) : theme.fg("success", row.label);
512
+ this.listContainer.addChild(new Text(`${prefix}${text}`, 0, 0));
513
+ }
514
+ else {
515
+ const text = isSelected ? theme.fg("accent", row.label) : row.label;
516
+ const detail = theme.fg("muted", ` ${row.detail}`);
517
+ this.listContainer.addChild(new Text(`${prefix}${text}`, 0, 0));
518
+ this.listContainer.addChild(new Text(`${detail}`, 0, 0));
519
+ }
520
+ }
521
+ }
522
+ // ---- input ----
523
+ handleInput(keyData) {
524
+ const kb = getKeybindings();
525
+ if (kb.matches(keyData, "tui.input.tab")) {
526
+ this.cycleTab();
527
+ return;
528
+ }
529
+ if (kb.matches(keyData, "tui.select.cancel") || matchesKey(keyData, Key.escape)) {
530
+ this.callbacks.onCancel();
531
+ return;
532
+ }
533
+ if (matchesKey(keyData, Key.ctrl("c"))) {
534
+ if (this.searchInput.getValue()) {
535
+ this.searchInput.setValue("");
536
+ this.applySearch();
537
+ }
538
+ else {
539
+ this.callbacks.onCancel();
540
+ }
541
+ return;
542
+ }
543
+ if (this.tab === "select") {
544
+ this.handleSelectInput(keyData, kb);
545
+ }
546
+ else if (this.tab === "cycle") {
547
+ this.handleCycleInput(keyData, kb);
548
+ }
549
+ else {
550
+ this.handleManageInput(keyData, kb);
551
+ }
552
+ }
553
+ handleSelectInput(keyData, kb) {
554
+ if (kb.matches(keyData, "tui.select.up")) {
555
+ if (this.selectRows.length === 0)
556
+ return;
557
+ let next = this.selectIndex === 0 ? this.selectRows.length - 1 : this.selectIndex - 1;
558
+ // Skip headers when moving
559
+ for (let guard = 0; guard < this.selectRows.length; guard++) {
560
+ if (this.selectRows[next]?.kind !== "header")
561
+ break;
562
+ next = next === 0 ? this.selectRows.length - 1 : next - 1;
563
+ }
564
+ this.selectIndex = next;
565
+ this.updateList();
566
+ return;
567
+ }
568
+ if (kb.matches(keyData, "tui.select.down")) {
569
+ if (this.selectRows.length === 0)
570
+ return;
571
+ let next = this.selectIndex === this.selectRows.length - 1 ? 0 : this.selectIndex + 1;
572
+ for (let guard = 0; guard < this.selectRows.length; guard++) {
573
+ if (this.selectRows[next]?.kind !== "header")
574
+ break;
575
+ next = next === this.selectRows.length - 1 ? 0 : next + 1;
576
+ }
577
+ this.selectIndex = next;
578
+ this.updateList();
579
+ return;
580
+ }
581
+ if (kb.matches(keyData, "tui.select.confirm")) {
582
+ this.handleConfirm();
583
+ return;
584
+ }
585
+ this.searchInput.handleInput(keyData);
586
+ this.applySearch();
587
+ }
588
+ handleCycleInput(keyData, kb) {
589
+ if (kb.matches(keyData, "tui.select.up")) {
590
+ if (this.cycleItems.length === 0)
591
+ return;
592
+ this.cycleIndex = this.cycleIndex === 0 ? this.cycleItems.length - 1 : this.cycleIndex - 1;
593
+ this.updateList();
594
+ return;
595
+ }
596
+ if (kb.matches(keyData, "tui.select.down")) {
597
+ if (this.cycleItems.length === 0)
598
+ return;
599
+ this.cycleIndex = this.cycleIndex === this.cycleItems.length - 1 ? 0 : this.cycleIndex + 1;
600
+ this.updateList();
601
+ return;
602
+ }
603
+ const reorderUp = kb.matches(keyData, "app.models.reorderUp");
604
+ const reorderDown = kb.matches(keyData, "app.models.reorderDown");
605
+ if (reorderUp || reorderDown) {
606
+ if (this.enabledIds === null)
607
+ return;
608
+ const item = this.cycleItems[this.cycleIndex];
609
+ if (item && isEnabled(this.enabledIds, item.fullId)) {
610
+ const delta = reorderUp ? -1 : 1;
611
+ const currentIndex = this.enabledIds.indexOf(item.fullId);
612
+ const newIndex = currentIndex + delta;
613
+ if (newIndex >= 0 && newIndex < this.enabledIds.length) {
614
+ this.enabledIds = move(this.enabledIds, item.fullId, delta);
615
+ this.cycleDirty = true;
616
+ this.cycleIndex += delta;
617
+ this.applySearch();
618
+ void this.callbacks.onCycleChange(this.enabledIds === null ? null : [...this.enabledIds]);
619
+ }
620
+ }
621
+ return;
622
+ }
623
+ if (kb.matches(keyData, "tui.select.confirm")) {
624
+ const item = this.cycleItems[this.cycleIndex];
625
+ if (item) {
626
+ this.enabledIds = toggle(this.enabledIds, item.fullId);
627
+ this.cycleDirty = true;
628
+ this.applySearch();
629
+ void this.callbacks.onCycleChange(this.enabledIds === null ? null : [...this.enabledIds]);
630
+ }
631
+ return;
632
+ }
633
+ if (kb.matches(keyData, "app.models.enableAll")) {
634
+ const targetIds = this.searchInput.getValue() ? this.cycleItems.map((i) => i.fullId) : undefined;
635
+ this.enabledIds = enableAll(this.enabledIds, this.cycleAllIds, targetIds);
636
+ this.cycleDirty = true;
637
+ this.applySearch();
638
+ void this.callbacks.onCycleChange(this.enabledIds === null ? null : [...this.enabledIds]);
639
+ return;
640
+ }
641
+ if (kb.matches(keyData, "app.models.clearAll")) {
642
+ const targetIds = this.searchInput.getValue() ? this.cycleItems.map((i) => i.fullId) : undefined;
643
+ this.enabledIds = clearAll(this.enabledIds, this.cycleAllIds, targetIds);
644
+ this.cycleDirty = true;
645
+ this.applySearch();
646
+ void this.callbacks.onCycleChange(this.enabledIds === null ? null : [...this.enabledIds]);
647
+ return;
648
+ }
649
+ if (kb.matches(keyData, "app.models.toggleProvider")) {
650
+ const item = this.cycleItems[this.cycleIndex];
651
+ if (item) {
652
+ const provider = item.model.provider;
653
+ const providerIds = this.cycleAllIds.filter((id) => this.cycleModelsById.get(id).provider === provider);
654
+ const allOn = providerIds.every((id) => isEnabled(this.enabledIds, id));
655
+ this.enabledIds = allOn
656
+ ? clearAll(this.enabledIds, this.cycleAllIds, providerIds)
657
+ : enableAll(this.enabledIds, this.cycleAllIds, providerIds);
658
+ this.cycleDirty = true;
659
+ this.applySearch();
660
+ void this.callbacks.onCycleChange(this.enabledIds === null ? null : [...this.enabledIds]);
661
+ }
662
+ return;
663
+ }
664
+ if (kb.matches(keyData, "app.models.save")) {
665
+ void this.callbacks.onCyclePersist(this.enabledIds === null ? null : [...this.enabledIds]);
666
+ this.cycleDirty = false;
667
+ this.footerText.setText(this.getFooterText());
668
+ return;
669
+ }
670
+ this.searchInput.handleInput(keyData);
671
+ this.applySearch();
672
+ }
673
+ handleManageInput(keyData, kb) {
674
+ if (kb.matches(keyData, "tui.select.up")) {
675
+ if (this.manageRows.length === 0)
676
+ return;
677
+ this.manageIndex = this.manageIndex === 0 ? this.manageRows.length - 1 : this.manageIndex - 1;
678
+ this.updateList();
679
+ return;
680
+ }
681
+ if (kb.matches(keyData, "tui.select.down")) {
682
+ if (this.manageRows.length === 0)
683
+ return;
684
+ this.manageIndex = this.manageIndex === this.manageRows.length - 1 ? 0 : this.manageIndex + 1;
685
+ this.updateList();
686
+ return;
687
+ }
688
+ if (kb.matches(keyData, "tui.select.confirm")) {
689
+ this.handleConfirm();
690
+ return;
691
+ }
692
+ // Backspace / Delete remove selected provider
693
+ if (matchesKey(keyData, Key.backspace) || matchesKey(keyData, Key.delete)) {
694
+ const row = this.manageRows[this.manageIndex];
695
+ if (row?.kind === "provider") {
696
+ void this.callbacks.onManageRemove(row.source, row.id);
697
+ }
698
+ return;
699
+ }
700
+ this.searchInput.handleInput(keyData);
701
+ this.applySearch();
702
+ }
703
+ handleConfirm() {
704
+ if (this.tab === "select") {
705
+ const row = this.selectRows[this.selectIndex];
706
+ if (!row)
707
+ return;
708
+ if (row.kind === "collapsed") {
709
+ this.expandedProviders.add(row.provider);
710
+ this.applySearch();
711
+ // Move selection to first model under that provider
712
+ const idx = this.selectRows.findIndex((r) => r.kind === "model" && r.item.provider === row.provider);
713
+ if (idx >= 0)
714
+ this.selectIndex = idx;
715
+ this.updateList();
716
+ return;
717
+ }
718
+ if (row.kind === "model") {
719
+ this.settingsManager.setDefaultModelAndProvider(row.item.model.provider, row.item.model.id);
720
+ void this.callbacks.onSelectModel(row.item.model);
721
+ }
722
+ return;
723
+ }
724
+ if (this.tab === "cycle") {
725
+ const item = this.cycleItems[this.cycleIndex];
726
+ if (item) {
727
+ this.enabledIds = toggle(this.enabledIds, item.fullId);
728
+ this.cycleDirty = true;
729
+ this.applySearch();
730
+ void this.callbacks.onCycleChange(this.enabledIds === null ? null : [...this.enabledIds]);
731
+ }
732
+ return;
733
+ }
734
+ const row = this.manageRows[this.manageIndex];
735
+ if (!row)
736
+ return;
737
+ if (row.kind === "action" && row.action === "add") {
738
+ void this.callbacks.onManageAdd();
739
+ return;
740
+ }
741
+ if (row.kind === "provider") {
742
+ void this.callbacks.onManageEdit(row.source, row.id);
743
+ }
744
+ }
745
+ /** Called by parent after manage mutation so list refreshes. */
746
+ async refreshAfterManage() {
747
+ await this.reload();
748
+ this.tui.requestRender();
749
+ }
750
+ }
751
+ /** Back-compat alias used by older tests/imports. */
752
+ export { ModelHubComponent as ModelSelectorComponent };
753
+ //# sourceMappingURL=model-hub.js.map