@rizal72/oc-models-pro 0.1.3 → 0.1.4

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