model-compare-opencode-plugin 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,9 @@ Each priority is a live alias inside the existing OpenRouter provider — no new
10
10
  | `openrouter/model-compare/price` | best price pick |
11
11
  | `openrouter/model-compare/quality` | best quality pick |
12
12
 
13
- The alias re-points at the current pick while OpenCode runs (refresh every 30 minutes, data cache 6 hours). It also pins the catalog's scored reasoning effort on each request, so the model runs at the depth its `quality` score was measured at. OpenCode's model picker shows it as `Model Compare: <priority> → <pick> · <effort> [alt <effort> <index>]`, with `(fallback)` while the fallback is serving, `(unavailable)` when the alias is disabled, and the `alt` part naming the best measured effort above the current one that OpenRouter can actually select.
13
+ The alias re-points at the current pick while OpenCode runs (refresh every 30 minutes, data cache 6 hours). It also pins the catalog's scored reasoning effort on each request, so the model runs at the depth its `quality` score was measured at. OpenCode's model picker shows it as `Model Compare: <priority> → <pick>`, with `(fallback)` while the fallback is serving and `(unavailable)` when the alias is disabled. The alias name deliberately carries no catalog effort or alternative: OpenCode shows the selected variant itself, and a baked-in depth would not follow your `#variant` choice.
14
+
15
+ **Effort vs variant.** The model-compare catalog and the request body call this reasoning level the *effort* (`quality_effort`, pinned as `reasoning.effort`); OpenCode calls the selectable model option a *variant* (`provider/model#variant`). The alias byline shows only the pick; the report dialog's status line uses “variant” and shows the highlighted model's variant and its best higher variant.
14
16
 
15
17
 
16
18
  ## Install
@@ -69,7 +71,7 @@ opencode run --model "openrouter/model-compare/price" "msg"
69
71
  Commands:
70
72
 
71
73
  - `/use-best [priority]` — switch the session to `openrouter/model-compare/<p>` (defaults to `defaultPriority`). Refuses a disabled alias.
72
- - `/model-compare` — top 5 per priority with scores and data age; each line also shows the reasoning effort behind `quality` (`· high`) and the best selectable measured alternative (`· alt max 56.0`). Priorities whose `weights` override actually takes effect are labelled `(custom weights)` with recomputed scores, and each alias's current pick is marked `← current pick`. The output is a synthetic message (`resume: false`): it costs no model turn and, in a headless session, surfaces in the transcript at the next turn. In the TUI it opens the report dialog instead, with `effort` and `alt` columns.
74
+ - `/model-compare` — top 5 per priority with scores and data age; each line also shows the reasoning effort behind `quality` (`· high`) and the best selectable measured alternative (`· alt max 56.0`). Priorities whose `weights` override actually takes effect are labelled `(custom weights)` with recomputed scores, and each alias's current pick is marked `← current pick`. The output is a synthetic message (`resume: false`): it costs no model turn and, in a headless session, surfaces in the transcript at the next turn. In the TUI it opens the report dialog instead, with model, quality and price columns and a status line that follows the cursor and shows the highlighted model's variant and its best higher variant.
73
75
 
74
76
 
75
77
  ### Keyboard shortcuts
@@ -78,14 +80,14 @@ Commands:
78
80
  |---|---|
79
81
  | `alt+m` | picks dialog — all three priorities with their current picks |
80
82
  | `alt+e` | toast the last response's served endpoint, alias responses only (`!` prefix flags a downgrade — not in the steering order) |
81
- | `alt+shift+m` | report dialog — the top-5 table; `return` switches the session to the highlighted model |
83
+ | `alt+shift+m` | report dialog — the top-5 table with a cursor-following variant status line; `return` switches the session to the highlighted model |
82
84
  | `up` / `down` | move the dialog cursor |
83
85
  | `return` | select the highlighted row (picks dialog: switch to that priority) |
84
86
  | `esc` | close the dialog |
85
87
 
86
88
  Both dialogs open anywhere — including the opening screen, where no session exists yet. There, picking a row (a priority in the picks dialog, a model in the report) creates a new session already bound to that model and opens it; merely opening a dialog never creates a session. With a session open, a pick switches that session as before. The TUI has no `/use-best` keybinding: the slash command already covers switching.
87
89
 
