@rizal72/oc-models-pro 0.1.1 → 0.1.3
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/package.json +5 -1
- package/tags.ts +127 -0
- package/tui.tsx +69 -17
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rizal72/oc-models-pro",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode v2 model picker with prices, context, intelligence, Go credit and the cost of a request",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/rizal72/opencode-plugins.git"
|
|
9
|
+
},
|
|
6
10
|
"license": "MIT",
|
|
7
11
|
"files": [
|
|
8
12
|
"*.ts",
|
package/tags.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Two letters per provider, so a row says where its model comes from without spending a column.
|
|
2
|
+
//
|
|
3
|
+
// The host's own two providers keep a tag chosen by hand, and those two are reserved: they exist in
|
|
4
|
+
// every installation, so nobody else may take their letters. Everything else is derived from the
|
|
5
|
+
// provider's display name — the only place where a merged name still keeps its seams ("DeepSeek" is
|
|
6
|
+
// Deep·Seek; the id `deepseek` no longer says so).
|
|
7
|
+
//
|
|
8
|
+
// Runnable standalone for its self-check: node plugins/oc-models-pro/tags.ts --self-check
|
|
9
|
+
|
|
10
|
+
/** Tags that belong to the host's own providers, present or not. */
|
|
11
|
+
const RESERVED: Record<string, string> = {
|
|
12
|
+
opencode: "OC",
|
|
13
|
+
"opencode-go": "GO",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The words of a display name: `OpenCode Go` is two, `OpenRouter` is two as well — but a camelCase
|
|
18
|
+
* split only when the name has no separator, so a brand like `GitHub Copilot` keeps `GitHub` whole.
|
|
19
|
+
*/
|
|
20
|
+
export function words(name: string): string[] {
|
|
21
|
+
const split = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
|
22
|
+
if (split.length > 1) return split
|
|
23
|
+
const camel = (split[0] ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(" ").filter(Boolean)
|
|
24
|
+
return camel.length > 1 ? camel : split
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the name asks for: the initials of its words, or two letters of a single one. */
|
|
28
|
+
function preferred(name: string): string {
|
|
29
|
+
const parts = words(name)
|
|
30
|
+
const letters = parts.length > 1 ? parts.map((part) => part[0]).join("") : (parts[0] ?? "?")
|
|
31
|
+
return letters.toUpperCase().padEnd(2, "?").slice(0, 2)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A tag per provider id, unique and order-independent. Derived in alphabetical order of id, so the
|
|
36
|
+
* same catalogue always yields the same tags whatever order the providers arrive in.
|
|
37
|
+
*/
|
|
38
|
+
export function tagsFor(providers: Array<{ id: string; name: string }>): Map<string, string> {
|
|
39
|
+
const taken = new Set(Object.values(RESERVED))
|
|
40
|
+
const tags = new Map<string, string>()
|
|
41
|
+
const order = [...providers].sort((a, b) => a.id.localeCompare(b.id))
|
|
42
|
+
|
|
43
|
+
for (const provider of order) {
|
|
44
|
+
const fixed = RESERVED[provider.id]
|
|
45
|
+
if (fixed) tags.set(provider.id, fixed)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const provider of order) {
|
|
49
|
+
if (tags.has(provider.id)) continue
|
|
50
|
+
const letters = (words(provider.name).join("") || "?").toUpperCase()
|
|
51
|
+
const first = letters[0] ?? "?"
|
|
52
|
+
let tag = preferred(provider.name)
|
|
53
|
+
if (taken.has(tag)) {
|
|
54
|
+
// Walk the name for a free pair, so `Google` steps aside from the reserved GO.
|
|
55
|
+
tag = ""
|
|
56
|
+
for (const letter of letters.slice(1)) {
|
|
57
|
+
// A doubled letter reads as a typo ("GG"): prefer anything else.
|
|
58
|
+
if (letter === first) continue
|
|
59
|
+
const candidate = first + letter
|
|
60
|
+
if (!taken.has(candidate)) {
|
|
61
|
+
tag = candidate
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (taken.has(tag) || !tag) {
|
|
67
|
+
tag = ""
|
|
68
|
+
for (let digit = 2; digit <= 9; digit++) {
|
|
69
|
+
if (!taken.has(first + digit)) {
|
|
70
|
+
tag = first + digit
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!tag) tag = "?" + (tags.size % 10)
|
|
76
|
+
taken.add(tag)
|
|
77
|
+
tags.set(provider.id, tag)
|
|
78
|
+
}
|
|
79
|
+
return tags
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Self-check: node plugins/oc-models-pro/tags.ts --self-check
|
|
83
|
+
if (process.argv[2] === "--self-check") {
|
|
84
|
+
const fail = (message: string): never => {
|
|
85
|
+
throw new Error("self-check: " + message)
|
|
86
|
+
}
|
|
87
|
+
const at = (providers: Array<{ id: string; name: string }>, id: string): string =>
|
|
88
|
+
tagsFor(providers).get(id) ?? fail("no tag for " + id)
|
|
89
|
+
|
|
90
|
+
if (at([{ id: "opencode", name: "anything" }], "opencode") !== "OC") fail("opencode must stay OC")
|
|
91
|
+
if (at([{ id: "opencode-go", name: "whatever" }], "opencode-go") !== "GO") fail("opencode-go must stay GO")
|
|
92
|
+
if (at([{ id: "openrouter", name: "OpenRouter" }], "openrouter") !== "OR") fail("OpenRouter must be OR")
|
|
93
|
+
if (at([{ id: "deepseek", name: "DeepSeek" }], "deepseek") !== "DS") fail("DeepSeek must be DS")
|
|
94
|
+
if (at([{ id: "github-copilot", name: "GitHub Copilot" }], "github-copilot") !== "GC") {
|
|
95
|
+
fail("GitHub Copilot must be GC")
|
|
96
|
+
}
|
|
97
|
+
// Reserved letters are reserved even when the host's provider is not in the list.
|
|
98
|
+
if (at([{ id: "google", name: "Google" }], "google") !== "GL") fail("Google must step aside from GO")
|
|
99
|
+
|
|
100
|
+
const twins = tagsFor([
|
|
101
|
+
{ id: "acme-one", name: "Acme" },
|
|
102
|
+
{ id: "acme-two", name: "Acme" },
|
|
103
|
+
])
|
|
104
|
+
if (new Set(twins.values()).size !== 2) fail("two providers share a tag")
|
|
105
|
+
|
|
106
|
+
const forward = tagsFor([
|
|
107
|
+
{ id: "beta", name: "Beta" },
|
|
108
|
+
{ id: "alpha", name: "Alpha" },
|
|
109
|
+
{ id: "google", name: "Google" },
|
|
110
|
+
])
|
|
111
|
+
const backward = tagsFor([
|
|
112
|
+
{ id: "google", name: "Google" },
|
|
113
|
+
{ id: "alpha", name: "Alpha" },
|
|
114
|
+
{ id: "beta", name: "Beta" },
|
|
115
|
+
])
|
|
116
|
+
if ([...forward].map(String).sort().join() !== [...backward].map(String).sort().join()) {
|
|
117
|
+
fail("the input order changes the tags")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
"self-check ok: " +
|
|
122
|
+
[...forward].map(([id, tag]) => id + "=" + tag).join(" ") +
|
|
123
|
+
" (" +
|
|
124
|
+
words("OpenCode Go").join("|") +
|
|
125
|
+
")",
|
|
126
|
+
)
|
|
127
|
+
}
|
package/tui.tsx
CHANGED
|
@@ -15,12 +15,15 @@ import { join } from "node:path"
|
|
|
15
15
|
import { benchFor, benchIndex, type Bench } from "./bench.ts"
|
|
16
16
|
import { credits, goPlan, key, type Credits } from "./multipliers.ts"
|
|
17
17
|
import { LEGEND_MIN, fit, hintLines, rowWidth, type Fit } from "./fit.ts"
|
|
18
|
+
import { tagsFor } from "./tags.ts"
|
|
18
19
|
|
|
19
20
|
interface Row {
|
|
20
21
|
name: string
|
|
21
22
|
providerID: string
|
|
22
23
|
modelID: string
|
|
23
24
|
provider: string
|
|
25
|
+
/** Two letters from `tags.ts`: the provider's, unique in the catalogue. */
|
|
26
|
+
tag: string
|
|
24
27
|
/** Undefined when the catalogue has no price for the model: an unknown figure, not a free one. */
|
|
25
28
|
input?: number
|
|
26
29
|
output?: number
|
|
@@ -179,17 +182,9 @@ function cell(text: string, width: number, align: "start" | "end" = "end"): stri
|
|
|
179
182
|
return align === "end" ? cut.padStart(width) : cut.padEnd(width)
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
/** Two letters
|
|
183
|
-
|
|
184
|
-
|
|
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)
|
|
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)
|
|
193
188
|
}
|
|
194
189
|
|
|
195
190
|
/**
|
|
@@ -235,7 +230,7 @@ function modelRow(row: Row, mine: boolean, favourite: boolean, f: Fit): string {
|
|
|
235
230
|
const notes = [row.peak ? "peak*" + row.peak : "", row.promo ? "+" + row.promo + "x" : ""].filter(Boolean)
|
|
236
231
|
return [
|
|
237
232
|
(mine ? "●" : " ") + (favourite ? "*" : " "),
|
|
238
|
-
|
|
233
|
+
padTag(row.tag),
|
|
239
234
|
label(row.name, f.name),
|
|
240
235
|
price(row.input, 6),
|
|
241
236
|
price(row.output, 6),
|
|
@@ -250,15 +245,27 @@ function modelRow(row: Row, mine: boolean, favourite: boolean, f: Fit): string {
|
|
|
250
245
|
|
|
251
246
|
const TRACE = join(tmpdir(), "oc-models-pro.log")
|
|
252
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
|
+
|
|
253
251
|
function trace(message: string): void {
|
|
254
252
|
try {
|
|
255
253
|
appendFileSync(TRACE, new Date().toISOString() + " " + message + "\n")
|
|
256
254
|
} catch {}
|
|
257
255
|
}
|
|
258
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
|
+
|
|
259
265
|
export default Plugin.define({
|
|
260
266
|
id: "oc-models-pro",
|
|
261
267
|
setup(context: any) {
|
|
268
|
+
trimTrace()
|
|
262
269
|
const ui = context.ui
|
|
263
270
|
const themeColour = (...paths: string[][]): any => {
|
|
264
271
|
for (const path of paths) {
|
|
@@ -343,6 +350,7 @@ export default Plugin.define({
|
|
|
343
350
|
const providers = new Map<string, string>(
|
|
344
351
|
listProviders().map((provider: any) => [provider.id, provider.name]),
|
|
345
352
|
)
|
|
353
|
+
const tags = tagsFor([...providers].map(([id, name]) => ({ id, name })))
|
|
346
354
|
const result: Row[] = []
|
|
347
355
|
for (const model of listModels()) {
|
|
348
356
|
const cost = (model.cost ?? []).find((entry: any) => !entry.tier) ?? model.cost?.[0]
|
|
@@ -355,6 +363,7 @@ export default Plugin.define({
|
|
|
355
363
|
// The host names an account-bound provider `<account> / <Provider>`, which in a group
|
|
356
364
|
// header reads as if the account were the thing: provider first, account in brackets.
|
|
357
365
|
provider: (providers.get(model.providerID) ?? model.providerID).replace(/^(.+?) \/ (.+)$/, "$2 ($1)"),
|
|
366
|
+
tag: tags.get(model.providerID) ?? model.providerID.slice(0, 2).toUpperCase(),
|
|
358
367
|
// A missing figure stays missing: a 0 here would put an unknown model at the top of the
|
|
359
368
|
// cheapest list and claim a context window it never declared.
|
|
360
369
|
input: typeof cost?.input === "number" ? cost.input : undefined,
|
|
@@ -417,6 +426,13 @@ export default Plugin.define({
|
|
|
417
426
|
}
|
|
418
427
|
|
|
419
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
|
+
)
|
|
420
436
|
const [selected, setSelected] = createSignal<string | undefined>(undefined)
|
|
421
437
|
/** The panel's width, in columns, and the layout it buys — reactive: the host resizes it. */
|
|
422
438
|
const width = createMemo(() => Math.floor(Number(props.panel.width) || 0), undefined, { equals: Object.is })
|
|
@@ -448,8 +464,31 @@ export default Plugin.define({
|
|
|
448
464
|
let field: any
|
|
449
465
|
/** Diagnostic: more than one mount per open means the panel is being rebuilt under us. */
|
|
450
466
|
let inputMounts = 0
|
|
451
|
-
/**
|
|
452
|
-
|
|
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
|
+
}
|
|
453
492
|
const clear = (): void => {
|
|
454
493
|
setQuery("")
|
|
455
494
|
if (field && !field.isDestroyed) field.value = ""
|
|
@@ -600,7 +639,13 @@ export default Plugin.define({
|
|
|
600
639
|
selectable().find((entry) => prefKey(entry.providerID, entry.modelID) === wanted)
|
|
601
640
|
if (!row) return
|
|
602
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?.()
|
|
603
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)
|
|
604
649
|
void (async () => {
|
|
605
650
|
if (!(await selectModel(row))) {
|
|
606
651
|
// The native never fails here — it writes the selection itself — so this is the one
|
|
@@ -692,6 +737,8 @@ export default Plugin.define({
|
|
|
692
737
|
description: "",
|
|
693
738
|
})),
|
|
694
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)
|
|
695
742
|
// Cancelling leaves the model without a variant, and the question will come back, as in
|
|
696
743
|
// the native picker.
|
|
697
744
|
if (chosen === undefined) return
|
|
@@ -762,7 +809,8 @@ export default Plugin.define({
|
|
|
762
809
|
fitted()
|
|
763
810
|
hints()
|
|
764
811
|
const element = field
|
|
765
|
-
if (
|
|
812
|
+
if (!element || element.isDestroyed || !props.panel.focused || holdsField()) return
|
|
813
|
+
trace("reflow: field takes the focus back")
|
|
766
814
|
element.focus()
|
|
767
815
|
})
|
|
768
816
|
|
|
@@ -824,6 +872,12 @@ export default Plugin.define({
|
|
|
824
872
|
borderColor={BORDER}
|
|
825
873
|
paddingLeft={1}
|
|
826
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
|
+
}}
|
|
827
881
|
>
|
|
828
882
|
<input
|
|
829
883
|
ref={(element: any) => {
|
|
@@ -838,8 +892,6 @@ export default Plugin.define({
|
|
|
838
892
|
if (!element.isDestroyed) element.focus()
|
|
839
893
|
}, 1)
|
|
840
894
|
}}
|
|
841
|
-
onFocus={() => (focused = true)}
|
|
842
|
-
onBlur={() => (focused = false)}
|
|
843
895
|
placeholder="Search (ctrl+u clear)"
|
|
844
896
|
flexGrow={1}
|
|
845
897
|
placeholderColor={themeColour(["text", "faint"])}
|