model-compare-opencode-plugin 0.1.1 → 0.3.0
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 +40 -8
- package/dist/tui.js +415 -67
- package/package.json +1 -1
- package/src/config.ts +5 -2
- package/src/core/catalog.ts +57 -12
- package/src/core/steering.ts +145 -0
- package/src/core/types.ts +28 -1
- package/src/index.ts +463 -15
- package/src/opencode/commands.ts +64 -3
- package/src/opencode/storage.ts +6 -1
- package/src/rpc.ts +45 -3
- package/src/sources/drops.ts +37 -0
- package/src/sources/index.ts +18 -6
- package/src/sources/local.ts +7 -3
- package/src/sources/remote.ts +68 -10
- package/src/tui.tsx +455 -86
- package/vendor/UPSTREAM.json +3 -3
- package/vendor/model_compare.py +1129 -7
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,
|
|
@@ -62,7 +68,8 @@ import {
|
|
|
62
68
|
type AliasView,
|
|
63
69
|
} from "./opencode/usebest"
|
|
64
70
|
import type { ReportEntry } from "./opencode/commands"
|
|
65
|
-
import { sanitizeCatalogText } from "./opencode/commands"
|
|
71
|
+
import { lastResponseLine, 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
|
|
@@ -74,6 +81,11 @@ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin"
|
|
|
74
81
|
// The RPC event the /model-compare command emits (see src/rpc.ts; the wire
|
|
75
82
|
// name is prefixed with the RPC's id).
|
|
76
83
|
const REPORT_EVENT = "rpc.model-compare.report"
|
|
84
|
+
// The event the runtime hook emits once per session when it would have
|
|
85
|
+
// steered a request but has no steering data (no catalog source) — the Q2
|
|
86
|
+
// fallback's runtime notification. The toast copy below is the whole message.
|
|
87
|
+
const CANNOT_STEER_EVENT = "rpc.model-compare.cannotSteer"
|
|
88
|
+
const CANNOT_STEER_COPY = "cannot steer this session; OpenRouter default routing applies"
|
|
77
89
|
|
|
78
90
|
// The list dialogs' lifecycle token (see showListDialog): a module-scope
|
|
79
91
|
// generation counter, bumped every time a dialog is armed, plus the armed
|
|
@@ -83,11 +95,129 @@ const REPORT_EVENT = "rpc.model-compare.report"
|
|
|
83
95
|
// and arming a new dialog closes whatever is still armed first.
|
|
84
96
|
let dialogGeneration = 0
|
|
85
97
|
let armedClose: (() => void) | null = null
|
|
98
|
+
// The armed dialog's reveal hook (see showListDialog): its own
|
|
99
|
+
// revealOnFrame, exposed module-scope so the setup-scope resize handler can
|
|
100
|
+
// re-reveal the cursor when a resize re-caps the row area under it. Set
|
|
101
|
+
// when a dialog is armed, cleared by its close() alongside armedClose.
|
|
102
|
+
let armedReveal: ((center: boolean) => void) | null = null
|
|
86
103
|
|
|
87
104
|
function StatusLine(props: { line: () => string; fg?: string }) {
|
|
88
105
|
return <text fg={props.fg}>{props.line()}</text>
|
|
89
106
|
}
|
|
90
107
|
|
|
108
|
+
// One list-dialog row (see showListDialog).
|
|
109
|
+
type ListRow = {
|
|
110
|
+
// Section headings render in the palette's category color, and are
|
|
111
|
+
// skipped by the cursor.
|
|
112
|
+
heading?: string
|
|
113
|
+
// A pre-built column-header line (the report table): dim, skipped by
|
|
114
|
+
// the cursor, and never gets a blank line above it.
|
|
115
|
+
headerLine?: string
|
|
116
|
+
label: string
|
|
117
|
+
// Trailing dim right-justified columns (the report table); when
|
|
118
|
+
// present, label renders in its own span so the two tones can differ.
|
|
119
|
+
columns?: string
|
|
120
|
+
selected?: string
|
|
121
|
+
value?: string
|
|
122
|
+
dim?: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The rows' ONE height rule, shared by three places that must agree or the
|
|
126
|
+
// scroll lands on the wrong line: the rows' JSX (the heading's blank line
|
|
127
|
+
// above), rowOffset's arithmetic and the row-area cap's content measure. A
|
|
128
|
+
// header-line row renders as its text ONLY — the JSX checks headerLine
|
|
129
|
+
// before heading — so a row carrying both fields counts the way it renders,
|
|
130
|
+
// never as both. Every other row is one line except the second and later
|
|
131
|
+
// headings, which carry one blank line above (the palette's heading
|
|
132
|
+
// recipe).
|
|
133
|
+
function isHeadingRow(row: ListRow): boolean {
|
|
134
|
+
return row.headerLine === undefined && row.heading !== undefined
|
|
135
|
+
}
|
|
136
|
+
function rowLines(row: ListRow, headingsSoFar: number): number {
|
|
137
|
+
return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1
|
|
138
|
+
}
|
|
139
|
+
// The rows' total LINE count (not their row count — heading blanks are
|
|
140
|
+
// content too); `upto` measures the first rows alone, which is how rowOffset
|
|
141
|
+
// gets a row's y within the content.
|
|
142
|
+
function rowsHeight(rows: readonly ListRow[], upto: number = rows.length): number {
|
|
143
|
+
let y = 0
|
|
144
|
+
let headings = 0
|
|
145
|
+
for (let k = 0; k < upto; k++) {
|
|
146
|
+
const row = rows[k]!
|
|
147
|
+
y += rowLines(row, headings)
|
|
148
|
+
if (isHeadingRow(row)) headings += 1
|
|
149
|
+
}
|
|
150
|
+
return y
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The list dialogs' row-area cap, the host command palette's own formula:
|
|
154
|
+
// min(content LINES, floor(terminal height / 2) - 6) — the first argument is
|
|
155
|
+
// rowsHeight(rows), never the rows' count, or the cap clips tail rows even
|
|
156
|
+
// on a roomy terminal (with the scrollbar hidden). The host's dialog layer
|
|
157
|
+
// caps nothing itself and anchors UNcentered dialogs a quarter viewport
|
|
158
|
+
// down — without this cap an uncapped list overflows the viewport at large
|
|
159
|
+
// font sizes (the report dialog did exactly that). Exported for the unit
|
|
160
|
+
// rig.
|
|
161
|
+
export function cappedRowsHeight(contentLines: number, terminalHeight: number): number {
|
|
162
|
+
return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// One report entry's defensive normalization, shared by the two paths that
|
|
166
|
+
// carry report data across a trust boundary (the /model-compare command's
|
|
167
|
+
// event and the TUI's direct RPC call): an entry missing any identity field
|
|
168
|
+
// is dropped, every other field falls back to its neutral value.
|
|
169
|
+
function toReportEntries(raw: unknown): ReportEntry[] {
|
|
170
|
+
if (!Array.isArray(raw)) return []
|
|
171
|
+
return raw.flatMap((item): ReportEntry[] => {
|
|
172
|
+
const entry = item as Partial<ReportEntry>
|
|
173
|
+
if (
|
|
174
|
+
typeof entry?.id !== "string" ||
|
|
175
|
+
typeof entry?.value !== "string" ||
|
|
176
|
+
typeof entry?.priority !== "string"
|
|
177
|
+
)
|
|
178
|
+
return []
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
priority: entry.priority as Priority,
|
|
182
|
+
rank: typeof entry.rank === "number" ? entry.rank : null,
|
|
183
|
+
// The F-20 split (see ReportEntry): `id` sanitized for display,
|
|
184
|
+
// `value` the raw catalog id for switching.
|
|
185
|
+
id: entry.id,
|
|
186
|
+
value: entry.value,
|
|
187
|
+
score: typeof entry.score === "string" ? entry.score : "-",
|
|
188
|
+
quality: typeof entry.quality === "string" ? entry.quality : "-",
|
|
189
|
+
price: typeof entry.price === "string" ? entry.price : "-",
|
|
190
|
+
current: entry.current === true,
|
|
191
|
+
fromFallback: entry.fromFallback === true,
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The report payload's footer capture (the last-response line and the alt+e
|
|
198
|
+
// toast's data), defensively normalized at the trust boundary like the
|
|
199
|
+
// entries: anything but a well-shaped object — or one without an endpoint —
|
|
200
|
+
// degrades to null (no capture), never a placeholder line.
|
|
201
|
+
function toCapture(raw: unknown): { endpoint: string; model: string; downgrade: boolean } | null {
|
|
202
|
+
if (typeof raw !== "object" || raw === null) return null
|
|
203
|
+
const capture = raw as { endpoint?: unknown; model?: unknown; downgrade?: unknown }
|
|
204
|
+
if (typeof capture.endpoint !== "string" || capture.endpoint === "") return null
|
|
205
|
+
return {
|
|
206
|
+
endpoint: capture.endpoint,
|
|
207
|
+
// The Task-5 handoff: a capture may carry an empty model (a response
|
|
208
|
+
// whose provider had no model id) — carried as-is, and the footer omits
|
|
209
|
+
// the model part rather than rendering an empty slot.
|
|
210
|
+
model: typeof capture.model === "string" ? capture.model : "",
|
|
211
|
+
downgrade: capture.downgrade === true,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The footer's steering note, defensively normalized: anything but a
|
|
216
|
+
// non-empty string means the note does not render (no steering data).
|
|
217
|
+
function toSteeringNote(raw: unknown): string | null {
|
|
218
|
+
return typeof raw === "string" && raw !== "" ? raw : null
|
|
219
|
+
}
|
|
220
|
+
|
|
91
221
|
export default Plugin.define({
|
|
92
222
|
id: "model-compare-tui",
|
|
93
223
|
async setup(context) {
|
|
@@ -97,6 +227,25 @@ export default Plugin.define({
|
|
|
97
227
|
const notify = (message: string, variant: "success" | "error" = "success") =>
|
|
98
228
|
context.ui.toast.show({ title: "model-compare", message, variant, duration: TOAST_MS })
|
|
99
229
|
|
|
230
|
+
// The terminal's size, reactive: the renderer emits "resize" carrying the
|
|
231
|
+
// fresh dimensions, and the dialogs' row-area caps must track font-size
|
|
232
|
+
// and window changes the way the host's own dialogs do (the palette caps
|
|
233
|
+
// its list at a fraction of the viewport; the dialog layer gives plugin
|
|
234
|
+
// dialogs no cap of their own). Detached in the returned cleanup.
|
|
235
|
+
const [terminalSize, setTerminalSize] = createSignal({
|
|
236
|
+
width: context.renderer.width,
|
|
237
|
+
height: context.renderer.height,
|
|
238
|
+
})
|
|
239
|
+
const onRendererResize = (width: number, height: number) => {
|
|
240
|
+
setTerminalSize({ width, height })
|
|
241
|
+
// A resize re-caps the armed dialog's row area under its cursor: the
|
|
242
|
+
// reveal re-runs on the next frame (see showListDialog's
|
|
243
|
+
// revealOnFrame) or a shrunken viewport can leave the cursor below
|
|
244
|
+
// the fold until the next key move.
|
|
245
|
+
armedReveal?.(false)
|
|
246
|
+
}
|
|
247
|
+
context.renderer.on("resize", onRendererResize)
|
|
248
|
+
|
|
100
249
|
let disposeRoot = () => {}
|
|
101
250
|
// The three slot disposers (the two footers and the app slot below):
|
|
102
251
|
// slots claimed but never released survive the plugin's unload and keep
|
|
@@ -116,6 +265,8 @@ export default Plugin.define({
|
|
|
116
265
|
sessionID: string
|
|
117
266
|
age: string | null
|
|
118
267
|
entries: ReportEntry[]
|
|
268
|
+
capture: { endpoint: string; model: string; downgrade: boolean } | null
|
|
269
|
+
steeringNote: string | null
|
|
119
270
|
} | null>(null)
|
|
120
271
|
|
|
121
272
|
// The registry view, reactive: OpenCode's data layer updates the list
|
|
@@ -177,27 +328,34 @@ export default Plugin.define({
|
|
|
177
328
|
return "balanced"
|
|
178
329
|
}
|
|
179
330
|
|
|
180
|
-
// Switch
|
|
181
|
-
//
|
|
182
|
-
//
|
|
331
|
+
// Switch to one of the plugin's aliases — the shared decision chain
|
|
332
|
+
// behind /use-best, the palette command and the picks dialog's
|
|
333
|
+
// selection. With a session open it switches THAT session; with none
|
|
334
|
+
// (the opening screen) it creates a session already bound to the alias
|
|
335
|
+
// and opens it — the pick is the session's starting model. A disabled
|
|
336
|
+
// alias refuses before anything is created.
|
|
183
337
|
async function switchToPriority(priority: Priority): Promise<void> {
|
|
338
|
+
const views = aliases()
|
|
339
|
+
if (!views.some((view) => view.priority === priority && view.enabled)) {
|
|
340
|
+
notify(useBestRefusal(priority), "error")
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
const view = views.find((item) => item.priority === priority)
|
|
344
|
+
const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""))
|
|
184
345
|
const sessionID = currentSessionID()
|
|
185
346
|
if (sessionID === null) {
|
|
186
|
-
|
|
347
|
+
await createSessionOnModel(
|
|
348
|
+
{ providerID: ALIAS_PROVIDER, id: aliasId(priority) },
|
|
349
|
+
notice,
|
|
350
|
+
)
|
|
187
351
|
return
|
|
188
352
|
}
|
|
189
353
|
try {
|
|
190
|
-
const views = aliases()
|
|
191
|
-
if (!views.some((view) => view.priority === priority && view.enabled)) {
|
|
192
|
-
notify(useBestRefusal(priority), "error")
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
354
|
await context.client.session.switchModel({
|
|
196
355
|
sessionID,
|
|
197
356
|
model: { providerID: ALIAS_PROVIDER, id: aliasId(priority) },
|
|
198
357
|
})
|
|
199
|
-
|
|
200
|
-
notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")))
|
|
358
|
+
notify(notice)
|
|
201
359
|
} catch (error) {
|
|
202
360
|
notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
203
361
|
}
|
|
@@ -219,6 +377,28 @@ export default Plugin.define({
|
|
|
219
377
|
}
|
|
220
378
|
}
|
|
221
379
|
|
|
380
|
+
// No session open (the opening screen's pick paths): create one already
|
|
381
|
+
// bound to the given model and navigate into it. Returns whether the
|
|
382
|
+
// session came up; a failure is toasted and nothing else happens — no
|
|
383
|
+
// half-open session to clean up.
|
|
384
|
+
async function createSessionOnModel(
|
|
385
|
+
model: { providerID: string; id: string },
|
|
386
|
+
success: string,
|
|
387
|
+
): Promise<boolean> {
|
|
388
|
+
try {
|
|
389
|
+
const session = await context.client.session.create({ model })
|
|
390
|
+
context.ui.router.navigate({ type: "session", sessionID: session.id })
|
|
391
|
+
notify(success)
|
|
392
|
+
return true
|
|
393
|
+
} catch (error) {
|
|
394
|
+
notify(
|
|
395
|
+
`could not open a session: ${error instanceof Error ? error.message : String(error)}`,
|
|
396
|
+
"error",
|
|
397
|
+
)
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
222
402
|
// The custom list dialog. The host's `dialog.select` (SDK 2.0.16
|
|
223
403
|
// runtime) renders one GROUPED box per category with one cursor each —
|
|
224
404
|
// its search flattening (verified against main) does not exist in this
|
|
@@ -229,21 +409,6 @@ export default Plugin.define({
|
|
|
229
409
|
// render closure (context-less — "Keymap.Provider is missing", the same
|
|
230
410
|
// trap as setup scope), so the app slot's IN-TREE effect registers it
|
|
231
411
|
// 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
412
|
|
|
248
413
|
// The keymap layer hand-off (see showListDialog): set while a list
|
|
249
414
|
// dialog is open, cleared on close; the app slot's in-tree effect maps
|
|
@@ -253,7 +418,15 @@ export default Plugin.define({
|
|
|
253
418
|
pick: () => void
|
|
254
419
|
} | null>(null)
|
|
255
420
|
|
|
256
|
-
function showListDialog(
|
|
421
|
+
function showListDialog(
|
|
422
|
+
title: string,
|
|
423
|
+
rows: ListRow[],
|
|
424
|
+
onPick: (value: string) => void,
|
|
425
|
+
// The footer's extra lines (the report dialog's steering note and
|
|
426
|
+
// last-response line), rendered muted ABOVE the Details line. Absent
|
|
427
|
+
// for the other dialogs (the picks dialog carries no footer lines).
|
|
428
|
+
footerLines: string[] = [],
|
|
429
|
+
): void {
|
|
257
430
|
const selectable = rows.filter((row) => row.value !== undefined)
|
|
258
431
|
if (selectable.length === 0) {
|
|
259
432
|
notify("nothing to select", "error")
|
|
@@ -289,14 +462,75 @@ export default Plugin.define({
|
|
|
289
462
|
// never updated — the cursor sat on row 1 however many times the
|
|
290
463
|
// keymap moved the internal index; observed live, 2026-10-11).
|
|
291
464
|
const [cursor, setCursor] = createSignal(initial)
|
|
465
|
+
// The scrollbox that carries the rows (see the JSX below); its ref
|
|
466
|
+
// lands when the host renders the dialog. The cursor's row is kept in
|
|
467
|
+
// view with the native lists' look-ahead: the content scrolls as soon
|
|
468
|
+
// as the cursor comes within three lines of an edge, and the cursor
|
|
469
|
+
// only touches an edge line when the content ends (the scroll clamps
|
|
470
|
+
// at its maximum).
|
|
471
|
+
//
|
|
472
|
+
// The row offsets are pure ARITHMETIC, not renderable geometry —
|
|
473
|
+
// rowsHeight (module scope) is the ONE row-height rule the rows' JSX
|
|
474
|
+
// and the row-area cap share. Reading the host's .y was tried and is
|
|
475
|
+
// unreliable across the plugin boundary — it reported the scrollbox's
|
|
476
|
+
// own position for every row — while scrollTop/scrollHeight/height
|
|
477
|
+
// behave as plain numbers.
|
|
478
|
+
let listScroll: ScrollBoxRenderable | undefined
|
|
479
|
+
let pendingReveal: (() => void) | undefined
|
|
480
|
+
const rowOffset = (index: number): number => rowsHeight(rows, index)
|
|
481
|
+
const reveal = (center: boolean) => {
|
|
482
|
+
const box = listScroll
|
|
483
|
+
if (box === undefined) return
|
|
484
|
+
const top = rowOffset(rows.indexOf(selectable[cursor()]))
|
|
485
|
+
const max = Math.max(0, box.scrollHeight - box.height)
|
|
486
|
+
// The look-ahead band, clamped to the window: at box.height <= 3 the
|
|
487
|
+
// three-line margin degenerates to plain ensure-visible (the row
|
|
488
|
+
// lands ON a window line instead of the overscroll an unclamped
|
|
489
|
+
// `box.height - 4` produced once it went negative — cap values of
|
|
490
|
+
// 1-3 are exactly what large font sizes reach).
|
|
491
|
+
const band = Math.min(3, Math.max(0, box.height - 1))
|
|
492
|
+
// Keep the row on-screen whatever the branch: the row's line must
|
|
493
|
+
// stay within [scrollTop, scrollTop + box.height - 1].
|
|
494
|
+
const visible = (target: number): number =>
|
|
495
|
+
Math.min(top, Math.max(top - box.height + 1, target))
|
|
496
|
+
let target = box.scrollTop
|
|
497
|
+
if (center) target = top - Math.floor(box.height / 2)
|
|
498
|
+
else if (top - box.scrollTop > box.height - 1 - band) target = visible(top - (box.height - 1 - band))
|
|
499
|
+
else if (top - box.scrollTop < band) target = visible(top - band)
|
|
500
|
+
box.scrollTo(Math.max(0, Math.min(target, max)))
|
|
501
|
+
}
|
|
502
|
+
// Positions exist only after a layout frame, so the reveal defers to
|
|
503
|
+
// the next rendered frame (the palette's own once(FRAME) +
|
|
504
|
+
// requestRender() pattern) rather than racing the renderer inside the
|
|
505
|
+
// key handler.
|
|
506
|
+
const revealOnFrame = (center: boolean) => {
|
|
507
|
+
if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal)
|
|
508
|
+
const run = () => {
|
|
509
|
+
pendingReveal = undefined
|
|
510
|
+
if (!alive) return
|
|
511
|
+
reveal(center)
|
|
512
|
+
}
|
|
513
|
+
pendingReveal = run
|
|
514
|
+
context.renderer.once("frame", run)
|
|
515
|
+
context.renderer.requestRender()
|
|
516
|
+
}
|
|
292
517
|
const close = () => {
|
|
293
518
|
if (!alive) return
|
|
294
519
|
alive = false
|
|
520
|
+
// A pending frame reveal is THIS dialog's alone: cancel it here or
|
|
521
|
+
// the once("frame") listener (and its closure over the rows)
|
|
522
|
+
// outlives the dialog — it self-guarded on `alive`, but leaked until
|
|
523
|
+
// the next frame.
|
|
524
|
+
if (pendingReveal !== undefined) {
|
|
525
|
+
context.renderer.off("frame", pendingReveal)
|
|
526
|
+
pendingReveal = undefined
|
|
527
|
+
}
|
|
295
528
|
// 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.
|
|
529
|
+
// shared state (the keymap layer, the host dialog, the reveal hook)
|
|
530
|
+
// now — clearing here would kill ITS keyboard and surface.
|
|
298
531
|
if (generation !== dialogGeneration) return
|
|
299
532
|
armedClose = null
|
|
533
|
+
armedReveal = null
|
|
300
534
|
setListKeys(null)
|
|
301
535
|
context.ui.dialog.clear()
|
|
302
536
|
}
|
|
@@ -305,6 +539,10 @@ export default Plugin.define({
|
|
|
305
539
|
// first is the last (the command palette's behavior).
|
|
306
540
|
const move = (delta: number) => {
|
|
307
541
|
setCursor((cursor() + delta + selectable.length) % selectable.length)
|
|
542
|
+
// Frame-deferred like every reveal — and it CANCELS a pending one,
|
|
543
|
+
// so the preselected row's open-time centering can never land after
|
|
544
|
+
// the user has already moved the cursor off it.
|
|
545
|
+
revealOnFrame(false)
|
|
308
546
|
}
|
|
309
547
|
const pick = () => {
|
|
310
548
|
const row = selectable[cursor()]
|
|
@@ -316,6 +554,7 @@ export default Plugin.define({
|
|
|
316
554
|
// slot below); it registers on the next reactive run. This dialog is
|
|
317
555
|
// now the armed one (the lifecycle token above).
|
|
318
556
|
armedClose = close
|
|
557
|
+
armedReveal = revealOnFrame
|
|
319
558
|
setListKeys({ move, pick })
|
|
320
559
|
|
|
321
560
|
context.ui.dialog.show(
|
|
@@ -332,7 +571,19 @@ export default Plugin.define({
|
|
|
332
571
|
esc
|
|
333
572
|
</text>
|
|
334
573
|
</box>
|
|
335
|
-
<
|
|
574
|
+
<scrollbox
|
|
575
|
+
ref={(element) => {
|
|
576
|
+
listScroll = element
|
|
577
|
+
// The report's preselected row (the current pick) opens
|
|
578
|
+
// centered in the scrollbox: scheduled from the ref, so the
|
|
579
|
+
// frame it waits for is the one that lays these rows out.
|
|
580
|
+
if (initial > 0) revealOnFrame(true)
|
|
581
|
+
}}
|
|
582
|
+
maxHeight={cappedRowsHeight(rowsHeight(rows), terminalSize().height)}
|
|
583
|
+
paddingLeft={1}
|
|
584
|
+
paddingRight={1}
|
|
585
|
+
scrollbarOptions={{ visible: false }}
|
|
586
|
+
>
|
|
336
587
|
{(() => {
|
|
337
588
|
let headings = 0
|
|
338
589
|
return rows.map((row) => {
|
|
@@ -343,11 +594,14 @@ export default Plugin.define({
|
|
|
343
594
|
}
|
|
344
595
|
if (row.heading !== undefined) {
|
|
345
596
|
// The palette's heading recipe: accent hue, bold, one
|
|
346
|
-
// blank line above every heading but the first
|
|
347
|
-
//
|
|
597
|
+
// blank line above every heading but the first — the
|
|
598
|
+
// blank is rowLines' call (the ONE row-height rule the
|
|
599
|
+
// offsets and the cap share), not a second copy of its
|
|
600
|
+
// condition. Flush with the title: 1 (list inset) + 3 = 4.
|
|
601
|
+
const paddingTop = rowLines(row, headings) - 1
|
|
348
602
|
headings += 1
|
|
349
603
|
return (
|
|
350
|
-
<box paddingTop={
|
|
604
|
+
<box paddingTop={paddingTop} paddingLeft={3}>
|
|
351
605
|
<text fg={accent} attributes={TextAttributes.BOLD}>
|
|
352
606
|
{row.heading}
|
|
353
607
|
</text>
|
|
@@ -388,21 +642,32 @@ export default Plugin.define({
|
|
|
388
642
|
)
|
|
389
643
|
})
|
|
390
644
|
})()}
|
|
391
|
-
</
|
|
645
|
+
</scrollbox>
|
|
646
|
+
{footerLines.map((line) => (
|
|
647
|
+
<box paddingLeft={4}>
|
|
648
|
+
<text fg={muted}>{line}</text>
|
|
649
|
+
</box>
|
|
650
|
+
))}
|
|
392
651
|
<box paddingLeft={4}>
|
|
393
|
-
<text fg={muted}>Details: https://
|
|
652
|
+
<text fg={muted}>Details: https://model-compare.dev</text>
|
|
394
653
|
</box>
|
|
395
654
|
</box>
|
|
396
655
|
),
|
|
397
656
|
close,
|
|
398
657
|
)
|
|
658
|
+
// The palette's presentation: vertically centered at the medium width.
|
|
659
|
+
// The size/centered state persists across dialogs (the host's own set
|
|
660
|
+
// it on every open), so the explicit set also clears a stale xlarge a
|
|
661
|
+
// host dialog may have left behind.
|
|
662
|
+
context.ui.dialog.set({ size: "medium", centered: true })
|
|
399
663
|
}
|
|
400
664
|
|
|
401
665
|
// The picks dialog: the three priorities, one cursor, palette styling.
|
|
402
666
|
// A snapshot at open time; unavailable priorities are dimmed — the
|
|
403
667
|
// cursor still reaches them, and choosing one is refused with a toast
|
|
404
668
|
// (switchToPriority's guard). Choosing an available priority switches
|
|
405
|
-
// the session to it
|
|
669
|
+
// the session to it — or, with no session open, creates one bound to
|
|
670
|
+
// it (the opening screen's path; opening the dialog never does).
|
|
406
671
|
function showPicksDialog(): void {
|
|
407
672
|
const views = aliases()
|
|
408
673
|
const rows: ListRow[] = PRIORITIES.map((priority) => {
|
|
@@ -425,9 +690,17 @@ export default Plugin.define({
|
|
|
425
690
|
// model switches straight to it. Empty entries mean the command had no
|
|
426
691
|
// ranking data.
|
|
427
692
|
function showReportDialog(data: {
|
|
428
|
-
|
|
693
|
+
// The session the report belongs to — null when the dialog was opened
|
|
694
|
+
// with no session (the opening screen's direct-RPC path); a pick then
|
|
695
|
+
// creates a session bound to the chosen model instead of switching.
|
|
696
|
+
sessionID: string | null
|
|
429
697
|
age: string | null
|
|
430
698
|
entries: ReportEntry[]
|
|
699
|
+
// The footer (design §Steering visibility, the user's option C): the
|
|
700
|
+
// steering note above the last-response line, both omitted when their
|
|
701
|
+
// data is absent — never a placeholder.
|
|
702
|
+
capture: { endpoint: string; model: string; downgrade: boolean } | null
|
|
703
|
+
steeringNote: string | null
|
|
431
704
|
}): void {
|
|
432
705
|
if (data.entries.length === 0) {
|
|
433
706
|
notify("no ranking data available yet", "error")
|
|
@@ -494,10 +767,30 @@ export default Plugin.define({
|
|
|
494
767
|
})
|
|
495
768
|
}
|
|
496
769
|
}
|
|
770
|
+
// The report dialog's footer (design §Decisions, option C): the
|
|
771
|
+
// steering note ABOVE the last-response line, both omitted when their
|
|
772
|
+
// data is absent — never a placeholder line.
|
|
773
|
+
const footerLines: string[] = []
|
|
774
|
+
if (data.steeringNote !== null) footerLines.push(data.steeringNote)
|
|
775
|
+
const lastResponse = lastResponseLine(data.capture)
|
|
776
|
+
if (lastResponse !== null) footerLines.push(lastResponse)
|
|
497
777
|
showListDialog(
|
|
498
778
|
`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`,
|
|
499
779
|
rows,
|
|
500
|
-
(id) =>
|
|
780
|
+
(id) => {
|
|
781
|
+
const sessionID = data.sessionID
|
|
782
|
+
if (sessionID !== null) {
|
|
783
|
+
void switchToModel(sessionID, id)
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
// The opening screen: the pick becomes a NEW session's model —
|
|
787
|
+
// the same concrete model the in-session path switches to.
|
|
788
|
+
void createSessionOnModel(
|
|
789
|
+
{ providerID: ALIAS_PROVIDER, id },
|
|
790
|
+
`switched to openrouter/${sanitizeCatalogText(id)}`,
|
|
791
|
+
)
|
|
792
|
+
},
|
|
793
|
+
footerLines,
|
|
501
794
|
)
|
|
502
795
|
}
|
|
503
796
|
|
|
@@ -510,7 +803,13 @@ export default Plugin.define({
|
|
|
510
803
|
const event = details as {
|
|
511
804
|
type?: unknown
|
|
512
805
|
location?: { directory?: unknown }
|
|
513
|
-
data?: {
|
|
806
|
+
data?: {
|
|
807
|
+
sessionID?: unknown
|
|
808
|
+
age?: unknown
|
|
809
|
+
entries?: unknown
|
|
810
|
+
capture?: unknown
|
|
811
|
+
steeringNote?: unknown
|
|
812
|
+
}
|
|
514
813
|
}
|
|
515
814
|
if (event.type !== REPORT_EVENT) return
|
|
516
815
|
if (
|
|
@@ -522,40 +821,49 @@ export default Plugin.define({
|
|
|
522
821
|
const data = event.data
|
|
523
822
|
if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return
|
|
524
823
|
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
|
-
})
|
|
824
|
+
const entries = toReportEntries(data.entries)
|
|
549
825
|
const eventSessionID = data.sessionID
|
|
550
826
|
// Ownerless callback: park the report on the signal — the app slot's
|
|
551
|
-
// in-tree effect opens the dialog (see pendingReport above).
|
|
827
|
+
// in-tree effect opens the dialog (see pendingReport above). The
|
|
828
|
+
// footer's fields ride the payload like the entries (defensively
|
|
829
|
+
// normalized at this trust boundary).
|
|
552
830
|
setPendingReport({
|
|
553
831
|
sessionID: eventSessionID,
|
|
554
832
|
age: typeof data.age === "string" ? data.age : null,
|
|
555
833
|
entries,
|
|
834
|
+
capture: toCapture(data.capture),
|
|
835
|
+
steeringNote: toSteeringNote(data.steeringNote),
|
|
556
836
|
})
|
|
557
837
|
})
|
|
558
838
|
|
|
839
|
+
// The cannot-steer fallback (Task 6): the runtime hook emits this event
|
|
840
|
+
// once per session when a steerable alias request finds no steering data
|
|
841
|
+
// (no catalog source). The TUI surfaces it as a toast with the exact
|
|
842
|
+
// fallback copy; headless shows nothing — like every TUI-coupled surface.
|
|
843
|
+
// Scoped exactly like the report listener: a foreign directory's session
|
|
844
|
+
// is never viewable here, and only the CURRENTLY viewed session toasts.
|
|
845
|
+
// Trade-off: a cannotSteer notice for a same-project background session
|
|
846
|
+
// (or a session the user isn't currently viewing) is dropped forever —
|
|
847
|
+
// the emit is once-per-session — but noise from unopened sessions is the
|
|
848
|
+
// worse failure; the notice is about the session the user interacts with.
|
|
849
|
+
const offCannotSteerEvent = context.data.listen(({ details }) => {
|
|
850
|
+
const event = details as {
|
|
851
|
+
type?: unknown
|
|
852
|
+
location?: { directory?: unknown }
|
|
853
|
+
data?: { sessionID?: unknown }
|
|
854
|
+
}
|
|
855
|
+
if (event.type !== CANNOT_STEER_EVENT) return
|
|
856
|
+
if (
|
|
857
|
+
typeof event.location?.directory === "string" &&
|
|
858
|
+
here !== undefined &&
|
|
859
|
+
event.location.directory !== here
|
|
860
|
+
)
|
|
861
|
+
return
|
|
862
|
+
const data = event.data
|
|
863
|
+
if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return
|
|
864
|
+
notify(CANNOT_STEER_COPY)
|
|
865
|
+
})
|
|
866
|
+
|
|
559
867
|
createRoot((dispose) => {
|
|
560
868
|
disposeRoot = dispose
|
|
561
869
|
|
|
@@ -628,29 +936,27 @@ export default Plugin.define({
|
|
|
628
936
|
|
|
629
937
|
// The picks dialog's command. No slash: /use-best already covers the
|
|
630
938
|
// switching surface (the server command is slash-routable; the TUI's
|
|
631
|
-
// duplicate of it was removed for exactly that reason).
|
|
939
|
+
// duplicate of it was removed for exactly that reason). The dialog opens
|
|
940
|
+
// everywhere — with no session it shows the current picks, and only
|
|
941
|
+
// picking one creates a session bound to it (switchToPriority).
|
|
632
942
|
const picksCommand: KeymapCommand = {
|
|
633
943
|
id: "model-compare.picks",
|
|
634
944
|
title: "Model-compare top picks",
|
|
635
945
|
group: "Model Compare",
|
|
636
946
|
bind: "alt+m",
|
|
637
947
|
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
|
-
},
|
|
948
|
+
run: () => showPicksDialog(),
|
|
645
949
|
}
|
|
646
950
|
|
|
647
|
-
// The report command: posts the server's
|
|
648
|
-
// RPC event opens the selectable report
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
951
|
+
// The report command: with a session open it posts the server's
|
|
952
|
+
// /model-compare command, whose RPC event opens the selectable report
|
|
953
|
+
// dialog here (and the transcript keeps the notice). With no session
|
|
954
|
+
// (the opening screen) there is nothing to post to, so the report is
|
|
955
|
+
// fetched straight from the plugin's report RPC — same builder, same
|
|
956
|
+
// entries — and picking a model creates a session bound to it (there is
|
|
957
|
+
// no composer-model setter to change instead; verified against the
|
|
958
|
+
// pinned runtime). The refusal-free opening also keeps the footer's
|
|
959
|
+
// status line honest: the shortcuts always DO something.
|
|
654
960
|
const reportCommand: KeymapCommand = {
|
|
655
961
|
id: "model-compare.report",
|
|
656
962
|
title: "Model-compare report",
|
|
@@ -659,18 +965,74 @@ export default Plugin.define({
|
|
|
659
965
|
palette: true,
|
|
660
966
|
run: async () => {
|
|
661
967
|
const sessionID = currentSessionID()
|
|
662
|
-
if (sessionID
|
|
663
|
-
|
|
968
|
+
if (sessionID !== null) {
|
|
969
|
+
try {
|
|
970
|
+
await context.client.session.command({ sessionID, name: "model-compare", text: "" })
|
|
971
|
+
} catch (error) {
|
|
972
|
+
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
973
|
+
}
|
|
664
974
|
return
|
|
665
975
|
}
|
|
666
976
|
try {
|
|
667
|
-
await context.client.
|
|
977
|
+
const report: unknown = await context.client.rpc(ModelCompare).report(
|
|
978
|
+
{},
|
|
979
|
+
here === undefined ? undefined : { location: { directory: here } },
|
|
980
|
+
)
|
|
981
|
+
const data = report as
|
|
982
|
+
| { age?: unknown; entries?: unknown; capture?: unknown; steeringNote?: unknown }
|
|
983
|
+
| undefined
|
|
984
|
+
showReportDialog({
|
|
985
|
+
sessionID: null,
|
|
986
|
+
age: typeof data?.age === "string" ? data.age : null,
|
|
987
|
+
entries: toReportEntries(data?.entries),
|
|
988
|
+
capture: toCapture(data?.capture),
|
|
989
|
+
steeringNote: toSteeringNote(data?.steeringNote),
|
|
990
|
+
})
|
|
668
991
|
} catch (error) {
|
|
669
992
|
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
670
993
|
}
|
|
671
994
|
},
|
|
672
995
|
}
|
|
673
996
|
|
|
997
|
+
// The served-endpoint toast (design §Steering visibility): alt+e toasts
|
|
998
|
+
// the CURRENT session's last response's served endpoint, looked up
|
|
999
|
+
// through the report RPC's footer fields (the capture record rides the
|
|
1000
|
+
// established report payload path). The copy is exact — normal:
|
|
1001
|
+
// "last response served by <endpoint>"; downgrade (the served provider
|
|
1002
|
+
// matched none of the applied steering order): "! served by <endpoint> —
|
|
1003
|
+
// not in steering order"; nothing toasts when nothing was captured for
|
|
1004
|
+
// the session (and with no session open there is nothing to look up).
|
|
1005
|
+
const endpointCommand: KeymapCommand = {
|
|
1006
|
+
id: "model-compare.endpoint",
|
|
1007
|
+
title: "Last served endpoint",
|
|
1008
|
+
group: "Model Compare",
|
|
1009
|
+
bind: "alt+e",
|
|
1010
|
+
palette: true,
|
|
1011
|
+
run: async () => {
|
|
1012
|
+
const sessionID = currentSessionID()
|
|
1013
|
+
if (sessionID === null) return
|
|
1014
|
+
try {
|
|
1015
|
+
const report: unknown = await context.client.rpc(ModelCompare).report(
|
|
1016
|
+
{ sessionID },
|
|
1017
|
+
here === undefined ? undefined : { location: { directory: here } },
|
|
1018
|
+
)
|
|
1019
|
+
const capture = toCapture((report as { capture?: unknown } | undefined)?.capture)
|
|
1020
|
+
if (capture === null) return
|
|
1021
|
+
// F-20: the endpoint is response data — flattened for the toast.
|
|
1022
|
+
notify(
|
|
1023
|
+
capture.downgrade
|
|
1024
|
+
? `! served by ${sanitizeCatalogText(capture.endpoint)} — not in steering order`
|
|
1025
|
+
: `last response served by ${sanitizeCatalogText(capture.endpoint)}`,
|
|
1026
|
+
)
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
notify(
|
|
1029
|
+
`endpoint lookup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1030
|
+
"error",
|
|
1031
|
+
)
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
}
|
|
1035
|
+
|
|
674
1036
|
// The palette-only use-best: the immediate-toast variant. The SLASH
|
|
675
1037
|
// surface is left to the server command (one entry everywhere); the
|
|
676
1038
|
// palette is TUI-layer territory, where this variant's instant feedback
|
|
@@ -751,7 +1113,7 @@ export default Plugin.define({
|
|
|
751
1113
|
try {
|
|
752
1114
|
context.keymap.layer(() => ({
|
|
753
1115
|
mode: "global",
|
|
754
|
-
commands: [useBestCommand, picksCommand, reportCommand],
|
|
1116
|
+
commands: [useBestCommand, picksCommand, reportCommand, endpointCommand],
|
|
755
1117
|
}))
|
|
756
1118
|
} catch (error) {
|
|
757
1119
|
layerFailure("the model-compare keymap layer", error)
|
|
@@ -761,8 +1123,15 @@ export default Plugin.define({
|
|
|
761
1123
|
})
|
|
762
1124
|
|
|
763
1125
|
return () => {
|
|
1126
|
+
// Disarm whatever dialog is still armed FIRST: its pending frame
|
|
1127
|
+
// reveal and modal keyboard must not outlive the plugin (the
|
|
1128
|
+
// module-scope armedClose/armedReveal survive setup's cleanup
|
|
1129
|
+
// otherwise).
|
|
1130
|
+
armedClose?.()
|
|
764
1131
|
if (resyncTimer !== undefined) clearInterval(resyncTimer)
|
|
1132
|
+
context.renderer.off("resize", onRendererResize)
|
|
765
1133
|
offReportEvent()
|
|
1134
|
+
offCannotSteerEvent()
|
|
766
1135
|
disposeRoot()
|
|
767
1136
|
disposeAppSlot()
|
|
768
1137
|
disposeHomeFooter()
|