@rizal72/oc-models-pro 0.3.1 → 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/aa.ts CHANGED
@@ -1,13 +1,4 @@
1
- // oc-models-pro/aa.ts — tokens per second for the models this machine has never run, from
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: a hanging endpoint gives up after this. */
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 — the same meeting point `bench.ts` uses, so one model has one key. */
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
- /** Six hours of grace: a benchmark does not move between two coffee breaks. */
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 (the track is elastic), then whole columns
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
- // The host hands `session.panel` a `width` in columns (pixels on the very first frame, which the
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: 32 + the fixed 53 is the 85-character table. */
6
+ /** The name column at its widest. */
8
7
  export const NAME = 32
9
- /** ...and at its narrowest. Below this a model name is unreadable, and a column is worth more. */
8
+ /** ...and at its narrowest. */
10
9
  export const NAME_MIN = 24
11
- /** The cells that never go: marker 2, tag 4, in 6, out 6, cred 4 and the nine separators. */
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: one with no data, then the note, then context, then int. */
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 refresh on their own TTLs (6 h, 1 day, 6 h); the measured speeds cost about a
23
- * second here and are worth keeping for a minute, which is also what keeps a poll cheap.
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: a thin column must look thin. */
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: what this machine measured, then Artificial Analysis, then the
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 from the Go plan page first, the native rule second: one is data, the
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: measured
58
- * 2026-10-28 against DeepSeek's own pricing page — `deepseek-flash` $0.15 off-peak / $0.30 peak, and the
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, `$10/month` in the `Go` heading; the first such figure on the page is Go's. */
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.1",
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 need it: the terminal draws the list and toggles a favourite from the panel, the desktop
4
- // writes the same file through its server half. It lives here so the recipe exists once — a second copy
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`: the provider's, unique in the catalogue. */
10
+ /** Two letters from `tags.ts`. */
16
11
  tag: string
17
- /** Undefined when the catalogue has no price for the model: an unknown figure, not a free one. */
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 $10 plan: the ordering multiplier, shown so the order explains itself. */
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. Outranks `tps` and carries no tilde. */
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
- /** A missing price is not a free one: both figures must be there, and be zero. */
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: what this machine measured first, the borrowed figure after. */
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: an unknown price is
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: free models never enter a price order,
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
- // What the desktop's panel reads: a plugin window has no Node built-ins, so every file and every fetch
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, account first as the host writes it. */
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 — an unknown figure, not a free one. */
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 $10 plan. 1 when the plan is unknown. */
22
+ /** Monthly limit ÷ the plan price. */
24
23
  credit: Count,
25
24
  promo: Count,
26
25
  peak: Count,
27
26
  int: Count,
28
- /** Borrowed from Artificial Analysis, or the catalogue's own when it still carries one. */
27
+ /** Borrowed: Artificial Analysis, or the catalogue's own. */
29
28
  tps: Count,
30
- /** Measured on this machine, on this provider. Outranks `tps` and carries no tilde. */
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 — the pair that tells a
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: every credit is 1. */
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: `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.
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. Derived in alphabetical order of id, so the
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. Absent outside Bun, where the CLI carries the self-check. */
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: without it a single long-running
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: text that does not fit is cut, never shifted right. The
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; blank when the catalogue has no figure, "free" when the figure is zero. */
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; `fg` takes them, a box background does not — it wants
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; the row text flips to the page colour on top.
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`: it wins over any catalogue figure. */
148
+ /** Measured on this machine, keyed `providerID/modelID`. */
167
149
  let measured = new Map();
168
- /** From Artificial Analysis, for the models this machine never ran. */
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: the credits are all 1. */
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: the same model on `deepseek` and on `opencode-go` is two
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. Measured
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. The renderer can hand it back,
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 — the prompt. The host does not
335
- * do it for a panel closed by a click, and there is no command that focuses the prompt: this is
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 host activates a panel's keyboard layers only while it owns input, and it disposes them
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: the native picker and `model.cycle_favorite` write it while this
365
- // panel is open. Watching the directory (not the file) survives the rename, and every event
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 types publish the keymap twice — `context.keymap` and `ui.keymap` — and this build answers
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) {