model-compare-opencode-plugin 0.1.0 → 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 +9 -5
- package/dist/tui.js +976 -0
- package/package.json +8 -3
- package/src/tui.tsx +309 -80
package/dist/tui.js
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
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 { use as _$use } from "@opentui/solid";
|
|
7
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
8
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
9
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
10
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
11
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
12
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
13
|
+
// src/tui.tsx
|
|
14
|
+
// The model-compare CLI plugin (design §10, "Notices and status"): the
|
|
15
|
+
// terminal-UI half of the plugin, loaded from the `./tui` export. It owns
|
|
16
|
+
// everything the headless server cannot show:
|
|
17
|
+
//
|
|
18
|
+
// * a live status line in the prompt and home footers, derived from the
|
|
19
|
+
// model registry (the runtime plugin keeps the alias entries' display
|
|
20
|
+
// names current, so the registry IS the state; no messaging involved);
|
|
21
|
+
// * the picks dialog (alt+m): a select over the three priorities —
|
|
22
|
+
// choosing one switches the session to it, with the same decision logic
|
|
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);
|
|
27
|
+
// * the report dialog (alt+shift+m): the /model-compare command emits its
|
|
28
|
+
// structured report as an RPC event, and this half opens it as a
|
|
29
|
+
// selectable dialog (selecting a ranked model switches straight to it) —
|
|
30
|
+
// one command serves both worlds, so no TUI duplicate of the server
|
|
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;
|
|
34
|
+
// * a toast when a pick flips (at most one per changed priority per
|
|
35
|
+
// reload, and never for the first observation after load).
|
|
36
|
+
//
|
|
37
|
+
// The headless path (servers, `opencode run`, API clients) has no TUI: its
|
|
38
|
+
// notices stay inbox items delivered at the session's next turn (the
|
|
39
|
+
// commands' `resume: false`), which is correct there — there is no UI to
|
|
40
|
+
// toast in.
|
|
41
|
+
//
|
|
42
|
+
// Owner discipline: everything the host's bridges read through Solid context
|
|
43
|
+
// (the keymap layer registration) runs in setup's own scope, under the owner
|
|
44
|
+
// the TUI host captured for this activation. The createRoot below detaches
|
|
45
|
+
// from that chain on purpose — it owns the solid-js lifetime of the memos
|
|
46
|
+
// and effects — so nothing inside it may call context APIs that resolve
|
|
47
|
+
// Solid context: `context.keymap.layer(...)` inside the root threw
|
|
48
|
+
// "Keymap.Provider is missing" and failed the whole TUI half (observed live,
|
|
49
|
+
// 2026-10-11). The memos and helper functions therefore live at setup scope,
|
|
50
|
+
// where the root's effects and the keymap layers can both close over them.
|
|
51
|
+
|
|
52
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
53
|
+
import { TextAttributes, RGBA } from "@opentui/core";
|
|
54
|
+
// The OpenTUI JSX element types (<text fg>, <box>): a type-only import loads
|
|
55
|
+
// the package's solid-js JSX augmentation without a runtime import — the TUI
|
|
56
|
+
// host already provides the runtime side.
|
|
57
|
+
|
|
58
|
+
import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
|
|
59
|
+
import { ALIAS_PROVIDER, PRIORITIES, alignedPriority, aliasId, pickOf, pickFromName, readAliasViews, statusLine, useBestDecision, useBestNotice, useBestRefusal } from "../src/opencode/usebest";
|
|
60
|
+
import { sanitizeCatalogText } from "../src/opencode/commands";
|
|
61
|
+
import { ModelCompare } from "../src/rpc";
|
|
62
|
+
const TOAST_MS = 6_000;
|
|
63
|
+
const RESYNC_MS = 30_000;
|
|
64
|
+
|
|
65
|
+
// The plugin package name fragment this entry shares state with: the config
|
|
66
|
+
// lookup matches `plugins[].package` entries against it to read the runtime
|
|
67
|
+
// plugin's options (defaultPriority) through the client's config API.
|
|
68
|
+
const PACKAGE_FRAGMENT = "model-compare-opencode-plugin";
|
|
69
|
+
// The RPC event the /model-compare command emits (see src/rpc.ts; the wire
|
|
70
|
+
// name is prefixed with the RPC's id).
|
|
71
|
+
const REPORT_EVENT = "rpc.model-compare.report";
|
|
72
|
+
|
|
73
|
+
// The list dialogs' lifecycle token (see showListDialog): a module-scope
|
|
74
|
+
// generation counter, bumped every time a dialog is armed, plus the armed
|
|
75
|
+
// dialog's close. A dialog's close() tears down the shared state (the modal
|
|
76
|
+
// keymap layer, the host dialog) only while that dialog is STILL the armed
|
|
77
|
+
// one — a stale close must not clear a newer dialog's keyboard and surface —
|
|
78
|
+
// and arming a new dialog closes whatever is still armed first.
|
|
79
|
+
let dialogGeneration = 0;
|
|
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;
|
|
86
|
+
function StatusLine(props) {
|
|
87
|
+
return (() => {
|
|
88
|
+
var _el$ = _$createElement("text");
|
|
89
|
+
_$insert(_el$, () => props.line());
|
|
90
|
+
_$effect(_$p => _$setProp(_el$, "fg", props.fg, _$p));
|
|
91
|
+
return _el$;
|
|
92
|
+
})();
|
|
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
|
+
}
|
|
161
|
+
export default Plugin.define({
|
|
162
|
+
id: "model-compare-tui",
|
|
163
|
+
async setup(context) {
|
|
164
|
+
const location = context.location ?? context.data.location.default();
|
|
165
|
+
const here = location?.directory;
|
|
166
|
+
const notify = (message, variant = "success") => context.ui.toast.show({
|
|
167
|
+
title: "model-compare",
|
|
168
|
+
message,
|
|
169
|
+
variant,
|
|
170
|
+
duration: TOAST_MS
|
|
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);
|
|
194
|
+
let disposeRoot = () => {};
|
|
195
|
+
// The three slot disposers (the two footers and the app slot below):
|
|
196
|
+
// slots claimed but never released survive the plugin's unload and keep
|
|
197
|
+
// rendering into a torn-down UI. Called from the returned cleanup.
|
|
198
|
+
let disposePromptFooter = () => {};
|
|
199
|
+
let disposeHomeFooter = () => {};
|
|
200
|
+
let disposeAppSlot = () => {};
|
|
201
|
+
let resyncTimer;
|
|
202
|
+
// The report dialog's hand-off: the /model-compare event callback runs
|
|
203
|
+
// ownerless (data.listen), and a dialog mounted from an ownerless
|
|
204
|
+
// callback renders but its keyboard never attaches — the mount lands
|
|
205
|
+
// outside the provider tree no matter how the call is wrapped. So the
|
|
206
|
+
// callback only PARKS the data on this signal; the app slot below owns
|
|
207
|
+
// an effect (created inside the tree) that opens the dialog when it
|
|
208
|
+
// fires.
|
|
209
|
+
const [pendingReport, setPendingReport] = createSignal(null);
|
|
210
|
+
|
|
211
|
+
// The registry view, reactive: OpenCode's data layer updates the list
|
|
212
|
+
// when the runtime plugin's reloads commit, so the memo re-runs. Created
|
|
213
|
+
// at setup scope (see the owner-discipline note above): the footers'
|
|
214
|
+
// slots and the flip-watch effect below read it inside the root, and the
|
|
215
|
+
// keymap layer's run() reads it outside — a memo works from both.
|
|
216
|
+
const aliases = createMemo(() => readAliasViews(context.data.location.model.list(location) ?? []));
|
|
217
|
+
const line = createMemo(() => statusLine(aliases()));
|
|
218
|
+
|
|
219
|
+
// The focused session: the router's current route when it is a session
|
|
220
|
+
// route; null otherwise (home, dialogs). Defensive: the route's shape
|
|
221
|
+
// is the host's, so every field is read guardedly.
|
|
222
|
+
function currentSessionID() {
|
|
223
|
+
const route = context.ui.router.current();
|
|
224
|
+
if (route === undefined || route === null) return null;
|
|
225
|
+
if (route.type !== undefined && route.type !== "session") return null;
|
|
226
|
+
if (typeof route.sessionID === "string") return route.sessionID;
|
|
227
|
+
if (typeof route.data?.sessionID === "string") return route.data.sessionID;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The runtime plugin's configured default priority, read through the
|
|
232
|
+
// client's config API: the config documents carry `plugins[].options`.
|
|
233
|
+
// Falls back to "balanced" when the option is absent or unreadable.
|
|
234
|
+
async function readDefaultPriority() {
|
|
235
|
+
try {
|
|
236
|
+
const entries = await context.client.config.get();
|
|
237
|
+
for (const entry of entries ?? []) {
|
|
238
|
+
// ConfigEntry is a union; only document entries carry the config.
|
|
239
|
+
if (entry.type !== "document") continue;
|
|
240
|
+
const plugins = entry.info.plugins;
|
|
241
|
+
if (!Array.isArray(plugins)) continue;
|
|
242
|
+
for (const plugin of plugins) {
|
|
243
|
+
if (typeof plugin !== "object" || plugin === null) continue;
|
|
244
|
+
const record = plugin;
|
|
245
|
+
const pkg = typeof record.package === "string" ? record.package : "";
|
|
246
|
+
if (!pkg.includes(PACKAGE_FRAGMENT)) continue;
|
|
247
|
+
const priority = record.options?.defaultPriority;
|
|
248
|
+
if (priority === "balanced" || priority === "price" || priority === "quality") {
|
|
249
|
+
return priority;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// Unreadable config: the default applies (§3 semantics).
|
|
255
|
+
}
|
|
256
|
+
return "balanced";
|
|
257
|
+
}
|
|
258
|
+
|
|
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.
|
|
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 ?? ""));
|
|
273
|
+
const sessionID = currentSessionID();
|
|
274
|
+
if (sessionID === null) {
|
|
275
|
+
await createSessionOnModel({
|
|
276
|
+
providerID: ALIAS_PROVIDER,
|
|
277
|
+
id: aliasId(priority)
|
|
278
|
+
}, notice);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
await context.client.session.switchModel({
|
|
283
|
+
sessionID,
|
|
284
|
+
model: {
|
|
285
|
+
providerID: ALIAS_PROVIDER,
|
|
286
|
+
id: aliasId(priority)
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
notify(notice);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Switch the current session straight to a concrete model (the report
|
|
296
|
+
// dialog's selection).
|
|
297
|
+
async function switchToModel(sessionID, id) {
|
|
298
|
+
try {
|
|
299
|
+
await context.client.session.switchModel({
|
|
300
|
+
sessionID,
|
|
301
|
+
model: {
|
|
302
|
+
providerID: ALIAS_PROVIDER,
|
|
303
|
+
id
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
// F-20: `id` here is the raw catalog id (the switch needs it) — the
|
|
307
|
+
// toast shows the flattened form so it cannot be line-injected.
|
|
308
|
+
notify(`switched to openrouter/${sanitizeCatalogText(id)}`);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
notify(`switch failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
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
|
+
|
|
335
|
+
// The custom list dialog. The host's `dialog.select` (SDK 2.0.16
|
|
336
|
+
// runtime) renders one GROUPED box per category with one cursor each —
|
|
337
|
+
// its search flattening (verified against main) does not exist in this
|
|
338
|
+
// build — so three simultaneous cursors move independently. This
|
|
339
|
+
// renderer replaces it: `dialog.show` + our own rows — one cursor,
|
|
340
|
+
// palette styling (bold title, dim esc, theme action colors on the
|
|
341
|
+
// selected row). The modal keymap layer cannot be registered from the
|
|
342
|
+
// render closure (context-less — "Keymap.Provider is missing", the same
|
|
343
|
+
// trap as setup scope), so the app slot's IN-TREE effect registers it
|
|
344
|
+
// while `listKeys` is armed, and the host unmounts it with the dialog.
|
|
345
|
+
|
|
346
|
+
// The keymap layer hand-off (see showListDialog): set while a list
|
|
347
|
+
// dialog is open, cleared on close; the app slot's in-tree effect maps
|
|
348
|
+
// it to a modal layer.
|
|
349
|
+
const [listKeys, setListKeys] = createSignal(null);
|
|
350
|
+
function showListDialog(title, rows, onPick) {
|
|
351
|
+
const selectable = rows.filter(row => row.value !== undefined);
|
|
352
|
+
if (selectable.length === 0) {
|
|
353
|
+
notify("nothing to select", "error");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const initial = Math.max(0, selectable.findIndex(row => row.selected !== undefined));
|
|
357
|
+
// The DIALOG surface: the exact theme call the host's own dialogs make
|
|
358
|
+
// (dialog-select/dialog-alert) — uniform colors with the host chrome
|
|
359
|
+
// and the palette's heading color.
|
|
360
|
+
const theme = context.theme.surface("dialog");
|
|
361
|
+
const base = theme.text.base;
|
|
362
|
+
const muted = theme.text.muted;
|
|
363
|
+
const accent = theme.hue.accent[200];
|
|
364
|
+
const hiFg = theme.text.action.primary.focused;
|
|
365
|
+
const hiBg = theme.background.action.primary.focused;
|
|
366
|
+
// The native rows' inactive background: explicitly transparent, so the
|
|
367
|
+
// dialog surface shows through.
|
|
368
|
+
const transparent = RGBA.fromInts(0, 0, 0, 0);
|
|
369
|
+
|
|
370
|
+
// This dialog's lifecycle token: its generation in the module-scope
|
|
371
|
+
// counter. Replaced dialogs are closed first (below), so a close()
|
|
372
|
+
// that finds a newer generation knows it lost ownership and must not
|
|
373
|
+
// touch the armed dialog's shared state.
|
|
374
|
+
armedClose?.();
|
|
375
|
+
const generation = ++dialogGeneration;
|
|
376
|
+
let alive = true;
|
|
377
|
+
// The cursor as a SIGNAL: the JSX closure reads it reactively, so
|
|
378
|
+
// every move repaints the dialog (a plain `let` painted once and
|
|
379
|
+
// never updated — the cursor sat on row 1 however many times the
|
|
380
|
+
// keymap moved the internal index; observed live, 2026-10-11).
|
|
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
|
+
};
|
|
431
|
+
const close = () => {
|
|
432
|
+
if (!alive) return;
|
|
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
|
+
}
|
|
442
|
+
// A newer dialog was armed since this one: its own close owns the
|
|
443
|
+
// shared state (the keymap layer, the host dialog, the reveal hook)
|
|
444
|
+
// now — clearing here would kill ITS keyboard and surface.
|
|
445
|
+
if (generation !== dialogGeneration) return;
|
|
446
|
+
armedClose = null;
|
|
447
|
+
armedReveal = null;
|
|
448
|
+
setListKeys(null);
|
|
449
|
+
context.ui.dialog.clear();
|
|
450
|
+
};
|
|
451
|
+
// move(±1) walks the SELECTABLE rows only; heading rows never take
|
|
452
|
+
// the cursor. Wraps: one past the last is the first, one before the
|
|
453
|
+
// first is the last (the command palette's behavior).
|
|
454
|
+
const move = delta => {
|
|
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);
|
|
460
|
+
};
|
|
461
|
+
const pick = () => {
|
|
462
|
+
const row = selectable[cursor()];
|
|
463
|
+
close();
|
|
464
|
+
onPick(row.value);
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// Arm the modal layer via the app slot's in-tree effect (see the app
|
|
468
|
+
// slot below); it registers on the next reactive run. This dialog is
|
|
469
|
+
// now the armed one (the lifecycle token above).
|
|
470
|
+
armedClose = close;
|
|
471
|
+
armedReveal = revealOnFrame;
|
|
472
|
+
setListKeys({
|
|
473
|
+
move,
|
|
474
|
+
pick
|
|
475
|
+
});
|
|
476
|
+
context.ui.dialog.show(() => // Top/bottom blanks follow the native boxes: 1 with a tip line at
|
|
477
|
+
// the bottom (the host's dialog panel contributes the other top
|
|
478
|
+
// blank, so our own top padding is 0).
|
|
479
|
+
(() => {
|
|
480
|
+
var _el$2 = _$createElement("box"),
|
|
481
|
+
_el$3 = _$createElement("box"),
|
|
482
|
+
_el$4 = _$createElement("text"),
|
|
483
|
+
_el$5 = _$createElement("text"),
|
|
484
|
+
_el$7 = _$createElement("scrollbox"),
|
|
485
|
+
_el$8 = _$createElement("box"),
|
|
486
|
+
_el$9 = _$createElement("text");
|
|
487
|
+
_$insertNode(_el$2, _el$3);
|
|
488
|
+
_$insertNode(_el$2, _el$7);
|
|
489
|
+
_$insertNode(_el$2, _el$8);
|
|
490
|
+
_$setProp(_el$2, "paddingTop", 0);
|
|
491
|
+
_$setProp(_el$2, "paddingBottom", 1);
|
|
492
|
+
_$setProp(_el$2, "gap", 1);
|
|
493
|
+
_$insertNode(_el$3, _el$4);
|
|
494
|
+
_$insertNode(_el$3, _el$5);
|
|
495
|
+
_$setProp(_el$3, "paddingLeft", 4);
|
|
496
|
+
_$setProp(_el$3, "paddingRight", 4);
|
|
497
|
+
_$setProp(_el$3, "flexDirection", "row");
|
|
498
|
+
_$setProp(_el$3, "justifyContent", "space-between");
|
|
499
|
+
_$setProp(_el$4, "fg", base);
|
|
500
|
+
_$insert(_el$4, title);
|
|
501
|
+
_$insertNode(_el$5, _$createTextNode(`esc`));
|
|
502
|
+
_$setProp(_el$5, "fg", muted);
|
|
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);
|
|
511
|
+
_$setProp(_el$7, "paddingLeft", 1);
|
|
512
|
+
_$setProp(_el$7, "paddingRight", 1);
|
|
513
|
+
_$setProp(_el$7, "scrollbarOptions", {
|
|
514
|
+
visible: false
|
|
515
|
+
});
|
|
516
|
+
_$insert(_el$7, () => {
|
|
517
|
+
let headings = 0;
|
|
518
|
+
return rows.map(row => {
|
|
519
|
+
if (row.headerLine !== undefined) {
|
|
520
|
+
// The table's column header: dim, cursor-skipped, and
|
|
521
|
+
// directly above the first section (no blank line).
|
|
522
|
+
return (() => {
|
|
523
|
+
var _el$1 = _$createElement("text");
|
|
524
|
+
_$setProp(_el$1, "fg", muted);
|
|
525
|
+
_$insert(_el$1, () => row.headerLine);
|
|
526
|
+
return _el$1;
|
|
527
|
+
})();
|
|
528
|
+
}
|
|
529
|
+
if (row.heading !== undefined) {
|
|
530
|
+
// The palette's heading recipe: accent hue, bold, one
|
|
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;
|
|
536
|
+
headings += 1;
|
|
537
|
+
return (() => {
|
|
538
|
+
var _el$10 = _$createElement("box"),
|
|
539
|
+
_el$11 = _$createElement("text");
|
|
540
|
+
_$insertNode(_el$10, _el$11);
|
|
541
|
+
_$setProp(_el$10, "paddingTop", paddingTop);
|
|
542
|
+
_$setProp(_el$10, "paddingLeft", 3);
|
|
543
|
+
_$setProp(_el$11, "fg", accent);
|
|
544
|
+
_$insert(_el$11, () => row.heading);
|
|
545
|
+
_$effect(_$p => _$setProp(_el$11, "attributes", TextAttributes.BOLD, _$p));
|
|
546
|
+
return _el$10;
|
|
547
|
+
})();
|
|
548
|
+
}
|
|
549
|
+
const active = row.value !== undefined && selectable[cursor()] === row;
|
|
550
|
+
// The row BOX carries the background: it stretches the
|
|
551
|
+
// list's full width, so the cursor is "justified" — from
|
|
552
|
+
// the 1-blank left inset to the 1-blank right inset —
|
|
553
|
+
// while the text stays at the content indent (4).
|
|
554
|
+
const nameFg = row.dim === true ? muted : active ? hiFg : base;
|
|
555
|
+
return (() => {
|
|
556
|
+
var _el$12 = _$createElement("box");
|
|
557
|
+
_$setProp(_el$12, "flexDirection", "row");
|
|
558
|
+
_$setProp(_el$12, "paddingLeft", 3);
|
|
559
|
+
_$setProp(_el$12, "paddingRight", 3);
|
|
560
|
+
_$setProp(_el$12, "backgroundColor", active ? hiBg : transparent);
|
|
561
|
+
_$insert(_el$12, (() => {
|
|
562
|
+
var _c$ = _$memo(() => row.columns !== undefined);
|
|
563
|
+
return () => _c$() ? (() => {
|
|
564
|
+
var _el$13 = _$createElement("text"),
|
|
565
|
+
_el$14 = _$createElement("span"),
|
|
566
|
+
_el$15 = _$createElement("span");
|
|
567
|
+
_$insertNode(_el$13, _el$14);
|
|
568
|
+
_$insertNode(_el$13, _el$15);
|
|
569
|
+
_$setProp(_el$14, "style", {
|
|
570
|
+
fg: nameFg
|
|
571
|
+
});
|
|
572
|
+
_$insert(_el$14, (() => {
|
|
573
|
+
var _c$2 = _$memo(() => !!(active && row.dim !== true));
|
|
574
|
+
return () => _c$2() ? (() => {
|
|
575
|
+
var _el$16 = _$createElement("b");
|
|
576
|
+
_$insert(_el$16, () => row.label);
|
|
577
|
+
return _el$16;
|
|
578
|
+
})() : row.label;
|
|
579
|
+
})());
|
|
580
|
+
_$insert(_el$15, () => row.columns);
|
|
581
|
+
_$effect(_$p => _$setProp(_el$15, "style", {
|
|
582
|
+
fg: row.dim === true || !active ? muted : hiFg
|
|
583
|
+
}, _$p));
|
|
584
|
+
return _el$13;
|
|
585
|
+
})() : (() => {
|
|
586
|
+
var _el$17 = _$createElement("text");
|
|
587
|
+
_$setProp(_el$17, "fg", nameFg);
|
|
588
|
+
_$insert(_el$17, () => row.label);
|
|
589
|
+
_$effect(_$p => _$setProp(_el$17, "attributes", active && row.dim !== true ? TextAttributes.BOLD : undefined, _$p));
|
|
590
|
+
return _el$17;
|
|
591
|
+
})();
|
|
592
|
+
})());
|
|
593
|
+
return _el$12;
|
|
594
|
+
})();
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
_$insertNode(_el$8, _el$9);
|
|
598
|
+
_$setProp(_el$8, "paddingLeft", 4);
|
|
599
|
+
_$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
|
|
600
|
+
_$setProp(_el$9, "fg", muted);
|
|
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
|
+
});
|
|
611
|
+
return _el$2;
|
|
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
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// The picks dialog: the three priorities, one cursor, palette styling.
|
|
624
|
+
// A snapshot at open time; unavailable priorities are dimmed — the
|
|
625
|
+
// cursor still reaches them, and choosing one is refused with a toast
|
|
626
|
+
// (switchToPriority's guard). Choosing an available priority switches
|
|
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).
|
|
629
|
+
function showPicksDialog() {
|
|
630
|
+
const views = aliases();
|
|
631
|
+
const rows = PRIORITIES.map(priority => {
|
|
632
|
+
const view = views.find(item => item.priority === priority);
|
|
633
|
+
const enabled = view !== undefined && view.enabled !== false;
|
|
634
|
+
return {
|
|
635
|
+
label: `${alignedPriority(priority)} → ${enabled ? pickOf(view) ?? "unavailable" : "disabled"}`,
|
|
636
|
+
value: priority,
|
|
637
|
+
dim: !enabled
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
showListDialog("Model Compare — current picks", rows, priority => {
|
|
641
|
+
void switchToPriority(priority);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// The report dialog: the ranked entries under bold-ish priority
|
|
646
|
+
// headings (palette category color, blank line above each — headings
|
|
647
|
+
// are skipped by the cursor), one cursor over all of them; choosing a
|
|
648
|
+
// model switches straight to it. Empty entries mean the command had no
|
|
649
|
+
// ranking data.
|
|
650
|
+
function showReportDialog(data) {
|
|
651
|
+
if (data.entries.length === 0) {
|
|
652
|
+
notify("no ranking data available yet", "error");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// The approved table: one dim column header at the top (directly above
|
|
656
|
+
// the first section), section headings with a blank line above all but
|
|
657
|
+
// the first, and right-justified quality/price columns. No score
|
|
658
|
+
// column — the transcript's text report keeps it. Column widths are
|
|
659
|
+
// clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
|
|
660
|
+
// to their clamped width, so with the name clamp below the row budget
|
|
661
|
+
// holds even for hostile/absurd column strings: what the clamp
|
|
662
|
+
// guarantees is that a row and the header stay within the dialog's
|
|
663
|
+
// fixed 52-char content width (hostile values get truncated rather
|
|
664
|
+
// than pushed into wrapping — the 40-char rankless fallback label did
|
|
665
|
+
// exactly that before the name clamp).
|
|
666
|
+
const labels = data.entries.map(entry => `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${entry.fromFallback ? " (fallback)" : ""}`);
|
|
667
|
+
const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]));
|
|
668
|
+
const colW = (values, floor, cap) => Math.min(cap, Math.max(floor, ...values.map(value => value.length)));
|
|
669
|
+
const qualW = colW(data.entries.map(entry => entry.quality), "qual".length, 8);
|
|
670
|
+
const priceW = colW(data.entries.map(entry => entry.price), "$ in/out".length, 16);
|
|
671
|
+
// A cell wider than its clamped column is truncated to fit.
|
|
672
|
+
const cell = (value, width) => value.length > width ? value.slice(0, width - 1) + "…" : value;
|
|
673
|
+
// Prices use padEnd (not padStart): padded to a common width the
|
|
674
|
+
// right edges stay flush while the fixed 2-space gap between columns
|
|
675
|
+
// stays exactly 2 (padStart pushed each value right by its slack).
|
|
676
|
+
// The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
|
|
677
|
+
// for row content; the header shares that budget (plus its 3-indent).
|
|
678
|
+
const fixed = 2 + qualW + 2 + priceW;
|
|
679
|
+
const nameW = Math.max(8, Math.min(52 - fixed, Math.max(...labels.map(label => label.length))));
|
|
680
|
+
const trim = label => label.length > nameW ? label.slice(0, nameW - 1) + "…" : label;
|
|
681
|
+
const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "$ in/out".padEnd(priceW);
|
|
682
|
+
const rows = [{
|
|
683
|
+
label: "",
|
|
684
|
+
headerLine
|
|
685
|
+
}];
|
|
686
|
+
for (const priority of PRIORITIES) {
|
|
687
|
+
const section = data.entries.filter(entry => entry.priority === priority);
|
|
688
|
+
if (section.length === 0) continue;
|
|
689
|
+
rows.push({
|
|
690
|
+
heading: priority,
|
|
691
|
+
label: ""
|
|
692
|
+
});
|
|
693
|
+
for (const entry of section) {
|
|
694
|
+
rows.push({
|
|
695
|
+
label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
|
|
696
|
+
columns: " " + cell(entry.quality, qualW).padStart(qualW) + " " + cell(entry.price, priceW).padEnd(priceW),
|
|
697
|
+
// F-20 id split: the row DISPLAYS the sanitized id (in `label`)
|
|
698
|
+
// but picks `value`, the raw catalog id switchToModel needs.
|
|
699
|
+
value: entry.value,
|
|
700
|
+
selected: entry.current ? entry.value : undefined
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
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
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The report dialog rides the /model-compare command's RPC event: ONE
|
|
719
|
+
// command serves both worlds — headless gets the transcript notice, the
|
|
720
|
+
// TUI gets this instant selectable dialog. Filtered to this plugin's
|
|
721
|
+
// location and the currently viewed session, so a headless run's report
|
|
722
|
+
// never pops a dialog over an unrelated session.
|
|
723
|
+
const offReportEvent = context.data.listen(({
|
|
724
|
+
details
|
|
725
|
+
}) => {
|
|
726
|
+
const event = details;
|
|
727
|
+
if (event.type !== REPORT_EVENT) return;
|
|
728
|
+
if (typeof event.location?.directory === "string" && here !== undefined && event.location.directory !== here) return;
|
|
729
|
+
const data = event.data;
|
|
730
|
+
if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
|
|
731
|
+
if (!Array.isArray(data.entries)) return;
|
|
732
|
+
const entries = toReportEntries(data.entries);
|
|
733
|
+
const eventSessionID = data.sessionID;
|
|
734
|
+
// Ownerless callback: park the report on the signal — the app slot's
|
|
735
|
+
// in-tree effect opens the dialog (see pendingReport above).
|
|
736
|
+
setPendingReport({
|
|
737
|
+
sessionID: eventSessionID,
|
|
738
|
+
age: typeof data.age === "string" ? data.age : null,
|
|
739
|
+
entries
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
createRoot(dispose => {
|
|
743
|
+
disposeRoot = dispose;
|
|
744
|
+
|
|
745
|
+
// Keep the data layer fresh: a belt-and-braces re-sync, since the
|
|
746
|
+
// registry also updates reactively. Cleared on unload below.
|
|
747
|
+
void context.data.location.model.sync(location);
|
|
748
|
+
resyncTimer = setInterval(() => {
|
|
749
|
+
void context.data.location.model.sync(location).catch(() => {});
|
|
750
|
+
}, RESYNC_MS);
|
|
751
|
+
|
|
752
|
+
// The status line in both footers, in the theme's text color (read
|
|
753
|
+
// inside the render, so a theme switch re-renders).
|
|
754
|
+
disposePromptFooter = context.ui.slot({
|
|
755
|
+
append: "prompt.footer.status",
|
|
756
|
+
render: () => _$createComponent(StatusLine, {
|
|
757
|
+
line: line,
|
|
758
|
+
get fg() {
|
|
759
|
+
return context.theme.text.base;
|
|
760
|
+
}
|
|
761
|
+
})
|
|
762
|
+
});
|
|
763
|
+
disposeHomeFooter = context.ui.slot({
|
|
764
|
+
append: "home.footer.status",
|
|
765
|
+
render: () => _$createComponent(StatusLine, {
|
|
766
|
+
line: line,
|
|
767
|
+
get fg() {
|
|
768
|
+
return context.theme.text.base;
|
|
769
|
+
}
|
|
770
|
+
})
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// Toast when a pick flips. A real per-priority diff over the pick ids
|
|
774
|
+
// — the previous pick string or null per priority — so only the
|
|
775
|
+
// priorities whose pick ACTUALLY changed toast: a "(fallback)"
|
|
776
|
+
// name-marker change with the same pick is not a flip, and going
|
|
777
|
+
// disabled (enabled→disabled, pick → null) is never toasted. The first
|
|
778
|
+
// observation after load only seeds the map (a load is not a change);
|
|
779
|
+
// when several flips land in one reload, each changed priority gets
|
|
780
|
+
// its own toast.
|
|
781
|
+
const previousPicks = new Map();
|
|
782
|
+
createEffect(() => {
|
|
783
|
+
const byPriority = new Map(aliases().map(view => [view.priority, view]));
|
|
784
|
+
for (const p of PRIORITIES) {
|
|
785
|
+
const view = byPriority.get(p);
|
|
786
|
+
// The pick IDENTITY: the "(fallback)" marker is a name decoration,
|
|
787
|
+
// not part of the pick — compare and toast without it. No
|
|
788
|
+
// observable pick (disabled, or a name with no arrow and no
|
|
789
|
+
// modelID) reads as null.
|
|
790
|
+
const pick = view === undefined || view.enabled === false ? null : (view.modelID ?? pickFromName(view.name))?.replace(/ \(fallback\)$/, "") ?? null;
|
|
791
|
+
const seen = previousPicks.has(p);
|
|
792
|
+
const before = previousPicks.get(p) ?? null;
|
|
793
|
+
previousPicks.set(p, pick);
|
|
794
|
+
if (!seen || pick === null || pick === before) continue;
|
|
795
|
+
// F-20: the pick is catalog-derived — flattened for the toast.
|
|
796
|
+
notify(`Model Compare: ${p} → ${sanitizeCatalogText(pick)}`);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// The keymap layers. They CANNOT be registered from setup's scope: the
|
|
802
|
+
// host's layer bridge wraps registration in a createRoot attached to the
|
|
803
|
+
// calling owner, and Solid context does not flow through a created root
|
|
804
|
+
// — `Keymap.createLayer`'s context lookup fails ("Keymap.Provider is
|
|
805
|
+
// missing") whether the call sits inside our own createRoot (v1, the
|
|
806
|
+
// failed load) or at setup scope (v2, silently swallowed: plugin console
|
|
807
|
+
// output reaches no log). The documented pattern is an "app" slot whose
|
|
808
|
+
// render registers the layers — the render runs INSIDE the mounted
|
|
809
|
+
// provider tree, mounts once, and lives for the whole TUI session. Each
|
|
810
|
+
// layer registers in its own try/catch whose failure surfaces as a
|
|
811
|
+
// toast: a registration failure must be visible, and one layer's failure
|
|
812
|
+
// must not take the others down.
|
|
813
|
+
const layerFailure = (what, error) => {
|
|
814
|
+
notify(`${what} failed to register: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// The picks dialog's command. No slash: /use-best already covers the
|
|
818
|
+
// switching surface (the server command is slash-routable; the TUI's
|
|
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).
|
|
822
|
+
const picksCommand = {
|
|
823
|
+
id: "model-compare.picks",
|
|
824
|
+
title: "Model-compare top picks",
|
|
825
|
+
group: "Model Compare",
|
|
826
|
+
bind: "alt+m",
|
|
827
|
+
palette: true,
|
|
828
|
+
run: () => showPicksDialog()
|
|
829
|
+
};
|
|
830
|
+
|
|
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.
|
|
840
|
+
const reportCommand = {
|
|
841
|
+
id: "model-compare.report",
|
|
842
|
+
title: "Model-compare report",
|
|
843
|
+
group: "Model Compare",
|
|
844
|
+
bind: "alt+shift+m",
|
|
845
|
+
palette: true,
|
|
846
|
+
run: async () => {
|
|
847
|
+
const sessionID = currentSessionID();
|
|
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
|
+
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
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)
|
|
871
|
+
});
|
|
872
|
+
} catch (error) {
|
|
873
|
+
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// The palette-only use-best: the immediate-toast variant. The SLASH
|
|
879
|
+
// surface is left to the server command (one entry everywhere); the
|
|
880
|
+
// palette is TUI-layer territory, where this variant's instant feedback
|
|
881
|
+
// lives.
|
|
882
|
+
const useBestCommand = {
|
|
883
|
+
id: "model-compare.use-best",
|
|
884
|
+
title: "Use best model",
|
|
885
|
+
group: "Model Compare",
|
|
886
|
+
palette: true,
|
|
887
|
+
run: async input => {
|
|
888
|
+
const decision = useBestDecision({
|
|
889
|
+
argument: input ?? "",
|
|
890
|
+
defaultPriority: await readDefaultPriority(),
|
|
891
|
+
enabled: p => aliases().some(view => view.priority === p && view.enabled)
|
|
892
|
+
});
|
|
893
|
+
if (!decision.ok) {
|
|
894
|
+
notify(decision.message, "error");
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
await switchToPriority(decision.priority);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
disposeAppSlot = context.ui.slot({
|
|
901
|
+
append: "app",
|
|
902
|
+
render: () => {
|
|
903
|
+
// The in-tree effect that opens the report dialog when the
|
|
904
|
+
// /model-compare event parks data on pendingReport (see the signal
|
|
905
|
+
// above): dialogs mounted from inside the tree get their keyboard;
|
|
906
|
+
// ownerless mounts do not (observed live, 2026-10-11).
|
|
907
|
+
createEffect(() => {
|
|
908
|
+
const pending = pendingReport();
|
|
909
|
+
if (pending === null) return;
|
|
910
|
+
setPendingReport(null);
|
|
911
|
+
showReportDialog(pending);
|
|
912
|
+
});
|
|
913
|
+
// The list dialogs' modal keymap layer (see showListDialog):
|
|
914
|
+
// registered HERE, inside the tree, while a list dialog is open —
|
|
915
|
+
// the render closure is context-less, so registration from there
|
|
916
|
+
// throws "Keymap.Provider is missing" (observed live 2026-10-11).
|
|
917
|
+
createEffect(() => {
|
|
918
|
+
const keys = listKeys();
|
|
919
|
+
try {
|
|
920
|
+
if (keys === null) return;
|
|
921
|
+
context.keymap.layer(() => ({
|
|
922
|
+
mode: "modal",
|
|
923
|
+
commands: [{
|
|
924
|
+
id: "model-compare.list.down",
|
|
925
|
+
title: "Next row",
|
|
926
|
+
group: "Dialog",
|
|
927
|
+
bind: "down",
|
|
928
|
+
run: () => keys.move(1)
|
|
929
|
+
}, {
|
|
930
|
+
id: "model-compare.list.up",
|
|
931
|
+
title: "Previous row",
|
|
932
|
+
group: "Dialog",
|
|
933
|
+
bind: "up",
|
|
934
|
+
run: () => keys.move(-1)
|
|
935
|
+
}, {
|
|
936
|
+
id: "model-compare.list.pick",
|
|
937
|
+
title: "Select",
|
|
938
|
+
group: "Dialog",
|
|
939
|
+
bind: "return",
|
|
940
|
+
run: () => keys.pick()
|
|
941
|
+
}]
|
|
942
|
+
}));
|
|
943
|
+
} catch (error) {
|
|
944
|
+
setListKeys(null);
|
|
945
|
+
layerFailure("the list dialog keymap layer", error);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
// The event callbacks (data.listen) run ownerless; the layer
|
|
949
|
+
// registration below carries its own failure toast.
|
|
950
|
+
try {
|
|
951
|
+
context.keymap.layer(() => ({
|
|
952
|
+
mode: "global",
|
|
953
|
+
commands: [useBestCommand, picksCommand, reportCommand]
|
|
954
|
+
}));
|
|
955
|
+
} catch (error) {
|
|
956
|
+
layerFailure("the model-compare keymap layer", error);
|
|
957
|
+
}
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
});
|
|
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?.();
|
|
967
|
+
if (resyncTimer !== undefined) clearInterval(resyncTimer);
|
|
968
|
+
context.renderer.off("resize", onRendererResize);
|
|
969
|
+
offReportEvent();
|
|
970
|
+
disposeRoot();
|
|
971
|
+
disposeAppSlot();
|
|
972
|
+
disposeHomeFooter();
|
|
973
|
+
disposePromptFooter();
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
});
|