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 +8 -4
- package/dist/tui.js +143 -35
- package/package.json +2 -1
- package/src/index.ts +3 -5
- package/src/opencode/aliases.ts +9 -17
- package/src/opencode/usebest.ts +9 -8
- package/src/tui.tsx +130 -38
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
|
|
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
|
|
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
|
|
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$
|
|
634
|
-
_el$
|
|
635
|
-
_$insertNode(_el$
|
|
636
|
-
_$setProp(_el$
|
|
637
|
-
_$setProp(_el$
|
|
638
|
-
_$insert(_el$
|
|
639
|
-
return _el$
|
|
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
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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(
|
|
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: "
|
|
790
|
+
// values: "10.00/100.00" (price).
|
|
716
791
|
const qualW = colW(data.entries.map(entry => entry.quality), "qual".length, 5);
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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+
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
const fixed = 2 + qualW + 2 +
|
|
733
|
-
const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(
|
|
734
|
-
const trim = label => label
|
|
735
|
-
const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "
|
|
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) ?? "")
|
|
750
|
-
columns: " " + cell(entry.quality, qualW)
|
|
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.
|
|
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
|
|
339
|
-
//
|
|
340
|
-
const
|
|
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) => {
|
package/src/opencode/aliases.ts
CHANGED
|
@@ -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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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)}${
|
|
58
|
+
name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${
|
|
67
59
|
target.fromFallback ? " (fallback)" : ""
|
|
68
|
-
}
|
|
60
|
+
}`,
|
|
69
61
|
enabled: true,
|
|
70
62
|
warnings: [],
|
|
71
63
|
}
|
package/src/opencode/usebest.ts
CHANGED
|
@@ -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.
|
|
85
|
-
// effort and the best alternative (" · high [alt max 56.0]")
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
//
|
|
93
|
-
// OpenRouter ids contain no spaces, so the
|
|
94
|
-
// fallback marker lives in the annotation
|
|
95
|
-
// with no annotations), so carry it over
|
|
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
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
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(
|
|
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: "
|
|
796
|
+
// values: "10.00/100.00" (price).
|
|
734
797
|
const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 5)
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
742
|
-
|
|
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+
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
const fixed = 2 + qualW + 2 +
|
|
752
|
-
const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(
|
|
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) ?? "")
|
|
868
|
+
label: padEndW(trim(labelOf.get(entry) ?? ""), nameW),
|
|
774
869
|
columns:
|
|
775
870
|
" " +
|
|
776
|
-
cell(entry.quality, 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)
|
|
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,
|