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