model-compare-opencode-plugin 0.5.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.
@@ -12,6 +12,7 @@
12
12
  // and the steeringFor cases here); a semantic change to one must be mirrored
13
13
  // in the other.
14
14
 
15
+ import { gatewayEffort } from "./reasoning"
15
16
  import type { ModelEntry, Priority } from "./types"
16
17
 
17
18
  // A model reference as the hook event carries it (`event.model`), mirroring
@@ -75,6 +76,30 @@ export function steeringFor(
75
76
  return models.get(pick.id)?.steering ?? null
76
77
  }
77
78
 
79
+ // The reasoning effort an alias request must pin, or null when it must not:
80
+ // the same alias/body/pick/model gates as steeringFor, plus the explicit
81
+ // variant rule. `#variant` is OpenCode's per-request selection (the event
82
+ // carries it on `model.variant`); "default" is the no-selection sentinel
83
+ // OpenCode stamps on `--model` refs (spike fact 1), so it does NOT count as an
84
+ // explicit variant. The catalog's `quality_effort` is the effort the score was
85
+ // measured at; the "reasoning" sentinel is a producer label and is never sent
86
+ // to the gateway.
87
+ export function reasoningEffortFor(
88
+ eventModel: ModelRef,
89
+ variant: string | undefined,
90
+ bodyModel: string | undefined,
91
+ picks: Partial<Record<Priority, AliasTarget>>,
92
+ models: Map<string, ModelEntry>,
93
+ ): string | null {
94
+ const p = aliasPriority(eventModel)
95
+ if (p === null) return null
96
+ if (variant !== undefined && variant !== "default") return null
97
+ const pick = picks[p]
98
+ if (pick === undefined || pick.id === null) return null
99
+ if (bodyModel !== undefined && bodyModel !== pick.id) return null
100
+ return gatewayEffort(models.get(pick.id)?.qualityEffort)
101
+ }
102
+
78
103
  function isPlainObject(value: unknown): value is Record<string, unknown> {
79
104
  return typeof value === "object" && value !== null && !Array.isArray(value)
80
105
  }
@@ -103,6 +128,17 @@ export function injectSteering(body: unknown, steering: Steering): unknown {
103
128
  }
104
129
  }
105
130
 
131
+ // The body with `reasoning: { effort }` added — or the input untouched when
132
+ // it already carries `reasoning` or the legacy `reasoning_effort` field (an
133
+ // explicit #variant, user config or another plugin wins; the plugin never
134
+ // clobbers a reasoning choice it did not make) or is not a JSON object.
135
+ // Reference inequality is the wiring's "re-create the Request" signal.
136
+ export function injectReasoning(body: unknown, effort: string): unknown {
137
+ if (!isPlainObject(body)) return body
138
+ if (Object.hasOwn(body, "reasoning") || Object.hasOwn(body, "reasoning_effort")) return body
139
+ return { ...body, reasoning: { effort } }
140
+ }
141
+
106
142
  // normalize_provider (vendor/model_compare.py): casefold the name and strip
107
143
  // ALL non-alphanumeric characters — "Z.AI", "z-ai" and "z ai" all normalize
108
144
  // to "zai". JS toLowerCase stands in for Python's casefold (provider display
package/src/core/types.ts CHANGED
@@ -6,6 +6,31 @@ export type Priority = "balanced" | "price" | "quality"
6
6
 
7
7
  export type Weights = { quality: number; price: number; context: number; age: number }
8
8
 