88
- Status: the footer shows `MC: balanced → <pick>` (balanced only — `disabled`/`unavailable`/`(fallback)` as applicable; all three priorities live in the picks dialog). The picker's alias name carries the effort and alternative; the footer stays a compact served-model line. Toasts announce `/use-best` outcomes and pick changes (never for the first observation after load), the `alt+e` served endpoint, and the cannot-steer notice — see [Steering and served endpoints](#steering-and-served-endpoints). Headless, command feedback is synthetic transcript messages at the next turn; the steering toasts have no headless surface.
90
+ Status: the footer shows `MC: balanced → <pick>` (balanced only — `disabled`/`unavailable`/`(fallback)` as applicable; all three priorities live in the picks dialog). The picker's alias name carries only the pick (plus the fallback marker); the report dialog's status line carries the variant and best higher variant. The footer stays a compact served-model line. Toasts announce `/use-best` outcomes and pick changes (never for the first observation after load), the `alt+e` served endpoint, and the cannot-steer notice — see [Steering and served endpoints](#steering-and-served-endpoints). Headless, command feedback is synthetic transcript messages at the next turn; the steering toasts have no headless surface.
89
91
 
90
92
 
91
93
  ## Configuration
@@ -201,11 +203,13 @@ Run a checkout instead of the npm package:
201
203
  OpenCode's local loader resolves a directory target strictly as `<dir>/index.*` — `exports` are ignored for local paths, and file targets are rejected. `src/` has the physical entry files (`src/index.ts`, `src/tui.tsx`); the plugin's imports resolve from the repo's `node_modules` (`npm ci` once at the repo root). The repo ships this setup as [`.test/`](.test/README.md). Two caveats: the watcher only watches package-style entries, so source edits need an OpenCode restart; and a bare package name is always npm-installed by OpenCode — it is not read from `node_modules` — so local testing must use the directory form.
202
204
 
203
205
  ```sh
206
+ npm run dev # launch the TUI against this checkout
204
207
  npm test # unit + vendored-conformance suites (hermetic)
205
208
  npm run build # regenerate dist/tui.js from src/tui.tsx
206
209
  MC_INTEGRATION=1 npx vitest run tests/integration/ # live-server suite (opt-in)
207
210
  ```
208
211
 
212
+ - `npm run dev` writes a scratch project under `/tmp/opencode/model-compare-dev`, points its plugin entry at this checkout's `src/`, hides the global OpenCode config for the run (so an installed `model-compare-opencode-plugin` cannot shadow the local copy), and runs `opencode --standalone` there. The isolation is config-only: the scratch project shares your OpenCode data directory (auth, plugin state, sessions), so alias switches made in the dev session rewrite your live aliases. Pass extra flags after `--` (for example `npm run dev -- --auto`), or use `--print-config` to inspect the generated project config. `MC_DEV_DIR` overrides the scratch location. Source edits need an OpenCode restart.
209
213
  - Local development loads `src/` directly and needs no build; the *published* package does — OpenCode's Solid JSX transform skips `node_modules` paths, so the npm-installed raw TSX cannot load (see [Install](#install) and the troubleshooting entry). `npm run build` precompiles `src/tui.tsx` through the same Babel pipeline as the host's loader (taken from the pinned `@opentui/solid`) into `dist/tui.js`, rebasing relative imports from `src/`-relative to `dist/`-relative; `prepublishOnly` runs it, and CI builds before the packaging check. `dist/` is regenerated, never committed.
210
214
  - The integration files skip unless `MC_INTEGRATION=1`, so plain `npm test` stays hermetic. The suite drives a real `opencode serve` on 127.0.0.1:14721 and needs the `opencode` CLI and `python3` (skips cleanly when either is missing); one case needs network access and the `sqlite3` CLI.
211
215
  - `node scripts/pack-smoke.mjs` (also in CI, after `node scripts/build-tui.mjs`) packs the plugin and checks the tarball against the packaging list (files, `exports`, relative imports, and that the TUI artifact is precompiled — no react import). It does **not** load the package in OpenCode — install the tarball into a scratch project and confirm OpenCode loads it before publishing.
package/dist/tui.js CHANGED
@@ -139,6 +139,68 @@ export function cappedRowsHeight(contentLines, terminalHeight) {
139
139
  return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6));
140
140
  }
141
141
 
