opencode-model-price-intel 1.0.0 → 1.1.0

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 CHANGED
@@ -1,11 +1,12 @@
1
1
  # opencode-model-price-intel
2
2
 
3
3
  [![CI](https://github.com/chris15joseph/opencode-model-price-intel/actions/workflows/ci.yml/badge.svg)](https://github.com/chris15joseph/opencode-model-price-intel/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/opencode-model-price-intel)](https://www.npmjs.com/package/opencode-model-price-intel)
4
5
  [![license](https://img.shields.io/npm/l/opencode-model-price-intel)](LICENSE)
5
6
 
6
7
  An OpenCode 2 plugin that puts **prices and the Artificial Analysis
7
- Intelligence Index** next to every model in `/models`, and adds a full
8
- `/model-pricing` table when you need the details.
8
+ Intelligence Index** next to every model in `/models`, and opens a full
9
+ `/model-pricing` table in a native TUI dialog when you need the details.
9
10
 
10
11
  ```text
11
12
  /models
@@ -23,13 +24,6 @@ so the first `/models` render is instant and the network never blocks it.
23
24
  Requires OpenCode 2.2.x (the plugin pins `@opencode/plugin` to `2.2.5`) and
24
25
  Node.js 20 or newer.
25
26
 
26
- > **npm status:** the package is staged but not yet published. Until it appears
27
- > on npm, install from Git:
28
- >
29
- > ```sh
30
- > opencode plugin add github:chris15joseph/opencode-model-price-intel
31
- > ```
32
-
33
27
  ```sh
34
28
  opencode plugin add opencode-model-price-intel
35
29
  ```
@@ -76,23 +70,40 @@ list the same plugin twice.
76
70
 
77
71
  | Option | Default | Meaning |
78
72
  | --- | --- | --- |
79
- | `decorateNames` | `true` | Append the price/score summary to `/models` names. The `/model-pricing` table still works when this is off. |
73
+ | `decorateNames` | `true` | Append the price/score summary to `/models` names. The `/model-pricing` dialog still works when this is off. |
80
74
  | `costFormat` | `"detailed"` | `"detailed"` renders `in $2.5 out $15 · cache read $0.25 write $0`; `"compact"` renders `$2.5/$15/$0.25/$0`. |
81
75
  | `aaApiKey` | unset | Artificial Analysis API key. Falls back to `ARTIFICIALANALYSIS_API_KEY`. Without either, the public model-list page is used. |
82
76
  | `refreshHours` | `12` | Score cache TTL and refresh interval, clamped to `1..596` hours. |
83
77
 
84
78
  Invalid option values log one warning and fall back to the defaults.
85
79
 
86
- ## Commands
80
+ ## Usage
81
+
82
+ Open the native model picker with `/models`. Each row's name carries the
83
+ one-line summary:
87
84
 
88
- | Command | What it does |
89
- | --- | --- |
90
- | `/model-pricing` | Prints a Markdown table with every model, provider, price tier, cache price, and AA score. |
91
- | `/prices` | Alias of `/model-pricing`. |
92
- | `/models` | The native picker, now decorated with the one-line summary. |
85
+ ```text
86
+ GPT 6 Astra · Turbo · in $2.5 out $15 · cache read $0.25 write $0 (2 tiers) · AA 56.9
87
+ ```
93
88
 
94
- The table is a synthetic session message; no model call is made. Multi-tier
95
- models get one row per price entry (`base`, then `>N` for context overrides).
89
+ Multi-tier models show the tier count; the `/models` row itself stays a single
90
+ line.
91
+
92
+ For the full table, run `/model-pricing` (or `/prices`). It opens a native TUI
93
+ dialog with an aligned, scrollable table of every provider, price tier, cache
94
+ price, and AA score. Click any column header to sort by it; click the same
95
+ header again to reverse the order. The active header shows `▲` (ascending) or
96
+ `▼` (descending), and multi-tier models stay together with their continuation
97
+ rows. The dialog can be dismissed with `Esc`, `Ctrl+C`, or a
98
+ click outside. The command handler is a toggle, so invoking it again while the
99
+ dialog is open closes it (the dialog is modal, so `Esc` is the usual path from
100
+ the prompt). The table is a TUI feature; the `/models` decoration is
101
+ server-side and works in every client.
102
+
103
+ The TUI part asks the server for structured rows over RPC, so the dialog uses
104
+ the same live registry and score cache as `/models`. It does not send a prompt
105
+ or make a model call. The table is laid out for the current terminal width and
106
+ scrolls vertically (and horizontally when the window is narrow).
96
107
 
97
108
  ## How it works
98
109
 
@@ -106,6 +117,8 @@ models get one row per price entry (`base`, then `>N` for context overrides).
106
117
  - The last good result is cached in OpenCode plugin storage for `refreshHours`.
107
118
  A cache older than 30 days is ignored.
108
119
  - The model registry reloads only when the score data actually changes.
120
+ - The TUI part is registered through the package's `./tui` export and fetches
121
+ the table through the server-side RPC in `./rpc`.
109
122
  - Unscored models show `AA n/a`. A model with no registry cost shows
110
123
  `pricing n/a` (or `n/a` in compact mode).
111
124
 
@@ -117,8 +130,10 @@ models get one row per price entry (`base`, then `>N` for context overrides).
117
130
  discounts, or regional pricing that the registry does not carry.
118
131
  - AA scores are comparative within one index major version. The plugin never
119
132
  mixes a v5 snapshot into a v6 result.
120
- - The plugin only decorates the list and prints a table. It does not route
121
- requests or change model selection.
133
+ - The plugin only decorates the model list and renders the pricing dialog. It
134
+ does not route requests or change model selection.
135
+ - The `/model-pricing` dialog requires the TUI. Other clients still get the
136
+ `/models` decoration.
122
137
 
123
138
  ## Development
124
139
 
@@ -131,16 +146,18 @@ npm test
131
146
  npm pack --dry-run
132
147
  ```
133
148
 
134
- The package exports raw TypeScript (`src/index.ts`); OpenCode 2.2.x transpiles
135
- it at load. Plain Node cannot import the installed package from `node_modules`.
149
+ The package exports raw TypeScript (`src/index.ts`, `src/rpc.ts`, and
150
+ `src/tui.tsx`); OpenCode 2.2.x transpiles it at load. Plain Node cannot import
151
+ the installed package from `node_modules`. The TUI part uses the host's
152
+ `@opentui` packages, declared as peer dependencies.
136
153
 
137
154
  ## Attribution
138
155
 
139
156
  Scores and related model data are produced by
140
157
  [Artificial Analysis](https://artificialanalysis.ai/). The `/model-pricing`
141
- header carries the source, index version, capture/fetch date, and attribution
142
- link. See [`NOTICE`](NOTICE) and [`SNAPSHOT-NOTICE.md`](SNAPSHOT-NOTICE.md) for
143
- provenance.
158
+ dialog carries the source, index version, capture/fetch date, and attribution
159
+ link. See [`NOTICE`](NOTICE) and [`SNAPSHOT-NOTICE.md`](SNAPSHOT-NOTICE.md)
160
+ for further provenance.
144
161
 
145
162
  ## License
146
163
 
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "opencode-model-price-intel",
3
- "version": "1.0.0",
4
- "description": "OpenCode 2 plugin that adds input/output/cache prices and Artificial Analysis Intelligence Index scores to /models, plus a full /model-pricing table.",
3
+ "version": "1.1.0",
4
+ "description": "OpenCode 2 plugin that adds input/output/cache prices and Artificial Analysis Intelligence Index scores to /models, plus a full /model-pricing TUI dialog.",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/index.ts"
7
+ ".": "./src/index.ts",
8
+ "./rpc": "./src/rpc.ts",
9
+ "./tui": "./src/tui.tsx"
8
10
  },
9
11
  "types": "./src/index.ts",
10
12
  "files": [
@@ -19,8 +21,16 @@
19
21
  "dependencies": {
20
22
  "@opencode/plugin": "2.2.5"
21
23
  },
24
+ "peerDependencies": {
25
+ "@opentui/core": ">=0.6.6",
26
+ "@opentui/solid": ">=0.6.6",
27
+ "solid-js": ">=1.9.0"
28
+ },
22
29
  "devDependencies": {
30
+ "@opentui/core": "^0.8.3",
31
+ "@opentui/solid": "^0.8.3",
23
32
  "@types/node": "22.20.5",
33
+ "solid-js": "^1.9.18",
24
34
  "typescript": "5.9.3"
25
35
  },
26
36
  "scripts": {
package/src/index.ts CHANGED
@@ -16,14 +16,18 @@
16
16
  *
17
17
  * Because the built-in `/models` picker renders `Model.Info.name` as the row
18
18
  * title, the pricing/score line is attached to the display name so `/models`
19
- * lists it per model. `/model-pricing` (alias `/prices`) prints the complete
20
- * table — every price tier, cache read/write, and the AA score — into the
21
- * session.
19
+ * lists it per model.
22
20
  *
23
21
  * Note: an experimental scrolling (marquee) mode for narrow screens existed
24
22
  * here and was removed — periodic name repaints made client lists reset their
25
23
  * scroll position (both the TUI and the web UI). Names are therefore static.
26
24
  *
25
+ * The full table is exposed over RPC (`src/rpc.ts`). The TUI part
26
+ * (`src/tui.tsx`) adds `/model-pricing` and `/prices`, fetches structured rows
27
+ * from this server and renders them in a native dialog table. Keeping the
28
+ * command in the TUI avoids the old behavior where `ctx.session.synthetic`
29
+ * delivered the table to the model as a prompt instead of displaying it.
30
+ *
27
31
  * Options (opencode.jsonc `plugins` entry):
28
32
  * - decorateNames: boolean attach the line to model names (default true)
29
33
  * - costFormat: "detailed" | "compact" cost line format (default detailed)
@@ -34,9 +38,10 @@
34
38
  */
35
39
 
36
40
  import { Plugin } from "@opencode/plugin"
37
- import type { CommandInvocation } from "@opencode/plugin/promise/command"
38
41
  import { fetchAAScores, lookupScore, snapshotScores, type AAFetched } from "./aa.ts"
39
- import { formatCost, pricingSegment, type CostFormat, type CostTier } from "./pricing.ts"
42
+ import { pricingSegment, type CostFormat } from "./pricing.ts"
43
+ import { ModelPricing } from "./rpc.ts"
44
+ import { pricingData, renderTable, type TableModel } from "./table.ts"
40
45
 
41
46
  const CACHE_KEY = "aa-scores/v3"
42
47
  const DEFAULT_REFRESH_HOURS = 12
@@ -53,15 +58,11 @@ interface ResolvedOptions {
53
58
  refreshMs: number
54
59
  }
55
60
 
56
- interface TableModel {
57
- readonly id: string
58
- readonly modelID?: string
61
+ function modelKey(model: {
59
62
  readonly providerID: string
60
- readonly name: string
61
- readonly cost: readonly CostTier[] | undefined
62
- }
63
-
64
- function modelKey(model: TableModel): string {
63
+ readonly modelID?: string
64
+ readonly id: string
65
+ }): string {
65
66
  return `${model.providerID}/${model.modelID || model.id}`
66
67
  }
67
68
 
@@ -218,63 +219,6 @@ function cachePayload(value: AAFetched): JsonValue {
218
219
  }
219
220
  }
220
221
 
221
- function markdownCell(value: unknown): string {
222
- return String(value ?? "")
223
- .replace(/\\/g, "\\\\")
224
- .replace(/\|/g, "\\|")
225
- .replace(/\r\n|\r|\n/g, " ")
226
- }
227
-
228
- function buildTable(
229
- models: readonly TableModel[],
230
- aa: AAFetched,
231
- baseNames: ReadonlyMap<string, string>,
232
- decorateNames: boolean,
233
- ): string {
234
- const lines: string[] = [
235
- "### Model pricing & Artificial Analysis Intelligence Index",
236
- "",
237
- `Prices per 1M tokens from the live OpenCode model registry. Scores: Artificial Analysis Intelligence Index v${markdownCell(aa.version)}, source ${markdownCell(aa.source)}, fetched ${markdownCell(aa.fetchedAt.slice(0, 10))}. Attribution: https://artificialanalysis.ai/`,
238
- "",
239
- "| Model | Provider | Tier | Input | Output | Cache read | Cache write | AA Intelligence |",
240
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
241
- ]
242
-
243
- for (const model of models) {
244
- const baseName = decorateNames
245
- ? (baseNames.get(modelKey(model)) ?? model.name)
246
- : model.name
247
- const score = lookupScore(aa.scores, model.id, baseName)
248
- const aaCell =
249
- score?.score != null
250
- ? `${Math.round(score.score * 10) / 10}${score.note ? ` (${score.note})` : ""}`
251
- : "n/a"
252
-
253
- let tiers: ReturnType<typeof formatCost> = []
254
- try {
255
- tiers = formatCost(model.cost)
256
- } catch (error) {
257
- console.warn(`[model-price-intel] could not format cost for ${modelKey(model)}:`, error)
258
- }
259
- const rows = tiers.length > 0 ? tiers : [undefined]
260
- rows.forEach((tier, index) => {
261
- const cells = [
262
- index === 0 ? baseName : "",
263
- index === 0 ? model.providerID : "",
264
- tier ? (tier.tierLabel ?? (index === 0 ? "base" : "")) : "base",
265
- tier ? tier.input : "n/a",
266
- tier ? tier.output : "n/a",
267
- tier ? tier.cacheRead : "n/a",
268
- tier ? tier.cacheWrite : "n/a",
269
- index === 0 ? aaCell : "",
270
- ]
271
- lines.push(`| ${cells.map(markdownCell).join(" | ")} |`)
272
- })
273
- }
274
-
275
- return lines.join("\n")
276
- }
277
-
278
222
  /** Remove our own decoration if a transform is replayed against a stale name. */
279
223
  function undecoratedName(name: string, price: string): string {
280
224
  const suffix = ` · ${price} · AA `
@@ -429,10 +373,10 @@ export default Plugin.define({
429
373
  })
430
374
  }
431
375
 
432
- // Full table as a synthetic session message (no model call needed). The
433
- // command is registered even when name decoration is disabled.
434
- await ctx.command.transform((editor) => {
435
- const execute = async (input: CommandInvocation): Promise<void> => {
376
+ // The TUI part calls this RPC to render the full table in a dialog. The
377
+ // server builds the rows because only it owns the live score cache.
378
+ await ctx.rpc.register(ModelPricing, {
379
+ table: async () => {
436
380
  let models: readonly TableModel[] = []
437
381
  try {
438
382
  const { data } = await ctx.model.list()
@@ -440,22 +384,9 @@ export default Plugin.define({
440
384
  } catch (error) {
441
385
  console.warn("[model-price-intel] could not list models for the pricing table:", error)
442
386
  }
443
- await ctx.session.synthetic({
444
- sessionID: input.sessionID,
445
- text: buildTable(models, aa, baseNames, options.decorateNames),
446
- })
447
- }
448
- editor.add({
449
- name: "model-pricing",
450
- description:
451
- "List input/output/cache prices and Artificial Analysis intelligence scores for every model",
452
- execute,
453
- })
454
- editor.add({
455
- name: "prices",
456
- description: "Alias of /model-pricing",
457
- execute,
458
- })
387
+ const data = pricingData(models, aa, baseNames, options.decorateNames)
388
+ return { ...data, text: renderTable(data) }
389
+ },
459
390
  })
460
391
 
461
392
  // Hydrate the cache and refresh live scores in the background. setup must
package/src/pricing.ts CHANGED
@@ -124,8 +124,7 @@ export type CostFormat = "detailed" | "compact"
124
124
  *
125
125
  * The compact form (<in>/<out>/<read>/<write>) keeps rows readable on narrow
126
126
  * screens such as mobile web sessions. Multi-tier models show their base tier
127
- * and an `(N tiers)` marker; the `/model-pricing` command lists every tier
128
- * with its `>N` context threshold.
127
+ * and an `(N tiers)` marker.
129
128
  */
130
129
  export function pricingSegment(
131
130
  cost: readonly CostTier[] | undefined,
package/src/rpc.ts ADDED
@@ -0,0 +1,65 @@
1
+ /** RPC contract shared by the server plugin and the TUI part. */
2
+
3
+ import { Rpc } from "@opencode/plugin/rpc"
4
+
5
+ /** One aligned table row; continuation tiers leave model/provider/aa blank. */
6
+ export interface PricingRow {
7
+ readonly model: string
8
+ readonly provider: string
9
+ readonly tier: string
10
+ readonly input: string
11
+ readonly output: string
12
+ readonly cache: string
13
+ readonly aa: string
14
+ }
15
+
16
+ export const ModelPricing = Rpc.define({
17
+ id: "model-price-intel",
18
+ methods: {
19
+ table: {
20
+ input: {
21
+ type: "object",
22
+ properties: {},
23
+ additionalProperties: false,
24
+ },
25
+ output: {
26
+ type: "object",
27
+ properties: {
28
+ /** Pre-rendered fallback table (also useful to copy for support). */
29
+ text: { type: "string" },
30
+ title: { type: "string" },
31
+ meta: { type: "string" },
32
+ footnote: { type: "string" },
33
+ rows: {
34
+ type: "array",
35
+ items: {
36
+ type: "object",
37
+ properties: {
38
+ model: { type: "string" },
39
+ provider: { type: "string" },
40
+ tier: { type: "string" },
41
+ input: { type: "string" },
42
+ output: { type: "string" },
43
+ cache: { type: "string" },
44
+ aa: { type: "string" },
45
+ },
46
+ required: ["model", "provider", "tier", "input", "output", "cache", "aa"],
47
+ additionalProperties: false,
48
+ },
49
+ },
50
+ },
51
+ required: ["text", "title", "meta", "footnote", "rows"],
52
+ additionalProperties: false,
53
+ },
54
+ },
55
+ },
56
+ events: {},
57
+ })
58
+
59
+ export interface PricingTable {
60
+ readonly text: string
61
+ readonly title: string
62
+ readonly meta: string
63
+ readonly footnote: string
64
+ readonly rows: readonly PricingRow[]
65
+ }
package/src/table.ts ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Pricing table data and rendering shared by the server RPC and the TUI
3
+ * dialog. The server owns the live registry and AA cache; the TUI only lays
4
+ * the returned rows out for the current terminal width.
5
+ */
6
+
7
+ import { lookupScore, type AAFetched } from "./aa.ts"
8
+ import { formatCost, type CostTier } from "./pricing.ts"
9
+ import type { PricingRow } from "./rpc.ts"
10
+
11
+ export interface TableModel {
12
+ readonly id: string
13
+ readonly modelID?: string
14
+ readonly providerID: string
15
+ readonly name: string
16
+ readonly cost: readonly CostTier[] | undefined
17
+ }
18
+
19
+ /** Everything the TUI needs to draw the table, minus the pre-rendered text. */
20
+ export interface PricingDataset {
21
+ readonly title: string
22
+ readonly meta: string
23
+ readonly footnote: string
24
+ readonly rows: readonly PricingRow[]
25
+ }
26
+
27
+ export type SortDirection = "asc" | "desc"
28
+
29
+ /** The active sort column and direction. Model groups sort together. */
30
+ export interface SortState {
31
+ readonly key: keyof PricingRow
32
+ readonly direction: SortDirection
33
+ }
34
+
35
+ /** One header cell, including the gap that follows it. */
36
+ export interface PricingTableColumn {
37
+ readonly key: keyof PricingRow
38
+ /** Header text plus the direction indicator when this column is sorted. */
39
+ readonly label: string
40
+ /** Cell width, not counting the inter-column gap. */
41
+ readonly width: number
42
+ readonly align: "left" | "right"
43
+ /** Padded header cell followed by `COLUMN_GAP` (empty on the last cell). */
44
+ readonly header: string
45
+ /** Set only for the active sort column. */
46
+ readonly direction?: SortDirection
47
+ }
48
+
49
+ export interface PricingTableLayout {
50
+ /** Header row, already padded to the chosen column widths. */
51
+ readonly header: string
52
+ /** A rule under the header, one character per header cell. */
53
+ readonly separator: string
54
+ /** One aligned line per pricing row. */
55
+ readonly lines: readonly string[]
56
+ /** Per-column layout, used by the TUI to make headers clickable. */
57
+ readonly columns: readonly PricingTableColumn[]
58
+ }
59
+
60
+ /** Fallback width used by the RPC `text` field and `dialog.alert`. */
61
+ export const DEFAULT_TABLE_WIDTH = 108
62
+
63
+ const TITLE = "Model pricing & Artificial Analysis Intelligence Index"
64
+ const FOOTNOTE =
65
+ "AA: * bundled v5 snapshot; ~ other match note (variant or entry name). Attribution: https://artificialanalysis.ai/"
66
+ const COLUMN_GAP = " "
67
+
68
+ interface Column {
69
+ readonly key: keyof PricingRow
70
+ readonly header: string
71
+ readonly align: "left" | "right"
72
+ readonly min: number
73
+ readonly max: number
74
+ }
75
+
76
+ const COLUMNS: readonly Column[] = [
77
+ { key: "model", header: "MODEL", align: "left", min: 10, max: 56 },
78
+ { key: "provider", header: "PROVIDER", align: "left", min: 6, max: 14 },
79
+ { key: "tier", header: "TIER", align: "left", min: 4, max: 9 },
80
+ { key: "input", header: "IN $", align: "right", min: 3, max: 12 },
81
+ { key: "output", header: "OUT $", align: "right", min: 3, max: 12 },
82
+ { key: "cache", header: "CACHE R/W $", align: "right", min: 7, max: 18 },
83
+ { key: "aa", header: "AA", align: "right", min: 2, max: 6 },
84
+ ]
85
+
86
+ function modelKey(model: TableModel): string {
87
+ return `${model.providerID}/${model.modelID || model.id}`
88
+ }
89
+
90
+ /** Keep one physical line per cell. */
91
+ function oneLine(value: unknown): string {
92
+ return String(value ?? "")
93
+ .replace(/\r\n|\r|\n/g, " ")
94
+ .replace(/\s+/g, " ")
95
+ .trim()
96
+ }
97
+
98
+ /** Drop the currency sign; the column header already states the unit. */
99
+ function price(value: string): string {
100
+ return value.startsWith("$") ? value.slice(1) : value
101
+ }
102
+
103
+ function aaCell(score: ReturnType<typeof lookupScore>): string {
104
+ if (score?.score == null) return "n/a"
105
+ const value = String(Math.round(score.score * 10) / 10)
106
+ if (!score.note) return value
107
+ return `${value}${score.note.includes("[snapshot]") ? "*" : "~"}`
108
+ }
109
+
110
+ /**
111
+ * Build the grouped table rows. The model and provider are written once per
112
+ * model; extra context tiers continue underneath with those columns blank.
113
+ */
114
+ export function pricingData(
115
+ models: readonly TableModel[],
116
+ aa: AAFetched,
117
+ baseNames: ReadonlyMap<string, string>,
118
+ decorateNames: boolean,
119
+ ): PricingDataset {
120
+ const rows: PricingRow[] = []
121
+
122
+ for (const model of models) {
123
+ const key = modelKey(model)
124
+ const baseName = decorateNames ? (baseNames.get(key) ?? model.name) : model.name
125
+ const score = lookupScore(aa.scores, model.id, baseName)
126
+ const aaValue = aaCell(score)
127
+
128
+ let tiers: ReturnType<typeof formatCost> = []
129
+ try {
130
+ tiers = formatCost(model.cost)
131
+ } catch (error) {
132
+ console.warn(`[model-price-intel] could not format cost for ${key}:`, error)
133
+ }
134
+
135
+ if (tiers.length === 0) {
136
+ rows.push({
137
+ model: oneLine(baseName),
138
+ provider: oneLine(model.providerID),
139
+ tier: "base",
140
+ input: "n/a",
141
+ output: "—",
142
+ cache: "—",
143
+ aa: aaValue,
144
+ })
145
+ continue
146
+ }
147
+
148
+ tiers.forEach((tier, index) => {
149
+ const first = index === 0
150
+ rows.push({
151
+ model: first ? oneLine(baseName) : "",
152
+ provider: first ? oneLine(model.providerID) : "",
153
+ tier: oneLine(tier.tierLabel ?? "base"),
154
+ input: tier.free ? "free" : price(tier.input),
155
+ output: tier.free ? "—" : price(tier.output),
156
+ cache: tier.free ? "—" : `${price(tier.cacheRead)}/${price(tier.cacheWrite)}`,
157
+ aa: first ? aaValue : "",
158
+ })
159
+ })
160
+ }
161
+
162
+ return {
163
+ title: TITLE,
164
+ meta: `Prices per 1M tokens (USD) from the live OpenCode model registry.\nScores: Artificial Analysis Intelligence Index v${aa.version}.\nSource: ${aa.source}, fetched ${aa.fetchedAt.slice(0, 10)}.`,
165
+ footnote: FOOTNOTE,
166
+ rows,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Toggle a sort: the first click on a column sorts ascending, a second click
172
+ * on the same column flips the direction, and a different column starts over.
173
+ */
174
+ export function nextSort(current: SortState | undefined, key: keyof PricingRow): SortState {
175
+ if (current?.key === key) {
176
+ return { key, direction: current.direction === "asc" ? "desc" : "asc" }
177
+ }
178
+ return { key, direction: "asc" }
179
+ }
180
+
181
+ interface RowGroup {
182
+ readonly rows: PricingRow[]
183
+ readonly index: number
184
+ }
185
+
186
+ /**
187
+ * Split the flat row list into model groups. A non-empty model starts a group;
188
+ * blank model cells are continuation tiers and stay with the group above.
189
+ */
190
+ function groupRows(rows: readonly PricingRow[]): RowGroup[] {
191
+ const groups: RowGroup[] = []
192
+ for (const row of rows) {
193
+ if (row.model !== "" || groups.length === 0) {
194
+ groups.push({ rows: [row], index: groups.length })
195
+ } else {
196
+ groups[groups.length - 1].rows.push(row)
197
+ }
198
+ }
199
+ return groups
200
+ }
201
+
202
+ type Sortable =
203
+ | { readonly kind: "text"; readonly text: string }
204
+ | { readonly kind: "number"; readonly values: readonly number[] }
205
+ | { readonly kind: "missing" }
206
+
207
+ function sortable(value: string, key: keyof PricingRow): Sortable {
208
+ const text = value.trim()
209
+ if (key === "model" || key === "provider" || key === "tier") {
210
+ return { kind: "text", text }
211
+ }
212
+ if (text === "" || text === "—" || text === "?" || text.toLowerCase() === "n/a") {
213
+ return { kind: "missing" }
214
+ }
215
+
216
+ // Cache cells are "read/write", so compare the read price first and use the
217
+ // write price as a tie-breaker. Other numeric cells are a single value.
218
+ const parts = key === "cache" ? text.split("/") : [text]
219
+ const values: number[] = []
220
+ for (const part of parts) {
221
+ const token = part.trim().replace(/^\$/, "").replace(/^</, "")
222
+ if (token.toLowerCase() === "free") {
223
+ values.push(0)
224
+ continue
225
+ }
226
+ const match = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/i.exec(token)
227
+ const number = match ? Number(match[0]) : Number.NaN
228
+ if (!Number.isFinite(number)) return { kind: "missing" }
229
+ values.push(number)
230
+ }
231
+ return { kind: "number", values }
232
+ }
233
+
234
+ function compareText(left: string, right: string): number {
235
+ const a = left.toLowerCase()
236
+ const b = right.toLowerCase()
237
+ if (a < b) return -1
238
+ if (a > b) return 1
239
+ if (left === right) return 0
240
+ return left < right ? -1 : 1
241
+ }
242
+
243
+ function compareSortable(left: Sortable, right: Sortable): number {
244
+ if (left.kind === "text" && right.kind === "text") {
245
+ return compareText(left.text, right.text)
246
+ }
247
+ if (left.kind === "number" && right.kind === "number") {
248
+ const length = Math.max(left.values.length, right.values.length)
249
+ for (let index = 0; index < length; index++) {
250
+ const a = left.values[index]
251
+ const b = right.values[index]
252
+ if (a === b) continue
253
+ if (a === undefined) return -1
254
+ if (b === undefined) return 1
255
+ return a < b ? -1 : 1
256
+ }
257
+ return 0
258
+ }
259
+ return 0
260
+ }
261
+
262
+ /**
263
+ * Sort model groups by one column while keeping continuation tiers attached.
264
+ * Sorting is stable: registry order survives ties in both directions, and
265
+ * missing/unpriced values sort last regardless of direction.
266
+ */
267
+ export function sortRows(
268
+ rows: readonly PricingRow[],
269
+ key: keyof PricingRow,
270
+ direction: SortDirection = "asc",
271
+ ): PricingRow[] {
272
+ const groups = groupRows(rows)
273
+ groups.sort((left, right) => {
274
+ const a = sortable(left.rows[0][key], key)
275
+ const b = sortable(right.rows[0][key], key)
276
+ if (a.kind === "missing" || b.kind === "missing") {
277
+ if (a.kind === b.kind) return left.index - right.index
278
+ return a.kind === "missing" ? 1 : -1
279
+ }
280
+ const comparison = compareSortable(a, b)
281
+ if (comparison !== 0) {
282
+ return direction === "asc" ? comparison : -comparison
283
+ }
284
+ return left.index - right.index
285
+ })
286
+ return groups.flatMap((group) => group.rows)
287
+ }
288
+
289
+ function clamp(value: number, min: number, max: number): number {
290
+ return Math.max(min, Math.min(max, value))
291
+ }
292
+
293
+ /** Shorten a cell to the column width, keeping one cell for the ellipsis. */
294
+ function truncate(value: string, width: number): string {
295
+ if (value.length <= width) return value
296
+ if (width <= 1) return "…".slice(0, width)
297
+ return `${value.slice(0, width - 1)}…`
298
+ }
299
+
300
+ function fit(value: string, width: number, align: "left" | "right"): string {
301
+ const text = truncate(oneLine(value), width)
302
+ const padding = " ".repeat(Math.max(0, width - text.length))
303
+ return align === "right" ? `${padding}${text}` : `${text}${padding}`
304
+ }
305
+
306
+ function headerLabel(column: Column, sort: SortState | undefined): string {
307
+ if (sort?.key !== column.key) return column.header
308
+ return `${column.header} ${sort.direction === "asc" ? "▲" : "▼"}`
309
+ }
310
+
311
+ function columnWidths(
312
+ rows: readonly PricingRow[],
313
+ width: number,
314
+ sort: SortState | undefined,
315
+ ): Record<keyof PricingRow, number> {
316
+ const natural = {} as Record<keyof PricingRow, number>
317
+ for (const column of COLUMNS) {
318
+ let widest = headerLabel(column, sort).length
319
+ for (const row of rows) widest = Math.max(widest, row[column.key].length)
320
+ natural[column.key] = clamp(widest, column.min, column.max)
321
+ }
322
+
323
+ // Give the leftover space to the model column so the table spans the dialog
324
+ // instead of leaving a ragged right edge. When space is tight, truncating the
325
+ // model name is preferable to overlapping the numeric columns.
326
+ const gaps = COLUMN_GAP.length * (COLUMNS.length - 1)
327
+ const fixed = COLUMNS.reduce(
328
+ (total, column) => total + (column.key === "model" ? 0 : natural[column.key]),
329
+ gaps,
330
+ )
331
+ const available = Math.max(0, width - fixed)
332
+ natural.model = clamp(available, COLUMNS[0].min, COLUMNS[0].max)
333
+
334
+ return natural
335
+ }
336
+
337
+ /** Lay the rows out as a monospace table for the given total width. */
338
+ export function formatRows(
339
+ rows: readonly PricingRow[],
340
+ width: number = DEFAULT_TABLE_WIDTH,
341
+ sort?: SortState,
342
+ ): PricingTableLayout {
343
+ const safeWidth = Math.max(24, Math.floor(width))
344
+ const widths = columnWidths(rows, safeWidth, sort)
345
+ const line = (cells: Readonly<Record<keyof PricingRow, string>>, trim: boolean): string => {
346
+ const rendered = COLUMNS.map((column) =>
347
+ fit(cells[column.key], widths[column.key], column.align),
348
+ ).join(COLUMN_GAP)
349
+ return trim ? rendered.trimEnd() : rendered
350
+ }
351
+
352
+ const columns = COLUMNS.map((column, index): PricingTableColumn => {
353
+ const label = headerLabel(column, sort)
354
+ const gap = index === COLUMNS.length - 1 ? "" : COLUMN_GAP
355
+ return {
356
+ key: column.key,
357
+ label: oneLine(label),
358
+ width: widths[column.key],
359
+ align: column.align,
360
+ header: `${fit(label, widths[column.key], column.align)}${gap}`,
361
+ direction: sort?.key === column.key ? sort.direction : undefined,
362
+ }
363
+ })
364
+ const header = columns.map((column) => column.header).join("")
365
+
366
+ return {
367
+ header,
368
+ separator: "─".repeat(header.length),
369
+ lines: rows.map((row) => line(row, true)),
370
+ columns,
371
+ }
372
+ }
373
+
374
+ /** Render the full text table used by the RPC fallback and tests. */
375
+ export function renderTable(
376
+ data: PricingDataset,
377
+ width: number = DEFAULT_TABLE_WIDTH,
378
+ ): string {
379
+ const layout = formatRows(data.rows, width)
380
+ return [
381
+ data.title,
382
+ "",
383
+ data.meta,
384
+ "",
385
+ layout.header,
386
+ layout.separator,
387
+ ...layout.lines,
388
+ "",
389
+ data.footnote,
390
+ ].join("\n")
391
+ }
392
+
393
+ /**
394
+ * Backwards-compatible text helper: build the dataset and render it at the
395
+ * default width.
396
+ */
397
+ export function buildTable(
398
+ models: readonly TableModel[],
399
+ aa: AAFetched,
400
+ baseNames: ReadonlyMap<string, string>,
401
+ decorateNames: boolean,
402
+ ): string {
403
+ return renderTable(pricingData(models, aa, baseNames, decorateNames))
404
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * TUI part: `/model-pricing` and `/prices` open a native dialog table.
3
+ *
4
+ * A dialog (rather than a session panel) keeps the right side of the session
5
+ * usable and inherits the built-in dialog behavior: scrolling, Escape,
6
+ * backdrop-click to close, and modal focus. The table rows come from the
7
+ * server over RPC because only the server owns the live registry and AA cache.
8
+ */
9
+
10
+ import { Plugin, usePlugin } from "@opencode/plugin/tui"
11
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
12
+ import { useTerminalDimensions } from "@opentui/solid"
13
+ import { For, Show, createMemo, createSignal } from "solid-js"
14
+ import { ModelPricing, type PricingTable } from "./rpc.ts"
15
+ import {
16
+ DEFAULT_TABLE_WIDTH,
17
+ formatRows,
18
+ nextSort,
19
+ sortRows,
20
+ type SortState,
21
+ } from "./table.ts"
22
+
23
+ function PricingDialog(props: {
24
+ readonly table: () => PricingTable | undefined
25
+ readonly onClose: () => void
26
+ }) {
27
+ const context = usePlugin()
28
+ const dimensions = useTerminalDimensions()
29
+ const theme = context.theme.surface("dialog")
30
+ const width = () => Math.max(40, Math.min(DEFAULT_TABLE_WIDTH, dimensions().width - 10))
31
+ const maxHeight = () => Math.max(5, Math.floor(dimensions().height / 2) - 6)
32
+ const [sort, setSort] = createSignal<SortState | undefined>()
33
+ const sortBy = (key: SortState["key"]) => setSort((current) => nextSort(current, key))
34
+
35
+ const focus = (scroll: ScrollBoxRenderable) => {
36
+ // The dialog has no search input, so give the table keyboard focus.
37
+ setTimeout(() => {
38
+ if (!scroll.isDestroyed) scroll.focus()
39
+ }, 1)
40
+ }
41
+
42
+ return (
43
+ <box flexDirection="column" paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
44
+ <box flexDirection="row" justifyContent="space-between">
45
+ <text attributes={TextAttributes.BOLD} fg={theme.text.base}>
46
+ Model pricing &amp; AA Intelligence Index
47
+ </text>
48
+ <text fg={theme.text.muted} onMouseUp={props.onClose}>
49
+ esc
50
+ </text>
51
+ </box>
52
+ <Show
53
+ when={props.table()}
54
+ fallback={<text fg={theme.text.muted}>Loading model pricing…</text>}
55
+ >
56
+ {(table: () => PricingTable) => {
57
+ const layout = createMemo(() => {
58
+ const state = sort()
59
+ const rows = state
60
+ ? sortRows(table().rows, state.key, state.direction)
61
+ : table().rows
62
+ return formatRows(rows, width(), state)
63
+ })
64
+ return (
65
+ <>
66
+ <text fg={theme.text.muted} wrapMode="word">
67
+ {table().meta}
68
+ </text>
69
+ <box flexDirection="column">
70
+ <box flexDirection="row" flexShrink={0}>
71
+ <For each={layout().columns}>
72
+ {(column) => (
73
+ <text
74
+ attributes={TextAttributes.BOLD}
75
+ fg={column.direction ? theme.text.base : theme.text.muted}
76
+ wrapMode="none"
77
+ flexShrink={0}
78
+ onMouseUp={(event) => {
79
+ // The host closes on a backdrop click; a header click
80
+ // must only change the sort order.
81
+ event.preventDefault()
82
+ event.stopPropagation()
83
+ sortBy(column.key)
84
+ }}
85
+ >
86
+ {column.header}
87
+ </text>
88
+ )}
89
+ </For>
90
+ </box>
91
+ <text fg={theme.text.faint} wrapMode="none">
92
+ {layout().separator}
93
+ </text>
94
+ <scrollbox
95
+ ref={focus}
96
+ maxHeight={maxHeight()}
97
+ scrollX
98
+ scrollbarOptions={{ visible: true }}
99
+ horizontalScrollbarOptions={{ visible: false }}
100
+ >
101
+ <Show
102
+ when={layout().lines.length > 0}
103
+ fallback={<text fg={theme.text.muted}>No models available.</text>}
104
+ >
105
+ <For each={layout().lines}>
106
+ {(line) => (
107
+ <text fg={theme.text.base} wrapMode="none">
108
+ {line}
109
+ </text>
110
+ )}
111
+ </For>
112
+ </Show>
113
+ </scrollbox>
114
+ </box>
115
+ <text fg={theme.text.muted} wrapMode="word">
116
+ {table().footnote}
117
+ </text>
118
+ </>
119
+ )
120
+ }}
121
+ </Show>
122
+ </box>
123
+ )
124
+ }
125
+
126
+ function message(error: unknown): string {
127
+ return error instanceof Error ? error.message : String(error)
128
+ }
129
+
130
+ export default Plugin.define({
131
+ id: "model-price-intel.tui",
132
+ async setup(context) {
133
+ const pricing = context.client.rpc(ModelPricing)
134
+ const [table, setTable] = createSignal<PricingTable | undefined>()
135
+ let open = false
136
+ // Ignore a response that belongs to a dialog the user already closed.
137
+ let request = 0
138
+
139
+ const close = () => {
140
+ request++
141
+ open = false
142
+ setTable(undefined)
143
+ }
144
+
145
+ const toggle = async () => {
146
+ if (open) {
147
+ open = false
148
+ request++
149
+ setTable(undefined)
150
+ context.ui.dialog.clear()
151
+ return
152
+ }
153
+
154
+ let location
155
+ try {
156
+ location = context.location ?? context.data.location.default()
157
+ } catch (error) {
158
+ context.ui.toast.show({
159
+ title: "Model pricing",
160
+ message: message(error),
161
+ variant: "error",
162
+ })
163
+ return
164
+ }
165
+
166
+ open = true
167
+ const id = ++request
168
+ setTable(undefined)
169
+
170
+ try {
171
+ context.ui.dialog.show(
172
+ () => <PricingDialog table={table} onClose={close} />,
173
+ close,
174
+ )
175
+ // `show` starts a fresh dialog at the default size, so apply the larger
176
+ // presentation afterwards.
177
+ context.ui.dialog.set({ size: "xlarge", centered: true })
178
+
179
+ const result = (await pricing.table({}, { location })) as PricingTable
180
+ if (!open || id !== request) return
181
+ setTable(result)
182
+ } catch (error) {
183
+ if (!open || id !== request) return
184
+ close()
185
+ context.ui.dialog.clear()
186
+ context.ui.toast.show({
187
+ title: "Model pricing",
188
+ message: message(error),
189
+ variant: "error",
190
+ })
191
+ }
192
+ }
193
+
194
+ context.keymap.layer(() => ({
195
+ mode: "global",
196
+ commands: [
197
+ {
198
+ id: "model-price-intel.prices",
199
+ title: "Model pricing",
200
+ group: "Model price intel",
201
+ slash: { name: "model-pricing", aliases: ["prices"] },
202
+ run: toggle,
203
+ },
204
+ ],
205
+ }))
206
+ },
207
+ })