9
+ // One measured reasoning effort (catalog v5): the AA intelligence index at
10
+ // that effort, AA's reference-price benchmark cost per task (display-only,
11
+ // never scored), and the record's provenance. `null` marks a missing value;
12
+ // the record itself survives so the effort can still be offered as a variant.
13
+ export type ReasoningDepth = {
14
+ effort: string
15
+ index: number | null
16
+ cost_per_task_usd: number | null
17
+ source: string | null
18
+ as_of: string | null
19
+ }
20
+
21
+ // The v5 per-model reasoning block. `supported_efforts` is what OpenRouter
22
+ // accepts as a discrete effort (expanded upstream, never null); `depths` is
23
+ // what some source measured (empty when nothing did). The raw flags are kept
24
+ // for provenance and for the single-state/non-reasoning display rules.
25
+ export type Reasoning = {
26
+ default_effort: string | null
27
+ supported_efforts: string[]
28
+ default_enabled: boolean | null
29
+ mandatory: boolean | null
30
+ supports_max_tokens: boolean | null
31
+ depths: ReasoningDepth[]
32
+ }
33
+
9
34
  export type ModelEntry = {
10
35
  id: string
11
36
  name: string
@@ -20,6 +45,14 @@ export type ModelEntry = {
20
45
  }
21
46
  context: number
22
47
  quality: number | null
48
+ // The effort `quality` was measured at (catalog v5 `quality_effort`). The
49
+ // alias request pins exactly this effort; the sentinel "reasoning" is a
50
+ // catalog label, never a gateway value. Optional only for hand-built /
51
+ // legacy projected entries: validateCatalog always writes it (null or a
52
+ // string), and validateStoredCatalog requires it on cached records.
53
+ qualityEffort?: string | null
54
+ // Present when the raw block projects cleanly (absent stays absent).
55
+ reasoning?: Reasoning
23
56
  // The steering block (F-11/F-12): upstream records the provider's
24
57
  // endpoint-routing hints as { model, provider: { order, ignore,
25
58
  // quantizations?, allow_fallbacks } }; the projection unwraps the provider
@@ -47,7 +80,7 @@ export type Catalog = {
47
80
  models: ModelEntry[]
48
81
  }
49
82
 
50
- // Upstream.s PRIORITY_WEIGHTS at the pinned tag (v0.5.0; unchanged since v0.3.0). Used when a
83
+ // Upstream's PRIORITY_WEIGHTS at the pinned tag (v0.6.0; unchanged since v0.3.0). Used when a
51
84
  // document carries no `parameters.weights` at all (design §5); individual
52
85
  // missing keys fall back to weight 0 instead.
53
86
  export const DEFAULT_WEIGHTS: Record<Priority, Weights> = {
package/src/index.ts CHANGED
@@ -19,7 +19,13 @@ import { parseConfig } from "./config"
19
19
  import { pinKey, type Pin } from "./core/pins"
20
20
  import { effectiveWeights, rankModels } from "./core/rank"
21
21
  import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced } from "./core/types"
22
- import { captureEndpoint, injectSteering, steeringFor } from "./core/steering"
22
+ import {
23
+ captureEndpoint,
24
+ injectReasoning,
25
+ injectSteering,
26
+ reasoningEffortFor,
27
+ steeringFor,
28
+ } from "./core/steering"
23
29
  import { formatReport, humanizeAge, reportEntries, steeringNote } from "./opencode/commands"
24
30
  import { ModelCompare } from "./rpc"
25
31
  import { planAlias, type AliasTarget } from "./opencode/aliases"
@@ -329,6 +335,8 @@ function applyAliases(state: WireState, editor: AliasEditor): void {
329
335
  // plan: planAlias only checks for undefined (it never reads model
330
336
  // internals — the applier does, below).
331
337
  const source = target.id === null ? undefined : editor.get(ALIAS_PROVIDER, target.id)
338
+ // The concrete editor model is the presence proof; the display name
339
+ // carries only the pick (OpenCode renders the selected variant itself).
332
340
  const plan = planAlias(p, target, source as unknown as ModelEntry | undefined)
333
341
 
334
342
  if (plan.enabled && plan.modelID !== null && source !== undefined) {
@@ -558,21 +566,36 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
558
566
  state.appliedSteering[event.sessionID] = null
559
567
  return
560
568
  }
561
- const steering = steeringFor(
569
+ const byId = new Map(
570
+ state.sourced.doc.models.map((entry): [string, ModelEntry] => [entry.id, entry]),
571
+ )
572
+ const bodyModel = bodyModelOf(body)
573
+ const steering = steeringFor(event.model, bodyModel, state.picks, byId)
574
+ // Two independent injections share one re-created Request: the
575
+ // provider-routing block and the catalog's scored reasoning effort.
576
+ // Each helper preserves a value the body already sets, so an explicit
577
+ // #variant (either signalled by the event's variant or by the body's
578
+ // own reasoning field) wins without withholding the other block.
579
+ let modified: unknown = body
580
+ let providerApplied = false
581
+ if (steering !== null) {
582
+ const next = injectSteering(modified, steering)
583
+ if (next !== modified) {
584
+ modified = next
585
+ providerApplied = true
586
+ }
587
+ }
588
+ const effort = reasoningEffortFor(
562
589
  event.model,
563
- bodyModelOf(body),
590
+ event.model.variant,
591
+ bodyModel,
564
592
  state.picks,
565
- new Map(state.sourced.doc.models.map((entry): [string, ModelEntry] => [entry.id, entry])),
593
+ byId,
566
594
  )
567
- if (steering === null) {
568
- // No steering data for the pick: untouched, fail open.
569
- state.appliedSteering[event.sessionID] = null
570
- return
571
- }
572
- const modified = injectSteering(body, steering)
595
+ if (effort !== null) modified = injectReasoning(modified, effort)
573
596
  if (modified === body) {
574
- // The body already carries a `provider` key (or is not an object):
575
- // never clobber routing the user or a provider configured.
597
+ // Neither block had anything to add: no steering data, no scored
598
+ // effort, or the body already carries the corresponding field.
576
599
  state.appliedSteering[event.sessionID] = null
577
600
  return
578
601
  }
@@ -592,7 +615,9 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
592
615
  })
593
616
  // Record the order actually applied — the response hook reads it as
594
617
  // captureEndpoint's order (the `!` flag fires only on a non-empty one).
595
- state.appliedSteering[event.sessionID] = steering.order
618
+ // A reasoning-only injection (or a body that already carried routing)
619
+ // applies no routing, so the record is null.
620
+ state.appliedSteering[event.sessionID] = providerApplied ? steering!.order : null
596
621
  } catch {
597
622
  // Fail open: any error leaves the original request proceeding
598
623
  // untouched, and records that nothing was injected.
@@ -24,6 +24,11 @@ export type AliasPlan = {
24
24
  // §8 rule: a missing pick model DISABLES the alias with a warning — it is never
25
25
  // silently skipped. `model` is undefined when the pick's model was removed by
26
26
  // another plugin, or when there is no catalog to look it up in.
27
+ //
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.
27
32
  export function planAlias(
28
33
  p: Priority,
29
34
  target: AliasTarget,
@@ -45,13 +50,14 @@ export function planAlias(
45
50
  warnings: [`pick model ${target.id} missing from catalog for ${p}`],
46
51
  }
47
52
  }
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.
48
56
  return {
49
57
  modelID: target.id,
50
- // The pick in the display name is flattened (F-20): `target.id` is
51
- // catalog-derived and the name surfaces in the model picker, the TUI
52
- // status line and dialogs. `modelID` stays RAW — switching needs the
53
- // real id.
54
- name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${target.fromFallback ? " (fallback)" : ""}`,
58
+ name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${
59
+ target.fromFallback ? " (fallback)" : ""
60
+ }`,
55
61
  enabled: true,
56
62
  warnings: [],
57
63
  }
@@ -8,6 +8,7 @@
8
8
  // not upstream's `generated_at`.
9
9
 
10
10
  import { effectiveWeights, overallScore } from "../core/rank"
11
+ import { formatDepthAlternative } from "../core/reasoning"
11
12
  import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced, Weights } from "../core/types"
12
13
  import type { AliasTarget } from "./aliases"
13
14
 
@@ -141,6 +142,30 @@ function overrideInEffect(
141
142
  return eff.applied && !eff.zeroSum ? eff.weights : null
142
143
  }
143
144
 
145
+ // Catalog v5 display suffixes: the effort the quality score was measured at
146
+ // (` · high`) and the best measured selectable alternative (` · alt max 56.0`).
147
+ // Both are catalog-derived, so they are flattened before joining the report
148
+ // text / dialog (F-20). An absent effort/alternative adds nothing.
149
+ function effortLabel(model: ModelEntry | undefined): string {
150
+ const effort = model?.qualityEffort
151
+ return effort == null ? "" : sanitizeCatalogText(effort)
152
+ }
153
+
154
+ function alternativeLabel(model: ModelEntry | undefined): string {
155
+ const alternative = formatDepthAlternative(model)
156
+ return alternative === "" ? "" : sanitizeCatalogText(alternative)
157
+ }
158
+
159
+ function reasoningSuffix(model: ModelEntry | undefined): string {
160
+ const label = effortLabel(model)
161
+ return label === "" ? "" : ` · ${label}`
162
+ }
163
+
164
+ function alternativeSuffix(model: ModelEntry | undefined): string {
165
+ const label = alternativeLabel(model)
166
+ return label === "" ? "" : ` · alt ${label}`
167
+ }
168
+
144
169
  // §10: /model-compare's report. `ranks` arrives already ranked, top-first
145
170
  // (under the same overrides, see ReportContext), and only the top 5 per
146
171
  // priority are rendered; a ranked id absent from the catalog still takes its
@@ -175,18 +200,23 @@ export function formatReport(
175
200
  model === undefined ? null : weights ? overallScore(model, weights) : model.scores.overall[p]
176
201
  const score = value === null ? "-" : value.toFixed(3)
177
202
  const marker = pick?.id === id ? " ← current pick" : ""
178
- lines.push(`${index + 1}. ${sanitizeCatalogText(id)} (${score})${marker}`)
203
+ lines.push(
204
+ `${index + 1}. ${sanitizeCatalogText(id)} (${score})${reasoningSuffix(model)}${alternativeSuffix(model)}${marker}`,
205
+ )
179
206
  }
180
207
  // A pick that is not visible in the list above needs its own line.
181
208
  if (pick !== undefined && (pick.id === null || !top.includes(pick.id))) {
209
+ const pickModel = pick.id === null ? undefined : byId.get(pick.id)
182
210
  if (pick.id === null) {
183
211
  lines.push("Current pick: none (alias disabled)")
184
212
  } else if (pick.fromFallback) {
185
- lines.push(`Current pick: ${sanitizeCatalogText(pick.id)} (fallback)`)
213
+ lines.push(
214
+ `Current pick: ${sanitizeCatalogText(pick.id)} (fallback)${reasoningSuffix(pickModel)}${alternativeSuffix(pickModel)}`,
215
+ )
186
216
  } else {
187
217
  const rank = ranks[p].indexOf(pick.id)
188
218
  lines.push(
189
- `Current pick: ${sanitizeCatalogText(pick.id)} (${rank === -1 ? "not in the ranking" : `rank ${rank + 1}`})`,
219
+ `Current pick: ${sanitizeCatalogText(pick.id)} (${rank === -1 ? "not in the ranking" : `rank ${rank + 1}`})${reasoningSuffix(pickModel)}${alternativeSuffix(pickModel)}`,
190
220
  )
191
221
  }
192
222
  }
@@ -225,6 +255,10 @@ export type ReportEntry = {
225
255
  value: string
226
256
  score: string
227
257
  quality: string
258
+ // The effort behind `quality` ("" when unknown) and the best selectable
259
+ // measured alternative as "<effort> <index>" ("" when there is none).
260
+ effort: string
261
+ alternative: string
228
262
  price: string
229
263
  current: boolean
230
264
  fromFallback: boolean
@@ -279,6 +313,8 @@ export function reportEntries(
279
313
  value: id,
280
314
  score: scoreValue === null ? "-" : scoreValue.toFixed(3),
281
315
  quality: fmtQuality(model),
316
+ effort: effortLabel(model),
317
+ alternative: alternativeLabel(model),
282
318
  price: fmtPrice(model),
283
319
  current,
284
320
  // A top-5 row that IS the current pick carries the pick's fallback
@@ -302,6 +338,8 @@ export function reportEntries(
302
338
  value: pick.id,
303
339
  score: "-",
304
340
  quality: fmtQuality(model),
341
+ effort: effortLabel(model),
342
+ alternative: alternativeLabel(model),
305
343
  price: fmtPrice(model),
306
344
  current: true,
307
345
  fromFallback: pick.fromFallback,
@@ -10,8 +10,10 @@
10
10
 
11
11
  import type { Priority } from "../core/types"
12
12
 
13
- // A model reference as configured on an agent (`agent.model`).
14
- export type ModelRef = { providerID: string; id: string }
13
+ // A model reference (agent config, session selection or request event). The
14
+ // optional `variant` is the `#variant` selection; alias identification stays
15
+ // variant-blind (it is not stable across binding styles).
16
+ export type ModelRef = { providerID: string; id: string; variant?: string }
15
17
 
16
18
  const ALIAS_PREFIX = "model-compare/"
17
19
 
@@ -80,12 +80,23 @@ export function readAliasViews(
80
80
  }
81
81
 
82
82
  // The pick shown in an alias's display name:
83
- // "Model Compare: <p> → <pick>[( fallback)]" → "<pick>[( fallback)]", or null
84
- // for the "(unavailable)" placeholder names.
83
+ // "Model Compare: <p> → <pick>[ (fallback)]" → "<pick>[ (fallback)]", or null
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.
85
88
  export function pickFromName(name: string): string | null {
86
89
  const arrow = name.indexOf("→")
87
90
  if (arrow === -1) return null
88
- return name.slice(arrow + 1).trim()
91
+ const rest = name.slice(arrow + 1).replace(/ \[alt .*\]$/, "")
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.
97
+ const [pick, ...annotations] = rest.split(" · ")
98
+ const fallback = annotations.join(" ").includes("(fallback)") ? " (fallback)" : ""
99
+ return `${pick.trim()}${fallback}`
89
100
  }
90
101
 
91
102
  // The pick an ENABLED alias view serves, or null when nothing observable is
package/src/rpc.ts CHANGED
@@ -54,11 +54,25 @@ export const ModelCompare = Rpc.define({
54
54
  value: { type: "string" },
55
55
  score: { type: "string" },
56
56
  quality: { type: "string" },
57
+ effort: { type: "string" },
58
+ alternative: { type: "string" },
57
59
  price: { type: "string" },
58
60
  current: { type: "boolean" },
59
61
  fromFallback: { type: "boolean" },
60
62
  },
61
- required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
63
+ required: [
64
+ "priority",
65
+ "rank",
66
+ "id",
67
+ "value",
68
+ "score",
69
+ "quality",
70
+ "effort",
71
+ "alternative",
72
+ "price",
73
+ "current",
74
+ "fromFallback",
75
+ ],
62
76
  additionalProperties: false,
63
77
  },
64
78
  },
@@ -88,11 +102,25 @@ export const ModelCompare = Rpc.define({
88
102
  value: { type: "string" },
89
103
  score: { type: "string" },
90
104
  quality: { type: "string" },
105
+ effort: { type: "string" },
106
+ alternative: { type: "string" },
91
107
  price: { type: "string" },
92
108
  current: { type: "boolean" },
93
109
  fromFallback: { type: "boolean" },
94
110
  },
95
- required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
111
+ required: [
112
+ "priority",
113
+ "rank",
114
+ "id",
115
+ "value",
116
+ "score",
117
+ "quality",
118
+ "effort",
119
+ "alternative",
120
+ "price",
121
+ "current",
122
+ "fromFallback",
123
+ ],
96
124
  additionalProperties: false,
97
125
  },
98
126
  },
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
@@ -186,6 +240,8 @@ function toReportEntries(raw: unknown): ReportEntry[] {
186
240
  value: entry.value,
187
241
  score: typeof entry.score === "string" ? entry.score : "-",
188
242
  quality: typeof entry.quality === "string" ? entry.quality : "-",
243
+ effort: typeof entry.effort === "string" ? entry.effort : "",
244
+ alternative: typeof entry.alternative === "string" ? entry.alternative : "",
189
245
  price: typeof entry.price === "string" ? entry.price : "-",
190
246
  current: entry.current === true,
191
247
  fromFallback: entry.fromFallback === true,
@@ -643,6 +699,14 @@ export default Plugin.define({
643
699
  })
644
700
  })()}
