model-compare-opencode-plugin 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +186 -2
- package/dist/tui.js +741 -0
- package/package.json +60 -4
- package/src/config.ts +197 -0
- package/src/core/catalog.ts +189 -0
- package/src/core/hash.ts +12 -0
- package/src/core/pins.ts +158 -0
- package/src/core/rank.ts +132 -0
- package/src/core/types.ts +50 -0
- package/src/index.ts +478 -0
- package/src/opencode/aliases.ts +58 -0
- package/src/opencode/commands.ts +251 -0
- package/src/opencode/guard.ts +44 -0
- package/src/opencode/refresh.ts +188 -0
- package/src/opencode/resolution.ts +69 -0
- package/src/opencode/storage.ts +97 -0
- package/src/opencode/usebest.ts +121 -0
- package/src/rpc.ts +78 -0
- package/src/sources/index.ts +115 -0
- package/src/sources/local.ts +175 -0
- package/src/sources/remote.ts +155 -0
- package/src/tui.tsx +772 -0
- package/vendor/UPSTREAM.json +6 -0
- package/vendor/model_compare.py +1950 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
// GENERATED by scripts/build-tui.mjs from src/tui.tsx — do not edit.
|
|
2
|
+
// Run `npm run build` after changing the source. See the build script's
|
|
3
|
+
// header for why the published package ships precompiled JSX.
|
|
4
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
5
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
6
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
7
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
8
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
9
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
10
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
11
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
12
|
+
// src/tui.tsx
|
|
13
|
+
// The model-compare CLI plugin (design §10, "Notices and status"): the
|
|
14
|
+
// terminal-UI half of the plugin, loaded from the `./tui` export. It owns
|
|
15
|
+
// everything the headless server cannot show:
|
|
16
|
+
//
|
|
17
|
+
// * a live status line in the prompt and home footers, derived from the
|
|
18
|
+
// model registry (the runtime plugin keeps the alias entries' display
|
|
19
|
+
// names current, so the registry IS the state; no messaging involved);
|
|
20
|
+
// * the picks dialog (alt+m): a select over the three priorities —
|
|
21
|
+
// choosing one switches the session to it, with the same decision logic
|
|
22
|
+
// as the server command;
|
|
23
|
+
// * the report dialog (alt+shift+m): the /model-compare command emits its
|
|
24
|
+
// structured report as an RPC event, and this half opens it as a
|
|
25
|
+
// selectable dialog (selecting a ranked model switches straight to it) —
|
|
26
|
+
// one command serves both worlds, so no TUI duplicate of the server
|
|
27
|
+
// command exists;
|
|
28
|
+
// * a toast when a pick flips (at most one per changed priority per
|
|
29
|
+
// reload, and never for the first observation after load).
|
|
30
|
+
//
|
|
31
|
+
// The headless path (servers, `opencode run`, API clients) has no TUI: its
|
|
32
|
+
// notices stay inbox items delivered at the session's next turn (the
|
|
33
|
+
// commands' `resume: false`), which is correct there — there is no UI to
|
|
34
|
+
// toast in.
|
|
35
|
+
//
|
|
36
|
+
// Owner discipline: everything the host's bridges read through Solid context
|
|
37
|
+
// (the keymap layer registration) runs in setup's own scope, under the owner
|
|
38
|
+
// the TUI host captured for this activation. The createRoot below detaches
|
|
39
|
+
// from that chain on purpose — it owns the solid-js lifetime of the memos
|
|
40
|
+
// and effects — so nothing inside it may call context APIs that resolve
|
|
41
|
+
// Solid context: `context.keymap.layer(...)` inside the root threw
|
|
42
|
+
// "Keymap.Provider is missing" and failed the whole TUI half (observed live,
|
|
43
|
+
// 2026-10-11). The memos and helper functions therefore live at setup scope,
|
|
44
|
+
// where the root's effects and the keymap layers can both close over them.
|
|
45
|
+
|
|
46
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
47
|
+
import { TextAttributes, RGBA } from "@opentui/core";
|
|
48
|
+
// The OpenTUI JSX element types (<text fg>, <box>): a type-only import loads
|
|
49
|
+
// the package's solid-js JSX augmentation without a runtime import — the TUI
|
|
50
|
+
// host already provides the runtime side.
|
|
51
|
+
|
|
52
|
+
import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
|
|
53
|
+
import { ALIAS_PROVIDER, PRIORITIES, alignedPriority, aliasId, pickOf, pickFromName, readAliasViews, statusLine, useBestDecision, useBestNotice, useBestRefusal } from "../src/opencode/usebest";
|
|
54
|
+
import { sanitizeCatalogText } from "../src/opencode/commands";
|
|
55
|
+
const TOAST_MS = 6_000;
|
|
56
|
+
const RESYNC_MS = 30_000;
|
|
57
|
+
|
|
58
|
+
// The plugin package name fragment this entry shares state with: the config
|
|
59
|
+
// lookup matches `plugins[].package` entries against it to read the runtime
|
|
60
|
+
// plugin's options (defaultPriority) through the client's config API.
|
|
61
|
+
const PACKAGE_FRAGMENT = "model-compare-opencode-plugin";
|
|
62
|
+
// The RPC event the /model-compare command emits (see src/rpc.ts; the wire
|
|
63
|
+
// name is prefixed with the RPC's id).
|
|
64
|
+
const REPORT_EVENT = "rpc.model-compare.report";
|
|
65
|
+
|
|
66
|
+
// The list dialogs' lifecycle token (see showListDialog): a module-scope
|
|
67
|
+
// generation counter, bumped every time a dialog is armed, plus the armed
|
|
68
|
+
// dialog's close. A dialog's close() tears down the shared state (the modal
|
|
69
|
+
// keymap layer, the host dialog) only while that dialog is STILL the armed
|
|
70
|
+
// one — a stale close must not clear a newer dialog's keyboard and surface —
|
|
71
|
+
// and arming a new dialog closes whatever is still armed first.
|
|
72
|
+
let dialogGeneration = 0;
|
|
73
|
+
let armedClose = null;
|
|
74
|
+
function StatusLine(props) {
|
|
75
|
+
return (() => {
|
|
76
|
+
var _el$ = _$createElement("text");
|
|
77
|
+
_$insert(_el$, () => props.line());
|
|
78
|
+
_$effect(_$p => _$setProp(_el$, "fg", props.fg, _$p));
|
|
79
|
+
return _el$;
|
|
80
|
+
})();
|
|
81
|
+
}
|
|
82
|
+
export default Plugin.define({
|
|
83
|
+
id: "model-compare-tui",
|
|
84
|
+
async setup(context) {
|
|
85
|
+
const location = context.location ?? context.data.location.default();
|
|
86
|
+
const here = location?.directory;
|
|
87
|
+
const notify = (message, variant = "success") => context.ui.toast.show({
|
|
88
|
+
title: "model-compare",
|
|
89
|
+
message,
|
|
90
|
+
variant,
|
|
91
|
+
duration: TOAST_MS
|
|
92
|
+
});
|
|
93
|
+
let disposeRoot = () => {};
|
|
94
|
+
// The three slot disposers (the two footers and the app slot below):
|
|
95
|
+
// slots claimed but never released survive the plugin's unload and keep
|
|
96
|
+
// rendering into a torn-down UI. Called from the returned cleanup.
|
|
97
|
+
let disposePromptFooter = () => {};
|
|
98
|
+
let disposeHomeFooter = () => {};
|
|
99
|
+
let disposeAppSlot = () => {};
|
|
100
|
+
let resyncTimer;
|
|
101
|
+
// The report dialog's hand-off: the /model-compare event callback runs
|
|
102
|
+
// ownerless (data.listen), and a dialog mounted from an ownerless
|
|
103
|
+
// callback renders but its keyboard never attaches — the mount lands
|
|
104
|
+
// outside the provider tree no matter how the call is wrapped. So the
|
|
105
|
+
// callback only PARKS the data on this signal; the app slot below owns
|
|
106
|
+
// an effect (created inside the tree) that opens the dialog when it
|
|
107
|
+
// fires.
|
|
108
|
+
const [pendingReport, setPendingReport] = createSignal(null);
|
|
109
|
+
|
|
110
|
+
// The registry view, reactive: OpenCode's data layer updates the list
|
|
111
|
+
// when the runtime plugin's reloads commit, so the memo re-runs. Created
|
|
112
|
+
// at setup scope (see the owner-discipline note above): the footers'
|
|
113
|
+
// slots and the flip-watch effect below read it inside the root, and the
|
|
114
|
+
// keymap layer's run() reads it outside — a memo works from both.
|
|
115
|
+
const aliases = createMemo(() => readAliasViews(context.data.location.model.list(location) ?? []));
|
|
116
|
+
const line = createMemo(() => statusLine(aliases()));
|
|
117
|
+
|
|
118
|
+
// The focused session: the router's current route when it is a session
|
|
119
|
+
// route; null otherwise (home, dialogs). Defensive: the route's shape
|
|
120
|
+
// is the host's, so every field is read guardedly.
|
|
121
|
+
function currentSessionID() {
|
|
122
|
+
const route = context.ui.router.current();
|
|
123
|
+
if (route === undefined || route === null) return null;
|
|
124
|
+
if (route.type !== undefined && route.type !== "session") return null;
|
|
125
|
+
if (typeof route.sessionID === "string") return route.sessionID;
|
|
126
|
+
if (typeof route.data?.sessionID === "string") return route.data.sessionID;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The runtime plugin's configured default priority, read through the
|
|
131
|
+
// client's config API: the config documents carry `plugins[].options`.
|
|
132
|
+
// Falls back to "balanced" when the option is absent or unreadable.
|
|
133
|
+
async function readDefaultPriority() {
|
|
134
|
+
try {
|
|
135
|
+
const entries = await context.client.config.get();
|
|
136
|
+
for (const entry of entries ?? []) {
|
|
137
|
+
// ConfigEntry is a union; only document entries carry the config.
|
|
138
|
+
if (entry.type !== "document") continue;
|
|
139
|
+
const plugins = entry.info.plugins;
|
|
140
|
+
if (!Array.isArray(plugins)) continue;
|
|
141
|
+
for (const plugin of plugins) {
|
|
142
|
+
if (typeof plugin !== "object" || plugin === null) continue;
|
|
143
|
+
const record = plugin;
|
|
144
|
+
const pkg = typeof record.package === "string" ? record.package : "";
|
|
145
|
+
if (!pkg.includes(PACKAGE_FRAGMENT)) continue;
|
|
146
|
+
const priority = record.options?.defaultPriority;
|
|
147
|
+
if (priority === "balanced" || priority === "price" || priority === "quality") {
|
|
148
|
+
return priority;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Unreadable config: the default applies (§3 semantics).
|
|
154
|
+
}
|
|
155
|
+
return "balanced";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Switch the current session to one of the plugin's aliases — the shared
|
|
159
|
+
// decision chain behind /use-best, the palette command and the picks
|
|
160
|
+
// dialog's selection.
|
|
161
|
+
async function switchToPriority(priority) {
|
|
162
|
+
const sessionID = currentSessionID();
|
|
163
|
+
if (sessionID === null) {
|
|
164
|
+
notify("Open a session first", "error");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const views = aliases();
|
|
169
|
+
if (!views.some(view => view.priority === priority && view.enabled)) {
|
|
170
|
+
notify(useBestRefusal(priority), "error");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await context.client.session.switchModel({
|
|
174
|
+
sessionID,
|
|
175
|
+
model: {
|
|
176
|
+
providerID: ALIAS_PROVIDER,
|
|
177
|
+
id: aliasId(priority)
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const view = views.find(item => item.priority === priority);
|
|
181
|
+
notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Switch the current session straight to a concrete model (the report
|
|
188
|
+
// dialog's selection).
|
|
189
|
+
async function switchToModel(sessionID, id) {
|
|
190
|
+
try {
|
|
191
|
+
await context.client.session.switchModel({
|
|
192
|
+
sessionID,
|
|
193
|
+
model: {
|
|
194
|
+
providerID: ALIAS_PROVIDER,
|
|
195
|
+
id
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// F-20: `id` here is the raw catalog id (the switch needs it) — the
|
|
199
|
+
// toast shows the flattened form so it cannot be line-injected.
|
|
200
|
+
notify(`switched to openrouter/${sanitizeCatalogText(id)}`);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
notify(`switch failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The custom list dialog. The host's `dialog.select` (SDK 2.0.16
|
|
207
|
+
// runtime) renders one GROUPED box per category with one cursor each —
|
|
208
|
+
// its search flattening (verified against main) does not exist in this
|
|
209
|
+
// build — so three simultaneous cursors move independently. This
|
|
210
|
+
// renderer replaces it: `dialog.show` + our own rows — one cursor,
|
|
211
|
+
// palette styling (bold title, dim esc, theme action colors on the
|
|
212
|
+
// selected row). The modal keymap layer cannot be registered from the
|
|
213
|
+
// render closure (context-less — "Keymap.Provider is missing", the same
|
|
214
|
+
// trap as setup scope), so the app slot's IN-TREE effect registers it
|
|
215
|
+
// while `listKeys` is armed, and the host unmounts it with the dialog.
|
|
216
|
+
|
|
217
|
+
// The keymap layer hand-off (see showListDialog): set while a list
|
|
218
|
+
// dialog is open, cleared on close; the app slot's in-tree effect maps
|
|
219
|
+
// it to a modal layer.
|
|
220
|
+
const [listKeys, setListKeys] = createSignal(null);
|
|
221
|
+
function showListDialog(title, rows, onPick) {
|
|
222
|
+
const selectable = rows.filter(row => row.value !== undefined);
|
|
223
|
+
if (selectable.length === 0) {
|
|
224
|
+
notify("nothing to select", "error");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const initial = Math.max(0, selectable.findIndex(row => row.selected !== undefined));
|
|
228
|
+
// The DIALOG surface: the exact theme call the host's own dialogs make
|
|
229
|
+
// (dialog-select/dialog-alert) — uniform colors with the host chrome
|
|
230
|
+
// and the palette's heading color.
|
|
231
|
+
const theme = context.theme.surface("dialog");
|
|
232
|
+
const base = theme.text.base;
|
|
233
|
+
const muted = theme.text.muted;
|
|
234
|
+
const accent = theme.hue.accent[200];
|
|
235
|
+
const hiFg = theme.text.action.primary.focused;
|
|
236
|
+
const hiBg = theme.background.action.primary.focused;
|
|
237
|
+
// The native rows' inactive background: explicitly transparent, so the
|
|
238
|
+
// dialog surface shows through.
|
|
239
|
+
const transparent = RGBA.fromInts(0, 0, 0, 0);
|
|
240
|
+
|
|
241
|
+
// This dialog's lifecycle token: its generation in the module-scope
|
|
242
|
+
// counter. Replaced dialogs are closed first (below), so a close()
|
|
243
|
+
// that finds a newer generation knows it lost ownership and must not
|
|
244
|
+
// touch the armed dialog's shared state.
|
|
245
|
+
armedClose?.();
|
|
246
|
+
const generation = ++dialogGeneration;
|
|
247
|
+
let alive = true;
|
|
248
|
+
// The cursor as a SIGNAL: the JSX closure reads it reactively, so
|
|
249
|
+
// every move repaints the dialog (a plain `let` painted once and
|
|
250
|
+
// never updated — the cursor sat on row 1 however many times the
|
|
251
|
+
// keymap moved the internal index; observed live, 2026-10-11).
|
|
252
|
+
const [cursor, setCursor] = createSignal(initial);
|
|
253
|
+
const close = () => {
|
|
254
|
+
if (!alive) return;
|
|
255
|
+
alive = false;
|
|
256
|
+
// A newer dialog was armed since this one: its own close owns the
|
|
257
|
+
// shared state (the keymap layer, the host dialog) now — clearing
|
|
258
|
+
// here would kill ITS keyboard and surface.
|
|
259
|
+
if (generation !== dialogGeneration) return;
|
|
260
|
+
armedClose = null;
|
|
261
|
+
setListKeys(null);
|
|
262
|
+
context.ui.dialog.clear();
|
|
263
|
+
};
|
|
264
|
+
// move(±1) walks the SELECTABLE rows only; heading rows never take
|
|
265
|
+
// the cursor. Wraps: one past the last is the first, one before the
|
|
266
|
+
// first is the last (the command palette's behavior).
|
|
267
|
+
const move = delta => {
|
|
268
|
+
setCursor((cursor() + delta + selectable.length) % selectable.length);
|
|
269
|
+
};
|
|
270
|
+
const pick = () => {
|
|
271
|
+
const row = selectable[cursor()];
|
|
272
|
+
close();
|
|
273
|
+
onPick(row.value);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Arm the modal layer via the app slot's in-tree effect (see the app
|
|
277
|
+
// slot below); it registers on the next reactive run. This dialog is
|
|
278
|
+
// now the armed one (the lifecycle token above).
|
|
279
|
+
armedClose = close;
|
|
280
|
+
setListKeys({
|
|
281
|
+
move,
|
|
282
|
+
pick
|
|
283
|
+
});
|
|
284
|
+
context.ui.dialog.show(() => // Top/bottom blanks follow the native boxes: 1 with a tip line at
|
|
285
|
+
// the bottom (the host's dialog panel contributes the other top
|
|
286
|
+
// blank, so our own top padding is 0).
|
|
287
|
+
(() => {
|
|
288
|
+
var _el$2 = _$createElement("box"),
|
|
289
|
+
_el$3 = _$createElement("box"),
|
|
290
|
+
_el$4 = _$createElement("text"),
|
|
291
|
+
_el$5 = _$createElement("text"),
|
|
292
|
+
_el$7 = _$createElement("box"),
|
|
293
|
+
_el$8 = _$createElement("box"),
|
|
294
|
+
_el$9 = _$createElement("text");
|
|
295
|
+
_$insertNode(_el$2, _el$3);
|
|
296
|
+
_$insertNode(_el$2, _el$7);
|
|
297
|
+
_$insertNode(_el$2, _el$8);
|
|
298
|
+
_$setProp(_el$2, "paddingTop", 0);
|
|
299
|
+
_$setProp(_el$2, "paddingBottom", 1);
|
|
300
|
+
_$setProp(_el$2, "gap", 1);
|
|
301
|
+
_$insertNode(_el$3, _el$4);
|
|
302
|
+
_$insertNode(_el$3, _el$5);
|
|
303
|
+
_$setProp(_el$3, "paddingLeft", 4);
|
|
304
|
+
_$setProp(_el$3, "paddingRight", 4);
|
|
305
|
+
_$setProp(_el$3, "flexDirection", "row");
|
|
306
|
+
_$setProp(_el$3, "justifyContent", "space-between");
|
|
307
|
+
_$setProp(_el$4, "fg", base);
|
|
308
|
+
_$insert(_el$4, title);
|
|
309
|
+
_$insertNode(_el$5, _$createTextNode(`esc`));
|
|
310
|
+
_$setProp(_el$5, "fg", muted);
|
|
311
|
+
_$setProp(_el$5, "onMouseUp", () => close());
|
|
312
|
+
_$setProp(_el$7, "paddingLeft", 1);
|
|
313
|
+
_$setProp(_el$7, "paddingRight", 1);
|
|
314
|
+
_$setProp(_el$7, "flexDirection", "column");
|
|
315
|
+
_$insert(_el$7, () => {
|
|
316
|
+
let headings = 0;
|
|
317
|
+
return rows.map(row => {
|
|
318
|
+
if (row.headerLine !== undefined) {
|
|
319
|
+
// The table's column header: dim, cursor-skipped, and
|
|
320
|
+
// directly above the first section (no blank line).
|
|
321
|
+
return (() => {
|
|
322
|
+
var _el$1 = _$createElement("text");
|
|
323
|
+
_$setProp(_el$1, "fg", muted);
|
|
324
|
+
_$insert(_el$1, () => row.headerLine);
|
|
325
|
+
return _el$1;
|
|
326
|
+
})();
|
|
327
|
+
}
|
|
328
|
+
if (row.heading !== undefined) {
|
|
329
|
+
// The palette's heading recipe: accent hue, bold, one
|
|
330
|
+
// blank line above every heading but the first. Flush
|
|
331
|
+
// with the title: 1 (list inset) + 3 = 4.
|
|
332
|
+
headings += 1;
|
|
333
|
+
return (() => {
|
|
334
|
+
var _el$10 = _$createElement("box"),
|
|
335
|
+
_el$11 = _$createElement("text");
|
|
336
|
+
_$insertNode(_el$10, _el$11);
|
|
337
|
+
_$setProp(_el$10, "paddingTop", headings > 1 ? 1 : 0);
|
|
338
|
+
_$setProp(_el$10, "paddingLeft", 3);
|
|
339
|
+
_$setProp(_el$11, "fg", accent);
|
|
340
|
+
_$insert(_el$11, () => row.heading);
|
|
341
|
+
_$effect(_$p => _$setProp(_el$11, "attributes", TextAttributes.BOLD, _$p));
|
|
342
|
+
return _el$10;
|
|
343
|
+
})();
|
|
344
|
+
}
|
|
345
|
+
const active = row.value !== undefined && selectable[cursor()] === row;
|
|
346
|
+
// The row BOX carries the background: it stretches the
|
|
347
|
+
// list's full width, so the cursor is "justified" — from
|
|
348
|
+
// the 1-blank left inset to the 1-blank right inset —
|
|
349
|
+
// while the text stays at the content indent (4).
|
|
350
|
+
const nameFg = row.dim === true ? muted : active ? hiFg : base;
|
|
351
|
+
return (() => {
|
|
352
|
+
var _el$12 = _$createElement("box");
|
|
353
|
+
_$setProp(_el$12, "flexDirection", "row");
|
|
354
|
+
_$setProp(_el$12, "paddingLeft", 3);
|
|
355
|
+
_$setProp(_el$12, "paddingRight", 3);
|
|
356
|
+
_$setProp(_el$12, "backgroundColor", active ? hiBg : transparent);
|
|
357
|
+
_$insert(_el$12, (() => {
|
|
358
|
+
var _c$ = _$memo(() => row.columns !== undefined);
|
|
359
|
+
return () => _c$() ? (() => {
|
|
360
|
+
var _el$13 = _$createElement("text"),
|
|
361
|
+
_el$14 = _$createElement("span"),
|
|
362
|
+
_el$15 = _$createElement("span");
|
|
363
|
+
_$insertNode(_el$13, _el$14);
|
|
364
|
+
_$insertNode(_el$13, _el$15);
|
|
365
|
+
_$setProp(_el$14, "style", {
|
|
366
|
+
fg: nameFg
|
|
367
|
+
});
|
|
368
|
+
_$insert(_el$14, (() => {
|
|
369
|
+
var _c$2 = _$memo(() => !!(active && row.dim !== true));
|
|
370
|
+
return () => _c$2() ? (() => {
|
|
371
|
+
var _el$16 = _$createElement("b");
|
|
372
|
+
_$insert(_el$16, () => row.label);
|
|
373
|
+
return _el$16;
|
|
374
|
+
})() : row.label;
|
|
375
|
+
})());
|
|
376
|
+
_$insert(_el$15, () => row.columns);
|
|
377
|
+
_$effect(_$p => _$setProp(_el$15, "style", {
|
|
378
|
+
fg: row.dim === true || !active ? muted : hiFg
|
|
379
|
+
}, _$p));
|
|
380
|
+
return _el$13;
|
|
381
|
+
})() : (() => {
|
|
382
|
+
var _el$17 = _$createElement("text");
|
|
383
|
+
_$setProp(_el$17, "fg", nameFg);
|
|
384
|
+
_$insert(_el$17, () => row.label);
|
|
385
|
+
_$effect(_$p => _$setProp(_el$17, "attributes", active && row.dim !== true ? TextAttributes.BOLD : undefined, _$p));
|
|
386
|
+
return _el$17;
|
|
387
|
+
})();
|
|
388
|
+
})());
|
|
389
|
+
return _el$12;
|
|
390
|
+
})();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
_$insertNode(_el$8, _el$9);
|
|
394
|
+
_$setProp(_el$8, "paddingLeft", 4);
|
|
395
|
+
_$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
|
|
396
|
+
_$setProp(_el$9, "fg", muted);
|
|
397
|
+
_$effect(_$p => _$setProp(_el$4, "attributes", TextAttributes.BOLD, _$p));
|
|
398
|
+
return _el$2;
|
|
399
|
+
})(), close);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// The picks dialog: the three priorities, one cursor, palette styling.
|
|
403
|
+
// A snapshot at open time; unavailable priorities are dimmed — the
|
|
404
|
+
// cursor still reaches them, and choosing one is refused with a toast
|
|
405
|
+
// (switchToPriority's guard). Choosing an available priority switches
|
|
406
|
+
// the session to it.
|
|
407
|
+
function showPicksDialog() {
|
|
408
|
+
const views = aliases();
|
|
409
|
+
const rows = PRIORITIES.map(priority => {
|
|
410
|
+
const view = views.find(item => item.priority === priority);
|
|
411
|
+
const enabled = view !== undefined && view.enabled !== false;
|
|
412
|
+
return {
|
|
413
|
+
label: `${alignedPriority(priority)} → ${enabled ? pickOf(view) ?? "unavailable" : "disabled"}`,
|
|
414
|
+
value: priority,
|
|
415
|
+
dim: !enabled
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
showListDialog("Model Compare — current picks", rows, priority => {
|
|
419
|
+
void switchToPriority(priority);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// The report dialog: the ranked entries under bold-ish priority
|
|
424
|
+
// headings (palette category color, blank line above each — headings
|
|
425
|
+
// are skipped by the cursor), one cursor over all of them; choosing a
|
|
426
|
+
// model switches straight to it. Empty entries mean the command had no
|
|
427
|
+
// ranking data.
|
|
428
|
+
function showReportDialog(data) {
|
|
429
|
+
if (data.entries.length === 0) {
|
|
430
|
+
notify("no ranking data available yet", "error");
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// The approved table: one dim column header at the top (directly above
|
|
434
|
+
// the first section), section headings with a blank line above all but
|
|
435
|
+
// the first, and right-justified quality/price columns. No score
|
|
436
|
+
// column — the transcript's text report keeps it. Column widths are
|
|
437
|
+
// clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
|
|
438
|
+
// to their clamped width, so with the name clamp below the row budget
|
|
439
|
+
// holds even for hostile/absurd column strings: what the clamp
|
|
440
|
+
// guarantees is that a row and the header stay within the dialog's
|
|
441
|
+
// fixed 52-char content width (hostile values get truncated rather
|
|
442
|
+
// than pushed into wrapping — the 40-char rankless fallback label did
|
|
443
|
+
// exactly that before the name clamp).
|
|
444
|
+
const labels = data.entries.map(entry => `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${entry.fromFallback ? " (fallback)" : ""}`);
|
|
445
|
+
const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]));
|
|
446
|
+
const colW = (values, floor, cap) => Math.min(cap, Math.max(floor, ...values.map(value => value.length)));
|
|
447
|
+
const qualW = colW(data.entries.map(entry => entry.quality), "qual".length, 8);
|
|
448
|
+
const priceW = colW(data.entries.map(entry => entry.price), "$ in/out".length, 16);
|
|
449
|
+
// A cell wider than its clamped column is truncated to fit.
|
|
450
|
+
const cell = (value, width) => value.length > width ? value.slice(0, width - 1) + "…" : value;
|
|
451
|
+
// Prices use padEnd (not padStart): padded to a common width the
|
|
452
|
+
// right edges stay flush while the fixed 2-space gap between columns
|
|
453
|
+
// stays exactly 2 (padStart pushed each value right by its slack).
|
|
454
|
+
// The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
|
|
455
|
+
// for row content; the header shares that budget (plus its 3-indent).
|
|
456
|
+
const fixed = 2 + qualW + 2 + priceW;
|
|
457
|
+
const nameW = Math.max(8, Math.min(52 - fixed, Math.max(...labels.map(label => label.length))));
|
|
458
|
+
const trim = label => label.length > nameW ? label.slice(0, nameW - 1) + "…" : label;
|
|
459
|
+
const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "$ in/out".padEnd(priceW);
|
|
460
|
+
const rows = [{
|
|
461
|
+
label: "",
|
|
462
|
+
headerLine
|
|
463
|
+
}];
|
|
464
|
+
for (const priority of PRIORITIES) {
|
|
465
|
+
const section = data.entries.filter(entry => entry.priority === priority);
|
|
466
|
+
if (section.length === 0) continue;
|
|
467
|
+
rows.push({
|
|
468
|
+
heading: priority,
|
|
469
|
+
label: ""
|
|
470
|
+
});
|
|
471
|
+
for (const entry of section) {
|
|
472
|
+
rows.push({
|
|
473
|
+
label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
|
|
474
|
+
columns: " " + cell(entry.quality, qualW).padStart(qualW) + " " + cell(entry.price, priceW).padEnd(priceW),
|
|
475
|
+
// F-20 id split: the row DISPLAYS the sanitized id (in `label`)
|
|
476
|
+
// but picks `value`, the raw catalog id switchToModel needs.
|
|
477
|
+
value: entry.value,
|
|
478
|
+
selected: entry.current ? entry.value : undefined
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => void switchToModel(data.sessionID, id));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// The report dialog rides the /model-compare command's RPC event: ONE
|
|
486
|
+
// command serves both worlds — headless gets the transcript notice, the
|
|
487
|
+
// TUI gets this instant selectable dialog. Filtered to this plugin's
|
|
488
|
+
// location and the currently viewed session, so a headless run's report
|
|
489
|
+
// never pops a dialog over an unrelated session.
|
|
490
|
+
const offReportEvent = context.data.listen(({
|
|
491
|
+
details
|
|
492
|
+
}) => {
|
|
493
|
+
const event = details;
|
|
494
|
+
if (event.type !== REPORT_EVENT) return;
|
|
495
|
+
if (typeof event.location?.directory === "string" && here !== undefined && event.location.directory !== here) return;
|
|
496
|
+
const data = event.data;
|
|
497
|
+
if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
|
|
498
|
+
if (!Array.isArray(data.entries)) return;
|
|
499
|
+
const entries = data.entries.flatMap(raw => {
|
|
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
|
+
});
|
|
516
|
+
const eventSessionID = data.sessionID;
|
|
517
|
+
// Ownerless callback: park the report on the signal — the app slot's
|
|
518
|
+
// in-tree effect opens the dialog (see pendingReport above).
|
|
519
|
+
setPendingReport({
|
|
520
|
+
sessionID: eventSessionID,
|
|
521
|
+
age: typeof data.age === "string" ? data.age : null,
|
|
522
|
+
entries
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
createRoot(dispose => {
|
|
526
|
+
disposeRoot = dispose;
|
|
527
|
+
|
|
528
|
+
// Keep the data layer fresh: a belt-and-braces re-sync, since the
|
|
529
|
+
// registry also updates reactively. Cleared on unload below.
|
|
530
|
+
void context.data.location.model.sync(location);
|
|
531
|
+
resyncTimer = setInterval(() => {
|
|
532
|
+
void context.data.location.model.sync(location).catch(() => {});
|
|
533
|
+
}, RESYNC_MS);
|
|
534
|
+
|
|
535
|
+
// The status line in both footers, in the theme's text color (read
|
|
536
|
+
// inside the render, so a theme switch re-renders).
|
|
537
|
+
disposePromptFooter = context.ui.slot({
|
|
538
|
+
append: "prompt.footer.status",
|
|
539
|
+
render: () => _$createComponent(StatusLine, {
|
|
540
|
+
line: line,
|
|
541
|
+
get fg() {
|
|
542
|
+
return context.theme.text.base;
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
});
|
|
546
|
+
disposeHomeFooter = context.ui.slot({
|
|
547
|
+
append: "home.footer.status",
|
|
548
|
+
render: () => _$createComponent(StatusLine, {
|
|
549
|
+
line: line,
|
|
550
|
+
get fg() {
|
|
551
|
+
return context.theme.text.base;
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Toast when a pick flips. A real per-priority diff over the pick ids
|
|
557
|
+
// — the previous pick string or null per priority — so only the
|
|
558
|
+
// priorities whose pick ACTUALLY changed toast: a "(fallback)"
|
|
559
|
+
// name-marker change with the same pick is not a flip, and going
|
|
560
|
+
// disabled (enabled→disabled, pick → null) is never toasted. The first
|
|
561
|
+
// observation after load only seeds the map (a load is not a change);
|
|
562
|
+
// when several flips land in one reload, each changed priority gets
|
|
563
|
+
// its own toast.
|
|
564
|
+
const previousPicks = new Map();
|
|
565
|
+
createEffect(() => {
|
|
566
|
+
const byPriority = new Map(aliases().map(view => [view.priority, view]));
|
|
567
|
+
for (const p of PRIORITIES) {
|
|
568
|
+
const view = byPriority.get(p);
|
|
569
|
+
// The pick IDENTITY: the "(fallback)" marker is a name decoration,
|
|
570
|
+
// not part of the pick — compare and toast without it. No
|
|
571
|
+
// observable pick (disabled, or a name with no arrow and no
|
|
572
|
+
// modelID) reads as null.
|
|
573
|
+
const pick = view === undefined || view.enabled === false ? null : (view.modelID ?? pickFromName(view.name))?.replace(/ \(fallback\)$/, "") ?? null;
|
|
574
|
+
const seen = previousPicks.has(p);
|
|
575
|
+
const before = previousPicks.get(p) ?? null;
|
|
576
|
+
previousPicks.set(p, pick);
|
|
577
|
+
if (!seen || pick === null || pick === before) continue;
|
|
578
|
+
// F-20: the pick is catalog-derived — flattened for the toast.
|
|
579
|
+
notify(`Model Compare: ${p} → ${sanitizeCatalogText(pick)}`);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// The keymap layers. They CANNOT be registered from setup's scope: the
|
|
585
|
+
// host's layer bridge wraps registration in a createRoot attached to the
|
|
586
|
+
// calling owner, and Solid context does not flow through a created root
|
|
587
|
+
// — `Keymap.createLayer`'s context lookup fails ("Keymap.Provider is
|
|
588
|
+
// missing") whether the call sits inside our own createRoot (v1, the
|
|
589
|
+
// failed load) or at setup scope (v2, silently swallowed: plugin console
|
|
590
|
+
// output reaches no log). The documented pattern is an "app" slot whose
|
|
591
|
+
// render registers the layers — the render runs INSIDE the mounted
|
|
592
|
+
// provider tree, mounts once, and lives for the whole TUI session. Each
|
|
593
|
+
// layer registers in its own try/catch whose failure surfaces as a
|
|
594
|
+
// toast: a registration failure must be visible, and one layer's failure
|
|
595
|
+
// must not take the others down.
|
|
596
|
+
const layerFailure = (what, error) => {
|
|
597
|
+
notify(`${what} failed to register: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// The picks dialog's command. No slash: /use-best already covers the
|
|
601
|
+
// switching surface (the server command is slash-routable; the TUI's
|
|
602
|
+
// duplicate of it was removed for exactly that reason).
|
|
603
|
+
const picksCommand = {
|
|
604
|
+
id: "model-compare.picks",
|
|
605
|
+
title: "Model-compare top picks",
|
|
606
|
+
group: "Model Compare",
|
|
607
|
+
bind: "alt+m",
|
|
608
|
+
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
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// The report command: posts the server's /model-compare command, whose
|
|
619
|
+
// RPC event opens the selectable report dialog here (and the transcript
|
|
620
|
+
// keeps the notice). A session is required: the dialog's whole point is
|
|
621
|
+
// switching a session's model, and with no session open there is
|
|
622
|
+
// nothing to switch — the pinned runtime (2.0.16) exposes no composer-
|
|
623
|
+
// model setter to plugins, so there is nothing to change either. The
|
|
624
|
+
// refusal says why and how.
|
|
625
|
+
const reportCommand = {
|
|
626
|
+
id: "model-compare.report",
|
|
627
|
+
title: "Model-compare report",
|
|
628
|
+
group: "Model Compare",
|
|
629
|
+
bind: "alt+shift+m",
|
|
630
|
+
palette: true,
|
|
631
|
+
run: async () => {
|
|
632
|
+
const sessionID = currentSessionID();
|
|
633
|
+
if (sessionID === null) {
|
|
634
|
+
notify("no session yet — submit any prompt to create one; a report pick switches that session", "error");
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
await context.client.session.command({
|
|
639
|
+
sessionID,
|
|
640
|
+
name: "model-compare",
|
|
641
|
+
text: ""
|
|
642
|
+
});
|
|
643
|
+
} catch (error) {
|
|
644
|
+
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// The palette-only use-best: the immediate-toast variant. The SLASH
|
|
650
|
+
// surface is left to the server command (one entry everywhere); the
|
|
651
|
+
// palette is TUI-layer territory, where this variant's instant feedback
|
|
652
|
+
// lives.
|
|
653
|
+
const useBestCommand = {
|
|
654
|
+
id: "model-compare.use-best",
|
|
655
|
+
title: "Use best model",
|
|
656
|
+
group: "Model Compare",
|
|
657
|
+
palette: true,
|
|
658
|
+
run: async input => {
|
|
659
|
+
const decision = useBestDecision({
|
|
660
|
+
argument: input ?? "",
|
|
661
|
+
defaultPriority: await readDefaultPriority(),
|
|
662
|
+
enabled: p => aliases().some(view => view.priority === p && view.enabled)
|
|
663
|
+
});
|
|
664
|
+
if (!decision.ok) {
|
|
665
|
+
notify(decision.message, "error");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
await switchToPriority(decision.priority);
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
disposeAppSlot = context.ui.slot({
|
|
672
|
+
append: "app",
|
|
673
|
+
render: () => {
|
|
674
|
+
// The in-tree effect that opens the report dialog when the
|
|
675
|
+
// /model-compare event parks data on pendingReport (see the signal
|
|
676
|
+
// above): dialogs mounted from inside the tree get their keyboard;
|
|
677
|
+
// ownerless mounts do not (observed live, 2026-10-11).
|
|
678
|
+
createEffect(() => {
|
|
679
|
+
const pending = pendingReport();
|
|
680
|
+
if (pending === null) return;
|
|
681
|
+
setPendingReport(null);
|
|
682
|
+
showReportDialog(pending);
|
|
683
|
+
});
|
|
684
|
+
// The list dialogs' modal keymap layer (see showListDialog):
|
|
685
|
+
// registered HERE, inside the tree, while a list dialog is open —
|
|
686
|
+
// the render closure is context-less, so registration from there
|
|
687
|
+
// throws "Keymap.Provider is missing" (observed live 2026-10-11).
|
|
688
|
+
createEffect(() => {
|
|
689
|
+
const keys = listKeys();
|
|
690
|
+
try {
|
|
691
|
+
if (keys === null) return;
|
|
692
|
+
context.keymap.layer(() => ({
|
|
693
|
+
mode: "modal",
|
|
694
|
+
commands: [{
|
|
695
|
+
id: "model-compare.list.down",
|
|
696
|
+
title: "Next row",
|
|
697
|
+
group: "Dialog",
|
|
698
|
+
bind: "down",
|
|
699
|
+
run: () => keys.move(1)
|
|
700
|
+
}, {
|
|
701
|
+
id: "model-compare.list.up",
|
|
702
|
+
title: "Previous row",
|
|
703
|
+
group: "Dialog",
|
|
704
|
+
bind: "up",
|
|
705
|
+
run: () => keys.move(-1)
|
|
706
|
+
}, {
|
|
707
|
+
id: "model-compare.list.pick",
|
|
708
|
+
title: "Select",
|
|
709
|
+
group: "Dialog",
|
|
710
|
+
bind: "return",
|
|
711
|
+
run: () => keys.pick()
|
|
712
|
+
}]
|
|
713
|
+
}));
|
|
714
|
+
} catch (error) {
|
|
715
|
+
setListKeys(null);
|
|
716
|
+
layerFailure("the list dialog keymap layer", error);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
// The event callbacks (data.listen) run ownerless; the layer
|
|
720
|
+
// registration below carries its own failure toast.
|
|
721
|
+
try {
|
|
722
|
+
context.keymap.layer(() => ({
|
|
723
|
+
mode: "global",
|
|
724
|
+
commands: [useBestCommand, picksCommand, reportCommand]
|
|
725
|
+
}));
|
|
726
|
+
} catch (error) {
|
|
727
|
+
layerFailure("the model-compare keymap layer", error);
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
return () => {
|
|
733
|
+
if (resyncTimer !== undefined) clearInterval(resyncTimer);
|
|
734
|
+
offReportEvent();
|
|
735
|
+
disposeRoot();
|
|
736
|
+
disposeAppSlot();
|
|
737
|
+
disposeHomeFooter();
|
|
738
|
+
disposePromptFooter();
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
});
|