@rizal72/oc-models-pro 0.0.0-stage → 0.1.1
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/README.md +52 -2
- package/bench.ts +142 -0
- package/fit.ts +98 -0
- package/index.ts +7 -0
- package/multipliers.ts +224 -0
- package/package.json +24 -5
- package/tui.tsx +961 -0
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
|
+
})
|