model-compare-opencode-plugin 0.1.3 → 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 +39 -7
- package/dist/tui.js +123 -10
- 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 +149 -9
- package/vendor/UPSTREAM.json +3 -3
- package/vendor/model_compare.py +1129 -7
package/src/tui.tsx
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
type AliasView,
|
|
69
69
|
} from "./opencode/usebest"
|
|
70
70
|
import type { ReportEntry } from "./opencode/commands"
|
|
71
|
-
import { sanitizeCatalogText } from "./opencode/commands"
|
|
71
|
+
import { lastResponseLine, sanitizeCatalogText } from "./opencode/commands"
|
|
72
72
|
import { ModelCompare } from "./rpc"
|
|
73
73
|
|
|
74
74
|
const TOAST_MS = 6_000
|
|
@@ -81,6 +81,11 @@ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin"
|
|
|
81
81
|
// The RPC event the /model-compare command emits (see src/rpc.ts; the wire
|
|
82
82
|
// name is prefixed with the RPC's id).
|
|
83
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"
|
|
84
89
|
|
|
85
90
|
// The list dialogs' lifecycle token (see showListDialog): a module-scope
|
|
86
91
|
// generation counter, bumped every time a dialog is armed, plus the armed
|
|
@@ -189,6 +194,30 @@ function toReportEntries(raw: unknown): ReportEntry[] {
|
|
|
189
194
|
})
|
|
190
195
|
}
|
|
191
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
|
+
|
|
192
221
|
export default Plugin.define({
|
|
193
222
|
id: "model-compare-tui",
|
|
194
223
|
async setup(context) {
|
|
@@ -236,6 +265,8 @@ export default Plugin.define({
|
|
|
236
265
|
sessionID: string
|
|
237
266
|
age: string | null
|
|
238
267
|
entries: ReportEntry[]
|
|
268
|
+
capture: { endpoint: string; model: string; downgrade: boolean } | null
|
|
269
|
+
steeringNote: string | null
|
|
239
270
|
} | null>(null)
|
|
240
271
|
|
|
241
272
|
// The registry view, reactive: OpenCode's data layer updates the list
|
|
@@ -387,7 +418,15 @@ export default Plugin.define({
|
|
|
387
418
|
pick: () => void
|
|
388
419
|
} | null>(null)
|
|
389
420
|
|
|
390
|
-
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 {
|
|
391
430
|
const selectable = rows.filter((row) => row.value !== undefined)
|
|
392
431
|
if (selectable.length === 0) {
|
|
393
432
|
notify("nothing to select", "error")
|
|
@@ -604,8 +643,13 @@ export default Plugin.define({
|
|
|
604
643
|
})
|
|
605
644
|
})()}
|
|
606
645
|
</scrollbox>
|
|
646
|
+
{footerLines.map((line) => (
|
|
647
|
+
<box paddingLeft={4}>
|
|
648
|
+
<text fg={muted}>{line}</text>
|
|
649
|
+
</box>
|
|
650
|
+
))}
|
|
607
651
|
<box paddingLeft={4}>
|
|
608
|
-
<text fg={muted}>Details: https://
|
|
652
|
+
<text fg={muted}>Details: https://model-compare.dev</text>
|
|
609
653
|
</box>
|
|
610
654
|
</box>
|
|
611
655
|
),
|
|
@@ -652,6 +696,11 @@ export default Plugin.define({
|
|
|
652
696
|
sessionID: string | null
|
|
653
697
|
age: string | null
|
|
654
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
|
|
655
704
|
}): void {
|
|
656
705
|
if (data.entries.length === 0) {
|
|
657
706
|
notify("no ranking data available yet", "error")
|
|
@@ -718,12 +767,20 @@ export default Plugin.define({
|
|
|
718
767
|
})
|
|
719
768
|
}
|
|
720
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)
|
|
721
777
|
showListDialog(
|
|
722
778
|
`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`,
|
|
723
779
|
rows,
|
|
724
780
|
(id) => {
|
|
725
|
-
|
|
726
|
-
|
|
781
|
+
const sessionID = data.sessionID
|
|
782
|
+
if (sessionID !== null) {
|
|
783
|
+
void switchToModel(sessionID, id)
|
|
727
784
|
return
|
|
728
785
|
}
|
|
729
786
|
// The opening screen: the pick becomes a NEW session's model —
|
|
@@ -733,6 +790,7 @@ export default Plugin.define({
|
|
|
733
790
|
`switched to openrouter/${sanitizeCatalogText(id)}`,
|
|
734
791
|
)
|
|
735
792
|
},
|
|
793
|
+
footerLines,
|
|
736
794
|
)
|
|
737
795
|
}
|
|
738
796
|
|
|
@@ -745,7 +803,13 @@ export default Plugin.define({
|
|
|
745
803
|
const event = details as {
|
|
746
804
|
type?: unknown
|
|
747
805
|
location?: { directory?: unknown }
|
|
748
|
-
data?: {
|
|
806
|
+
data?: {
|
|
807
|
+
sessionID?: unknown
|
|
808
|
+
age?: unknown
|
|
809
|
+
entries?: unknown
|
|
810
|
+
capture?: unknown
|
|
811
|
+
steeringNote?: unknown
|
|
812
|
+
}
|
|
749
813
|
}
|
|
750
814
|
if (event.type !== REPORT_EVENT) return
|
|
751
815
|
if (
|
|
@@ -760,14 +824,46 @@ export default Plugin.define({
|
|
|
760
824
|
const entries = toReportEntries(data.entries)
|
|
761
825
|
const eventSessionID = data.sessionID
|
|
762
826
|
// Ownerless callback: park the report on the signal — the app slot's
|
|
763
|
-
// 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).
|
|
764
830
|
setPendingReport({
|
|
765
831
|
sessionID: eventSessionID,
|
|
766
832
|
age: typeof data.age === "string" ? data.age : null,
|
|
767
833
|
entries,
|
|
834
|
+
capture: toCapture(data.capture),
|
|
835
|
+
steeringNote: toSteeringNote(data.steeringNote),
|
|
768
836
|
})
|
|
769
837
|
})
|
|
770
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
|
+
|
|
771
867
|
createRoot((dispose) => {
|
|
772
868
|
disposeRoot = dispose
|
|
773
869
|
|
|
@@ -882,11 +978,15 @@ export default Plugin.define({
|
|
|
882
978
|
{},
|
|
883
979
|
here === undefined ? undefined : { location: { directory: here } },
|
|
884
980
|
)
|
|
885
|
-
const data = report as
|
|
981
|
+
const data = report as
|
|
982
|
+
| { age?: unknown; entries?: unknown; capture?: unknown; steeringNote?: unknown }
|
|
983
|
+
| undefined
|
|
886
984
|
showReportDialog({
|
|
887
985
|
sessionID: null,
|
|
888
986
|
age: typeof data?.age === "string" ? data.age : null,
|
|
889
987
|
entries: toReportEntries(data?.entries),
|
|
988
|
+
capture: toCapture(data?.capture),
|
|
989
|
+
steeringNote: toSteeringNote(data?.steeringNote),
|
|
890
990
|
})
|
|
891
991
|
} catch (error) {
|
|
892
992
|
notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
@@ -894,6 +994,45 @@ export default Plugin.define({
|
|
|
894
994
|
},
|
|
895
995
|
}
|
|
896
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
|
+
|
|
897
1036
|
// The palette-only use-best: the immediate-toast variant. The SLASH
|
|
898
1037
|
// surface is left to the server command (one entry everywhere); the
|
|
899
1038
|
// palette is TUI-layer territory, where this variant's instant feedback
|
|
@@ -974,7 +1113,7 @@ export default Plugin.define({
|
|
|
974
1113
|
try {
|
|
975
1114
|
context.keymap.layer(() => ({
|
|
976
1115
|
mode: "global",
|
|
977
|
-
commands: [useBestCommand, picksCommand, reportCommand],
|
|
1116
|
+
commands: [useBestCommand, picksCommand, reportCommand, endpointCommand],
|
|
978
1117
|
}))
|
|
979
1118
|
} catch (error) {
|
|
980
1119
|
layerFailure("the model-compare keymap layer", error)
|
|
@@ -992,6 +1131,7 @@ export default Plugin.define({
|
|
|
992
1131
|
if (resyncTimer !== undefined) clearInterval(resyncTimer)
|
|
993
1132
|
context.renderer.off("resize", onRendererResize)
|
|
994
1133
|
offReportEvent()
|
|
1134
|
+
offCannotSteerEvent()
|
|
995
1135
|
disposeRoot()
|
|
996
1136
|
disposeAppSlot()
|
|
997
1137
|
disposeHomeFooter()
|
package/vendor/UPSTREAM.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repo": "canonical/model-compare",
|
|
3
|
-
"tag": "v0.
|
|
4
|
-
"commit": "
|
|
5
|
-
"sha256": "
|
|
3
|
+
"tag": "v0.4.0",
|
|
4
|
+
"commit": "6f4b59f93fec4bbad3068c531d401c91cb9a2ce9",
|
|
5
|
+
"sha256": "f70b3e02811c5d5dc53a4f23d5a0d1efa2b03ceda9124c5ad7b2013412b00135"
|
|
6
6
|
}
|