@rizal72/oc-models-pro 0.3.0 → 0.3.2
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 +12 -0
- package/aa.ts +3 -13
- package/bench.ts +1 -12
- package/dist/gui-experimental.cjs +1 -3
- package/fit.ts +5 -6
- package/index.ts +5 -15
- package/multipliers.ts +3 -21
- package/package.json +2 -1
- package/prefs.ts +2 -6
- package/rows.ts +8 -18
- package/rpc.ts +8 -10
- package/tags.ts +2 -11
- package/tps.ts +2 -13
- package/tui.js +20 -84
package/README.md
CHANGED
|
@@ -15,6 +15,18 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
|
|
|
15
15
|
"plugins": ["@rizal72/oc-models-pro"]
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
## Update
|
|
19
|
+
|
|
20
|
+
V2 checks package plugins at startup and never applies the update, so a package only moves when you ask:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
opencode plugin check # which of the installed packages are behind
|
|
24
|
+
opencode plugin update # apply them, and let the server reload
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install [`@rizal72/oc-plugin-updates`](https://www.npmjs.com/package/@rizal72/oc-plugin-updates): the
|
|
28
|
+
count appears in a pill above the composer, with the update one click away.
|
|
29
|
+
|
|
18
30
|
## TUI
|
|
19
31
|
|
|
20
32
|
`/models-pro`, or `Ctrl+X` then `p`, inside a session.
|
package/aa.ts
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
// oc-models-pro/aa.ts —
|
|
2
|
-
// Artificial Analysis' free API. Attribution: https://artificialanalysis.ai/
|
|
3
|
-
//
|
|
4
|
-
// Their number is the same measurement as ours — "the average number of tokens received per second,
|
|
5
|
-
// after the first token is received" — so filling an empty cell with it does not mix two families,
|
|
6
|
-
// the way `int` and speed already do. The panel marks those values with a leading `~`, because they
|
|
7
|
-
// were measured on their infrastructure and not on this person's provider.
|
|
8
|
-
//
|
|
9
|
-
// No key, no calls: without ARTIFICIAL_ANALYSIS_API_KEY this module returns an empty map and nothing
|
|
10
|
-
// leaves the machine. With a key the response is cached six hours, which their terms ask for.
|
|
1
|
+
// oc-models-pro/aa.ts — borrowed speed, from Artificial Analysis' free API.
|
|
11
2
|
//
|
|
12
3
|
// Runnable standalone for its self-check: node plugins/oc-models-pro/aa.ts --self-check
|
|
13
4
|
|
|
@@ -21,7 +12,7 @@ export const AA = "https://artificialanalysis.ai/api/v2/data/llms/models"
|
|
|
21
12
|
const CACHE_DIR = process.env.XDG_CACHE_HOME || join(homedir(), ".cache")
|
|
22
13
|
const CACHE = join(CACHE_DIR, "oc-models-pro", "aa.json")
|
|
23
14
|
const GRACE_MS = 6 * 60 * 60 * 1000
|
|
24
|
-
/** Optional decoration must never hold the panel
|
|
15
|
+
/** Optional decoration must never hold the panel. */
|
|
25
16
|
const FETCH_MS = 5_000
|
|
26
17
|
|
|
27
18
|
interface Cached {
|
|
@@ -34,7 +25,7 @@ export function apiKey(): string | undefined {
|
|
|
34
25
|
return key && key.trim() ? key.trim() : undefined
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
/** Name first, slug second
|
|
28
|
+
/** Name first, slug second. */
|
|
38
29
|
export function buildIndex(payload: any): Map<string, number> {
|
|
39
30
|
const index = new Map<string, number>()
|
|
40
31
|
for (const entry of payload?.data ?? []) {
|
|
@@ -68,7 +59,6 @@ async function cached(): Promise<Cached | undefined> {
|
|
|
68
59
|
}
|
|
69
60
|
}
|
|
70
61
|
|
|
71
|
-
/** Empty without a key: this is the whole cost of the column being optional. */
|
|
72
62
|
export async function aaIndex(): Promise<Map<string, number>> {
|
|
73
63
|
const key = apiKey()
|
|
74
64
|
if (!key) return new Map()
|
package/bench.ts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
// oc-models-pro: intelligence per model, from the stencil.so catalog.
|
|
2
2
|
//
|
|
3
|
-
// The catalog is keyed by gateway and its ids are qualified (`alibaba/deepseek-v4.1-flash`), so the
|
|
4
|
-
// comparison uses the last segment — and the key is built from *our* model's display name, never its
|
|
5
|
-
// id. That is what makes the aliases meet: the same model is `deepseek/deepseek-flash` here and
|
|
6
|
-
// "DeepSeek V4.1 Flash 1017" to the eye, while stencil calls it `deepseek-v4.1-flash`. The name is
|
|
7
|
-
// stripped of the labels a catalogue attaches (`latest`, `contributor`, `free`, `preview`) and of
|
|
8
|
-
// snapshot stamps (`1017`, `0731`); the model id is the fallback when the name finds nothing.
|
|
9
|
-
//
|
|
10
|
-
// `int` is on most entries; `tps` was on none of them when this was last measured (2026-10-19), so the
|
|
11
|
-
// speed column is fed by `tps.ts` and `aa.ts` instead — this file's `tps` stays only as the last resort.
|
|
12
|
-
// A figure that is not there is never guessed.
|
|
13
|
-
//
|
|
14
3
|
// Runnable standalone for its self-check: node plugins/oc-models-pro/bench.ts --self-check
|
|
15
4
|
|
|
16
5
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
@@ -86,7 +75,7 @@ async function cached(): Promise<Cached | undefined> {
|
|
|
86
75
|
}
|
|
87
76
|
}
|
|
88
77
|
|
|
89
|
-
/**
|
|
78
|
+
/** Grace, not freshness: a benchmark does not move between two coffee breaks. */
|
|
90
79
|
export async function benchIndex(): Promise<Map<string, Bench>> {
|
|
91
80
|
const previous = await cached()
|
|
92
81
|
if (previous && Date.now() - previous.fetched < GRACE_MS) return indexOf(previous)
|
|
@@ -37,9 +37,7 @@ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{val
|
|
|
37
37
|
.omp-warn { padding: 4px 8px; opacity: .7; }
|
|
38
38
|
.omp-fine { padding: 4px 8px; opacity: .55; font-size: 11px;
|
|
39
39
|
border-top: 1px solid color-mix(in srgb, currentColor 8%, transparent); }
|
|
40
|
-
/* The terminal's cascade, in pixels: the name shrinks first
|
|
41
|
-
go in the order fit.ts uses — tok/s, then context, then intelligence. The three price figures never
|
|
42
|
-
go: they are what the list is for. Below the last rung the name truncates instead of scrolling. */
|
|
40
|
+
/* The terminal's cascade, in pixels: the name shrinks first, then whole columns go in fit.ts's order. */
|
|
43
41
|
@container (max-width: 560px) {
|
|
44
42
|
.omp-tps, .omp-note { display: none; }
|
|
45
43
|
.omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 3.4rem 2.4rem 2.6rem; }
|
package/fit.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
// oc-models-pro — how a row and the footer fit a panel of a given width, in columns.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// clamp absorbs). Everything that depends on it lives here, so the thresholds sit in one place:
|
|
3
|
+
// Everything that depends on it lives here, so the thresholds sit in one place:
|
|
5
4
|
// node plugins/oc-models-pro/fit.ts --self-check
|
|
6
5
|
|
|
7
|
-
/** The name column at its widest
|
|
6
|
+
/** The name column at its widest. */
|
|
8
7
|
export const NAME = 32
|
|
9
|
-
/** ...and at its narrowest.
|
|
8
|
+
/** ...and at its narrowest. */
|
|
10
9
|
export const NAME_MIN = 24
|
|
11
|
-
/** The cells that never go
|
|
10
|
+
/** The cells that never go. */
|
|
12
11
|
const FIXED = 53
|
|
13
12
|
|
|
14
13
|
export type Fit = { name: number; ctx: boolean; int: boolean; tkps: boolean; note: boolean }
|
|
15
14
|
|
|
16
|
-
/** Columns dropped as the panel narrows
|
|
15
|
+
/** Columns dropped as the panel narrows, in the order the width runs out. */
|
|
17
16
|
const DROPS = [
|
|
18
17
|
{ key: "tkps", width: 7 },
|
|
19
18
|
{ key: "note", width: 7 },
|
package/index.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
1
|
// oc-models-pro — server half, for the desktop.
|
|
2
|
-
//
|
|
3
|
-
// The terminal reads the catalogue and the four sources in-process. A plugin window cannot: `bun:sqlite`,
|
|
4
|
-
// `node:fs` and the three fetches are all outside its reach — and the AA key lives in this process's
|
|
5
|
-
// environment, which is where the server has it and the window does not. So the whole table is assembled
|
|
6
|
-
// here and the window asks for it over `models.snapshot`.
|
|
7
|
-
//
|
|
8
|
-
// The rows are the terminal's, field for field, so the two views can be compared: same catalogue, same
|
|
9
|
-
// credits, same speeds. Only the rendering differs.
|
|
10
2
|
import { Plugin } from "@opencode/plugin"
|
|
11
3
|
|
|
12
4
|
import { aaIndex } from "./aa.ts"
|
|
@@ -19,8 +11,8 @@ import { Models, type ModelsSnapshot } from "./rpc.ts"
|
|
|
19
11
|
import type { Row } from "./rows.ts"
|
|
20
12
|
|
|
21
13
|
/**
|
|
22
|
-
* The fetch-backed sources
|
|
23
|
-
*
|
|
14
|
+
* The fetch-backed sources keep their own TTLs; the measured speeds are worth a minute, which is also
|
|
15
|
+
* what keeps a poll cheap.
|
|
24
16
|
*/
|
|
25
17
|
const CACHE_MS = 60_000
|
|
26
18
|
|
|
@@ -39,7 +31,7 @@ export default Plugin.define({
|
|
|
39
31
|
async setup(ctx) {
|
|
40
32
|
let cache: { at: number; value: ModelsSnapshot } | undefined
|
|
41
33
|
|
|
42
|
-
/** The four sources, each failure named instead of swallowed
|
|
34
|
+
/** The four sources, each failure named instead of swallowed. */
|
|
43
35
|
async function sources(problems: string[]): Promise<Sources> {
|
|
44
36
|
const [plan, bench, estimates, speeds] = await Promise.allSettled([
|
|
45
37
|
goPlan(),
|
|
@@ -86,16 +78,14 @@ export default Plugin.define({
|
|
|
86
78
|
const cost = (model.cost ?? []).find((entry: any) => !entry.tier) ?? model.cost?.[0]
|
|
87
79
|
const limit = model.limit ?? {}
|
|
88
80
|
const score = benchFor(src.bench ?? new Map(), model.name, model.modelID)
|
|
89
|
-
// Three sources, one order:
|
|
90
|
-
// catalogue. A measured figure is never displaced by an estimate.
|
|
81
|
+
// Three sources, one order: measured, then Artificial Analysis, then the catalogue.
|
|
91
82
|
const mine = src.measured?.get(model.providerID + "/" + model.modelID)
|
|
92
83
|
const theirs =
|
|
93
84
|
mine === undefined
|
|
94
85
|
? (src.estimated?.get(benchKey(model.name)) ?? src.estimated?.get(benchKey(model.modelID)) ?? score?.tps)
|
|
95
86
|
: undefined
|
|
96
87
|
if (mine !== undefined || theirs !== undefined) withSpeed += 1
|
|
97
|
-
// The measured ratio
|
|
98
|
-
// other is the fact no table reports (the catalogue lists a single DeepSeek price).
|
|
88
|
+
// The plan page's measured ratio first, the native rule second.
|
|
99
89
|
const planPeak = isGo(model.providerID) ? src.credits?.get(creditKey(model.modelID))?.peak : undefined
|
|
100
90
|
const peak = planPeak ?? nativePeak(model.providerID)
|
|
101
91
|
rows.push({
|
package/multipliers.ts
CHANGED
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
// oc-models-pro: what the Go plan gives per model, read from the live Go docs page.
|
|
2
2
|
//
|
|
3
|
-
// The table is server-rendered — no JS, no auth — so a plain fetch and a row regex are enough. The
|
|
4
|
-
// mirror that would have been cleaner, the `.mdx` on GitHub, is stale by design: it carries the old
|
|
5
|
-
// limits (a model at $60 where the deployed page says $80), so the page is the source and the cache
|
|
6
|
-
// carries the date it was read.
|
|
7
|
-
//
|
|
8
|
-
// Two different figures live on that page, and confusing them was the first bug here:
|
|
9
|
-
// - the **credit multiplier**: monthly limit ÷ plan price (DeepSeek V4.1 Flash: $80 on a $10 plan =
|
|
10
|
-
// 8x). Every model has one, and it is what orders the list by effective price;
|
|
11
|
-
// - the **promotion note** (`4x · Limited time`): limit ÷ the struck-through base, i.e. this model's
|
|
12
|
-
// allowance is 4x its normal one. Only a few rows carry it.
|
|
13
|
-
//
|
|
14
|
-
// The page has no model ids, rows match by display name; a model with two tariffs appears twice
|
|
15
|
-
// (`… (Off-Peak)` / `… (Peak)`), and the table repeats per plan (`go`, `go-plus`) — the first
|
|
16
|
-
// occurrence wins, so the values are the standard plan's.
|
|
17
|
-
//
|
|
18
3
|
// Runnable standalone for its self-check: node plugins/oc-models-pro/multipliers.ts --self-check
|
|
19
4
|
|
|
20
5
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
@@ -54,11 +39,8 @@ export interface Credits {
|
|
|
54
39
|
peak?: number
|
|
55
40
|
}
|
|
56
41
|
|
|
57
|
-
/** The native DeepSeek tariff doubles at peak, and the catalogue lists the **off-peak** figure
|
|
58
|
-
*
|
|
59
|
-
* catalogue carries 0.15. Duplicated from `oc-budget/peak.ts` on purpose: the two packages are published
|
|
60
|
-
* apart and neither may import the other, so each carries the one fact it needs. That copy also owns the
|
|
61
|
-
* clock and the wallet; this one only knows which *list* price doubles, so the note stays static. */
|
|
42
|
+
/** The native DeepSeek tariff doubles at peak, and the catalogue lists the **off-peak** figure, so the
|
|
43
|
+
* note is static: this function only knows which *list* price doubles. */
|
|
62
44
|
export function nativePeak(providerID: string): number | undefined {
|
|
63
45
|
return providerID === "deepseek" ? 2 : undefined
|
|
64
46
|
}
|
|
@@ -123,7 +105,7 @@ export function parseGoTable(html: string): GoLimit[] {
|
|
|
123
105
|
return limits
|
|
124
106
|
}
|
|
125
107
|
|
|
126
|
-
/** The plan price
|
|
108
|
+
/** The plan price: the first `$/month` figure on the page is Go's. */
|
|
127
109
|
export function parsePlanPrice(html: string): number | undefined {
|
|
128
110
|
const found = text(html).match(/\$\s?([\d.]+)\s*\/\s*month/i)
|
|
129
111
|
return found ? Number(found[1]) : undefined
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rizal72/oc-models-pro",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode v2 model picker with prices, context, intelligence, tok/s, Go credit and the cost of a request",
|
|
6
6
|
"repository": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"files": [
|
|
12
12
|
"*.ts",
|
|
13
|
+
"!*.test.ts",
|
|
13
14
|
"tui.js",
|
|
14
15
|
"README.md",
|
|
15
16
|
"dist"
|
package/prefs.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
// oc-models-pro/prefs.ts — the picker's own state file, `~/.local/state/opencode/model.json`.
|
|
2
2
|
//
|
|
3
|
-
// Both halves
|
|
4
|
-
//
|
|
5
|
-
// is a second way to lose `recent[]` or `variant{}`.
|
|
6
|
-
//
|
|
7
|
-
// The host watches this path and `model.json.*`. The write is `.model.json.tmp` then rename: the leading
|
|
8
|
-
// dot keeps the temp name outside that glob, so the host never sees half a file.
|
|
3
|
+
// Both halves write it, so the recipe exists once: a second copy is a second way to lose `recent[]`
|
|
4
|
+
// or `variant{}`.
|
|
9
5
|
import { readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
10
6
|
import { homedir } from "node:os"
|
|
11
7
|
import { join } from "node:path"
|
package/rows.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
// oc-models-pro/rows.ts — the facts both surfaces read the same way.
|
|
2
2
|
//
|
|
3
|
-
// The terminal draws a monospace table and the desktop draws a grid, but what a row *means* is not the
|
|
4
|
-
// renderer's business: the effective price, what counts as free, and the ten orders are one definition
|
|
5
|
-
// here. Two copies would drift the way they already did once — the terminal kept free models out of the
|
|
6
|
-
// price order and the desktop did not.
|
|
7
|
-
//
|
|
8
3
|
// Runnable standalone for its self-check: node plugins/oc-models-pro/rows.ts --self-check
|
|
9
4
|
|
|
10
5
|
export interface Row {
|
|
@@ -12,14 +7,14 @@ export interface Row {
|
|
|
12
7
|
providerID: string
|
|
13
8
|
modelID: string
|
|
14
9
|
provider: string
|
|
15
|
-
/** Two letters from `tags.ts
|
|
10
|
+
/** Two letters from `tags.ts`. */
|
|
16
11
|
tag: string
|
|
17
|
-
/** Undefined when the catalogue has no price for the model
|
|
12
|
+
/** Undefined when the catalogue has no price for the model. */
|
|
18
13
|
input?: number
|
|
19
14
|
output?: number
|
|
20
15
|
/** Undefined when the model carries no limit. */
|
|
21
16
|
context?: number
|
|
22
|
-
/** Monthly limit ÷ the
|
|
17
|
+
/** Monthly limit ÷ the plan price: the ordering multiplier. */
|
|
23
18
|
credit: number
|
|
24
19
|
/** Allowance ÷ its normal one, when the page marks a promotion. */
|
|
25
20
|
promo?: number
|
|
@@ -28,7 +23,7 @@ export interface Row {
|
|
|
28
23
|
/** Stencil's intelligence score and its speed; absent when the catalogue has no row for the model. */
|
|
29
24
|
int?: number
|
|
30
25
|
tps?: number
|
|
31
|
-
/** Tokens per second measured here, on this provider.
|
|
26
|
+
/** Tokens per second measured here, on this provider. */
|
|
32
27
|
measured?: number
|
|
33
28
|
enabled: boolean
|
|
34
29
|
}
|
|
@@ -40,12 +35,12 @@ export function effective(row: Row): number | undefined {
|
|
|
40
35
|
return row.input / (row.credit || 1)
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
/**
|
|
38
|
+
/** Both figures must be there, and be zero. */
|
|
44
39
|
export function isFree(row: Row): boolean {
|
|
45
40
|
return row.input === 0 && row.output === 0
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
/** The speed to sort and print
|
|
43
|
+
/** The speed to sort and print. */
|
|
49
44
|
export function speedOf(row: Row): number | undefined {
|
|
50
45
|
return row.measured ?? row.tps
|
|
51
46
|
}
|
|
@@ -78,8 +73,7 @@ export interface Order {
|
|
|
78
73
|
value(row: Row, context: SortContext): number | undefined
|
|
79
74
|
}
|
|
80
75
|
|
|
81
|
-
/** Whatever an order cannot measure sorts last, never first, and in either direction
|
|
82
|
-
* not the cheapest thing on the list, and a missing score is not the highest. */
|
|
76
|
+
/** Whatever an order cannot measure sorts last, never first, and in either direction. */
|
|
83
77
|
|
|
84
78
|
export const ORDERS: Record<SortKey, Order> = {
|
|
85
79
|
effective: { label: "effective price", short: "effective↑", value: effective },
|
|
@@ -92,7 +86,6 @@ export const ORDERS: Record<SortKey, Order> = {
|
|
|
92
86
|
row.input === undefined || row.output === undefined ? undefined : (row.input + row.output) / (row.credit || 1),
|
|
93
87
|
},
|
|
94
88
|
context: { label: "context window", short: "ctx↓", desc: true, value: (row) => row.context },
|
|
95
|
-
// A missing score sorts last, never first: Pi's rule, and the honest one.
|
|
96
89
|
int: { label: "intelligence", short: "int↓", desc: true, value: (row) => row.int },
|
|
97
90
|
speed: { label: "speed", short: "speed↓", desc: true, value: (row) => speedOf(row) },
|
|
98
91
|
recent: {
|
|
@@ -158,10 +151,7 @@ export interface Section {
|
|
|
158
151
|
}
|
|
159
152
|
|
|
160
153
|
/**
|
|
161
|
-
* The order the surfaces draw, which is not the order they sort
|
|
162
|
-
* so they follow their provider's paying rows; and the model in use is never buried — it leads its
|
|
163
|
-
* provider, and its provider leads the list. Both surfaces held a copy of this (`gui.tsx` and
|
|
164
|
-
* `tui.tsx`), and the copies drifted the moment a filter was added.
|
|
154
|
+
* The order the surfaces draw, which is not the order they sort.
|
|
165
155
|
*/
|
|
166
156
|
export function arrange(list: Row[], options: { inUse?: string; grouped: boolean }): Section[] {
|
|
167
157
|
const used = options.inUse === undefined ? undefined : list.find((row) => id(row) === options.inUse)
|
package/rpc.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// stays in the server half and the window asks for the finished table.
|
|
1
|
+
// The two calls across the split, and the one row shape both surfaces sort.
|
|
3
2
|
import { Rpc } from "@opencode/plugin/rpc"
|
|
4
3
|
|
|
5
4
|
import type { Row } from "./rows.ts"
|
|
@@ -12,22 +11,22 @@ const Row = {
|
|
|
12
11
|
providerID: { type: "string" },
|
|
13
12
|
modelID: { type: "string" },
|
|
14
13
|
name: { type: "string" },
|
|
15
|
-
/** The provider's display name,
|
|
14
|
+
/** The provider's display name, as the host writes it. */
|
|
16
15
|
provider: { type: "string" },
|
|
17
16
|
/** Two letters from `tags.ts`. */
|
|
18
17
|
tag: { type: "string" },
|
|
19
|
-
/** Prices per million tokens; absent when the catalogue has none
|
|
18
|
+
/** Prices per million tokens; absent when the catalogue has none. */
|
|
20
19
|
input: Count,
|
|
21
20
|
output: Count,
|
|
22
21
|
context: Count,
|
|
23
|
-
/** Monthly limit ÷ the
|
|
22
|
+
/** Monthly limit ÷ the plan price. */
|
|
24
23
|
credit: Count,
|
|
25
24
|
promo: Count,
|
|
26
25
|
peak: Count,
|
|
27
26
|
int: Count,
|
|
28
|
-
/** Borrowed
|
|
27
|
+
/** Borrowed: Artificial Analysis, or the catalogue's own. */
|
|
29
28
|
tps: Count,
|
|
30
|
-
/** Measured on this machine, on this provider.
|
|
29
|
+
/** Measured on this machine, on this provider. */
|
|
31
30
|
measured: Count,
|
|
32
31
|
enabled: { type: "boolean" },
|
|
33
32
|
},
|
|
@@ -41,11 +40,10 @@ const Snapshot = {
|
|
|
41
40
|
rows: { type: "array", items: Row },
|
|
42
41
|
/** `providerID/modelID` of the models the host keeps as favourites, from `model.json`. */
|
|
43
42
|
favorites: { type: "array", items: { type: "string" } },
|
|
44
|
-
/** How many offers the catalogue holds, and how many came out with a speed
|
|
45
|
-
* thin source from a broken key. */
|
|
43
|
+
/** How many offers the catalogue holds, and how many came out with a speed. */
|
|
46
44
|
offers: Count,
|
|
47
45
|
withSpeed: Count,
|
|
48
|
-
/** False when the Go plan could not be read and no cache was there
|
|
46
|
+
/** False when the Go plan could not be read and no cache was there. */
|
|
49
47
|
credits: { type: "boolean" },
|
|
50
48
|
/** What failed, in the words the panel shows. Empty when everything came back. */
|
|
51
49
|
problems: { type: "array", items: { type: "string" } },
|
package/tags.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
// Two letters per provider, so a row says where its model comes from without spending a column.
|
|
2
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
3
|
// Runnable standalone for its self-check: node plugins/oc-models-pro/tags.ts --self-check
|
|
9
4
|
|
|
10
5
|
/** Tags that belong to the host's own providers, present or not. */
|
|
@@ -14,8 +9,7 @@ const RESERVED: Record<string, string> = {
|
|
|
14
9
|
}
|
|
15
10
|
|
|
16
11
|
/**
|
|
17
|
-
* The words of a display name
|
|
18
|
-
* split only when the name has no separator, so a brand like `GitHub Copilot` keeps `GitHub` whole.
|
|
12
|
+
* The words of a display name.
|
|
19
13
|
*/
|
|
20
14
|
export function words(name: string): string[] {
|
|
21
15
|
const split = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
|
@@ -32,8 +26,7 @@ function preferred(name: string): string {
|
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
/**
|
|
35
|
-
* A tag per provider id, unique and order-independent.
|
|
36
|
-
* same catalogue always yields the same tags whatever order the providers arrive in.
|
|
29
|
+
* A tag per provider id, unique and order-independent.
|
|
37
30
|
*/
|
|
38
31
|
export function tagsFor(providers: Array<{ id: string; name: string }>): Map<string, string> {
|
|
39
32
|
const taken = new Set(Object.values(RESERVED))
|
|
@@ -51,10 +44,8 @@ export function tagsFor(providers: Array<{ id: string; name: string }>): Map<str
|
|
|
51
44
|
const first = letters[0] ?? "?"
|
|
52
45
|
let tag = preferred(provider.name)
|
|
53
46
|
if (taken.has(tag)) {
|
|
54
|
-
// Walk the name for a free pair, so `Google` steps aside from the reserved GO.
|
|
55
47
|
tag = ""
|
|
56
48
|
for (const letter of letters.slice(1)) {
|
|
57
|
-
// A doubled letter reads as a typo ("GG"): prefer anything else.
|
|
58
49
|
if (letter === first) continue
|
|
59
50
|
const candidate = first + letter
|
|
60
51
|
if (!taken.has(candidate)) {
|
package/tps.ts
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
// oc-models-pro/tps.ts — tokens per second, measured on this machine.
|
|
2
2
|
//
|
|
3
|
-
// Not an estimate from a catalogue: it is what this provider actually did, for the models this
|
|
4
|
-
// person actually ran. The number is the one the industry publishes, so it can be read next to
|
|
5
|
-
// anything online: (output + reasoning tokens) over the window from the first streamed token to the
|
|
6
|
-
// last, time-to-first-token excluded — Artificial Analysis' "output speed", and `completion_tokens`
|
|
7
|
-
// as OpenAI, Anthropic and OpenRouter bill it, where reasoning is part of the output.
|
|
8
|
-
//
|
|
9
|
-
// The window is the last seven days when it holds at least three turns, and all of history when it
|
|
10
|
-
// does not: a number from a provider's good week is worth less than the provider's behaviour now,
|
|
11
|
-
// but an empty cell for a model used once last month is worth less still.
|
|
12
|
-
//
|
|
13
3
|
// Runnable standalone for its self-check: node plugins/oc-models-pro/tps.ts --self-check
|
|
14
4
|
|
|
15
5
|
import { execFile } from "node:child_process"
|
|
@@ -45,7 +35,7 @@ export function databasePath(): string {
|
|
|
45
35
|
return process.env.OPENCODE_DB || join(data, "opencode", "opencode.db")
|
|
46
36
|
}
|
|
47
37
|
|
|
48
|
-
/** The host's own SQLite
|
|
38
|
+
/** The host's own SQLite; absent outside Bun. */
|
|
49
39
|
async function sqlite(): Promise<typeof import("bun:sqlite") | undefined> {
|
|
50
40
|
try {
|
|
51
41
|
return await import("bun:sqlite")
|
|
@@ -141,8 +131,7 @@ export async function speedIndex(database = databasePath()): Promise<{
|
|
|
141
131
|
const recent = all.filter((t) => t.at >= since)
|
|
142
132
|
const use = recent.length >= MIN_SAMPLES ? recent : all
|
|
143
133
|
if (use.length === 0) continue
|
|
144
|
-
// One voice per session, then the median of those voices
|
|
145
|
-
// session elects the number on its own, and this person's sessions run for days.
|
|
134
|
+
// One voice per session, then the median of those voices.
|
|
146
135
|
const perSession = new Map<string, number[]>()
|
|
147
136
|
for (const t of use) {
|
|
148
137
|
const list = perSession.get(t.sid) ?? []
|
package/tui.js
CHANGED
|
@@ -8,14 +8,6 @@ import { insertNode as _$insertNode } from "@opentui/solid";
|
|
|
8
8
|
import { setProp as _$setProp } from "@opentui/solid";
|
|
9
9
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
10
10
|
// oc-models-pro — the model list with prices, ported from Pi's `pi-model-pro`.
|
|
11
|
-
//
|
|
12
|
-
// We render it ourselves, in a `session.panel`: the native dialog caps an option line at 60 columns,
|
|
13
|
-
// measured, which cannot hold prices, context, intelligence and the Go credit side by side. The host
|
|
14
|
-
// owns the panel's placement and focus; the list owns its columns.
|
|
15
|
-
//
|
|
16
|
-
// Prices are native, never adjusted: the multiplier gets its own column and the effective price only
|
|
17
|
-
// decides the order, so a promoted model sorts first while the figures stay honest. The model in use
|
|
18
|
-
// is the first row whatever it costs: the free/paying split never buries it.
|
|
19
11
|
import { Plugin } from "@opencode/plugin/tui";
|
|
20
12
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
|
21
13
|
import { appendFileSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
@@ -47,8 +39,7 @@ function tokens(value) {
|
|
|
47
39
|
}
|
|
48
40
|
|
|
49
41
|
/**
|
|
50
|
-
* Every cell is exactly `width` characters:
|
|
51
|
-
* columns are hand-padded monospace, so one `padStart` short of its width slides the whole row.
|
|
42
|
+
* Every cell is exactly `width` characters: one `padStart` short of its width slides the whole row.
|
|
52
43
|
*/
|
|
53
44
|
function cell(text, width, align = "end") {
|
|
54
45
|
const cut = text.length > width ? text.slice(0, width) : text;
|
|
@@ -59,17 +50,11 @@ function cell(text, width, align = "end") {
|
|
|
59
50
|
function padTag(letters) {
|
|
60
51
|
return (letters.padEnd(2) + " ").slice(0, 4);
|
|
61
52
|
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Column widths live in `fit.ts`: they are no longer fixed. The table is 85 characters at its widest
|
|
65
|
-
* and the name gives way first (down to 24), then whole columns go. `panel.width` reads as pixels
|
|
66
|
-
* on the first render, so `fit` clamps anything implausible back to the wide layout.
|
|
67
|
-
*/
|
|
68
53
|
function label(name, width) {
|
|
69
54
|
return name.length > width ? name.slice(0, width - 1) + "…" : name.padEnd(width);
|
|
70
55
|
}
|
|
71
56
|
|
|
72
|
-
/** A price cell
|
|
57
|
+
/** A price cell: blank for no figure, "free" for zero. */
|
|
73
58
|
function price(value, width) {
|
|
74
59
|
if (value === undefined) return cell("", width);
|
|
75
60
|
const shown = value === 0 ? "free" : String(Math.round(value * 1000) / 1000);
|
|
@@ -93,9 +78,7 @@ function speedCell(row, f) {
|
|
|
93
78
|
function rowParts(row, mine, favourite, f) {
|
|
94
79
|
const notes = [row.peak ? "peak*" + row.peak : "", row.promo ? "+" + row.promo + "x" : ""].filter(Boolean);
|
|
95
80
|
const head = [(mine ? "●" : " ") + (favourite ? "*" : " "), padTag(row.tag), label(row.name, f.name), price(row.input, 6), price(row.output, 6), ...(f.ctx ? [cell(tokens(row.context), 5)] : []), ...(f.int ? [cell(row.int !== undefined ? row.int.toFixed(1) : "", 5)] : [])];
|
|
96
|
-
const tail = [
|
|
97
|
-
// Any credit other than 1 changes the effective price, so any of them is shown.
|
|
98
|
-
cell(row.credit !== 1 ? "×" + row.credit : "", 4), ...(f.note ? [cell(notes.join(" "), 6)] : [])];
|
|
81
|
+
const tail = [cell(row.credit !== 1 ? "×" + row.credit : "", 4), ...(f.note ? [cell(notes.join(" "), 6)] : [])];
|
|
99
82
|
const speed = speedCell(row, f);
|
|
100
83
|
if (!speed) return {
|
|
101
84
|
before: head.concat(tail).join(" "),
|
|
@@ -145,8 +128,7 @@ export default Plugin.define({
|
|
|
145
128
|
const BASE = themeColour(["text", "base"]);
|
|
146
129
|
const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM;
|
|
147
130
|
const ACCENT = themeColour(["syntax", "function"], ["syntax", "variable"]) ?? FALLBACK_ACCENT;
|
|
148
|
-
// Theme colours reach us as RGBA objects
|
|
149
|
-
// a string, so the two surfaces are converted to hex.
|
|
131
|
+
// Theme colours reach us as RGBA objects, which a box background cannot take: convert to hex.
|
|
150
132
|
const hex = node => {
|
|
151
133
|
if (typeof node === "string") return node;
|
|
152
134
|
const buffer = node?.buffer;
|
|
@@ -155,7 +137,7 @@ export default Plugin.define({
|
|
|
155
137
|
return "#" + channels.join("");
|
|
156
138
|
};
|
|
157
139
|
const SURFACE = hex(themeColour(["surface", "base"], ["background", "raised", "base"]));
|
|
158
|
-
// The cyan the native controls use when focused
|
|
140
|
+
// The cyan the native controls use when focused.
|
|
159
141
|
const SELECTED = hex(themeColour(["background", "action", "primary", "focused"], ["surface", "high"]));
|
|
160
142
|
const ON_SELECTED = hex(themeColour(["background", "base"], ["text", "base"]));
|
|
161
143
|
const BORDER = hex(themeColour(["border", "base"], ["text", "faint"]));
|
|
@@ -163,14 +145,12 @@ export default Plugin.define({
|
|
|
163
145
|
prefs = readPrefs();
|
|
164
146
|
let creditIndex = new Map();
|
|
165
147
|
let bench = new Map();
|
|
166
|
-
/** Measured on this machine, keyed `providerID/modelID
|
|
148
|
+
/** Measured on this machine, keyed `providerID/modelID`. */
|
|
167
149
|
let measured = new Map();
|
|
168
|
-
/** From Artificial Analysis
|
|
150
|
+
/** From Artificial Analysis. */
|
|
169
151
|
let estimated = new Map();
|
|
170
|
-
/** False when the Go page could not be read and no cache was there
|
|
152
|
+
/** False when the Go page could not be read and no cache was there. */
|
|
171
153
|
let creditsKnown = false;
|
|
172
|
-
// Signals, not plain variables: the header shows them, and a plain variable left the title
|
|
173
|
-
// saying "effective price" whatever the list was actually sorted by.
|
|
174
154
|
const [sort, setSort] = createSignal("effective");
|
|
175
155
|
const [grouped, setGrouped] = createSignal(true);
|
|
176
156
|
const [scope, setScope] = createSignal("all");
|
|
@@ -209,8 +189,6 @@ export default Plugin.define({
|
|
|
209
189
|
const cost = (model.cost ?? []).find(entry => !entry.tier) ?? model.cost?.[0];
|
|
210
190
|
const limit = model.limit ?? {};
|
|
211
191
|
const score = benchFor(bench, model.name, model.modelID);
|
|
212
|
-
// Three sources, one order: what this machine measured, then Artificial Analysis, then
|
|
213
|
-
// the catalogue. A measured figure is never displaced by an estimate.
|
|
214
192
|
const mine = measured.get(model.providerID + "/" + model.modelID);
|
|
215
193
|
const theirs = mine === undefined ? estimated.get(benchKey(model.name)) ?? estimated.get(benchKey(model.modelID)) ?? score?.tps : undefined;
|
|
216
194
|
result.push({
|
|
@@ -221,18 +199,11 @@ export default Plugin.define({
|
|
|
221
199
|
// header reads as if the account were the thing: provider first, account in brackets.
|
|
222
200
|
provider: (providers.get(model.providerID) ?? model.providerID).replace(/^(.+?) \/ (.+)$/, "$2 ($1)"),
|
|
223
201
|
tag: tags.get(model.providerID) ?? model.providerID.slice(0, 2).toUpperCase(),
|
|
224
|
-
// A missing figure stays missing: a 0 here would put an unknown model at the top of the
|
|
225
|
-
// cheapest list and claim a context window it never declared.
|
|
226
202
|
input: typeof cost?.input === "number" ? cost.input : undefined,
|
|
227
203
|
output: typeof cost?.output === "number" ? cost.output : undefined,
|
|
228
204
|
context: typeof limit.context === "number" ? limit.context : undefined,
|
|
229
|
-
// The page describes the Go plan only, and its names are not the catalogue's: match the
|
|
230
|
-
// model id, and only on the provider the plan applies to. By name alone, OpenRouter's
|
|
231
|
-
// "DeepSeek V4.1 Flash" inherited the Go credit.
|
|
232
205
|
credit: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.credit ?? 1 : 1,
|
|
233
206
|
promo: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.promo : undefined,
|
|
234
|
-
// Measured on the Go plan first, then the native rule: the catalogue lists one
|
|
235
|
-
// DeepSeek price, so only the second can say it doubles.
|
|
236
207
|
peak: (isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined) ?? nativePeak(model.providerID),
|
|
237
208
|
int: score?.int,
|
|
238
209
|
tps: theirs,
|
|
@@ -240,8 +211,7 @@ export default Plugin.define({
|
|
|
240
211
|
enabled: model.enabled !== false
|
|
241
212
|
});
|
|
242
213
|
}
|
|
243
|
-
// Every provider keeps its row
|
|
244
|
-
// different offers, and hiding one hid `deepseek-v4-pro` from the provider that hosts it.
|
|
214
|
+
// Every provider keeps its row.
|
|
245
215
|
return result;
|
|
246
216
|
}
|
|
247
217
|
async function warm() {
|
|
@@ -260,15 +230,12 @@ export default Plugin.define({
|
|
|
260
230
|
// One fetch a day at most; a failure only costs the credit column and the effective ordering.
|
|
261
231
|
const plan = await goPlan();
|
|
262
232
|
creditIndex = plan ? credits(plan) : new Map();
|
|
263
|
-
// The three reads run together: the panel waits for the slowest, not for their sum.
|
|
264
|
-
// comes first in the ordering below — the only figure that is this provider's own behaviour.
|
|
233
|
+
// The three reads run together: the panel waits for the slowest, not for their sum.
|
|
265
234
|
const started = Date.now();
|
|
266
235
|
const [benchmarks, speeds, estimates] = await Promise.all([benchIndex(), speedIndex(), aaIndex()]);
|
|
267
236
|
bench = benchmarks;
|
|
268
237
|
measured = new Map([...speeds.speeds].map(([key, speed]) => [key, speed.tps]));
|
|
269
238
|
estimated = estimates;
|
|
270
|
-
// How many of the models on offer actually come out with a figure: the one number that says
|
|
271
|
-
// whether a thin column is a thin source or a broken key.
|
|
272
239
|
const offer = listModels();
|
|
273
240
|
const withSpeed = offer.filter(model => {
|
|
274
241
|
if (measured.has(model.providerID + "/" + model.modelID)) return true;
|
|
@@ -287,8 +254,7 @@ export default Plugin.define({
|
|
|
287
254
|
trace("open: panel requested");
|
|
288
255
|
}
|
|
289
256
|
const Panel = props => {
|
|
290
|
-
// Who had the focus before the panel took it: the prompt itself.
|
|
291
|
-
// and nothing else in the API can — no host command focuses the prompt from a plugin.
|
|
257
|
+
// Who had the focus before the panel took it: the prompt itself.
|
|
292
258
|
const previousFocus = context.renderer?.currentFocusedRenderable;
|
|
293
259
|
trace("panel: previous focus=" + (previousFocus ? previousFocus.constructor?.name ?? "?" : "none"));
|
|
294
260
|
const [selected, setSelected] = createSignal(undefined);
|
|
@@ -296,7 +262,6 @@ export default Plugin.define({
|
|
|
296
262
|
const width = createMemo(() => Math.floor(Number(props.panel.width) || 0), undefined, {
|
|
297
263
|
equals: Object.is
|
|
298
264
|
});
|
|
299
|
-
// The scrollbox draws its scrollbar in the last column, so the table gets one less.
|
|
300
265
|
// Both layouts carry an equality check: the width settles from pixels to columns on open,
|
|
301
266
|
// and a memo that fires on that tick re-renders the panel for no visual change — which is
|
|
302
267
|
// what took the focus off the search field.
|
|
@@ -331,9 +296,8 @@ export default Plugin.define({
|
|
|
331
296
|
*/
|
|
332
297
|
const holdsField = () => Boolean(field) && context.renderer?.currentFocusedRenderable === field;
|
|
333
298
|
/**
|
|
334
|
-
* Hands the focus back to whoever held it before the panel opened
|
|
335
|
-
*
|
|
336
|
-
* the renderer's own method, and it is what makes a click on a row behave like `esc`.
|
|
299
|
+
* Hands the focus back to whoever held it before the panel opened, which is what makes a click on
|
|
300
|
+
* a row behave like `esc`: the renderer's own method, since no command focuses the prompt.
|
|
337
301
|
*/
|
|
338
302
|
const focusPrompt = () => {
|
|
339
303
|
try {
|
|
@@ -352,18 +316,15 @@ export default Plugin.define({
|
|
|
352
316
|
if (field && !field.isDestroyed) field.value = "";
|
|
353
317
|
};
|
|
354
318
|
|
|
355
|
-
// The
|
|
356
|
-
// when the panel unmounts: measured three openings in a row, every one reading 10 reachable
|
|
357
|
-
// `models-pro.*` commands and never 20 — a leaked layer would show up here first.
|
|
319
|
+
// The trace line is the alarm for a leaked layer: it prints on every mount.
|
|
358
320
|
try {
|
|
359
321
|
trace("panel: " + context.keymap.commands().filter(entry => String(entry.id ?? "").startsWith("models-pro.")).length + " models-pro commands reachable");
|
|
360
322
|
} catch (error) {
|
|
361
323
|
fail("panel commands", error);
|
|
362
324
|
}
|
|
363
325
|
|
|
364
|
-
// The state file is shared
|
|
365
|
-
//
|
|
366
|
-
// re-reads the file so the star and the `favorites` scope stay true.
|
|
326
|
+
// The state file is shared and written under us: watch the directory, which survives the
|
|
327
|
+
// rename, and re-read on every event.
|
|
367
328
|
try {
|
|
368
329
|
const watcher = watch(join(homedir(), ".local/state/opencode"), (_event, name) => {
|
|
369
330
|
if (String(name) !== "model.json") return;
|
|
@@ -397,10 +358,6 @@ export default Plugin.define({
|
|
|
397
358
|
});
|
|
398
359
|
});
|
|
399
360
|
|
|
400
|
-
// Free models never enter the price order: they are not cheap, they are unmetered, and at
|
|
401
|
-
// the top they hide everything that costs something. Grouped, each one still sits under
|
|
402
|
-
// its own provider, after that provider's paying rows — the group answers "who serves
|
|
403
|
-
// this", and a global "Free" bucket would answer it wrong. Flat, they keep one bucket.
|
|
404
361
|
// What is drawn, and in which order, is `arrange`'s business — the same function the
|
|
405
362
|
// desktop panel calls. This half only turns sections into the headers and rows a
|
|
406
363
|
// terminal list wants.
|
|
@@ -439,8 +396,6 @@ export default Plugin.define({
|
|
|
439
396
|
setSelected(key);
|
|
440
397
|
reveal(key);
|
|
441
398
|
}
|
|
442
|
-
|
|
443
|
-
/** The list is taller than the panel: the selection has to drag the viewport with it. */
|
|
444
399
|
let scroller;
|
|
445
400
|
function reveal(key) {
|
|
446
401
|
const box = scroller;
|
|
@@ -478,15 +433,7 @@ export default Plugin.define({
|
|
|
478
433
|
})();
|
|
479
434
|
}
|
|
480
435
|
|
|
481
|
-
/**
|
|
482
|
-
* The host owns the model selection: the native picker writes it with the host's own
|
|
483
|
-
* `model.set`, per session and agent, and the prompt sends it to the server. The plugin API
|
|
484
|
-
* has no setter for it — `ui.model` reads it and changes its variant, nothing more — so the
|
|
485
|
-
* host makes the change: `model.cycle_favorite` ends on the first favourite, the same
|
|
486
|
-
* internal call the picker makes. The list is prepared for that call and put back right
|
|
487
|
-
* after. One entry is what makes the call safe to repeat: whatever is selected, it lands on
|
|
488
|
-
* that entry.
|
|
489
|
-
*/
|
|
436
|
+
/** Switches the model through the host's own command, with the favourite list as transport. */
|
|
490
437
|
async function selectModel(row) {
|
|
491
438
|
// The upstream fix, when it lands: the host's setter, and no list at all.
|
|
492
439
|
const native = ui.model.set;
|
|
@@ -526,11 +473,7 @@ export default Plugin.define({
|
|
|
526
473
|
return landed;
|
|
527
474
|
}
|
|
528
475
|
|
|
529
|
-
/**
|
|
530
|
-
* A model whose variant was never chosen has none applied, and the native picker asks once for
|
|
531
|
-
* it. Same rule here: a saved entry — `default` included — means silence; no entry opens the
|
|
532
|
-
* question, and the host's own `variant.set` is what persists the answer.
|
|
533
|
-
*/
|
|
476
|
+
/** The native picker asks once for a variant a model has none of: same rule here. */
|
|
534
477
|
async function settleVariant(row) {
|
|
535
478
|
// The file is the source, not the copy read at open: a variant saved meanwhile (native
|
|
536
479
|
// picker, prompt) has to count as an answer.
|
|
@@ -559,8 +502,6 @@ export default Plugin.define({
|
|
|
559
502
|
});
|
|
560
503
|
// A dialog takes the focus and does not hand it back either: the same command, once it closed.
|
|
561
504
|
setTimeout(focusPrompt, 40);
|
|
562
|
-
// Cancelling leaves the model without a variant, and the question will come back, as in
|
|
563
|
-
// the native picker.
|
|
564
505
|
if (chosen === undefined) return;
|
|
565
506
|
const applied = ui.model.variant.set(chosen === "default" ? undefined : chosen);
|
|
566
507
|
trace("variant set: " + chosen + " -> " + applied);
|
|
@@ -651,10 +592,7 @@ export default Plugin.define({
|
|
|
651
592
|
title: "Clear the search, or close the list",
|
|
652
593
|
bind: "esc",
|
|
653
594
|
run: () => query() ? clear() : props.panel.close()
|
|
654
|
-
},
|
|
655
|
-
// `ctrl+y`: `f11` never reached the panel in the terminal. `ctrl+f` marks a favourite,
|
|
656
|
-
// as in the native dialog. `f` alone stays unusable: it would be typed into the search.
|
|
657
|
-
{
|
|
595
|
+
}, {
|
|
658
596
|
id: "models-pro.fullscreen",
|
|
659
597
|
title: "Full screen",
|
|
660
598
|
bind: "ctrl+y",
|
|
@@ -796,7 +734,6 @@ export default Plugin.define({
|
|
|
796
734
|
const key = prefKey(row.providerID, row.modelID);
|
|
797
735
|
const mine = () => key === mineKey();
|
|
798
736
|
const active = () => key === selectedKey();
|
|
799
|
-
// A favourite is marked everywhere but in the favourites scope, where every row is one.
|
|
800
737
|
const favourite = () => {
|
|
801
738
|
revision();
|
|
802
739
|
return scope() !== "favorites" && prefs.favorite.has(key);
|
|
@@ -915,8 +852,7 @@ export default Plugin.define({
|
|
|
915
852
|
}
|
|
916
853
|
}
|
|
917
854
|
};
|
|
918
|
-
// The
|
|
919
|
-
// for one of them only. Try both, record which one took it, never fail silently.
|
|
855
|
+
// The command goes on whichever keymap the build publishes; the trace records which took it.
|
|
920
856
|
const keymaps = [["context.keymap", context.keymap], ["ui.keymap", context.ui?.keymap]];
|
|
921
857
|
let registered = false;
|
|
922
858
|
for (const [label, keymap] of keymaps) {
|