142
+ // The terminal display width of one code point, in columns: 2 for the ranges
143
+ // terminals render double-width — East Asian Wide/Fullwidth (CJK, Hangul) and
144
+ // the emoji/symbol planes — and 1 for everything else. Deliberately
145
+ // CONSERVATIVE: it never undercounts the wide content the catalog can carry.
146
+ // Blocks that are only partially wide are covered at BLOCK width — the
147
+ // narrow tails inside the SMP symbol planes (alchemical symbols, chess)
148
+ // count 2 anyway — and variation selector 16 (emoji presentation) counts 1
149
+ // itself, so a base+VS16 sequence such as ☺️ always totals ≥ 2 even when the
150
+ // base alone would not. Overcounting only makes a clamp cut
151
+ // earlier; it can never let a line render wider than its budget. (Genuinely
152
+ // East-Asian-Ambiguous characters OUTSIDE these ranges — ±, an en dash,
153
+ // Greek — count 1; a terminal font that renders those wide is beyond the
154
+ // clamp's scope. Catalog text is sanitized to a single line by
155
+ // sanitizeCatalogText, so width is the only wrap risk left.)
156
+ function charWidth(codePoint) {
157
+ // Variation selectors (VS1–VS16) are zero-width in reality. VS16 forces
158
+ // emoji presentation on the preceding base, so the pair renders 2 columns:
159
+ // counting the selector itself as 1 keeps any base+VS16 sequence at ≥ 2
160
+ // even when the base is NOT in a wide block (⌨️ = U+2328+VS16), while a
161
+ // wide base + VS16 overcounts by one — the safe direction.
162
+ if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) return 1;
163
+ if (codePoint >= 0x1100 && codePoint <= 0x115f ||
164
+ // Hangul Jamo
165
+ codePoint >= 0x2e80 && codePoint <= 0xa4cf ||
166
+ // CJK radicals … Yi
167
+ codePoint >= 0xa960 && codePoint <= 0xa97f ||
168
+ // Hangul Jamo Ext. A
169
+ codePoint >= 0xac00 && codePoint <= 0xd7a3 ||
170
+ // Hangul syllables
171
+ codePoint >= 0xf900 && codePoint <= 0xfaff ||
172
+ // CJK compat ideographs
173
+ codePoint >= 0xfe10 && codePoint <= 0xfe19 ||
174
+ // vertical forms
175
+ codePoint >= 0xfe30 && codePoint <= 0xfe6f ||
176
+ // CJK compat forms
177
+ codePoint >= 0xff00 && codePoint <= 0xff60 ||
178
+ // fullwidth forms
179
+ codePoint >= 0xffe0 && codePoint <= 0xffe6 ||
180
+ // fullwidth signs
181
+ codePoint >= 0x2600 && codePoint <= 0x27bf ||
182
+ // misc symbols + dingbats (☺ ✨ ⚠ — ambiguous, commonly 2 under emoji presentation)
183
+ codePoint >= 0x2b00 && codePoint <= 0x2bff ||
184
+ // misc symbols and arrows (⭐)
185
+ codePoint >= 0x1f000 && codePoint <= 0x1faff ||
186
+ // the SMP symbol planes: mahjong/cards, enclosed ideographs, pictographs, transport (🚀), supplemental, symbols ext-A
187
+ codePoint >= 0x20000 && codePoint <= 0x2fffd ||
188
+ // CJK ext B+
189
+ codePoint >= 0x30000 && codePoint <= 0x3fffd // CJK ext G+
190
+ ) {
191
+ return 2;
192
+ }
193
+ return 1;
194
+ }
195
+
196
+ // A string's terminal display width (the sum of its code points' widths).
197
+ // Exported for the unit rig, which asserts rendered lines against it.
198
+ export function strWidth(value) {
199
+ let width = 0;
200
+ for (const char of value) width += charWidth(char.codePointAt(0));
201
+ return width;
202
+ }
203
+
142
204
  // One report entry's defensive normalization, shared by the two paths that
143
205
  // carry report data across a trust boundary (the /model-compare command's
144
206
  // event and the TUI's direct RPC call): an entry missing any identity field
@@ -629,14 +691,26 @@ export default Plugin.define({
629
691
  })();
630
692
  });
631
693
  });
694
+ _$insert(_el$2, () => {
695
+ const row = selectable[cursor()];
696
+ return row.status === undefined ? null : (() => {
697
+ var _el$18 = _$createElement("box"),
698
+ _el$19 = _$createElement("text");
699
+ _$insertNode(_el$18, _el$19);
700
+ _$setProp(_el$18, "paddingLeft", 4);
701
+ _$setProp(_el$19, "fg", muted);
702
+ _$insert(_el$19, () => row.status);
703
+ return _el$18;
704
+ })();
705
+ }, _el$8);
632
706
  _$insert(_el$2, () => footerLines.map(line => (() => {
633
- var _el$18 = _$createElement("box"),
634
- _el$19 = _$createElement("text");
635
- _$insertNode(_el$18, _el$19);
636
- _$setProp(_el$18, "paddingLeft", 4);
637
- _$setProp(_el$19, "fg", muted);
638
- _$insert(_el$19, line);
639
- return _el$18;
707
+ var _el$20 = _$createElement("box"),
708
+ _el$21 = _$createElement("text");
709
+ _$insertNode(_el$20, _el$21);
710
+ _$setProp(_el$20, "paddingLeft", 4);
711
+ _$setProp(_el$21, "fg", muted);
712
+ _$insert(_el$21, line);
713
+ return _el$20;
640
714
  })()), _el$8);
641
715
  _$insertNode(_el$8, _el$9);
642
716
  _$setProp(_el$8, "paddingLeft", 4);
@@ -696,43 +770,76 @@ export default Plugin.define({
696
770
  notify("no ranking data available yet", "error");
697
771
  return;
698
772
  }
699
- // The approved table: one dim column header at the top (directly above
700
- // the first section), section headings with a blank line above all but
701
- // the first, and right-justified quality/price columns. No score
702
- // column — the transcript's text report keeps it. Column widths are
703
- // clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
704
- // to their clamped width, so with the name clamp below the row budget
705
- // holds even for hostile/absurd column strings: what the clamp
706
- // guarantees is that a row and the header stay within the dialog's
707
- // fixed 52-char content width (hostile values get truncated rather
708
- // than pushed into wrapping — the 40-char rankless fallback label did
709
- // exactly that before the name clamp).
773
+ // The approved table now carries three columns: model name, quality
774
+ // and price. The scored variant (the catalog's effort) and the best
775
+ // higher variant moved into a cursor-following status line below the
776
+ // list (see ListRow.status), so the table stays readable. No score
777
+ // column — the transcript's text report keeps it. Column widths are clamped
778
+ // (qual ≤ 5, $ in/out ≤ 13) and oversized cells are truncated to their
779
+ // clamped width, so with the name clamp below the row budget holds
780
+ // even for hostile/absurd column strings: what the clamp guarantees is
781
+ // that a row and the header stay within the dialog's fixed 52-char
782
+ // content width (hostile values get truncated rather than pushed into
783
+ // wrapping — the 40-char rankless fallback label did exactly that
784
+ // before the name clamp).
710
785
  const labels = data.entries.map(entry => `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${entry.fromFallback ? " (fallback)" : ""}`);
711
786
  const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]));
