model-compare-opencode-plugin 0.1.1 → 0.1.3
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 +1 -1
- package/dist/tui.js +296 -61
- package/package.json +1 -1
- package/src/tui.tsx +309 -80
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Commands:
|
|
|
82
82
|
| `return` | select the highlighted row (picks dialog: switch to that priority) |
|
|
83
83
|
| `esc` | close the dialog |
|
|
84
84
|
|
|
85
|
-
Both dialogs
|
|
85
|
+
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.
|
|
86
86
|
|
|
87
87
|
Status: the footer shows `MC: balanced → <pick>` (balanced only — `disabled`/`unavailable`/`(fallback)` as applicable; all three priorities live in the picks dialog). Toasts announce `/use-best` outcomes and pick changes — never for the first observation after load. Headless, all feedback is synthetic transcript messages at the next turn.
|
|
88
88
|
|
package/dist/tui.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// header for why the published package ships precompiled JSX.
|
|
4
4
|
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
5
5
|
import { memo as _$memo } from "@opentui/solid";
|
|
6
|
+
import { use as _$use } from "@opentui/solid";
|
|
6
7
|
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
7
8
|
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
8
9
|
import { setProp as _$setProp } from "@opentui/solid";
|
|
@@ -19,12 +20,17 @@ import { createElement as _$createElement } from "@opentui/solid";
|
|
|
19
20
|
// names current, so the registry IS the state; no messaging involved);
|
|
20
21
|
// * the picks dialog (alt+m): a select over the three priorities —
|
|
21
22
|
// choosing one switches the session to it, with the same decision logic
|
|
22
|
-
// as the server command;
|
|
23
|
+
// as the server command; with no session open (the opening screen)
|
|
24
|
+
// choosing one CREATES a session already bound to that alias and opens
|
|
25
|
+
// it, so the shortcuts work from the first keystroke (merely OPENING a
|
|
26
|
+
// dialog never creates a session);
|
|
23
27
|
// * the report dialog (alt+shift+m): the /model-compare command emits its
|
|
24
28
|
// structured report as an RPC event, and this half opens it as a
|
|
25
29
|
// selectable dialog (selecting a ranked model switches straight to it) —
|
|
26
30
|
// one command serves both worlds, so no TUI duplicate of the server
|
|
27
|
-
// command exists;
|
|
31
|
+
// command exists; with no session open the report is fetched directly
|
|
32
|
+
// over the plugin's RPC (no session command to post it to) and a pick
|
|
33
|
+
// creates a session bound to that model;
|
|
28
34
|
// * a toast when a pick flips (at most one per changed priority per
|
|
29
35
|
// reload, and never for the first observation after load).
|
|
30
36
|
//
|
|
@@ -52,6 +58,7 @@ import { TextAttributes, RGBA } from "@opentui/core";
|
|
|
52
58
|
import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
|
|
53
59
|
import { ALIAS_PROVIDER, PRIORITIES, alignedPriority, aliasId, pickOf, pickFromName, readAliasViews, statusLine, useBestDecision, useBestNotice, useBestRefusal } from "../src/opencode/usebest";
|
|
54
60
|
import { sanitizeCatalogText } from "../src/opencode/commands";
|
|
61
|
+
import { ModelCompare } from "../src/rpc";
|
|
55
62
|
const TOAST_MS = 6_000;
|
|
56
63
|
const RESYNC_MS = 30_000;
|
|
57
64
|
|
|
@@ -71,6 +78,11 @@ const REPORT_EVENT = "rpc.model-compare.report";
|
|
|
71
78
|
// and arming a new dialog closes whatever is still armed first.
|
|
72
79
|
let dialogGeneration = 0;
|
|
73
80
|
let armedClose = null;
|
|
81
|
+
// The armed dialog's reveal hook (see showListDialog): its own
|
|
82
|
+
// revealOnFrame, exposed module-scope so the setup-scope resize handler can
|
|
83
|
+
// re-reveal the cursor when a resize re-caps the row area under it. Set
|
|
84
|
+
// when a dialog is armed, cleared by its close() alongside armedClose.
|
|
85
|
+
let armedReveal = null;
|
|
74
86
|
function StatusLine(props) {
|
|
75
87
|
return (() => {
|
|
76
88
|
var _el$ = _$createElement("text");
|
|
@@ -79,6 +91,73 @@ function StatusLine(props) {
|
|
|
79
91
|
return _el$;
|
|
80
92
|
})();
|
|
81
93
|
}
|
|
94
|
+
|
|
95
|
+
// One list-dialog row (see showListDialog).
|
|
96
|
+
|
|
97
|
+
// The rows' ONE height rule, shared by three places that must agree or the
|
|
98
|
+
// scroll lands on the wrong line: the rows' JSX (the heading's blank line
|
|
99
|
+
// above), rowOffset's arithmetic and the row-area cap's content measure. A
|
|
100
|
+
// header-line row renders as its text ONLY — the JSX checks headerLine
|
|
101
|
+
// before heading — so a row carrying both fields counts the way it renders,
|
|
102
|
+
// never as both. Every other row is one line except the second and later
|
|
103
|
+
// headings, which carry one blank line above (the palette's heading
|
|
104
|
+
// recipe).
|
|
105
|
+
function isHeadingRow(row) {
|
|
106
|
+
return row.headerLine === undefined && row.heading !== undefined;
|
|
107
|
+
}
|
|
108
|
+
function rowLines(row, headingsSoFar) {
|
|
109
|
+
return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1;
|
|
110
|
+
}
|
|
111
|
+
// The rows' total LINE count (not their row count — heading blanks are
|
|
112
|
+
// content too); `upto` measures the first rows alone, which is how rowOffset
|
|
113
|
+
// gets a row's y within the content.
|
|
114
|
+
function rowsHeight(rows, upto = rows.length) {
|
|
115
|
+
let y = 0;
|
|
116
|
+
let headings = 0;
|
|
117
|
+
for (let k = 0; k < upto; k++) {
|
|
118
|
+
const row = rows[k];
|
|
119
|
+
y += rowLines(row, headings);
|
|
120
|
+
if (isHeadingRow(row)) headings += 1;
|
|
121
|
+
}
|
|
122
|
+
return y;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The list dialogs' row-area cap, the host command palette's own formula:
|
|
126
|
+
// min(content LINES, floor(terminal height / 2) - 6) — the first argument is
|
|
127
|
+
// rowsHeight(rows), never the rows' count, or the cap clips tail rows even
|
|
128
|
+
// on a roomy terminal (with the scrollbar hidden). The host's dialog layer
|
|
129
|
+
// caps nothing itself and anchors UNcentered dialogs a quarter viewport
|
|
130
|
+
// down — without this cap an uncapped list overflows the viewport at large
|
|
131
|
+
// font sizes (the report dialog did exactly that). Exported for the unit
|
|
132
|
+
// rig.
|
|
133
|
+
export function cappedRowsHeight(contentLines, terminalHeight) {
|
|
134
|
+
return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// One report entry's defensive normalization, shared by the two paths that
|
|
138
|
+
// carry report data across a trust boundary (the /model-compare command's
|
|
139
|
+
// event and the TUI's direct RPC call): an entry missing any identity field
|
|
140
|
+
// is dropped, every other field falls back to its neutral value.
|
|
141
|
+
function toReportEntries(raw) {
|
|
142
|
+
if (!Array.isArray(raw)) return [];
|
|
143
|
+
return raw.flatMap(item => {
|
|
144
|
+
const entry = item;
|
|
145
|
+
if (typeof entry?.id !== "string" || typeof entry?.value !== "string" || typeof entry?.priority !== "string") return [];
|
|
146
|
+
return [{
|
|
147
|
+
priority: entry.priority,
|
|
148
|
+
rank: typeof entry.rank === "number" ? entry.rank : null,
|
|
149
|
+
// The F-20 split (see ReportEntry): `id` sanitized for display,
|
|
150
|
+
// `value` the raw catalog id for switching.
|
|
151
|
+
id: entry.id,
|
|
152
|
+
value: entry.value,
|
|
153
|
+
score: typeof entry.score === "string" ? entry.score : "-",
|
|
154
|
+
quality: typeof entry.quality === "string" ? entry.quality : "-",
|
|
155
|
+
price: typeof entry.price === "string" ? entry.price : "-",
|
|
156
|
+
current: entry.current === true,
|
|
157
|
+
fromFallback: entry.fromFallback === true
|
|
158
|
+
}];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
82
161
|
export default Plugin.define({
|
|
83
162
|
id: "model-compare-tui",
|
|
84
163
|
async setup(context) {
|
|
@@ -90,6 +169,28 @@ export default Plugin.define({
|
|
|
90
169
|
variant,
|
|
91
170
|
duration: TOAST_MS
|
|
92
171
|
});
|
|
172
|
+
|
|
173
|
+
// The terminal's size, reactive: the renderer emits "resize" carrying the
|
|
174
|
+
// fresh dimensions, and the dialogs' row-area caps must track font-size
|
|
175
|
+
// and window changes the way the host's own dialogs do (the palette caps
|
|
176
|
+
// its list at a fraction of the viewport; the dialog layer gives plugin
|
|
177
|
+
// dialogs no cap of their own). Detached in the returned cleanup.
|
|
178
|
+
const [terminalSize, setTerminalSize] = createSignal({
|
|
179
|
+
width: context.renderer.width,
|
|
180
|
+
height: context.renderer.height
|
|
181
|
+
});
|
|
182
|
+
const onRendererResize = (width, height) => {
|
|
183
|
+
setTerminalSize({
|
|
184
|
+
width,
|
|
185
|
+
height
|
|
186
|
+
});
|
|
187
|
+
// A resize re-caps the armed dialog's row area under its cursor: the
|
|
188
|
+
// reveal re-runs on the next frame (see showListDialog's
|
|
189
|
+
// revealOnFrame) or a shrunken viewport can leave the cursor below
|
|
190
|
+
// the fold until the next key move.
|
|
191
|
+
armedReveal?.(false);
|
|
192
|
+
};
|
|
193
|
+
context.renderer.on("resize", onRendererResize);
|
|
93
194
|
let disposeRoot = () => {};
|
|
94
195
|
// The three slot disposers (the two footers and the app slot below):
|
|
95
196
|
// slots claimed but never released survive the plugin's unload and keep
|
|
@@ -155,21 +256,29 @@ export default Plugin.define({
|
|
|
155
256
|
return "balanced";
|
|
156
257
|
}
|
|
157
258
|
|
|
158
|
-
// Switch
|
|
159
|
-
//
|
|
160
|
-
//
|
|
259
|
+
// Switch to one of the plugin's aliases — the shared decision chain
|
|
260
|
+
// behind /use-best, the palette command and the picks dialog's
|
|
261
|
+
// selection. With a session open it switches THAT session; with none
|
|
262
|
+
// (the opening screen) it creates a session already bound to the alias
|
|
263
|
+
// and opens it — the pick is the session's starting model. A disabled
|
|
264
|
+
// alias refuses before anything is created.
|
|
161
265
|
async function switchToPriority(priority) {
|
|
266
|
+
const views = aliases();
|
|
267
|
+
if (!views.some(view => view.priority === priority && view.enabled)) {
|
|
268
|
+
notify(useBestRefusal(priority), "error");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const view = views.find(item => item.priority === priority);
|
|
272
|
+
const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""));
|
|
162
273
|
const sessionID = currentSessionID();
|
|
163
274
|
if (sessionID === null) {
|
|
164
|
-
|
|
275
|
+
await createSessionOnModel({
|
|
276
|
+
providerID: ALIAS_PROVIDER,
|
|
277
|
+
id: aliasId(priority)
|
|
278
|
+
}, notice);
|
|
165
279
|
return;
|
|
166
280
|
}
|
|
167
281
|
try {
|
|
168
|
-
const views = aliases();
|
|
169
|
-
if (!views.some(view => view.priority === priority && view.enabled)) {
|
|
170
|
-
notify(useBestRefusal(priority), "error");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
282
|
await context.client.session.switchModel({
|
|
174
283
|
sessionID,
|
|
175
284
|
model: {
|
|
@@ -177,8 +286,7 @@ export default Plugin.define({
|
|
|
177
286
|
id: aliasId(priority)
|
|
178
287
|
}
|
|
179
288
|
});
|
|
180
|
-
|
|
181
|
-
notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")));
|
|
289
|
+
notify(notice);
|
|
182
290
|
} catch (error) {
|
|
183
291
|
notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
184
292
|
}
|
|
@@ -203,6 +311,27 @@ export default Plugin.define({
|
|
|
203
311
|
}
|
|
204
312
|
}
|
|
205
313
|
|
|
314
|
+
// No session open (the opening screen's pick paths): create one already
|
|
315
|
+
// bound to the given model and navigate into it. Returns whether the
|
|
316
|
+
// session came up; a failure is toasted and nothing else happens — no
|
|
317
|
+
// half-open session to clean up.
|
|
318
|
+
async function createSessionOnModel(model, success) {
|
|
319
|
+
try {
|
|
320
|
+
const session = await context.client.session.create({
|
|
321
|
+
model
|
|
322
|
+
});
|
|
323
|
+
context.ui.router.navigate({
|
|
324
|
+
type: "session",
|
|
325
|
+
sessionID: session.id
|
|
326
|
+
});
|
|
327
|
+
notify(success);
|
|
328
|
+
return true;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
notify(`could not open a session: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
206
335
|
// The custom list dialog. The host's `dialog.select` (SDK 2.0.16
|
|
207
336
|
// runtime) renders one GROUPED box per category with one cursor each —
|
|
208
337
|
// its search flattening (verified against main) does not exist in this
|
|
@@ -250,14 +379,72 @@ export default Plugin.define({
|
|
|
250
379
|
// never updated — the cursor sat on row 1 however many times the
|
|
251
380
|
// keymap moved the internal index; observed live, 2026-10-11).
|
|
252
381
|
const [cursor, setCursor] = createSignal(initial);
|
|
382
|
+
// The scrollbox that carries the rows (see the JSX below); its ref
|
|
383
|
+
// lands when the host renders the dialog. The cursor's row is kept in
|
|
384
|
+
// view with the native lists' look-ahead: the content scrolls as soon
|
|
385
|
+
// as the cursor comes within three lines of an edge, and the cursor
|
|
386
|
+
// only touches an edge line when the content ends (the scroll clamps
|
|
387
|
+
// at its maximum).
|
|
388
|
+
//
|
|
389
|
+
// The row offsets are pure ARITHMETIC, not renderable geometry —
|
|
390
|
+
// rowsHeight (module scope) is the ONE row-height rule the rows' JSX
|
|
391
|
+
// and the row-area cap share. Reading the host's .y was tried and is
|
|
392
|
+
// unreliable across the plugin boundary — it reported the scrollbox's
|
|
393
|
+
// own position for every row — while scrollTop/scrollHeight/height
|
|
394
|
+
// behave as plain numbers.
|
|
395
|
+
let listScroll;
|
|
396
|
+
let pendingReveal;
|
|
397
|
+
const rowOffset = index => rowsHeight(rows, index);
|
|
398
|
+
const reveal = center => {
|
|
399
|
+
const box = listScroll;
|
|
400
|
+
if (box === undefined) return;
|
|
401
|
+
const top = rowOffset(rows.indexOf(selectable[cursor()]));
|
|
402
|
+
const max = Math.max(0, box.scrollHeight - box.height);
|
|
403
|
+
// The look-ahead band, clamped to the window: at box.height <= 3 the
|
|
404
|
+
// three-line margin degenerates to plain ensure-visible (the row
|
|
405
|
+
// lands ON a window line instead of the overscroll an unclamped
|
|
406
|
+
// `box.height - 4` produced once it went negative — cap values of
|
|
407
|
+
// 1-3 are exactly what large font sizes reach).
|
|
408
|
+
const band = Math.min(3, Math.max(0, box.height - 1));
|
|
409
|
+
// Keep the row on-screen whatever the branch: the row's line must
|
|
410
|
+
// stay within [scrollTop, scrollTop + box.height - 1].
|
|
411
|
+
const visible = target => Math.min(top, Math.max(top - box.height + 1, target));
|
|
412
|
+
let target = box.scrollTop;
|
|
413
|
+
if (center) target = top - Math.floor(box.height / 2);else if (top - box.scrollTop > box.height - 1 - band) target = visible(top - (box.height - 1 - band));else if (top - box.scrollTop < band) target = visible(top - band);
|
|
414
|
+
box.scrollTo(Math.max(0, Math.min(target, max)));
|
|
415
|
+
};
|
|
416
|
+
// Positions exist only after a layout frame, so the reveal defers to
|
|
417
|
+
// the next rendered frame (the palette's own once(FRAME) +
|
|
418
|
+
// requestRender() pattern) rather than racing the renderer inside the
|
|
419
|
+
// key handler.
|
|
420
|
+
const revealOnFrame = center => {
|
|
421
|
+
if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal);
|
|
422
|
+
const run = () => {
|
|
423
|
+
pendingReveal = undefined;
|
|
424
|
+
if (!alive) return;
|
|
425
|
+
reveal(center);
|
|
426
|
+
};
|
|
427
|
+
pendingReveal = run;
|
|
428
|
+
context.renderer.once("frame", run);
|
|
429
|
+
context.renderer.requestRender();
|
|
430
|
+
};
|
|
253
431
|
const close = () => {
|
|
254
432
|
if (!alive) return;
|
|
255
433
|
alive = false;
|
|
434
|
+
// A pending frame reveal is THIS dialog's alone: cancel it here or
|
|
435
|
+
// the once("frame") listener (and its closure over the rows)
|
|
436
|
+
// outlives the dialog — it self-guarded on `alive`, but leaked until
|
|
437
|
+
// the next frame.
|
|
438
|
+
if (pendingReveal !== undefined) {
|
|
439
|
+
context.renderer.off("frame", pendingReveal);
|
|
440
|
+
pendingReveal = undefined;
|
|
441
|
+
}
|
|
256
442
|
// A newer dialog was armed since this one: its own close owns the
|
|
257
|
-
// shared state (the keymap layer, the host dialog
|
|
258
|
-
// here would kill ITS keyboard and surface.
|
|
443
|
+
// shared state (the keymap layer, the host dialog, the reveal hook)
|
|
444
|
+
// now — clearing here would kill ITS keyboard and surface.
|
|
259
445
|
if (generation !== dialogGeneration) return;
|
|
260
446
|
armedClose = null;
|
|
447
|
+
armedReveal = null;
|
|
261
448
|
setListKeys(null);
|
|
262
449
|
context.ui.dialog.clear();
|
|
263
450
|
};
|
|
@@ -266,6 +453,10 @@ export default Plugin.define({
|
|
|
266
453
|
// first is the last (the command palette's behavior).
|
|
267
454
|
const move = delta => {
|
|
268
455
|
setCursor((cursor() + delta + selectable.length) % selectable.length);
|
|
456
|
+
// Frame-deferred like every reveal — and it CANCELS a pending one,
|
|
457
|
+
// so the preselected row's open-time centering can never land after
|
|
458
|
+
// the user has already moved the cursor off it.
|
|
459
|
+
revealOnFrame(false);
|
|
269
460
|
};
|
|
270
461
|
const pick = () => {
|
|
271
462
|
const row = selectable[cursor()];
|
|
@@ -277,6 +468,7 @@ export default Plugin.define({
|
|
|
277
468
|
// slot below); it registers on the next reactive run. This dialog is
|
|
278
469
|
// now the armed one (the lifecycle token above).
|
|
279
470
|
armedClose = close;
|
|
471
|
+
armedReveal = revealOnFrame;
|
|
280
472
|
setListKeys({
|
|
281
473
|
move,
|
|
282
474
|
pick
|
|
@@ -289,7 +481,7 @@ export default Plugin.define({
|
|
|
289
481
|
_el$3 = _$createElement("box"),
|
|
290
482
|
_el$4 = _$createElement("text"),
|
|
291
483
|
_el$5 = _$createElement("text"),
|
|
292
|
-
_el$7 = _$createElement("
|
|
484
|
+
_el$7 = _$createElement("scrollbox"),
|
|
293
485
|
_el$8 = _$createElement("box"),
|
|
294
486
|
_el$9 = _$createElement("text");
|
|
295
487
|
_$insertNode(_el$2, _el$3);
|
|
@@ -309,9 +501,18 @@ export default Plugin.define({
|
|
|
309
501
|
_$insertNode(_el$5, _$createTextNode(`esc`));
|
|
310
502
|
_$setProp(_el$5, "fg", muted);
|
|
311
503
|
_$setProp(_el$5, "onMouseUp", () => close());
|
|
504
|
+
_$use(element => {
|
|
505
|
+
listScroll = element;
|
|
506
|
+
// The report's preselected row (the current pick) opens
|
|
507
|
+
// centered in the scrollbox: scheduled from the ref, so the
|
|
508
|
+
// frame it waits for is the one that lays these rows out.
|
|
509
|
+
if (initial > 0) revealOnFrame(true);
|
|
510
|
+
}, _el$7);
|
|
312
511
|
_$setProp(_el$7, "paddingLeft", 1);
|
|
313
512
|
_$setProp(_el$7, "paddingRight", 1);
|
|
314
|
-
_$setProp(_el$7, "
|
|
513
|
+
_$setProp(_el$7, "scrollbarOptions", {
|
|
514
|
+
visible: false
|
|
515
|
+
});
|
|
315
516
|
_$insert(_el$7, () => {
|
|
316
517
|
let headings = 0;
|
|
317
518
|
return rows.map(row => {
|
|
@@ -327,14 +528,17 @@ export default Plugin.define({
|
|
|
327
528
|
}
|
|
328
529
|
if (row.heading !== undefined) {
|
|
329
530
|
// The palette's heading recipe: accent hue, bold, one
|
|
330
|
-
// blank line above every heading but the first
|
|
331
|
-
//
|
|
531
|
+
// blank line above every heading but the first — the
|
|
532
|
+
// blank is rowLines' call (the ONE row-height rule the
|
|
533
|
+
// offsets and the cap share), not a second copy of its
|
|
534
|
+
// condition. Flush with the title: 1 (list inset) + 3 = 4.
|
|
535
|
+
const paddingTop = rowLines(row, headings) - 1;
|
|
332
536
|
headings += 1;
|
|
333
537
|
return (() => {
|
|
334
538
|
var _el$10 = _$createElement("box"),
|
|
335
539
|
_el$11 = _$createElement("text");
|
|
336
540
|
_$insertNode(_el$10, _el$11);
|
|
337
|
-
_$setProp(_el$10, "paddingTop",
|
|
541
|
+
_$setProp(_el$10, "paddingTop", paddingTop);
|
|
338
542
|
_$setProp(_el$10, "paddingLeft", 3);
|
|
339
543
|
_$setProp(_el$11, "fg", accent);
|
|
340
544
|
_$insert(_el$11, () => row.heading);
|
|
@@ -394,16 +598,34 @@ export default Plugin.define({
|
|
|
394
598
|
_$setProp(_el$8, "paddingLeft", 4);
|
|
395
599
|
_$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
|
|
396
600
|
_$setProp(_el$9, "fg", muted);
|
|
397
|
-
_$effect(
|
|
601
|
+
_$effect(_p$ => {
|
|
602
|
+
var _v$ = TextAttributes.BOLD,
|
|
603
|
+
_v$2 = cappedRowsHeight(rowsHeight(rows), terminalSize().height);
|
|
604
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "attributes", _v$, _p$.e));
|
|
605
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "maxHeight", _v$2, _p$.t));
|
|
606
|
+
return _p$;
|
|
607
|
+
}, {
|
|
608
|
+
e: undefined,
|
|
609
|
+
t: undefined
|
|
610
|
+
});
|
|
398
611
|
return _el$2;
|
|
399
612
|
})(), close);
|
|
613
|
+
// The palette's presentation: vertically centered at the medium width.
|
|
614
|
+
// The size/centered state persists across dialogs (the host's own set
|
|
615
|
+
// it on every open), so the explicit set also clears a stale xlarge a
|
|
616
|
+
// host dialog may have left behind.
|
|
617
|
+
context.ui.dialog.set({
|
|
618
|
+
size: "medium",
|
|
619
|
+
centered: true
|
|
620
|
+
});
|
|
400
621
|
}
|
|
401
622
|
|
|
402
623
|
// The picks dialog: the three priorities, one cursor, palette styling.
|
|
403
624
|
// A snapshot at open time; unavailable priorities are dimmed — the
|
|
404
625
|
// cursor still reaches them, and choosing one is refused with a toast
|
|
405
626
|
// (switchToPriority's guard). Choosing an available priority switches
|
|
406
|
-
// the session to it
|
|
627
|
+
// the session to it — or, with no session open, creates one bound to
|
|
628
|
+
// it (the opening screen's path; opening the dialog never does).
|
|
407
629
|
function showPicksDialog() {
|
|
408
630
|
const views = aliases();
|
|
409
631
|
const rows = PRIORITIES.map(priority => {
|
|
@@ -479,7 +701,18 @@ export default Plugin.define({
|
|
|
479
701
|
});
|
|
480
702
|
}
|
|
481
703
|
}
|
|
482
|
-
showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id =>
|
|
704
|
+
showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => {
|
|
705
|
+
if (data.sessionID !== null) {
|
|
706
|
+
void switchToModel(data.sessionID, id);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
// The opening screen: the pick becomes a NEW session's model —
|
|
710
|
+
// the same concrete model the in-session path switches to.
|
|
711
|
+
void createSessionOnModel({
|
|
712
|
+
providerID: ALIAS_PROVIDER,
|
|
713
|
+
id
|
|
714
|
+
}, `switched to openrouter/${sanitizeCatalogText(id)}`);
|
|
715
|
+
});
|
|
483
716
|
}
|
|
484
717
|
|
|
485
718
|
// The report dialog rides the /model-compare command's RPC event: ONE
|
|
@@ -496,23 +729,7 @@ export default Plugin.define({
|
|
|
496
729
|
const data = event.data;
|
|
497
730
|
if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
|
|
498
731
|
if (!Array.isArray(data.entries)) return;
|
|
499
|
-
const entries = data.entries
|
|
500
|
-
const entry = raw;
|
|
501
|
-
if (typeof entry?.id !== "string" || typeof entry?.value !== "string" || typeof entry?.priority !== "string") return [];
|
|
502
|
-
return [{
|
|
503
|
-
priority: entry.priority,
|
|
504
|
-
rank: typeof entry.rank === "number" ? entry.rank : null,
|
|
505
|
-
// The F-20 split (see ReportEntry): `id` sanitized for display,
|
|
506
|
-
// `value` the raw catalog id for switching.
|
|
507
|
-
id: entry.id,
|
|
508
|
-
value: entry.value,
|
|
509
|
-
score: typeof entry.score === "string" ? entry.score : "-",
|
|
510
|
-
quality: typeof entry.quality === "string" ? entry.quality : "-",
|
|
511
|
-
price: typeof entry.price === "string" ? entry.price : "-",
|
|
512
|
-
current: entry.current === true,
|
|
513
|
-
fromFallback: entry.fromFallback === true
|
|
514
|
-
}];
|
|
515
|
-
});
|
|
732
|
+
const entries = toReportEntries(data.entries);
|
|
516
733
|
const eventSessionID = data.sessionID;
|
|
517
734
|
// Ownerless callback: park the report on the signal — the app slot's
|
|
518
735
|
// in-tree effect opens the dialog (see pendingReport above).
|
|
@@ -599,29 +816,27 @@ export default Plugin.define({
|
|
|
599
816
|
|
|
600
817
|
// The picks dialog's command. No slash: /use-best already covers the
|
|
601
818
|
// switching surface (the server command is slash-routable; the TUI's
|
|
602
|
-
// duplicate of it was removed for exactly that reason).
|
|
819
|
+
// duplicate of it was removed for exactly that reason). The dialog opens
|
|
820
|
+
// everywhere — with no session it shows the current picks, and only
|
|
821
|
+
// picking one creates a session bound to it (switchToPriority).
|
|
603
822
|
const picksCommand = {
|
|
604
823
|
id: "model-compare.picks",
|
|
605
824
|
title: "Model-compare top picks",
|
|
606
825
|
group: "Model Compare",
|
|
607
826
|
bind: "alt+m",
|
|
608
827
|
palette: true,
|
|
609
|
-
run: () =>
|
|
610
|
-
if (currentSessionID() === null) {
|
|
611
|
-
notify("no session yet — submit any prompt to create one; picking a priority switches that session", "error");
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
showPicksDialog();
|
|
615
|
-
}
|
|
828
|
+
run: () => showPicksDialog()
|
|
616
829
|
};
|
|
617
830
|
|
|
618
|
-
// The report command: posts the server's
|
|
619
|
-
// RPC event opens the selectable report
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
831
|
+
// The report command: with a session open it posts the server's
|
|
832
|
+
// /model-compare command, whose RPC event opens the selectable report
|
|
833
|
+
// dialog here (and the transcript keeps the notice). With no session
|
|
834
|
+
// (the opening screen) there is nothing to post to, so the report is
|
|
835
|
+
// fetched straight from the plugin's report RPC — same builder, same
|
|
836
|
+
// entries — and picking a model creates a session bound to it (there is
|
|
837
|
+
// no composer-model setter to change instead; verified against the
|
|
838
|
+
// pinned runtime). The refusal-free opening also keeps the footer's
|
|
839
|
+
// status line honest: the shortcuts always DO something.
|
|
625
840
|
const reportCommand = {
|
|
626
841
|
id: "model-compare.report",
|
|
627
842
|
title: "Model-compare report",
|
|
@@ -630,15 +845,29 @@ export default Plugin.define({
|
|
|
630
845
|
palette: true,
|
|
631
846
|
run: async () => {
|
|
632
847
|
const sessionID = currentSessionID();
|
|
633
|
-
if (sessionID
|
|
634
|
-
|
|
848
|
+
if (sessionID !== null) {
|
|
849
|
+
try {
|
|
850
|
+
await context.client.session.command({
|
|
851
|
+
sessionID,
|
|
852
|
+
name: "model-compare",
|
|
853
|
+
text: ""
|
|
854
|
+
});
|
|
855
|
+
} catch (error) {
|
|
856
|
+
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
857
|
+
}
|
|
635
858
|
return;
|
|
636
859
|
}
|
|
637
860
|
try {
|
|
638
|
-
await context.client.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
861
|
+
const report = await context.client.rpc(ModelCompare).report({}, here === undefined ? undefined : {
|
|
862
|
+
location: {
|
|
863
|
+
directory: here
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
const data = report;
|
|
867
|
+
showReportDialog({
|
|
868
|
+
sessionID: null,
|
|
869
|
+
age: typeof data?.age === "string" ? data.age : null,
|
|
870
|
+
entries: toReportEntries(data?.entries)
|
|
642
871
|
});
|
|
643
872
|
} catch (error) {
|
|
644
873
|
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
@@ -730,7 +959,13 @@ export default Plugin.define({
|
|
|
730
959
|
}
|
|
731
960
|
});
|
|
732
961
|
return () => {
|
|
962
|
+
// Disarm whatever dialog is still armed FIRST: its pending frame
|
|
963
|
+
// reveal and modal keyboard must not outlive the plugin (the
|
|
964
|
+
// module-scope armedClose/armedReveal survive setup's cleanup
|
|
965
|
+
// otherwise).
|
|
966
|
+
armedClose?.();
|
|
733
967
|
if (resyncTimer !== undefined) clearInterval(resyncTimer);
|
|
968
|
+
context.renderer.off("resize", onRendererResize);
|
|
734
969
|
offReportEvent();
|
|
735
970
|
disposeRoot();
|
|
736
971
|
disposeAppSlot();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-compare-opencode-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
package/src/tui.tsx
CHANGED
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
// names current, so the registry IS the state; no messaging involved);
|
|
9
9
|
// * the picks dialog (alt+m): a select over the three priorities —
|
|
10
10
|
// choosing one switches the session to it, with the same decision logic
|
|
11
|
-
// as the server command;
|
|
11
|
+
// as the server command; with no session open (the opening screen)
|
|
12
|
+
// choosing one CREATES a session already bound to that alias and opens
|
|
13
|
+
// it, so the shortcuts work from the first keystroke (merely OPENING a
|
|
14
|
+
// dialog never creates a session);
|
|
12
15
|
// * the report dialog (alt+shift+m): the /model-compare command emits its
|
|
13
16
|
// structured report as an RPC event, and this half opens it as a
|
|
14
17
|
// selectable dialog (selecting a ranked model switches straight to it) —
|
|
15
18
|
// one command serves both worlds, so no TUI duplicate of the server
|
|
16
|
-
// command exists;
|
|
19
|
+
// command exists; with no session open the report is fetched directly
|
|
20
|
+
// over the plugin's RPC (no session command to post it to) and a pick
|
|
21
|
+
// creates a session bound to that model;
|
|
17
22
|
// * a toast when a pick flips (at most one per changed priority per
|
|
18
23
|
// reload, and never for the first observation after load).
|
|
19
24
|
//
|
|
@@ -39,6 +44,7 @@ import { TextAttributes, RGBA } from "@opentui/core"
|
|
|
39
44
|
// the package's solid-js JSX augmentation without a runtime import — the TUI
|
|
40
45
|
// host already provides the runtime side.
|
|
41
46
|
import type { JSX } from "@opentui/solid"
|
|
47
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
42
48
|
import {
|
|
43
49
|
createEffect,
|
|
44
50
|
createMemo,
|
|
@@ -63,6 +69,7 @@ import {
|
|
|
63
69
|
} from "./opencode/usebest"
|
|
64
70
|
import type { ReportEntry } from "./opencode/commands"
|
|
65
71
|
import { sanitizeCatalogText } from "./opencode/commands"
|
|
72
|
+
import { ModelCompare } from "./rpc"
|
|
66
73
|
|
|
67
74
|
const TOAST_MS = 6_000
|
|
68
75
|
const RESYNC_MS = 30_000
|
|
@@ -83,11 +90,105 @@ const REPORT_EVENT = "rpc.model-compare.report"
|
|
|
83
90
|
// and arming a new dialog closes whatever is still armed first.
|
|
84
91
|
let dialogGeneration = 0
|
|
85
92
|
let armedClose: (() => void) | null = null
|
|
93
|
+
// The armed dialog's reveal hook (see showListDialog): its own
|
|
94
|
+
// revealOnFrame, exposed module-scope so the setup-scope resize handler can
|
|
95
|
+
// re-reveal the cursor when a resize re-caps the row area under it. Set
|
|
96
|
+
// when a dialog is armed, cleared by its close() alongside armedClose.
|
|
97
|
+
let armedReveal: ((center: boolean) => void) | null = null
|
|
86
98
|
|
|
87
99
|
function StatusLine(props: { line: () => string; fg?: string }) {
|
|
88
100
|
return <text fg={props.fg}>{props.line()}</text>
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
// One list-dialog row (see showListDialog).
|
|
104
|
+
type ListRow = {
|
|
105
|
+
// Section headings render in the palette's category color, and are
|
|
106
|
+
// skipped by the cursor.
|
|
107
|
+
heading?: string
|
|
108
|
+
// A pre-built column-header line (the report table): dim, skipped by
|
|
109
|
+
// the cursor, and never gets a blank line above it.
|
|
110
|
+
headerLine?: string
|
|
111
|
+
label: string
|
|
112
|
+
// Trailing dim right-justified columns (the report table); when
|
|
113
|
+
// present, label renders in its own span so the two tones can differ.
|
|
114
|
+
columns?: string
|
|
115
|
+
selected?: string
|
|
116
|
+
value?: string
|
|
117
|
+
dim?: boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The rows' ONE height rule, shared by three places that must agree or the
|
|
121
|
+
// scroll lands on the wrong line: the rows' JSX (the heading's blank line
|
|
122
|
+
// above), rowOffset's arithmetic and the row-area cap's content measure. A
|
|
123
|
+
// header-line row renders as its text ONLY — the JSX checks headerLine
|
|
124
|
+
// before heading — so a row carrying both fields counts the way it renders,
|
|
125
|
+
// never as both. Every other row is one line except the second and later
|
|
126
|
+
// headings, which carry one blank line above (the palette's heading
|
|
127
|
+
// recipe).
|
|
128
|
+
function isHeadingRow(row: ListRow): boolean {
|
|
129
|
+
return row.headerLine === undefined && row.heading !== undefined
|
|
130
|
+
}
|
|
131
|
+
function rowLines(row: ListRow, headingsSoFar: number): number {
|
|
132
|
+
return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1
|
|
133
|
+
}
|
|
134
|
+
// The rows' total LINE count (not their row count — heading blanks are
|
|
135
|
+
// content too); `upto` measures the first rows alone, which is how rowOffset
|
|
136
|
+
// gets a row's y within the content.
|
|
137
|
+
function rowsHeight(rows: readonly ListRow[], upto: number = rows.length): number {
|
|
138
|
+
let y = 0
|
|
139
|
+
let headings = 0
|
|
140
|
+
for (let k = 0; k < upto; k++) {
|
|
141
|
+
const row = rows[k]!
|
|
142
|
+
y += rowLines(row, headings)
|
|
143
|
+
if (isHeadingRow(row)) headings += 1
|
|
144
|
+
}
|
|
145
|
+
return y
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The list dialogs' row-area cap, the host command palette's own formula:
|
|
149
|
+
// min(content LINES, floor(terminal height / 2) - 6) — the first argument is
|
|
150
|
+
// rowsHeight(rows), never the rows' count, or the cap clips tail rows even
|
|
151
|
+
// on a roomy terminal (with the scrollbar hidden). The host's dialog layer
|
|
152
|
+
// caps nothing itself and anchors UNcentered dialogs a quarter viewport
|
|
153
|
+
// down — without this cap an uncapped list overflows the viewport at large
|
|
154
|
+
// font sizes (the report dialog did exactly that). Exported for the unit
|
|
155
|
+
// rig.
|
|
156
|
+
export function cappedRowsHeight(contentLines: number, terminalHeight: number): number {
|
|
157
|
+
return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// One report entry's defensive normalization, shared by the two paths that
|
|
161
|
+
// carry report data across a trust boundary (the /model-compare command's
|
|
162
|
+
// event and the TUI's direct RPC call): an entry missing any identity field
|
|
163
|
+
// is dropped, every other field falls back to its neutral value.
|
|
164
|
+
function toReportEntries(raw: unknown): ReportEntry[] {
|
|
165
|
+
if (!Array.isArray(raw)) return []
|
|
166
|
+
return raw.flatMap((item): ReportEntry[] => {
|
|
167
|
+
const entry = item as Partial<ReportEntry>
|
|
168
|
+
if (
|
|
169
|
+
typeof entry?.id !== "string" ||
|
|
170
|
+
typeof entry?.value !== "string" ||
|
|
171
|
+
typeof entry?.priority !== "string"
|
|
172
|
+
)
|
|
173
|
+
return []
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
priority: entry.priority as Priority,
|
|
177
|
+
rank: typeof entry.rank === "number" ? entry.rank : null,
|
|
178
|
+
// The F-20 split (see ReportEntry): `id` sanitized for display,
|
|
179
|
+
// `value` the raw catalog id for switching.
|
|
180
|
+
id: entry.id,
|
|
181
|
+
value: entry.value,
|
|
182
|
+
score: typeof entry.score === "string" ? entry.score : "-",
|
|
183
|
+
quality: typeof entry.quality === "string" ? entry.quality : "-",
|
|
184
|
+
price: typeof entry.price === "string" ? entry.price : "-",
|
|
185
|
+
current: entry.current === true,
|
|
186
|
+
fromFallback: entry.fromFallback === true,
|
|
187
|
+
},
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
91
192
|
export default Plugin.define({
|
|
92
193
|
id: "model-compare-tui",
|
|
93
194
|
async setup(context) {
|
|
@@ -97,6 +198,25 @@ export default Plugin.define({
|
|
|
97
198
|
const notify = (message: string, variant: "success" | "error" = "success") =>
|
|
98
199
|
context.ui.toast.show({ title: "model-compare", message, variant, duration: TOAST_MS })
|
|
99
200
|
|
|
201
|
+
// The terminal's size, reactive: the renderer emits "resize" carrying the
|
|
202
|
+
// fresh dimensions, and the dialogs' row-area caps must track font-size
|
|
203
|
+
// and window changes the way the host's own dialogs do (the palette caps
|
|
204
|
+
// its list at a fraction of the viewport; the dialog layer gives plugin
|
|
205
|
+
// dialogs no cap of their own). Detached in the returned cleanup.
|
|
206
|
+
const [terminalSize, setTerminalSize] = createSignal({
|
|
207
|
+
width: context.renderer.width,
|
|
208
|
+
height: context.renderer.height,
|
|
209
|
+
})
|
|
210
|
+
const onRendererResize = (width: number, height: number) => {
|
|
211
|
+
setTerminalSize({ width, height })
|
|
212
|
+
// A resize re-caps the armed dialog's row area under its cursor: the
|
|
213
|
+
// reveal re-runs on the next frame (see showListDialog's
|
|
214
|
+
// revealOnFrame) or a shrunken viewport can leave the cursor below
|
|
215
|
+
// the fold until the next key move.
|
|
216
|
+
armedReveal?.(false)
|
|
217
|
+
}
|
|
218
|
+
context.renderer.on("resize", onRendererResize)
|
|
219
|
+
|
|
100
220
|
let disposeRoot = () => {}
|
|
101
221
|
// The three slot disposers (the two footers and the app slot below):
|
|
102
222
|
// slots claimed but never released survive the plugin's unload and keep
|
|
@@ -177,27 +297,34 @@ export default Plugin.define({
|
|
|
177
297
|
return "balanced"
|
|
178
298
|
}
|
|
179
299
|
|
|
180
|
-
// Switch
|
|
181
|
-
//
|
|
182
|
-
//
|
|
300
|
+
// Switch to one of the plugin's aliases — the shared decision chain
|
|
301
|
+
// behind /use-best, the palette command and the picks dialog's
|
|
302
|
+
// selection. With a session open it switches THAT session; with none
|
|
303
|
+
// (the opening screen) it creates a session already bound to the alias
|
|
304
|
+
// and opens it — the pick is the session's starting model. A disabled
|
|
305
|
+
// alias refuses before anything is created.
|
|
183
306
|
async function switchToPriority(priority: Priority): Promise<void> {
|
|
307
|
+
const views = aliases()
|
|
308
|
+
if (!views.some((view) => view.priority === priority && view.enabled)) {
|
|
309
|
+
notify(useBestRefusal(priority), "error")
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
const view = views.find((item) => item.priority === priority)
|
|
313
|
+
const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""))
|
|
184
314
|
const sessionID = currentSessionID()
|
|
185
315
|
if (sessionID === null) {
|
|
186
|
-
|
|
316
|
+
await createSessionOnModel(
|
|
317
|
+
{ providerID: ALIAS_PROVIDER, id: aliasId(priority) },
|
|
318
|
+
notice,
|
|
319
|
+
)
|
|
187
320
|
return
|
|
188
321
|
}
|
|
189
322
|
try {
|
|
190
|
-
const views = aliases()
|
|
191
|
-
if (!views.some((view) => view.priority === priority && view.enabled)) {
|
|
192
|
-
notify(useBestRefusal(priority), "error")
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
323
|
await context.client.session.switchModel({
|
|
196
324
|
sessionID,
|
|
197
325
|
model: { providerID: ALIAS_PROVIDER, id: aliasId(priority) },
|
|
198
326
|
})
|
|
199
|
-
|
|
200
|
-
notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")))
|
|
327
|
+
notify(notice)
|
|
201
328
|
} catch (error) {
|
|
202
329
|
notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
203
330
|
}
|
|
@@ -219,6 +346,28 @@ export default Plugin.define({
|
|
|
219
346
|
}
|
|
220
347
|
}
|
|
221
348
|
|
|
349
|
+
// No session open (the opening screen's pick paths): create one already
|
|
350
|
+
// bound to the given model and navigate into it. Returns whether the
|
|
351
|
+
// session came up; a failure is toasted and nothing else happens — no
|
|
352
|
+
// half-open session to clean up.
|
|
353
|
+
async function createSessionOnModel(
|
|
354
|
+
model: { providerID: string; id: string },
|
|
355
|
+
success: string,
|
|
356
|
+
): Promise<boolean> {
|
|
357
|
+
try {
|
|
358
|
+
const session = await context.client.session.create({ model })
|
|
359
|
+
context.ui.router.navigate({ type: "session", sessionID: session.id })
|
|
360
|
+
notify(success)
|
|
361
|
+
return true
|
|
362
|
+
} catch (error) {
|
|
363
|
+
notify(
|
|
364
|
+
`could not open a session: ${error instanceof Error ? error.message : String(error)}`,
|
|
365
|
+
"error",
|
|
366
|
+
)
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
222
371
|
// The custom list dialog. The host's `dialog.select` (SDK 2.0.16
|
|
223
372
|
// runtime) renders one GROUPED box per category with one cursor each —
|
|
224
373
|
// its search flattening (verified against main) does not exist in this
|
|
@@ -229,21 +378,6 @@ export default Plugin.define({
|
|
|
229
378
|
// render closure (context-less — "Keymap.Provider is missing", the same
|
|
230
379
|
// trap as setup scope), so the app slot's IN-TREE effect registers it
|
|
231
380
|
// while `listKeys` is armed, and the host unmounts it with the dialog.
|
|
232
|
-
type ListRow = {
|
|
233
|
-
// Section headings render in the palette's category color, and are
|
|
234
|
-
// skipped by the cursor.
|
|
235
|
-
heading?: string
|
|
236
|
-
// A pre-built column-header line (the report table): dim, skipped by
|
|
237
|
-
// the cursor, and never gets a blank line above it.
|
|
238
|
-
headerLine?: string
|
|
239
|
-
label: string
|
|
240
|
-
// Trailing dim right-justified columns (the report table); when
|
|
241
|
-
// present, label renders in its own span so the two tones can differ.
|
|
242
|
-
columns?: string
|
|
243
|
-
selected?: string
|
|
244
|
-
value?: string
|
|
245
|
-
dim?: boolean
|
|
246
|
-
}
|
|
247
381
|
|
|
248
382
|
// The keymap layer hand-off (see showListDialog): set while a list
|
|
249
383
|
// dialog is open, cleared on close; the app slot's in-tree effect maps
|
|
@@ -289,14 +423,75 @@ export default Plugin.define({
|
|
|
289
423
|
// never updated — the cursor sat on row 1 however many times the
|
|
290
424
|
// keymap moved the internal index; observed live, 2026-10-11).
|
|
291
425
|
const [cursor, setCursor] = createSignal(initial)
|
|
426
|
+
// The scrollbox that carries the rows (see the JSX below); its ref
|
|
427
|
+
// lands when the host renders the dialog. The cursor's row is kept in
|
|
428
|
+
// view with the native lists' look-ahead: the content scrolls as soon
|
|
429
|
+
// as the cursor comes within three lines of an edge, and the cursor
|
|
430
|
+
// only touches an edge line when the content ends (the scroll clamps
|
|
431
|
+
// at its maximum).
|
|
432
|
+
//
|
|
433
|
+
// The row offsets are pure ARITHMETIC, not renderable geometry —
|
|
434
|
+
// rowsHeight (module scope) is the ONE row-height rule the rows' JSX
|
|
435
|
+
// and the row-area cap share. Reading the host's .y was tried and is
|
|
436
|
+
// unreliable across the plugin boundary — it reported the scrollbox's
|
|
437
|
+
// own position for every row — while scrollTop/scrollHeight/height
|
|
438
|
+
// behave as plain numbers.
|
|
439
|
+
let listScroll: ScrollBoxRenderable | undefined
|
|
440
|
+
let pendingReveal: (() => void) | undefined
|
|
441
|
+
const rowOffset = (index: number): number => rowsHeight(rows, index)
|
|
442
|
+
const reveal = (center: boolean) => {
|
|
443
|
+
const box = listScroll
|
|
444
|
+
if (box === undefined) return
|
|
445
|
+
const top = rowOffset(rows.indexOf(selectable[cursor()]))
|
|
446
|
+
const max = Math.max(0, box.scrollHeight - box.height)
|
|
447
|
+
// The look-ahead band, clamped to the window: at box.height <= 3 the
|
|
448
|
+
// three-line margin degenerates to plain ensure-visible (the row
|
|
449
|
+
// lands ON a window line instead of the overscroll an unclamped
|
|
450
|
+
// `box.height - 4` produced once it went negative — cap values of
|
|
451
|
+
// 1-3 are exactly what large font sizes reach).
|
|
452
|
+
const band = Math.min(3, Math.max(0, box.height - 1))
|
|
453
|
+
// Keep the row on-screen whatever the branch: the row's line must
|
|
454
|
+
// stay within [scrollTop, scrollTop + box.height - 1].
|
|
455
|
+
const visible = (target: number): number =>
|
|
456
|
+
Math.min(top, Math.max(top - box.height + 1, target))
|
|
457
|
+
let target = box.scrollTop
|
|
458
|
+
if (center) target = top - Math.floor(box.height / 2)
|
|
459
|
+
else if (top - box.scrollTop > box.height - 1 - band) target = visible(top - (box.height - 1 - band))
|
|
460
|
+
else if (top - box.scrollTop < band) target = visible(top - band)
|
|
461
|
+
box.scrollTo(Math.max(0, Math.min(target, max)))
|
|
462
|
+
}
|
|
463
|
+
// Positions exist only after a layout frame, so the reveal defers to
|
|
464
|
+
// the next rendered frame (the palette's own once(FRAME) +
|
|
465
|
+
// requestRender() pattern) rather than racing the renderer inside the
|
|
466
|
+
// key handler.
|
|
467
|
+
const revealOnFrame = (center: boolean) => {
|
|
468
|
+
if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal)
|
|
469
|
+
const run = () => {
|
|
470
|
+
pendingReveal = undefined
|
|
471
|
+
if (!alive) return
|
|
472
|
+
reveal(center)
|
|
473
|
+
}
|
|
474
|
+
pendingReveal = run
|
|
475
|
+
context.renderer.once("frame", run)
|
|
476
|
+
context.renderer.requestRender()
|
|
477
|
+
}
|
|
292
478
|
const close = () => {
|
|
293
479
|
if (!alive) return
|
|
294
480
|
alive = false
|
|
481
|
+
// A pending frame reveal is THIS dialog's alone: cancel it here or
|
|
482
|
+
// the once("frame") listener (and its closure over the rows)
|
|
483
|
+
// outlives the dialog — it self-guarded on `alive`, but leaked until
|
|
484
|
+
// the next frame.
|
|
485
|
+
if (pendingReveal !== undefined) {
|
|
486
|
+
context.renderer.off("frame", pendingReveal)
|
|
487
|
+
pendingReveal = undefined
|
|
488
|
+
}
|
|
295
489
|
// A newer dialog was armed since this one: its own close owns the
|
|
296
|
-
// shared state (the keymap layer, the host dialog
|
|
297
|
-
// here would kill ITS keyboard and surface.
|
|
490
|
+
// shared state (the keymap layer, the host dialog, the reveal hook)
|
|
491
|
+
// now — clearing here would kill ITS keyboard and surface.
|
|
298
492
|
if (generation !== dialogGeneration) return
|
|
299
493
|
armedClose = null
|
|
494
|
+
armedReveal = null
|
|
300
495
|
setListKeys(null)
|
|
301
496
|
context.ui.dialog.clear()
|
|
302
497
|
}
|
|
@@ -305,6 +500,10 @@ export default Plugin.define({
|
|
|
305
500
|
// first is the last (the command palette's behavior).
|
|
306
501
|
const move = (delta: number) => {
|
|
307
502
|
setCursor((cursor() + delta + selectable.length) % selectable.length)
|
|
503
|
+
// Frame-deferred like every reveal — and it CANCELS a pending one,
|
|
504
|
+
// so the preselected row's open-time centering can never land after
|
|
505
|
+
// the user has already moved the cursor off it.
|
|
506
|
+
revealOnFrame(false)
|
|
308
507
|
}
|
|
309
508
|
const pick = () => {
|
|
310
509
|
const row = selectable[cursor()]
|
|
@@ -316,6 +515,7 @@ export default Plugin.define({
|
|
|
316
515
|
// slot below); it registers on the next reactive run. This dialog is
|
|
317
516
|
// now the armed one (the lifecycle token above).
|
|
318
517
|
armedClose = close
|
|
518
|
+
armedReveal = revealOnFrame
|
|
319
519
|
setListKeys({ move, pick })
|
|
320
520
|
|
|
321
521
|
context.ui.dialog.show(
|
|
@@ -332,7 +532,19 @@ export default Plugin.define({
|
|
|
332
532
|
esc
|
|
333
533
|
</text>
|
|
334
534
|
</box>
|
|
335
|
-
<
|
|
535
|
+
<scrollbox
|
|
536
|
+
ref={(element) => {
|
|
537
|
+
listScroll = element
|
|
538
|
+
// The report's preselected row (the current pick) opens
|
|
539
|
+
// centered in the scrollbox: scheduled from the ref, so the
|
|
540
|
+
// frame it waits for is the one that lays these rows out.
|
|
541
|
+
if (initial > 0) revealOnFrame(true)
|
|
542
|
+
}}
|
|
543
|
+
maxHeight={cappedRowsHeight(rowsHeight(rows), terminalSize().height)}
|
|
544
|
+
paddingLeft={1}
|
|
545
|
+
paddingRight={1}
|
|
546
|
+
scrollbarOptions={{ visible: false }}
|
|
547
|
+
>
|
|
336
548
|
{(() => {
|
|
337
549
|
let headings = 0
|
|
338
550
|
return rows.map((row) => {
|
|
@@ -343,11 +555,14 @@ export default Plugin.define({
|
|
|
343
555
|
}
|
|
344
556
|
if (row.heading !== undefined) {
|
|
345
557
|
// The palette's heading recipe: accent hue, bold, one
|
|
346
|
-
// blank line above every heading but the first
|
|
347
|
-
//
|
|
558
|
+
// blank line above every heading but the first — the
|
|
559
|
+
// blank is rowLines' call (the ONE row-height rule the
|
|
560
|
+
// offsets and the cap share), not a second copy of its
|
|
561
|
+
// condition. Flush with the title: 1 (list inset) + 3 = 4.
|
|
562
|
+
const paddingTop = rowLines(row, headings) - 1
|
|
348
563
|
headings += 1
|
|
349
564
|
return (
|
|
350
|
-
<box paddingTop={
|
|
565
|
+
<box paddingTop={paddingTop} paddingLeft={3}>
|
|
351
566
|
<text fg={accent} attributes={TextAttributes.BOLD}>
|
|
352
567
|
{row.heading}
|
|
353
568
|
</text>
|
|
@@ -388,7 +603,7 @@ export default Plugin.define({
|
|
|
388
603
|
)
|
|
389
604
|
})
|
|
390
605
|
})()}
|
|
391
|
-
</
|
|
606
|
+
</scrollbox>
|
|
392
607
|
<box paddingLeft={4}>
|
|
393
608
|
<text fg={muted}>Details: https://canonical.github.io/model-compare</text>
|
|
394
609
|
</box>
|
|
@@ -396,13 +611,19 @@ export default Plugin.define({
|
|
|
396
611
|
),
|
|
397
612
|
close,
|
|
398
613
|
)
|
|
614
|
+
// The palette's presentation: vertically centered at the medium width.
|
|
615
|
+
// The size/centered state persists across dialogs (the host's own set
|
|
616
|
+
// it on every open), so the explicit set also clears a stale xlarge a
|
|
617
|
+
// host dialog may have left behind.
|
|
618
|
+
context.ui.dialog.set({ size: "medium", centered: true })
|
|
399
619
|
}
|
|
400
620
|
|
|
401
621
|
// The picks dialog: the three priorities, one cursor, palette styling.
|
|
402
622
|
// A snapshot at open time; unavailable priorities are dimmed — the
|
|
403
623
|
// cursor still reaches them, and choosing one is refused with a toast
|
|
404
624
|
// (switchToPriority's guard). Choosing an available priority switches
|
|
405
|
-
// the session to it
|
|
625
|
+
// the session to it — or, with no session open, creates one bound to
|
|
626
|
+
// it (the opening screen's path; opening the dialog never does).
|
|
406
627
|
function showPicksDialog(): void {
|
|
407
628
|
const views = aliases()
|
|
408
629
|
const rows: ListRow[] = PRIORITIES.map((priority) => {
|
|
@@ -425,7 +646,10 @@ export default Plugin.define({
|
|
|
425
646
|
// model switches straight to it. Empty entries mean the command had no
|
|
426
647
|
// ranking data.
|
|
427
648
|
function showReportDialog(data: {
|
|
428
|
-
|
|
649
|
+
// The session the report belongs to — null when the dialog was opened
|
|
650
|
+
// with no session (the opening screen's direct-RPC path); a pick then
|
|
651
|
+
// creates a session bound to the chosen model instead of switching.
|
|
652
|
+
sessionID: string | null
|
|
429
653
|
age: string | null
|
|
430
654
|
entries: ReportEntry[]
|
|
431
655
|
}): void {
|
|
@@ -497,7 +721,18 @@ export default Plugin.define({
|
|
|
497
721
|
showListDialog(
|
|
498
722
|
`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`,
|
|
499
723
|
rows,
|
|
500
|
-
(id) =>
|
|
724
|
+
(id) => {
|
|
725
|
+
if (data.sessionID !== null) {
|
|
726
|
+
void switchToModel(data.sessionID, id)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
// The opening screen: the pick becomes a NEW session's model —
|
|
730
|
+
// the same concrete model the in-session path switches to.
|
|
731
|
+
void createSessionOnModel(
|
|
732
|
+
{ providerID: ALIAS_PROVIDER, id },
|
|
733
|
+
`switched to openrouter/${sanitizeCatalogText(id)}`,
|
|
734
|
+
)
|
|
735
|
+
},
|
|
501
736
|
)
|
|
502
737
|
}
|
|
503
738
|
|
|
@@ -522,30 +757,7 @@ export default Plugin.define({
|
|
|
522
757
|
const data = event.data
|
|
523
758
|
if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return
|
|
524
759
|
if (!Array.isArray(data.entries)) return
|
|
525
|
-
const entries = data.entries
|
|
526
|
-
const entry = raw as Partial<ReportEntry>
|
|
527
|
-
if (
|
|
528
|
-
typeof entry?.id !== "string" ||
|
|
529
|
-
typeof entry?.value !== "string" ||
|
|
530
|
-
typeof entry?.priority !== "string"
|
|
531
|
-
)
|
|
532
|
-
return []
|
|
533
|
-
return [
|
|
534
|
-
{
|
|
535
|
-
priority: entry.priority as Priority,
|
|
536
|
-
rank: typeof entry.rank === "number" ? entry.rank : null,
|
|
537
|
-
// The F-20 split (see ReportEntry): `id` sanitized for display,
|
|
538
|
-
// `value` the raw catalog id for switching.
|
|
539
|
-
id: entry.id,
|
|
540
|
-
value: entry.value,
|
|
541
|
-
score: typeof entry.score === "string" ? entry.score : "-",
|
|
542
|
-
quality: typeof entry.quality === "string" ? entry.quality : "-",
|
|
543
|
-
price: typeof entry.price === "string" ? entry.price : "-",
|
|
544
|
-
current: entry.current === true,
|
|
545
|
-
fromFallback: entry.fromFallback === true,
|
|
546
|
-
},
|
|
547
|
-
]
|
|
548
|
-
})
|
|
760
|
+
const entries = toReportEntries(data.entries)
|
|
549
761
|
const eventSessionID = data.sessionID
|
|
550
762
|
// Ownerless callback: park the report on the signal — the app slot's
|
|
551
763
|
// in-tree effect opens the dialog (see pendingReport above).
|
|
@@ -628,29 +840,27 @@ export default Plugin.define({
|
|
|
628
840
|
|
|
629
841
|
// The picks dialog's command. No slash: /use-best already covers the
|
|
630
842
|
// switching surface (the server command is slash-routable; the TUI's
|
|
631
|
-
// duplicate of it was removed for exactly that reason).
|
|
843
|
+
// duplicate of it was removed for exactly that reason). The dialog opens
|
|
844
|
+
// everywhere — with no session it shows the current picks, and only
|
|
845
|
+
// picking one creates a session bound to it (switchToPriority).
|
|
632
846
|
const picksCommand: KeymapCommand = {
|
|
633
847
|
id: "model-compare.picks",
|
|
634
848
|
title: "Model-compare top picks",
|
|
635
849
|
group: "Model Compare",
|
|
636
850
|
bind: "alt+m",
|
|
637
851
|
palette: true,
|
|
638
|
-
run: () =>
|
|
639
|
-
if (currentSessionID() === null) {
|
|
640
|
-
notify("no session yet — submit any prompt to create one; picking a priority switches that session", "error")
|
|
641
|
-
return
|
|
642
|
-
}
|
|
643
|
-
showPicksDialog()
|
|
644
|
-
},
|
|
852
|
+
run: () => showPicksDialog(),
|
|
645
853
|
}
|
|
646
854
|
|
|
647
|
-
// The report command: posts the server's
|
|
648
|
-
// RPC event opens the selectable report
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
855
|
+
// The report command: with a session open it posts the server's
|
|
856
|
+
// /model-compare command, whose RPC event opens the selectable report
|
|
857
|
+
// dialog here (and the transcript keeps the notice). With no session
|
|
858
|
+
// (the opening screen) there is nothing to post to, so the report is
|
|
859
|
+
// fetched straight from the plugin's report RPC — same builder, same
|
|
860
|
+
// entries — and picking a model creates a session bound to it (there is
|
|
861
|
+
// no composer-model setter to change instead; verified against the
|
|
862
|
+
// pinned runtime). The refusal-free opening also keeps the footer's
|
|
863
|
+
// status line honest: the shortcuts always DO something.
|
|
654
864
|
const reportCommand: KeymapCommand = {
|
|
655
865
|
id: "model-compare.report",
|
|
656
866
|
title: "Model-compare report",
|
|
@@ -659,12 +869,25 @@ export default Plugin.define({
|
|
|
659
869
|
palette: true,
|
|
660
870
|
run: async () => {
|
|
661
871
|
const sessionID = currentSessionID()
|
|
662
|
-
if (sessionID
|
|
663
|
-
|
|
872
|
+
if (sessionID !== null) {
|
|
873
|
+
try {
|
|
874
|
+
await context.client.session.command({ sessionID, name: "model-compare", text: "" })
|
|
875
|
+
} catch (error) {
|
|
876
|
+
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
877
|
+
}
|
|
664
878
|
return
|
|
665
879
|
}
|
|
666
880
|
try {
|
|
667
|
-
await context.client.
|
|
881
|
+
const report: unknown = await context.client.rpc(ModelCompare).report(
|
|
882
|
+
{},
|
|
883
|
+
here === undefined ? undefined : { location: { directory: here } },
|
|
884
|
+
)
|
|
885
|
+
const data = report as { age?: unknown; entries?: unknown } | undefined
|
|
886
|
+
showReportDialog({
|
|
887
|
+
sessionID: null,
|
|
888
|
+
age: typeof data?.age === "string" ? data.age : null,
|
|
889
|
+
entries: toReportEntries(data?.entries),
|
|
890
|
+
})
|
|
668
891
|
} catch (error) {
|
|
669
892
|
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
670
893
|
}
|
|
@@ -761,7 +984,13 @@ export default Plugin.define({
|
|
|
761
984
|
})
|
|
762
985
|
|
|
763
986
|
return () => {
|
|
987
|
+
// Disarm whatever dialog is still armed FIRST: its pending frame
|
|
988
|
+
// reveal and modal keyboard must not outlive the plugin (the
|
|
989
|
+
// module-scope armedClose/armedReveal survive setup's cleanup
|
|
990
|
+
// otherwise).
|
|
991
|
+
armedClose?.()
|
|
764
992
|
if (resyncTimer !== undefined) clearInterval(resyncTimer)
|
|
993
|
+
context.renderer.off("resize", onRendererResize)
|
|
765
994
|
offReportEvent()
|
|
766
995
|
disposeRoot()
|
|
767
996
|
disposeAppSlot()
|