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