712
- const colW = (values, floor, cap) => Math.min(cap, Math.max(floor, ...values.map(value => value.length)));
787
+ const colW = (values, floor, cap) => Math.min(cap, Math.max(floor, ...values.map(strWidth)));
713
788
  // Quality is a one-decimal AA index (max "100.0"); the caps leave the
714
789
  // 52-char row budget intact (see `fixed` below) while fitting real
715
- // values: "minimal" (effort), "minimal 100.0" (alt), "10.00/100.00".
790
+ // values: "10.00/100.00" (price).
716
791
  const qualW = colW(data.entries.map(entry => entry.quality), "qual".length, 5);
717
- // Catalog v5: the effort the quality was measured at and the best
718
- // selectable measured alternative ("max 56.0"). A missing value is "-".
719
- const effortW = colW(data.entries.map(entry => entry.effort || "-"), "effort".length, 7);
720
- const altW = colW(data.entries.map(entry => entry.alternative || "-"), "alt".length, 13);
792
+ // The status line's variant and best-higher-variant cells keep their
793
+ // old column caps: the status box is a direct child of the 60-wide
794
+ // dialog with only its own 4-char left inset, so one text line has 56
795
+ // columns — the worst case, 9 ("variant: ") + 7 + 24 (" · best higher
796
+ // variant: ") + 13 = 53, leaves 3 columns of slack. Clamping each cell
797
+ // (in display columns, see cell) keeps hostile catalog strings from
798
+ // wrapping.
799
+ const effortW = 7;
800
+ const altW = 13;
721
801
  const priceW = colW(data.entries.map(entry => entry.price), "$ in/out".length, 13);
722
- // A cell wider than its clamped column is truncated to fit.
723
- const cell = (value, width) => value.length > width ? value.slice(0, width - 1) + "…" : value;
802
+ // A cell wider than its clamped column is truncated to fit — measured
803
+ // in terminal COLUMNS (strWidth), not string length: a 7-code-point
804
+ // CJK effort label renders 14 columns wide and would push the row
805
+ // past its budget if length were the measure.
806
+ const cell = (value, width) => {
807
+ if (strWidth(value) <= width) return value;
808
+ // Reserve one column for the ellipsis; walk whole code points so a
809
+ // wide char is never cut in half.
810
+ let used = 0;
811
+ let out = "";
812
+ for (const char of value) {
813
+ const w = charWidth(char.codePointAt(0));
814
+ if (used + w > width - 1) break;
815
+ out += char;
816
+ used += w;
817
+ }
818
+ return `${out}…`;
819
+ };
820
+ // The pads match cell's measure, so a truncated wide cell and its
821
+ // padding land on the same column arithmetic (padEnd keeps the
822
+ // prices' flush right edges — see below).
823
+ const padEndW = (value, width) => value + " ".repeat(Math.max(0, width - strWidth(value)));
824
+ const padStartW = (value, width) => " ".repeat(Math.max(0, width - strWidth(value))) + value;
724
825
  // Prices use padEnd (not padStart): padded to a common width the
725
826
  // right edges stay flush while the fixed 2-space gap between columns
726
827
  // stays exactly 2 (padStart pushed each value right by its slack).
727
828
  // The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
728
829
  // for row content; the header shares that budget (plus its 3-indent).
