@rizal72/oc-models-pro 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tui.js ADDED
@@ -0,0 +1,1024 @@
1
+ import { effect as _$effect } from "@opentui/solid";
2
+ import { createComponent as _$createComponent } from "@opentui/solid";
3
+ import { use as _$use } from "@opentui/solid";
4
+ import { memo as _$memo } from "@opentui/solid";
5
+ import { insert as _$insert } from "@opentui/solid";
6
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
7
+ import { insertNode as _$insertNode } from "@opentui/solid";
8
+ import { setProp as _$setProp } from "@opentui/solid";
9
+ import { createElement as _$createElement } from "@opentui/solid";
10
+ // oc-models-pro — the model list with prices, ported from Pi's `pi-model-pro`.
11
+ //
12
+ // We render it ourselves, in a `session.panel`: the native dialog caps an option line at 60 columns,
13
+ // measured, which cannot hold prices, context, intelligence and the Go credit side by side. The host
14
+ // owns the panel's placement and focus; the list owns its columns.
15
+ //
16
+ // Prices are native, never adjusted: the multiplier gets its own column and the effective price only
17
+ // decides the order, so a promoted model sorts first while the figures stay honest. The model in use
18
+ // is the first row whatever it costs: the free/paying split never buries it.
19
+ import { Plugin } from "@opencode/plugin/tui";
20
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
21
+ import { appendFileSync, readFileSync, renameSync, watch, writeFileSync } from "node:fs";
22
+ import { homedir, tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { benchFor, benchIndex } from "./bench.ts";
25
+ import { credits, goPlan, key } from "./multipliers.ts";
26
+ import { LEGEND_MIN, fit, hintLines, rowWidth } from "./fit.ts";
27
+ import { tagsFor } from "./tags.ts";
28
+ const SCOPES = ["all", "favorites", "paying"];
29
+ const prefKey = (providerID, modelID) => providerID + "/" + modelID;
30
+
31
+ /** The picker's own state file: `favorite[]` and `recent[]` are `{ providerID, modelID }`. */
32
+
33
+ const PREFS_FILE = join(homedir(), ".local/state/opencode/model.json");
34
+ /** Same directory, a leading dot: the host watches `model.json.*`, not this. */
35
+ const TEMP_FILE = join(homedir(), ".local/state/opencode", ".model.json.tmp");
36
+ let prefs = {
37
+ favorite: new Set(),
38
+ recent: new Map(),
39
+ variants: new Map()
40
+ };
41
+
42
+ /** The host's `favorite[]` as it is in the file: a key cannot be split back — a model id may hold a slash. */
43
+ function readFavourites() {
44
+ try {
45
+ const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
46
+ if (!Array.isArray(data.favorite)) return [];
47
+ return data.favorite.filter(entry => typeof entry?.providerID === "string" && typeof entry?.modelID === "string");
48
+ } catch (error) {
49
+ // A missing file is not an error: starting empty creates it on the write. Anything else is.
50
+ if (error?.code === "ENOENT") return [];
51
+ trace("favourites: " + (error instanceof Error ? error.message : String(error)));
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Rewrites `favorite[]` with the rest of the file untouched. The host watches this path and
57
+ * `model.json.*`, so the atomic rename is seen and the native picker, `model.cycle_favorite` and this
58
+ * list stay in step; a dot in front keeps the temp name outside that glob. */
59
+ function writeFavourites(list) {
60
+ try {
61
+ let raw;
62
+ try {
63
+ raw = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
64
+ } catch (error) {
65
+ if (error?.code !== "ENOENT") throw error;
66
+ raw = {};
67
+ }
68
+ raw.favorite = list;
69
+ writeFileSync(TEMP_FILE, JSON.stringify(raw));
70
+ renameSync(TEMP_FILE, PREFS_FILE);
71
+ return true;
72
+ } catch (error) {
73
+ trace("favourites: " + (error instanceof Error ? error.message : String(error)));
74
+ return false;
75
+ }
76
+ }
77
+ function toggleFavorite(providerID, modelID) {
78
+ const key = prefKey(providerID, modelID);
79
+ const list = readFavourites();
80
+ if (!list) return undefined;
81
+ const kept = list.filter(entry => prefKey(entry.providerID, entry.modelID) !== key);
82
+ const on = kept.length === list.length;
83
+ return writeFavourites(on ? [...kept, {
84
+ providerID,
85
+ modelID
86
+ }] : kept) ? on : undefined;
87
+ }
88
+ function readPrefs() {
89
+ const parsed = {
90
+ favorite: new Set(),
91
+ recent: new Map(),
92
+ variants: new Map()
93
+ };
94
+ try {
95
+ const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
96
+ for (const entry of data.favorite ?? []) parsed.favorite.add(prefKey(entry.providerID, entry.modelID));
97
+ for (const [index, entry] of (data.recent ?? []).entries()) parsed.recent.set(prefKey(entry.providerID, entry.modelID), index);
98
+ for (const [model, variant] of Object.entries(data.variant ?? {})) if (typeof variant === "string") parsed.variants.set(model, variant);
99
+ } catch (error) {
100
+ // A corrupt state file empties the favourites and the recent list: say so instead of pretending.
101
+ trace("prefs unreadable: " + (error instanceof Error ? error.message : String(error)));
102
+ }
103
+ return parsed;
104
+ }
105
+
106
+ /** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
107
+ * not zero — it sorts last, or it would sit at the top of the cheapest list forever. */
108
+ function effective(row) {
109
+ if (row.input === undefined) return Number.POSITIVE_INFINITY;
110
+ return row.input / (row.credit || 1);
111
+ }
112
+ const SORTS = [{
113
+ label: "effective price",
114
+ short: "effective↑",
115
+ value: effective
116
+ }, {
117
+ label: "input price",
118
+ short: "in↑",
119
+ value: row => row.input ?? Number.POSITIVE_INFINITY
120
+ }, {
121
+ label: "output price",
122
+ short: "out↑",
123
+ value: row => row.output ?? Number.POSITIVE_INFINITY
124
+ }, {
125
+ label: "input + output",
126
+ short: "in+out↑",
127
+ value: row => row.input === undefined || row.output === undefined ? Number.POSITIVE_INFINITY : (row.input + row.output) / (row.credit || 1)
128
+ }, {
129
+ label: "context window",
130
+ short: "ctx↓",
131
+ value: row => row.context === undefined ? Number.POSITIVE_INFINITY : -row.context
132
+ },
133
+ // A missing score sorts last, never first: Pi's rule, and the honest one.
134
+ {
135
+ label: "intelligence",
136
+ short: "int↓",
137
+ value: row => -(row.int ?? -1)
138
+ }, {
139
+ label: "speed",
140
+ short: "speed↓",
141
+ value: row => -(row.tps ?? -1)
142
+ }, {
143
+ label: "recently used",
144
+ short: "recent",
145
+ value: row => prefs.recent.get(prefKey(row.providerID, row.modelID)) ?? 1e9
146
+ }, {
147
+ label: "name",
148
+ short: "name",
149
+ value: row => 0
150
+ }];
151
+
152
+ /** Fallbacks, used only when the theme defines none of the candidates. */
153
+ const FALLBACK_DIM = "#8a8a8a";
154
+ const FALLBACK_ACCENT = "#5a9fd4";
155
+ function tokens(value) {
156
+ if (value === undefined) return "";
157
+ if (value >= 1_000_000) return Math.round(value / 1_000_000) + "M";
158
+ if (value >= 1000) return Math.round(value / 1000) + "k";
159
+ return String(value);
160
+ }
161
+
162
+ /**
163
+ * Every cell is exactly `width` characters: text that does not fit is cut, never shifted right. The
164
+ * columns are hand-padded monospace, so one `padStart` short of its width slides the whole row.
165
+ */
166
+ function cell(text, width, align = "end") {
167
+ const cut = text.length > width ? text.slice(0, width) : text;
168
+ return align === "end" ? cut.padStart(width) : cut.padEnd(width);
169
+ }
170
+
171
+ /** Two letters and the gap after them: always four characters, or the name column shifts left. */
172
+ function padTag(letters) {
173
+ return (letters.padEnd(2) + " ").slice(0, 4);
174
+ }
175
+
176
+ /**
177
+ * Column widths live in `fit.ts`: they are no longer fixed. The table is 85 characters at its widest
178
+ * and the name gives way first (down to 24), then whole columns go. `panel.width` reads as pixels
179
+ * on the first render, so `fit` clamps anything implausible back to the wide layout.
180
+ */
181
+ function label(name, width) {
182
+ return name.length > width ? name.slice(0, width - 1) + "…" : name.padEnd(width);
183
+ }
184
+
185
+ /** A price cell; blank when the catalogue has no figure, "free" when the figure is zero. */
186
+ function price(value, width) {
187
+ if (value === undefined) return cell("", width);
188
+ const shown = value === 0 ? "free" : String(Math.round(value * 1000) / 1000);
189
+ return cell(shown, width);
190
+ }
191
+ function isGo(providerID) {
192
+ return providerID === "opencode-go";
193
+ }
194
+ function free(row) {
195
+ return row.input === 0 && row.output === 0;
196
+ }
197
+ function headerRow(f) {
198
+ return [" ", " ", "model".padEnd(f.name), cell("in", 6), cell("out", 6), ...(f.ctx ? [cell("ctx", 5)] : []), ...(f.int ? [cell("int", 5)] : []), ...(f.tkps ? [cell("tok/s", 6)] : []), cell("cred", 4), ...(f.note ? [cell("note", 6)] : [])].join(" ");
199
+ }
200
+ function modelRow(row, mine, favourite, f) {
201
+ const notes = [row.peak ? "peak*" + row.peak : "", row.promo ? "+" + row.promo + "x" : ""].filter(Boolean);
202
+ return [(mine ? "●" : " ") + (favourite ? "*" : " "), padTag(row.tag), label(row.name, f.name), price(row.input, 6), price(row.output, 6), ...(f.ctx ? [cell(tokens(row.context), 5)] : []), ...(f.int ? [cell(row.int !== undefined ? row.int.toFixed(1) : "", 5)] : []), ...(f.tkps ? [cell(row.tps !== undefined ? "~" + Math.round(row.tps) : "", 6)] : []),
203
+ // Any credit other than 1 changes the effective price, so any of them is shown.
204
+ cell(row.credit !== 1 ? "×" + row.credit : "", 4), ...(f.note ? [cell(notes.join(" "), 6)] : [])].join(" ");
205
+ }
206
+ const TRACE = join(tmpdir(), "oc-models-pro.log");
207
+
208
+ /** The tail is the part worth reading; the file is trimmed at startup, never during a session. */
209
+ const TRACE_LINES = 1000;
210
+ function trace(message) {
211
+ try {
212
+ appendFileSync(TRACE, new Date().toISOString() + " " + message + "\n");
213
+ } catch {}
214
+ }
215
+
216
+ /** Keeps the log to its last `TRACE_LINES` lines, so it neither grows without bound nor loses the recent past. */
217
+ function trimTrace() {
218
+ try {
219
+ const lines = readFileSync(TRACE, "utf8").split("\n");
220
+ if (lines.length > TRACE_LINES) writeFileSync(TRACE, lines.slice(-TRACE_LINES).join("\n"));
221
+ } catch {}
222
+ }
223
+ export default Plugin.define({
224
+ id: "oc-models-pro",
225
+ setup(context) {
226
+ trimTrace();
227
+ const ui = context.ui;
228
+ const themeColour = (...paths) => {
229
+ for (const path of paths) {
230
+ let node = context.theme;
231
+ for (const key of path) node = node?.[key];
232
+ if (node !== undefined && node !== null) return node;
233
+ }
234
+ return undefined;
235
+ };
236
+ const BASE = themeColour(["text", "base"]);
237
+ const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM;
238
+ const ACCENT = themeColour(["syntax", "function"], ["syntax", "variable"]) ?? FALLBACK_ACCENT;
239
+ // Theme colours reach us as RGBA objects; `fg` takes them, a box background does not — it wants
240
+ // a string, so the two surfaces are converted to hex.
241
+ const hex = node => {
242
+ if (typeof node === "string") return node;
243
+ const buffer = node?.buffer;
244
+ if (!buffer) return undefined;
245
+ const channels = [0, 1, 2].map(index => Math.max(0, Math.min(255, Number(buffer[index]) || 0)).toString(16).padStart(2, "0"));
246
+ return "#" + channels.join("");
247
+ };
248
+ const SURFACE = hex(themeColour(["surface", "base"], ["background", "raised", "base"]));
249
+ // The cyan the native controls use when focused; the row text flips to the page colour on top.
250
+ const SELECTED = hex(themeColour(["background", "action", "primary", "focused"], ["surface", "high"]));
251
+ const ON_SELECTED = hex(themeColour(["background", "base"], ["text", "base"]));
252
+ const BORDER = hex(themeColour(["border", "base"], ["text", "faint"]));
253
+ trace("setup: context=" + Object.keys(context).join(","));
254
+ prefs = readPrefs();
255
+ let creditIndex = new Map();
256
+ let bench = new Map();
257
+ /** False when the Go page could not be read and no cache was there: the credits are all 1. */
258
+ let creditsKnown = false;
259
+ // Signals, not plain variables: the header shows them, and a plain variable left the title
260
+ // saying "effective price" whatever the list was actually sorted by.
261
+ const [sort, setSort] = createSignal(0);
262
+ const [grouped, setGrouped] = createSignal(true);
263
+ const [scope, setScope] = createSignal("all");
264
+ function fail(label, error) {
265
+ trace(label + ": " + (error instanceof Error ? error.message + " @ " + (error.stack ?? "").split("\n")[1] : String(error)));
266
+ }
267
+ function locationRef() {
268
+ return context.location ?? context.data.location.default();
269
+ }
270
+
271
+ /** The collections live under `data.location`; per location, and empty until synced once. */
272
+ function listModels() {
273
+ try {
274
+ return context.data.location.model.list(locationRef()) ?? [];
275
+ } catch (error) {
276
+ fail("models", error);
277
+ return [];
278
+ }
279
+ }
280
+ function listProviders() {
281
+ try {
282
+ return context.data.location.provider.list(locationRef()) ?? [];
283
+ } catch (error) {
284
+ fail("providers", error);
285
+ return [];
286
+ }
287
+ }
288
+ function rows() {
289
+ const providers = new Map(listProviders().map(provider => [provider.id, provider.name]));
290
+ const tags = tagsFor([...providers].map(([id, name]) => ({
291
+ id,
292
+ name
293
+ })));
294
+ const result = [];
295
+ for (const model of listModels()) {
296
+ const cost = (model.cost ?? []).find(entry => !entry.tier) ?? model.cost?.[0];
297
+ const limit = model.limit ?? {};
298
+ const score = benchFor(bench, model.name, model.modelID);
299
+ result.push({
300
+ name: model.name,
301
+ providerID: model.providerID,
302
+ modelID: model.modelID,
303
+ // The host names an account-bound provider `<account> / <Provider>`, which in a group
304
+ // header reads as if the account were the thing: provider first, account in brackets.
305
+ provider: (providers.get(model.providerID) ?? model.providerID).replace(/^(.+?) \/ (.+)$/, "$2 ($1)"),
306
+ tag: tags.get(model.providerID) ?? model.providerID.slice(0, 2).toUpperCase(),
307
+ // A missing figure stays missing: a 0 here would put an unknown model at the top of the
308
+ // cheapest list and claim a context window it never declared.
309
+ input: typeof cost?.input === "number" ? cost.input : undefined,
310
+ output: typeof cost?.output === "number" ? cost.output : undefined,
311
+ context: typeof limit.context === "number" ? limit.context : undefined,
312
+ // The page describes the Go plan only, and its names are not the catalogue's: match the
313
+ // model id, and only on the provider the plan applies to. By name alone, OpenRouter's
314
+ // "DeepSeek V4.1 Flash" inherited the Go credit.
315
+ credit: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.credit ?? 1 : 1,
316
+ promo: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.promo : undefined,
317
+ peak: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined,
318
+ int: score?.int,
319
+ tps: score?.tps,
320
+ enabled: model.enabled !== false
321
+ });
322
+ }
323
+ // Every provider keeps its row: the same model on `deepseek` and on `opencode-go` is two
324
+ // different offers, and hiding one hid `deepseek-v4-pro` from the provider that hosts it.
325
+ return result;
326
+ }
327
+ async function warm() {
328
+ const ref = locationRef();
329
+ try {
330
+ if (listModels().length === 0) await context.data.location.model.sync(ref);
331
+ if (listProviders().length === 0) await context.data.location.provider.sync(ref);
332
+ trace("warm: models=" + listModels().length + " providers=" + listProviders().length);
333
+ } catch (error) {
334
+ fail("warm", error);
335
+ }
336
+ }
337
+ async function open() {
338
+ prefs = readPrefs();
339
+ await warm();
340
+ // One fetch a day at most; a failure only costs the credit column and the effective ordering.
341
+ const plan = await goPlan();
342
+ creditIndex = plan ? credits(plan) : new Map();
343
+ bench = await benchIndex();
344
+ creditsKnown = creditIndex.size > 0;
345
+ trace("open: models=" + listModels().length + " bench=" + bench.size + " plan=" + (plan ? "$" + plan.price + " " + plan.limits.length + " rows" : "none") + " credits=" + creditIndex.size + " prefs=" + prefs.favorite.size + "/" + prefs.recent.size);
346
+ if (!ui.panel.open("oc-models-pro")) {
347
+ ui.toast.show({
348
+ message: "open a session first",
349
+ variant: "warning"
350
+ });
351
+ return;
352
+ }
353
+ trace("open: panel requested");
354
+ }
355
+ const Panel = props => {
356
+ // Who had the focus before the panel took it: the prompt itself. The renderer can hand it back,
357
+ // and nothing else in the API can — no host command focuses the prompt from a plugin.
358
+ const previousFocus = context.renderer?.currentFocusedRenderable;
359
+ trace("panel: previous focus=" + (previousFocus ? previousFocus.constructor?.name ?? "?" : "none"));
360
+ const [selected, setSelected] = createSignal(undefined);
361
+ /** The panel's width, in columns, and the layout it buys — reactive: the host resizes it. */
362
+ const width = createMemo(() => Math.floor(Number(props.panel.width) || 0), undefined, {
363
+ equals: Object.is
364
+ });
365
+ // The scrollbox draws its scrollbar in the last column, so the table gets one less.
366
+ // Both layouts carry an equality check: the width settles from pixels to columns on open,
367
+ // and a memo that fires on that tick re-renders the panel for no visual change — which is
368
+ // what took the focus off the search field.
369
+ const fitted = createMemo(() => fit(width() - 1), undefined, {
370
+ equals: (a, b) => a.name === b.name && a.tkps === b.tkps && a.note === b.note && a.ctx === b.ctx && a.int === b.int
371
+ });
372
+ const hints = createMemo(() => hintLines(width()), undefined, {
373
+ equals: (a, b) => a.join("\n") === b.join("\n")
374
+ });
375
+ /** The legend shortens when the panel cannot hold the long one. */
376
+ const compactLegend = createMemo(() => width() < LEGEND_MIN, undefined, {
377
+ equals: Object.is
378
+ });
379
+ // Hover moves the selection, like the native lists; during a wheel scroll the host fires
380
+ // over-events on whatever row lands under the pointer, so they are ignored until it stops.
381
+ let lastScroll = 0;
382
+ const hover = key => {
383
+ if (Date.now() - lastScroll < 150) return;
384
+ if (selectedKey() !== key) setSelected(key);
385
+ };
386
+ const [query, setQuery] = createSignal("");
387
+ // The app's state file can change under us (a toggle here, a model switched by the host);
388
+ // bumping this is what re-reads it and re-renders the rows.
389
+ const [revision, setRevision] = createSignal(0);
390
+ // The input keeps its own text and cursor; clearing means clearing it too.
391
+ let field;
392
+ /** Diagnostic: more than one mount per open means the panel is being rebuilt under us. */
393
+ let inputMounts = 0;
394
+ /**
395
+ * Who the host's renderer has focus on: the truth the blur/refocus guessing could only infer.
396
+ * Clicking a row or the field moves it without necessarily firing a blur on the input.
397
+ */
398
+ const holdsField = () => Boolean(field) && context.renderer?.currentFocusedRenderable === field;
399
+ /**
400
+ * Hands the focus back to whoever held it before the panel opened — the prompt. The host does not
401
+ * do it for a panel closed by a click, and there is no command that focuses the prompt: this is
402
+ * the renderer's own method, and it is what makes a click on a row behave like `esc`.
403
+ */
404
+ const focusPrompt = () => {
405
+ try {
406
+ if (!previousFocus || previousFocus.isDestroyed || !context.renderer?.focusRenderable) {
407
+ trace("focus: no target");
408
+ return;
409
+ }
410
+ context.renderer.focusRenderable(previousFocus);
411
+ trace("focus: restored, current=" + (context.renderer.currentFocusedRenderable === previousFocus ? "yes" : "no"));
412
+ } catch (error) {
413
+ fail("focus", error);
414
+ }
415
+ };
416
+ const clear = () => {
417
+ setQuery("");
418
+ if (field && !field.isDestroyed) field.value = "";
419
+ };
420
+
421
+ // The host activates a panel's keyboard layers only while it owns input, and it disposes them
422
+ // when the panel unmounts: measured three openings in a row, every one reading 10 reachable
423
+ // `models-pro.*` commands and never 20 — a leaked layer would show up here first.
424
+ try {
425
+ trace("panel: " + context.keymap.commands().filter(entry => String(entry.id ?? "").startsWith("models-pro.")).length + " models-pro commands reachable");
426
+ } catch (error) {
427
+ fail("panel commands", error);
428
+ }
429
+
430
+ // The state file is shared: the native picker and `model.cycle_favorite` write it while this
431
+ // panel is open. Watching the directory (not the file) survives the rename, and every event
432
+ // re-reads the file so the star and the `favorites` scope stay true.
433
+ try {
434
+ const watcher = watch(join(homedir(), ".local/state/opencode"), (_event, name) => {
435
+ if (String(name) !== "model.json") return;
436
+ prefs = readPrefs();
437
+ setRevision(revision() + 1);
438
+ });
439
+ onCleanup(() => watcher.close());
440
+ } catch (error) {
441
+ fail("watch prefs", error);
442
+ }
443
+ const mineKey = createMemo(() => {
444
+ const mine = ui.model.current();
445
+ return mine ? prefKey(mine.providerID, mine.modelID) : undefined;
446
+ });
447
+
448
+ /** Built only when the filter, the scope or a toggle changes — never on a selection move. */
449
+ const itemList = createMemo(() => {
450
+ revision();
451
+ const mine = mineKey();
452
+ const needle = query().trim().toLowerCase();
453
+ let list = rows().filter(row => row.enabled);
454
+ if (scope() === "favorites") list = list.filter(row => prefs.favorite.has(prefKey(row.providerID, row.modelID)) || prefKey(row.providerID, row.modelID) === mine);
455
+ if (scope() === "paying") list = list.filter(row => !free(row) || prefKey(row.providerID, row.modelID) === mine);
456
+ if (needle) list = list.filter(row => (row.name + " " + row.provider + " " + row.providerID).toLowerCase().includes(needle));
457
+ list = [...list].sort((a, b) => {
458
+ const mineA = prefKey(a.providerID, a.modelID) === mine;
459
+ const mineB = prefKey(b.providerID, b.modelID) === mine;
460
+ if (mineA !== mineB) return mineA ? -1 : 1;
461
+ if (SORTS[sort()].label !== "name") {
462
+ const delta = SORTS[sort()].value(a) - SORTS[sort()].value(b);
463
+ if (delta) return delta;
464
+ }
465
+ return a.name.localeCompare(b.name);
466
+ });
467
+
468
+ // Free models never enter the price order: they are not cheap, they are unmetered, and at
469
+ // the top they hide everything that costs something. Grouped, each one still sits under
470
+ // its own provider, after that provider's paying rows — the group answers "who serves
471
+ // this", and a global "Free" bucket would answer it wrong. Flat, they keep one bucket.
472
+ const items = [];
473
+ if (grouped()) {
474
+ const groups = new Map();
475
+ for (const row of list) {
476
+ const bucket = groups.get(row.provider) ?? [];
477
+ bucket.push(row);
478
+ groups.set(row.provider, bucket);
479
+ }
480
+ // Two bits: the model in use first, then the active order inside each of the two classes.
481
+ // It leads its group even when it is free — it is the state of the session, not a price.
482
+ for (const bucket of groups.values()) bucket.sort((a, b) => {
483
+ const mineA = prefKey(a.providerID, a.modelID) === mine;
484
+ const mineB = prefKey(b.providerID, b.modelID) === mine;
485
+ if (mineA !== mineB) return mineA ? -1 : 1;
486
+ return Number(free(a)) - Number(free(b));
487
+ });
488
+ const names = [...groups.keys()].sort((a, b) => a.localeCompare(b));
489
+ // The provider of the model in use comes first, like the native picker.
490
+ const mineProvider = list.find(row => prefKey(row.providerID, row.modelID) === mine)?.provider;
491
+ if (mineProvider) names.sort((a, b) => a === mineProvider ? -1 : b === mineProvider ? 1 : 0);
492
+ for (const name of names) {
493
+ items.push({
494
+ header: name
495
+ });
496
+ for (const row of groups.get(name)) items.push({
497
+ row
498
+ });
499
+ }
500
+ } else {
501
+ // The model in use leads the list, free or not: it is the state of the session, not a
502
+ // price to compare. The other free models keep the bucket at the end.
503
+ for (const row of list) if (!free(row) || prefKey(row.providerID, row.modelID) === mine) items.push({
504
+ row
505
+ });
506
+ const freeRows = list.filter(row => free(row) && prefKey(row.providerID, row.modelID) !== mine);
507
+ if (freeRows.length) {
508
+ items.push({
509
+ header: "Free"
510
+ });
511
+ for (const row of freeRows) items.push({
512
+ row
513
+ });
514
+ }
515
+ }
516
+ return items;
517
+ });
518
+ const selectable = createMemo(() => itemList().flatMap(item => item.row ? [item.row] : []));
519
+
520
+ /** Derived: the stored key if it still exists, else the model in use, else the first row. */
521
+ const selectedKey = createMemo(() => {
522
+ const list = selectable();
523
+ const stored = selected();
524
+ if (stored && list.some(row => prefKey(row.providerID, row.modelID) === stored)) return stored;
525
+ const mine = mineKey();
526
+ if (mine && list.some(row => prefKey(row.providerID, row.modelID) === mine)) return mine;
527
+ const first = list[0];
528
+ return first ? prefKey(first.providerID, first.modelID) : undefined;
529
+ });
530
+ function move(delta) {
531
+ const list = selectable();
532
+ if (list.length === 0) return;
533
+ const at = list.findIndex(row => prefKey(row.providerID, row.modelID) === selectedKey());
534
+ const next = Math.min(list.length - 1, Math.max(0, (at === -1 ? 0 : at) + delta));
535
+ const key = prefKey(list[next].providerID, list[next].modelID);
536
+ setSelected(key);
537
+ reveal(key);
538
+ }
539
+
540
+ /** The list is taller than the panel: the selection has to drag the viewport with it. */
541
+ let scroller;
542
+ function reveal(key) {
543
+ const box = scroller;
544
+ if (!box || box.isDestroyed) return;
545
+ try {
546
+ box.scrollChildIntoView("mp-" + key);
547
+ } catch (error) {
548
+ fail("scroll", error);
549
+ }
550
+ }
551
+ function choose(target) {
552
+ const wanted = selectedKey();
553
+ const row = target ?? selectable().find(entry => prefKey(entry.providerID, entry.modelID) === wanted);
554
+ if (!row) return;
555
+ trace("choose: " + prefKey(row.providerID, row.modelID));
556
+ // Esc closes with the search field focused and the host hands the prompt its focus back; a
557
+ // click leaves it on the row, so the field takes it again before the panel goes away.
558
+ if (field && !field.isDestroyed) field.focus?.();
559
+ props.panel.close();
560
+ // The host returns the prompt's focus on its own only most of the time, and never while its
561
+ // unmount is still running: the saved renderable takes it back once the panel is gone.
562
+ if (props.panel.presentation === "panel") setTimeout(focusPrompt, 40);
563
+ void (async () => {
564
+ if (!(await selectModel(row))) {
565
+ // The native never fails here — it writes the selection itself — so this is the one
566
+ // thing it has no equivalent for. A failure nobody announced is worse.
567
+ const now = ui.model.current();
568
+ ui.toast.show({
569
+ message: row.name + " was not applied — the session kept " + (now ? now.providerID + "/" + now.modelID : "the previous model"),
570
+ variant: "error"
571
+ });
572
+ return;
573
+ }
574
+ await settleVariant(row);
575
+ })();
576
+ }
577
+
578
+ /**
579
+ * The host owns the model selection: the native picker writes it with the host's own
580
+ * `model.set`, per session and agent, and the prompt sends it to the server. The plugin API
581
+ * has no setter for it — `ui.model` reads it and changes its variant, nothing more — so the
582
+ * host makes the change: `model.cycle_favorite` ends on the first favourite, the same
583
+ * internal call the picker makes. The list is prepared for that call and put back right
584
+ * after. One entry is what makes the call safe to repeat: whatever is selected, it lands on
585
+ * that entry.
586
+ */
587
+ async function selectModel(row) {
588
+ // The upstream fix, when it lands: the host's setter, and no list at all.
589
+ const native = ui.model.set;
590
+ if (typeof native === "function") {
591
+ native.call(ui.model, {
592
+ providerID: row.providerID,
593
+ modelID: row.modelID
594
+ });
595
+ const now = ui.model.current();
596
+ return now?.providerID === row.providerID && now?.modelID === row.modelID;
597
+ }
598
+ const list = readFavourites();
599
+ if (!list) return false;
600
+ if (!writeFavourites([{
601
+ providerID: row.providerID,
602
+ modelID: row.modelID
603
+ }])) return false;
604
+ // The host watches the file: it needs a moment before the command reads the list.
605
+ await new Promise(resolve => setTimeout(resolve, 100));
606
+ let landed = false;
607
+ for (let attempt = 0; attempt < 20 && !landed; attempt++) {
608
+ // A second call covers the moment the host had not read the file yet.
609
+ if (attempt % 4 === 0) {
610
+ try {
611
+ context.keymap.dispatch("model.cycle_favorite");
612
+ } catch (error) {
613
+ fail("dispatch", error);
614
+ break;
615
+ }
616
+ }
617
+ const now = ui.model.current();
618
+ landed = now?.providerID === row.providerID && now?.modelID === row.modelID;
619
+ if (!landed) await new Promise(resolve => setTimeout(resolve, 50));
620
+ }
621
+ writeFavourites(list);
622
+ trace("select: " + row.name + (landed ? " landed" : " did not land"));
623
+ return landed;
624
+ }
625
+
626
+ /**
627
+ * A model whose variant was never chosen has none applied, and the native picker asks once for
628
+ * it. Same rule here: a saved entry — `default` included — means silence; no entry opens the
629
+ * question, and the host's own `variant.set` is what persists the answer.
630
+ */
631
+ async function settleVariant(row) {
632
+ // The file is the source, not the copy read at open: a variant saved meanwhile (native
633
+ // picker, prompt) has to count as an answer.
634
+ prefs = readPrefs();
635
+ if (prefs.variants.has(prefKey(row.providerID, row.modelID))) return;
636
+ // `list()` and `set()` act on the selected model, which is now the new one: the question
637
+ // is asked before anything could move it again.
638
+ let variants = [];
639
+ try {
640
+ variants = [...ui.model.variant.list()];
641
+ } catch (error) {
642
+ fail("variants", error);
643
+ return;
644
+ }
645
+ if (variants.length === 0) return;
646
+ trace("variants: " + variants.join(",") + " for " + row.name);
647
+ const picks = ["default", ...variants];
648
+ const chosen = await ui.dialog.select({
649
+ title: "Variant — " + row.name,
650
+ placeholder: "filter variants",
651
+ options: picks.map(id => ({
652
+ title: id === "default" ? "Default (no variant)" : id,
653
+ value: id,
654
+ description: ""
655
+ }))
656
+ });
657
+ // A dialog takes the focus and does not hand it back either: the same command, once it closed.
658
+ setTimeout(focusPrompt, 40);
659
+ // Cancelling leaves the model without a variant, and the question will come back, as in
660
+ // the native picker.
661
+ if (chosen === undefined) return;
662
+ const applied = ui.model.variant.set(chosen === "default" ? undefined : chosen);
663
+ trace("variant set: " + chosen + " -> " + applied);
664
+ }
665
+ function cycleSort() {
666
+ setSort((sort() + 1) % SORTS.length);
667
+ }
668
+ function recentSort() {
669
+ setSort(SORTS.findIndex(entry => entry.label === "recently used"));
670
+ }
671
+ function toggleGroups() {
672
+ setGrouped(!grouped());
673
+ }
674
+ function cycleScope() {
675
+ setScope(SCOPES[(SCOPES.indexOf(scope()) + 1) % SCOPES.length]);
676
+ }
677
+
678
+ /** Writes the app's state file, then re-reads it: the row and the native picker agree. */
679
+ function favourite() {
680
+ const wanted = selectedKey();
681
+ const row = selectable().find(entry => prefKey(entry.providerID, entry.modelID) === wanted);
682
+ if (!row) return;
683
+ const on = toggleFavorite(row.providerID, row.modelID);
684
+ if (on === undefined) {
685
+ ui.toast.show({
686
+ message: "could not write " + PREFS_FILE,
687
+ variant: "error"
688
+ });
689
+ return;
690
+ }
691
+ prefs = readPrefs();
692
+ setRevision(revision() + 1);
693
+ ui.toast.show({
694
+ message: row.name + (on ? " added to" : " removed from") + " favourites",
695
+ variant: on ? "success" : "info"
696
+ });
697
+ }
698
+ context.keymap.layer(() => ({
699
+ mode: "global",
700
+ enabled: () => props.panel.focused,
701
+ commands: [{
702
+ id: "models-pro.clear",
703
+ title: "Clear the search",
704
+ bind: "ctrl+u",
705
+ run: () => clear()
706
+ }, {
707
+ id: "models-pro.up",
708
+ title: "Previous model",
709
+ bind: "up",
710
+ run: () => move(-1)
711
+ }, {
712
+ id: "models-pro.down",
713
+ title: "Next model",
714
+ bind: "down",
715
+ run: () => move(1)
716
+ }, {
717
+ id: "models-pro.choose",
718
+ title: "Use the model",
719
+ bind: "enter",
720
+ run: () => choose()
721
+ }, {
722
+ id: "models-pro.sort",
723
+ title: "Sort",
724
+ bind: "ctrl+o",
725
+ run: () => cycleSort()
726
+ }, {
727
+ id: "models-pro.groups",
728
+ title: "Group by provider",
729
+ bind: "ctrl+g",
730
+ run: () => toggleGroups()
731
+ }, {
732
+ id: "models-pro.scope",
733
+ title: "Scope",
734
+ bind: "ctrl+s",
735
+ run: () => cycleScope()
736
+ }, {
737
+ id: "models-pro.recent",
738
+ title: "Recently used",
739
+ bind: "ctrl+r",
740
+ run: () => recentSort()
741
+ }, {
742
+ id: "models-pro.favorite",
743
+ title: "Toggle favourite",
744
+ bind: "ctrl+f",
745
+ run: () => favourite()
746
+ }, {
747
+ id: "models-pro.close",
748
+ title: "Clear the search, or close the list",
749
+ bind: "esc",
750
+ run: () => query() ? clear() : props.panel.close()
751
+ },
752
+ // `ctrl+y`: `f11` never reached the panel in the terminal. `ctrl+f` marks a favourite,
753
+ // as in the native dialog. `f` alone stays unusable: it would be typed into the search.
754
+ {
755
+ id: "models-pro.fullscreen",
756
+ title: "Full screen",
757
+ bind: "ctrl+y",
758
+ run: () => props.panel.toggleFullscreen()
759
+ }]
760
+ }));
761
+ trace("panel: mounted");
762
+
763
+ // A reflow — the width settling from pixels, the footer taking a second line — can take the
764
+ // focus off the search field. The panel still has it, so the field takes it back. This does
765
+ // not depend on the memo equality guards being honoured by the host's renderer.
766
+ createEffect(() => {
767
+ fitted();
768
+ hints();
769
+ const element = field;
770
+ if (!element || element.isDestroyed || !props.panel.focused || holdsField()) return;
771
+ trace("reflow: field takes the focus back");
772
+ element.focus();
773
+ });
774
+
775
+ // The table is hand-padded, so its width is measured on the real rows instead of trusted:
776
+ // a mismatch against what the panel can hold shows up in the trace next to every open.
777
+ try {
778
+ const sample = selectable()[0];
779
+ const f = fitted();
780
+ trace("panel widths: panel=" + width() + " header=" + headerRow(f).length + " row=" + (sample ? modelRow(sample, false, false, f).length : 0) + " fits=" + rowWidth(f) + " name=" + f.name + " cols=" + [f.tkps && "tok/s", f.note && "note", f.ctx && "ctx", f.int && "int"].filter(Boolean).join(",") + " rows=" + selectable().length);
781
+ } catch (error) {
782
+ fail("panel widths", error);
783
+ }
784
+ return (() => {
785
+ var _el$ = _$createElement("box"),
786
+ _el$2 = _$createElement("box"),
787
+ _el$3 = _$createElement("text"),
788
+ _el$4 = _$createElement("b"),
789
+ _el$6 = _$createElement("text"),
790
+ _el$7 = _$createTextNode(` `),
791
+ _el$8 = _$createTextNode(` ·`),
792
+ _el$9 = _$createElement("text"),
793
+ _el$0 = _$createElement("b"),
794
+ _el$1 = _$createElement("text"),
795
+ _el$10 = _$createElement("box"),
796
+ _el$11 = _$createElement("text"),
797
+ _el$12 = _$createElement("box"),
798
+ _el$13 = _$createElement("input"),
799
+ _el$14 = _$createElement("box"),
800
+ _el$15 = _$createElement("text"),
801
+ _el$16 = _$createElement("scrollbox"),
802
+ _el$17 = _$createElement("box");
803
+ _$insertNode(_el$, _el$2);
804
+ _$insertNode(_el$, _el$10);
805
+ _$insertNode(_el$, _el$12);
806
+ _$insertNode(_el$, _el$14);
807
+ _$insertNode(_el$, _el$16);
808
+ _$insertNode(_el$, _el$17);
809
+ _$setProp(_el$, "flexDirection", "column");
810
+ _$setProp(_el$, "flexGrow", 1);
811
+ _$insertNode(_el$2, _el$3);
812
+ _$insertNode(_el$2, _el$6);
813
+ _$insertNode(_el$2, _el$9);
814
+ _$insertNode(_el$2, _el$1);
815
+ _$setProp(_el$2, "flexDirection", "row");
816
+ _$setProp(_el$2, "height", 1);
817
+ _$setProp(_el$2, "flexShrink", 0);
818
+ _$insertNode(_el$3, _el$4);
819
+ _$setProp(_el$3, "fg", BASE);
820
+ _$setProp(_el$3, "wrapMode", "none");
821
+ _$insertNode(_el$4, _$createTextNode(`Models`));
822
+ _$insertNode(_el$6, _el$7);
823
+ _$insertNode(_el$6, _el$8);
824
+ _$setProp(_el$6, "fg", DIM);
825
+ _$setProp(_el$6, "wrapMode", "none");
826
+ _$insert(_el$6, () => "sort: " + SORTS[sort()].short, _el$8);
827
+ _$insertNode(_el$9, _el$0);
828
+ _$setProp(_el$9, "fg", BASE);
829
+ _$setProp(_el$9, "wrapMode", "none");
830
+ _$insert(_el$0, scope);
831
+ _$setProp(_el$1, "fg", DIM);
832
+ _$setProp(_el$1, "wrapMode", "none");
833
+ _$insert(_el$1, () => " · " + (grouped() ? "grouped" : "flat") + (creditsKnown ? "" : " · credits n/a"));
834
+ _$insertNode(_el$10, _el$11);
835
+ _$setProp(_el$10, "height", 1);
836
+ _$setProp(_el$10, "flexShrink", 0);
837
+ _$setProp(_el$11, "fg", DIM);
838
+ _$setProp(_el$11, "wrapMode", "none");
839
+ _$insert(_el$11, () => compactLegend() ? "×N credit · peak*2 · +4x promo" : "(×N = Go credit · peak*2 = the tariff doubles at peak · +Nx = promotion)");
840
+ _$insertNode(_el$12, _el$13);
841
+ _$setProp(_el$12, "height", 3);
842
+ _$setProp(_el$12, "flexShrink", 0);
843
+ _$setProp(_el$12, "border", ["top", "bottom", "left", "right"]);
844
+ _$setProp(_el$12, "borderColor", BORDER);
845
+ _$setProp(_el$12, "paddingLeft", 1);
846
+ _$setProp(_el$12, "paddingRight", 1);
847
+ _$setProp(_el$12, "onMouseDown", () => {
848
+ // A click inside the field moves the host's focus elsewhere; take it back a tick later.
849
+ setTimeout(() => {
850
+ if (field && !field.isDestroyed) field.focus();
851
+ }, 1);
852
+ });
853
+ _$use(element => {
854
+ field = element;
855
+ if (!element) return;
856
+ const mount = ++inputMounts;
857
+ trace("panel: input mounted #" + mount);
858
+ // The native dialog's own recipe: tag it as the filter, then focus it a tick later.
859
+ element.traits = {
860
+ status: "FILTER"
861
+ };
862
+ setTimeout(() => {
863
+ trace("panel: input focus #" + mount + " destroyed=" + element.isDestroyed);
864
+ if (!element.isDestroyed) element.focus();
865
+ }, 1);
866
+ }, _el$13);
867
+ _$setProp(_el$13, "placeholder", "Search (ctrl+u clear)");
868
+ _$setProp(_el$13, "flexGrow", 1);
869
+ _$setProp(_el$13, "onInput", value => setQuery(typeof value === "string" ? value : ""));
870
+ _$insertNode(_el$14, _el$15);
871
+ _$setProp(_el$14, "height", 1);
872
+ _$setProp(_el$14, "flexShrink", 0);
873
+ _$setProp(_el$15, "fg", DIM);
874
+ _$setProp(_el$15, "wrapMode", "none");
875
+ _$insert(_el$15, () => headerRow(fitted()));
876
+ _$use(element => scroller = element, _el$16);
877
+ _$setProp(_el$16, "flexGrow", 1);
878
+ _$setProp(_el$16, "flexShrink", 1);
879
+ _$setProp(_el$16, "onMouseScroll", () => lastScroll = Date.now());
880
+ _$insert(_el$16, _$createComponent(For, {
881
+ get each() {
882
+ return itemList();
883
+ },
884
+ children: item => {
885
+ if (item.header) return (() => {
886
+ var _el$18 = _$createElement("text");
887
+ _$setProp(_el$18, "fg", DIM);
888
+ _$setProp(_el$18, "wrapMode", "none");
889
+ _$insert(_el$18, () => "── " + item.header + " " + "─".repeat(Math.max(0, 56 - item.header.length)));
890
+ return _el$18;
891
+ })();
892
+ const row = item.row;
893
+ const key = prefKey(row.providerID, row.modelID);
894
+ const mine = () => key === mineKey();
895
+ const active = () => key === selectedKey();
896
+ // A favourite is marked everywhere but in the favourites scope, where every row is one.
897
+ const favourite = () => {
898
+ revision();
899
+ return scope() !== "favorites" && prefs.favorite.has(key);
900
+ };
901
+ return (() => {
902
+ var _el$19 = _$createElement("box"),
903
+ _el$20 = _$createElement("text");
904
+ _$insertNode(_el$19, _el$20);
905
+ _$setProp(_el$19, "id", "mp-" + key);
906
+ _$setProp(_el$19, "height", 1);
907
+ _$setProp(_el$19, "onMouseOver", () => hover(key));
908
+ _$setProp(_el$19, "onMouseMove", () => hover(key));
909
+ _$setProp(_el$19, "onMouseDown", () => {
910
+ setSelected(key);
911
+ choose(row);
912
+ });
913
+ _$setProp(_el$20, "wrapMode", "none");
914
+ _$insert(_el$20, () => modelRow(row, mine(), favourite(), fitted()));
915
+ _$effect(_p$ => {
916
+ var _v$6 = active() ? SELECTED : undefined,
917
+ _v$7 = active() ? ON_SELECTED : mine() ? ACCENT : BASE;
918
+ _v$6 !== _p$.e && (_p$.e = _$setProp(_el$19, "backgroundColor", _v$6, _p$.e));
919
+ _v$7 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$7, _p$.t));
920
+ return _p$;
921
+ }, {
922
+ e: undefined,
923
+ t: undefined
924
+ });
925
+ return _el$19;
926
+ })();
927
+ }
928
+ }));
929
+ _$setProp(_el$17, "flexDirection", "column");
930
+ _$setProp(_el$17, "flexShrink", 0);
931
+ _$setProp(_el$17, "backgroundColor", SURFACE);
932
+ _$insert(_el$17, _$createComponent(For, {
933
+ get each() {
934
+ return hints();
935
+ },
936
+ children: line => (() => {
937
+ var _el$21 = _$createElement("box"),
938
+ _el$22 = _$createElement("text");
939
+ _$insertNode(_el$21, _el$22);
940
+ _$setProp(_el$21, "height", 1);
941
+ _$setProp(_el$22, "fg", DIM);
942
+ _$setProp(_el$22, "wrapMode", "none");
943
+ _$insert(_el$22, line);
944
+ return _el$21;
945
+ })()
946
+ }));
947
+ _$effect(_p$ => {
948
+ var _v$ = themeColour(["text", "faint"]),
949
+ _v$2 = themeColour(["text", "base"]),
950
+ _v$3 = themeColour(["background", "formfield", "focused"]),
951
+ _v$4 = themeColour(["text", "formfield", "focused"]),
952
+ _v$5 = hints().length;
953
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$13, "placeholderColor", _v$, _p$.e));
954
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$13, "focusedTextColor", _v$2, _p$.t));
955
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$13, "focusedBackgroundColor", _v$3, _p$.a));
956
+ _v$4 !== _p$.o && (_p$.o = _$setProp(_el$13, "cursorColor", _v$4, _p$.o));
957
+ _v$5 !== _p$.i && (_p$.i = _$setProp(_el$17, "height", _v$5, _p$.i));
958
+ return _p$;
959
+ }, {
960
+ e: undefined,
961
+ t: undefined,
962
+ a: undefined,
963
+ o: undefined,
964
+ i: undefined
965
+ });
966
+ return _el$;
967
+ })();
968
+ };
969
+ context.ui.slot({
970
+ append: "session.panel",
971
+ render: panel => _$createComponent(Show, {
972
+ get when() {
973
+ return panel.name === "oc-models-pro";
974
+ },
975
+ get children() {
976
+ return _$createComponent(Panel, {
977
+ panel: panel
978
+ });
979
+ }
980
+ })
981
+ });
982
+ const command = {
983
+ id: "models-pro",
984
+ title: "Models with prices (pro)",
985
+ description: "Pick a model by price, context or credit multiplier",
986
+ slash: {
987
+ name: "models-pro"
988
+ },
989
+ palette: true,
990
+ bind: "<leader>p",
991
+ run: () => {
992
+ // The synchronous part of open() runs during the call, so it needs a plain try:
993
+ // a rejected promise would never see it.
994
+ try {
995
+ void open().catch(error => fail("open async", error));
996
+ } catch (error) {
997
+ fail("open sync", error);
998
+ }
999
+ }
1000
+ };
1001
+ // The types publish the keymap twice — `context.keymap` and `ui.keymap` — and this build answers
1002
+ // for one of them only. Try both, record which one took it, never fail silently.
1003
+ const keymaps = [["context.keymap", context.keymap], ["ui.keymap", context.ui?.keymap]];
1004
+ let registered = false;
1005
+ for (const [label, keymap] of keymaps) {
1006
+ if (!keymap || typeof keymap.layer !== "function") {
1007
+ trace(label + ": not a keymap (" + typeof keymap + ")");
1008
+ continue;
1009
+ }
1010
+ try {
1011
+ keymap.layer(() => ({
1012
+ mode: "global",
1013
+ commands: [command]
1014
+ }));
1015
+ trace("registered through " + label + "; bind=" + keymap.shortcuts("models-pro").join(","));
1016
+ registered = true;
1017
+ break;
1018
+ } catch (error) {
1019
+ trace(label + " failed: " + (error instanceof Error ? error.message : String(error)));
1020
+ }
1021
+ }
1022
+ if (!registered) trace("no keymap accepted the command; ui=" + (context.ui ? Object.keys(context.ui).join(",") : "none"));
1023
+ }
1024
+ });