645
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
+ })()}
646
710
  {footerLines.map((line) => (
647
711
  <box paddingLeft={4}>
648
712
  <text fg={muted}>{line}</text>
@@ -706,17 +770,18 @@ export default Plugin.define({
706
770
  notify("no ranking data available yet", "error")
707
771
  return
708
772
  }
709
- // The approved table: one dim column header at the top (directly above
710
- // the first section), section headings with a blank line above all but
711
- // the first, and right-justified quality/price columns. No score
712
- // column — the transcript's text report keeps it. Column widths are
713
- // clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
714
- // to their clamped width, so with the name clamp below the row budget
715
- // holds even for hostile/absurd column strings: what the clamp
716
- // guarantees is that a row and the header stay within the dialog's
717
- // fixed 52-char content width (hostile values get truncated rather
718
- // than pushed into wrapping — the 40-char rankless fallback label did
719
- // 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).
720
785
  const labels = data.entries.map(
721
786
  (entry) =>
722
787
  `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${
@@ -725,21 +790,57 @@ export default Plugin.define({
725
790
  )
726
791
  const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]))
727
792
  const colW = (values: string[], floor: number, cap: number): number =>
728
- Math.min(cap, Math.max(floor, ...values.map((value) => value.length)))
729
- const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 8)
730
- const priceW = colW(data.entries.map((entry) => entry.price), "$ in/out".length, 16)
731
- // A cell wider than its clamped column is truncated to fit.
732
- const cell = (value: string, width: number): string =>
733
- value.length > width ? value.slice(0, width - 1) + "…" : value
793
+ Math.min(cap, Math.max(floor, ...values.map(strWidth)))
794
+ // Quality is a one-decimal AA index (max "100.0"); the caps leave the
795
+ // 52-char row budget intact (see `fixed` below) while fitting real
796
+ // values: "10.00/100.00" (price).
797
+ const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 5)
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
807
+ const priceW = colW(data.entries.map((entry) => entry.price), "$ in/out".length, 13)
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
734
833
  // Prices use padEnd (not padStart): padded to a common width the
735
834
  // right edges stay flush while the fixed 2-space gap between columns
736
835
  // stays exactly 2 (padStart pushed each value right by its slack).
737
836
  // The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
738
837
  // for row content; the header shares that budget (plus its 3-indent).
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).
739
841
  const fixed = 2 + qualW + 2 + priceW
740
- const nameW = Math.max(8, Math.min(52 - fixed, Math.max(...labels.map((label) => label.length))))
741
- const trim = (label: string): string =>
742
- label.length > nameW ? label.slice(0, nameW - 1) + "…" : label
842
+ const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(strWidth))))
843
+ const trim = (label: string): string => cell(label, nameW)
743
844
  const headerLine =
744
845
  " " +
745
846
  " ".repeat(nameW) +
@@ -747,6 +848,16 @@ export default Plugin.define({
747
848
  "qual".padStart(qualW) +
748
849
  " " +
749
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
+ )}`
750
861
  const rows: ListRow[] = [{ label: "", headerLine }]
751
862
  for (const priority of PRIORITIES) {
752
863
  const section = data.entries.filter((entry) => entry.priority === priority)
@@ -754,12 +865,13 @@ export default Plugin.define({
754
865
  rows.push({ heading: priority, label: "" })
755
866
  for (const entry of section) {
756
867
  rows.push({
757
- label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
868
+ label: padEndW(trim(labelOf.get(entry) ?? ""), nameW),
758
869
  columns:
759
870
  " " +
760
- cell(entry.quality, qualW).padStart(qualW) +
871
+ padStartW(cell(entry.quality, qualW), qualW) +
761
872
  " " +
762
- cell(entry.price, priceW).padEnd(priceW),
873
+ padEndW(cell(entry.price, priceW), priceW),
874
+ status: statusOf(entry),
763
875
  // F-20 id split: the row DISPLAYS the sanitized id (in `label`)
764
876
  // but picks `value`, the raw catalog id switchToModel needs.
765
877
  value: entry.value,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "repo": "canonical/model-compare",
3
- "tag": "v0.5.0",
4
- "commit": "2e0e0cbb0ebf1d1b3f03b2c1c949eb930730d8a4",
5
- "sha256": "cebf2d100b020183c0236f1d127768933bca0e10ed615dd00ee549ebb86323b4"
3
+ "tag": "v0.6.0",
4
+ "commit": "2f39ed320ca87703b1a91a6a89b3ac796c5212ae",
5
+ "sha256": "94627727c2c5ef0e5d2f7b1753952aaa02d375a4fb2bd35c82124891a5b29830"
6
6
  }