729
- // Column caps keep the budget even for hostile strings: 5+7+13+13 plus
730
- // the four 2-char paddings and the 2-char row indent = 46, leaving the
731
- // name floor of 6 (the name column shrinks first for real content).
732
- const fixed = 2 + qualW + 2 + effortW + 2 + altW + 2 + priceW;
733
- const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(label => label.length))));
734
- const trim = label => label.length > nameW ? label.slice(0, nameW - 1) + "…" : label;
735
- const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "effort".padStart(effortW) + " " + "alt".padEnd(altW) + " " + "$ in/out".padEnd(priceW);
830
+ // Column caps keep the budget even for hostile strings: 5+13 plus the
831
+ // two 2-char paddings and the 2-char row indent = 24, leaving plenty
832
+ // for the name (the name column shrinks first for real content).
833
+ const fixed = 2 + qualW + 2 + priceW;
834
+ const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(strWidth))));
835
+ const trim = label => cell(label, nameW);
836
+ const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "$ in/out".padEnd(priceW);
837
+ // Catalog v5 calls this the effort the quality was measured at, and the
838
+ // best selectable measured alternative ("max 56.0"). OpenCode calls the
839
+ // selectable option a VARIANT, so the TUI uses "variant" throughout to
840
+ // match what the user sees in OpenCode's model picker. A missing value
841
+ // is "-", and "best higher variant" spells out what "alt" meant.
842
+ const statusOf = entry => `variant: ${cell(entry.effort || "-", effortW)} · best higher variant: ${cell(entry.alternative || "-", altW)}`;
736
843
  const rows = [{
737
844
  label: "",
738
845
  headerLine
@@ -746,8 +853,9 @@ export default Plugin.define({
746
853
  });
747
854
  for (const entry of section) {
748
855
  rows.push({
749
- label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
750
- columns: " " + cell(entry.quality, qualW).padStart(qualW) + " " + cell(entry.effort || "-", effortW).padStart(effortW) + " " + cell(entry.alternative || "-", altW).padEnd(altW) + " " + cell(entry.price, priceW).padEnd(priceW),
856
+ label: padEndW(trim(labelOf.get(entry) ?? ""), nameW),
857
+ columns: " " + padStartW(cell(entry.quality, qualW), qualW) + " " + padEndW(cell(entry.price, priceW), priceW),
858
+ status: statusOf(entry),
751
859
  // F-20 id split: the row DISPLAYS the sanitized id (in `label`)
752
860
  // but picks `value`, the raw catalog id switchToModel needs.
753
861
  value: entry.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-compare-opencode-plugin",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "OpenCode plugin that switches OpenRouter model aliases to the best value-for-money pick from canonical/model-compare's ranking (balanced, price, quality).",
5
5
  "keywords": [
6
6
  "opencode",
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "scripts": {
41
41
  "build": "node scripts/build-tui.mjs",
42
+ "dev": "node scripts/dev-local.mjs",
42
43
  "test": "vitest run",
43
44
  "typecheck": "tsc --noEmit",
44
45
  "prepublishOnly": "npm run build && npm run typecheck && npm test && node scripts/pack-smoke.mjs"
package/src/index.ts CHANGED
@@ -335,11 +335,9 @@ function applyAliases(state: WireState, editor: AliasEditor): void {
335
335
  // plan: planAlias only checks for undefined (it never reads model
336
336
  // internals — the applier does, below).
337
337
  const source = target.id === null ? undefined : editor.get(ALIAS_PROVIDER, target.id)
338
- // The concrete editor model proves presence; the catalog entry supplies
339
- // the v5 effort/alternative labels (the editor model has no ranking data).
340
- const catalogModel =
341
- target.id === null ? undefined : state.sourced?.doc.models.find((entry) => entry.id === target.id)
342
- const plan = planAlias(p, target, source as unknown as ModelEntry | undefined, catalogModel)
338
+ // The concrete editor model is the presence proof; the display name
339
+ // carries only the pick (OpenCode renders the selected variant itself).
340
+ const plan = planAlias(p, target, source as unknown as ModelEntry | undefined)
343
341
 
344
342
  if (plan.enabled && plan.modelID !== null && source !== undefined) {
345
343
  editor.update(ALIAS_PROVIDER, aliasId(p), (model) => {
@@ -5,7 +5,6 @@
5
5
  // is the applier's job (it reads them through editor.get), so this module never
6
6
  // touches model internals.
7
7
 
8
- import { formatDepthAlternative } from "../core/reasoning"
9
8
  import type { ModelEntry, Priority } from "../core/types"
10
9
  import { sanitizeCatalogText } from "./commands"
11
10
 
@@ -26,16 +25,14 @@ export type AliasPlan = {
26
25
  // silently skipped. `model` is undefined when the pick's model was removed by
27
26
  // another plugin, or when there is no catalog to look it up in.
28
27
  //
29
- // `displayModel` is the CATALOG entry for the same pick: the applier's
30
- // presence proof is the editor's concrete model (which carries no ranking
31
- // metadata), so the effort/alternative label reads the catalog entry instead.
32
- // Absent (a fallback pick with no catalog, or a caller that has only the
33
- // editor model) falls back to `model`.
28
+ // The display name carries ONLY the pick (plus the fallback marker). OpenCode
29
+ // shows the selected variant beside the model itself, so baking the catalog's
30
+ // scored effort/alternative into the name would show a stale depth that does
31
+ // not follow the user's `#variant` selection.
34
32
  export function planAlias(
35
33
  p: Priority,
36
34
  target: AliasTarget,
37
35
  model: ModelEntry | undefined,
38
- displayModel?: ModelEntry,
39
36
  ): AliasPlan {
40
37
  if (target.id === null) {
41
38
  return {
@@ -53,19 +50,14 @@ export function planAlias(
53
50
  warnings: [`pick model ${target.id} missing from catalog for ${p}`],
54
51
  }
55
52
  }
56
- // The effort the pick's score represents, followed by the best measured
57
- // selectable alternative (catalog v5). Both are catalog-derived and
58
- // flattened (F-20) for the model picker, the TUI status line and dialogs;
59
- // `modelID` stays RAW — switching needs the real id.
60
- const shown = displayModel ?? model
61
- const effort =
62
- shown.qualityEffort == null ? "" : ` · ${sanitizeCatalogText(shown.qualityEffort)}`
63
- const alternative = formatDepthAlternative(shown)
53
+ // The pick is catalog-derived, so it is flattened (F-20) for the model
54
+ // picker and the TUI status line; `modelID` stays RAW — switching needs the
55
+ // real id.
64
56
  return {
65
57
  modelID: target.id,
66
- name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${effort}${
58
+ name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${
67
59
  target.fromFallback ? " (fallback)" : ""
68
- }${alternative === "" ? "" : ` [alt ${sanitizeCatalogText(alternative)}]`}`,
60
+ }`,
69
61
  enabled: true,
70
62
  warnings: [],
71
63
  }
@@ -81,18 +81,19 @@ export function readAliasViews(
81
81
 
82
82
  // The pick shown in an alias's display name:
83
83
  // "Model Compare: <p> → <pick>[ (fallback)]" → "<pick>[ (fallback)]", or null
84
- // for the "(unavailable)" placeholder names. A v5 name appends the scored
85
- // effort and the best alternative (" · high [alt max 56.0]") AFTER the pick
86
- // (the fallback marker stays between them); those annotations are stripped so
87
- // the status line and toasts still name the served model, not its depth.
84
+ // for the "(unavailable)" placeholder names. Older v5 names appended the
85
+ // scored effort and the best alternative (" · high [alt max 56.0]") after the
86
+ // pick; those annotations are no longer written, but remain stripped so a
87
+ // registry entry left by an older plugin still names the served model.
88
88
  export function pickFromName(name: string): string | null {
89
89
  const arrow = name.indexOf("→")
90
90
  if (arrow === -1) return null
91
91
  const rest = name.slice(arrow + 1).replace(/ \[alt .*\]$/, "")
92
- // Our name builder separates the pick from its v5 annotations with " · ";
93
- // OpenRouter ids contain no spaces, so the first segment is the pick. The
94
- // fallback marker lives in the annotation segment (or already after a pick
95
- // with no annotations), so carry it over exactly once.
92
+ // Legacy v5 names (no longer written — see above) separated the pick from
93
+ // its annotations with " · "; OpenRouter ids contain no spaces, so the
94
+ // first segment is the pick. The fallback marker lives in the annotation
95
+ // segment (or already after a pick with no annotations), so carry it over
96
+ // exactly once.
96
97
  const [pick, ...annotations] = rest.split(" · ")
97
98
  const fallback = annotations.join(" ").includes("(fallback)") ? " (fallback)" : ""
98
99
  return `${pick.trim()}${fallback}`
package/src/tui.tsx CHANGED
@@ -117,6 +117,10 @@ type ListRow = {
117
117
  // Trailing dim right-justified columns (the report table); when
118
118
  // present, label renders in its own span so the two tones can differ.
119
119
  columns?: string
120
+ // The cursor-following status line (the report table's variant and best
121
+ // higher variant): the dialog renders this row's status below the list and
122
+ // swaps it as the cursor moves.
123
+ status?: string
120
124
  selected?: string
121
125
  value?: string
122
126
  dim?: boolean
@@ -162,6 +166,56 @@ export function cappedRowsHeight(contentLines: number, terminalHeight: number):
162
166
  return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6))
163
167
  }
164
168
 
169
+ // The terminal display width of one code point, in columns: 2 for the ranges
170
+ // terminals render double-width — East Asian Wide/Fullwidth (CJK, Hangul) and
171
+ // the emoji/symbol planes — and 1 for everything else. Deliberately
172
+ // CONSERVATIVE: it never undercounts the wide content the catalog can carry.
173
+ // Blocks that are only partially wide are covered at BLOCK width — the
174
+ // narrow tails inside the SMP symbol planes (alchemical symbols, chess)
175
+ // count 2 anyway — and variation selector 16 (emoji presentation) counts 1
176
+ // itself, so a base+VS16 sequence such as ☺️ always totals ≥ 2 even when the
177
+ // base alone would not. Overcounting only makes a clamp cut
178
+ // earlier; it can never let a line render wider than its budget. (Genuinely
179
+ // East-Asian-Ambiguous characters OUTSIDE these ranges — ±, an en dash,
180
+ // Greek — count 1; a terminal font that renders those wide is beyond the
181
+ // clamp's scope. Catalog text is sanitized to a single line by
182
+ // sanitizeCatalogText, so width is the only wrap risk left.)
183
+ function charWidth(codePoint: number): number {
184
+ // Variation selectors (VS1–VS16) are zero-width in reality. VS16 forces
185
+ // emoji presentation on the preceding base, so the pair renders 2 columns:
186
+ // counting the selector itself as 1 keeps any base+VS16 sequence at ≥ 2
187
+ // even when the base is NOT in a wide block (⌨️ = U+2328+VS16), while a
188
+ // wide base + VS16 overcounts by one — the safe direction.
189
+ if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) return 1
190
+ if (
191
+ (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
192
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || // CJK radicals … Yi
193
+ (codePoint >= 0xa960 && codePoint <= 0xa97f) || // Hangul Jamo Ext. A
194
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
195
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compat ideographs
196
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) || // vertical forms
197
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compat forms
198
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
199
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
200
+ (codePoint >= 0x2600 && codePoint <= 0x27bf) || // misc symbols + dingbats (☺ ✨ ⚠ — ambiguous, commonly 2 under emoji presentation)
201
+ (codePoint >= 0x2b00 && codePoint <= 0x2bff) || // misc symbols and arrows (⭐)
202
+ (codePoint >= 0x1f000 && codePoint <= 0x1faff) || // the SMP symbol planes: mahjong/cards, enclosed ideographs, pictographs, transport (🚀), supplemental, symbols ext-A
203
+ (codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK ext B+
204
+ (codePoint >= 0x30000 && codePoint <= 0x3fffd) // CJK ext G+
205
+ ) {
206
+ return 2
207
+ }
208
+ return 1
209
+ }
210
+
211
+ // A string's terminal display width (the sum of its code points' widths).
212
+ // Exported for the unit rig, which asserts rendered lines against it.
213
+ export function strWidth(value: string): number {
214
+ let width = 0
215
+ for (const char of value) width += charWidth(char.codePointAt(0)!)
216
+ return width
217
+ }
218
+
165
219
  // One report entry's defensive normalization, shared by the two paths that
166
220
  // carry report data across a trust boundary (the /model-compare command's
167
221
  // event and the TUI's direct RPC call): an entry missing any identity field
@@ -645,6 +699,14 @@ export default Plugin.define({
645
699
  })
646
700
  })()}
647
701
  </scrollbox>
702
+ {(() => {
703
+ const row = selectable[cursor()]
704
+ return row.status === undefined ? null : (
705
+ <box paddingLeft={4}>
706
+ <text fg={muted}>{row.status}</text>
707
+ </box>
708
+ )
709
+ })()}
648
710
  {footerLines.map((line) => (
649
711
  <box paddingLeft={4}>
650
712
  <text fg={muted}>{line}</text>
@@ -708,17 +770,18 @@ export default Plugin.define({
708
770
  notify("no ranking data available yet", "error")
709
771
  return
710
772
  }
711
- // The approved table: one dim column header at the top (directly above
712
- // the first section), section headings with a blank line above all but
713
- // the first, and right-justified quality/price columns. No score
714
- // column — the transcript's text report keeps it. Column widths are
715
- // clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
716
- // to their clamped width, so with the name clamp below the row budget
717
- // holds even for hostile/absurd column strings: what the clamp
718
- // guarantees is that a row and the header stay within the dialog's
719
- // fixed 52-char content width (hostile values get truncated rather
720
- // than pushed into wrapping — the 40-char rankless fallback label did
721
- // exactly that before the name clamp).
773
+ // The approved table now carries three columns: model name, quality
774
+ // and price. The scored variant (the catalog's effort) and the best
775
+ // higher variant moved into a cursor-following status line below the
776
+ // list (see ListRow.status), so the table stays readable. No score
777
+ // column — the transcript's text report keeps it. Column widths are clamped
778
+ // (qual ≤ 5, $ in/out ≤ 13) and oversized cells are truncated to their
779
+ // clamped width, so with the name clamp below the row budget holds
780
+ // even for hostile/absurd column strings: what the clamp guarantees is
781
+ // that a row and the header stay within the dialog's fixed 52-char
782
+ // content width (hostile values get truncated rather than pushed into
783
+ // wrapping — the 40-char rankless fallback label did exactly that
784
+ // before the name clamp).
722
785
  const labels = data.entries.map(
723
786
  (entry) =>
724
787
  `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${
@@ -727,42 +790,74 @@ export default Plugin.define({
727
790
  )
728
791
  const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]))
729
792
  const colW = (values: string[], floor: number, cap: number): number =>
730
- Math.min(cap, Math.max(floor, ...values.map((value) => value.length)))
793
+ Math.min(cap, Math.max(floor, ...values.map(strWidth)))
731
794
  // Quality is a one-decimal AA index (max "100.0"); the caps leave the
732
795
  // 52-char row budget intact (see `fixed` below) while fitting real
733
- // values: "minimal" (effort), "minimal 100.0" (alt), "10.00/100.00".
796
+ // values: "10.00/100.00" (price).
734
797
  const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 5)
735
- // Catalog v5: the effort the quality was measured at and the best
736
- // selectable measured alternative ("max 56.0"). A missing value is "-".
737
- const effortW = colW(data.entries.map((entry) => entry.effort || "-"), "effort".length, 7)
738
- const altW = colW(data.entries.map((entry) => entry.alternative || "-"), "alt".length, 13)
798
+ // The status line's variant and best-higher-variant cells keep their
799
+ // old column caps: the status box is a direct child of the 60-wide
800
+ // dialog with only its own 4-char left inset, so one text line has 56
801
+ // columns — the worst case, 9 ("variant: ") + 7 + 24 (" · best higher
802
+ // variant: ") + 13 = 53, leaves 3 columns of slack. Clamping each cell
803
+ // (in display columns, see cell) keeps hostile catalog strings from
804
+ // wrapping.
805
+ const effortW = 7
806
+ const altW = 13
739
807
  const priceW = colW(data.entries.map((entry) => entry.price), "$ in/out".length, 13)
740
- // A cell wider than its clamped column is truncated to fit.
741
- const cell = (value: string, width: number): string =>
742
- value.length > width ? value.slice(0, width - 1) + "…" : value
808
+ // A cell wider than its clamped column is truncated to fit — measured
809
+ // in terminal COLUMNS (strWidth), not string length: a 7-code-point
810
+ // CJK effort label renders 14 columns wide and would push the row
811
+ // past its budget if length were the measure.
812
+ const cell = (value: string, width: number): string => {
813
+ if (strWidth(value) <= width) return value
814
+ // Reserve one column for the ellipsis; walk whole code points so a
815
+ // wide char is never cut in half.
816
+ let used = 0
817
+ let out = ""
818
+ for (const char of value) {
819
+ const w = charWidth(char.codePointAt(0)!)
820
+ if (used + w > width - 1) break
821
+ out += char
822
+ used += w
823
+ }
824
+ return `${out}…`
825
+ }
826
+ // The pads match cell's measure, so a truncated wide cell and its
827
+ // padding land on the same column arithmetic (padEnd keeps the
828
+ // prices' flush right edges — see below).
829
+ const padEndW = (value: string, width: number): string =>
830
+ value + " ".repeat(Math.max(0, width - strWidth(value)))
831
+ const padStartW = (value: string, width: number): string =>
832
+ " ".repeat(Math.max(0, width - strWidth(value))) + value
743
833
  // Prices use padEnd (not padStart): padded to a common width the
744
834
  // right edges stay flush while the fixed 2-space gap between columns
745
835
  // stays exactly 2 (padStart pushed each value right by its slack).
746
836
  // The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
747
837
  // for row content; the header shares that budget (plus its 3-indent).
748
- // Column caps keep the budget even for hostile strings: 5+7+13+13 plus
749
- // the four 2-char paddings and the 2-char row indent = 46, leaving the
750
- // name floor of 6 (the name column shrinks first for real content).
751
- const fixed = 2 + qualW + 2 + effortW + 2 + altW + 2 + priceW
752
- const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map((label) => label.length))))
753
- const trim = (label: string): string =>
754
- label.length > nameW ? label.slice(0, nameW - 1) + "…" : label
838
+ // Column caps keep the budget even for hostile strings: 5+13 plus the
839
+ // two 2-char paddings and the 2-char row indent = 24, leaving plenty
840
+ // for the name (the name column shrinks first for real content).
841
+ const fixed = 2 + qualW + 2 + priceW
842
+ const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(strWidth))))
843
+ const trim = (label: string): string => cell(label, nameW)
755
844
  const headerLine =
756
845
  " " +
757
846
  " ".repeat(nameW) +
758
847
  " " +
759
848
  "qual".padStart(qualW) +
760
849
  " " +
761
- "effort".padStart(effortW) +
762
- " " +
763
- "alt".padEnd(altW) +
764
- " " +
765
850
  "$ in/out".padEnd(priceW)
851
+ // Catalog v5 calls this the effort the quality was measured at, and the
852
+ // best selectable measured alternative ("max 56.0"). OpenCode calls the
853
+ // selectable option a VARIANT, so the TUI uses "variant" throughout to
854
+ // match what the user sees in OpenCode's model picker. A missing value
855
+ // is "-", and "best higher variant" spells out what "alt" meant.
856
+ const statusOf = (entry: ReportEntry): string =>
857
+ `variant: ${cell(entry.effort || "-", effortW)} · best higher variant: ${cell(
858
+ entry.alternative || "-",
859
+ altW,
860
+ )}`
766
861
  const rows: ListRow[] = [{ label: "", headerLine }]
767
862
  for (const priority of PRIORITIES) {
768
863
  const section = data.entries.filter((entry) => entry.priority === priority)
@@ -770,16 +865,13 @@ export default Plugin.define({
770
865
  rows.push({ heading: priority, label: "" })
771
866
  for (const entry of section) {
772
867
  rows.push({
773
- label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
868
+ label: padEndW(trim(labelOf.get(entry) ?? ""), nameW),
774
869
  columns:
775
870
  " " +
776
- cell(entry.quality, qualW).padStart(qualW) +
777
- " " +
778
- cell(entry.effort || "-", effortW).padStart(effortW) +
779
- " " +
780
- cell(entry.alternative || "-", altW).padEnd(altW) +
871
+ padStartW(cell(entry.quality, qualW), qualW) +
781
872
  " " +
782
- cell(entry.price, priceW).padEnd(priceW),
873
+ padEndW(cell(entry.price, priceW), priceW),
874
+ status: statusOf(entry),
783
875
  // F-20 id split: the row DISPLAYS the sanitized id (in `label`)
784
876
  // but picks `value`, the raw catalog id switchToModel needs.
785
877
  value: entry.value,