@rizal72/oc-models-pro 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @rizal72/oc-models-pro
2
2
 
3
- A model picker that shows what a request costs: price in and out, context, intelligence, `tok/s` and
4
- the Go credit, sorted by what a request actually costs. Enter switches.
3
+ A model picker that shows what a request costs: price in and out, context, intelligence, `tok/s` and the
4
+ Go credit, sorted by what a request actually costs. Enter switches.
5
5
 
6
6
  ## Install
7
7
 
@@ -15,7 +15,19 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
15
15
  "plugins": ["@rizal72/oc-models-pro"]
16
16
  ```
17
17
 
18
- ## Use
18
+ ## Update
19
+
20
+ V2 checks package plugins at startup and never applies the update, so a package only moves when you ask:
21
+
22
+ ```sh
23
+ opencode plugin check # which of the installed packages are behind
24
+ opencode plugin update # apply them, and let the server reload
25
+ ```
26
+
27
+ Or install [`@rizal72/oc-plugin-updates`](https://www.npmjs.com/package/@rizal72/oc-plugin-updates): the
28
+ count appears in a pill above the composer, with the update one click away.
29
+
30
+ ## TUI
19
31
 
20
32
  `/models-pro`, or `Ctrl+X` then `p`, inside a session.
21
33
 
@@ -30,7 +42,12 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
30
42
  | `ctrl+u` | clear the search |
31
43
  | `esc` | clear the search, else close |
32
44
 
33
- ## Where the numbers come from
45
+ ## GUI
46
+
47
+ The same table as a side panel: `mod+shift+m`, `/models-pro`, or the session menu. Click a column to sort
48
+ by it, a star to favourite, a row to switch. The search box, the scope buttons and a refresh sit above it.
49
+
50
+ ## How it works
34
51
 
35
52
  | column | source |
36
53
  |---|---|
@@ -40,7 +57,8 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
40
57
  | ~tok/s | Artificial Analysis, with a free key |
41
58
  | cred | the Go plan page, cached 1 day, `opencode-go` only |
42
59
 
43
- Favourites and recents are the native picker's (`~/.local/state/opencode/model.json`), so both agree.
60
+ Favourites and recents are the native picker's (`~/.local/state/opencode/model.json`), so both agree. The
61
+ switch is the app's own path, never a write to the session or that file.
44
62
 
45
63
  ## Optional: tok/s for models you have not run
46
64
 
package/bench.ts CHANGED
@@ -91,7 +91,10 @@ export async function benchIndex(): Promise<Map<string, Bench>> {
91
91
  const previous = await cached()
92
92
  if (previous && Date.now() - previous.fetched < GRACE_MS) return indexOf(previous)
93
93
  try {
94
- const response = await fetch(CATALOG, { headers: { "user-agent": "oc-models-pro" } })
94
+ const response = await fetch(CATALOG, {
95
+ headers: { "user-agent": "oc-models-pro" },
96
+ signal: AbortSignal.timeout(20_000),
97
+ })
95
98
  if (!response.ok) throw new Error("HTTP " + response.status)
96
99
  const built = buildIndex(await response.json())
97
100
  if (built.size === 0) throw new Error("catalog shape changed")
@@ -115,7 +118,7 @@ export function benchFor(index: Map<string, Bench>, name: string, id: string): B
115
118
  // Self-check: node plugins/oc-models-pro/bench.ts --self-check
116
119
  if (process.argv[2] === "--self-check") {
117
120
  const run = async () => {
118
- const response = await fetch(CATALOG)
121
+ const response = await fetch(CATALOG, { signal: AbortSignal.timeout(20_000) })
119
122
  if (!response.ok) throw new Error("self-check: HTTP " + response.status)
120
123
  const index = buildIndex(await response.json())
121
124
  if (index.size < 200) throw new Error("self-check: only " + index.size + " models keyed")
@@ -0,0 +1,58 @@
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("@opencode/plugin/gui"),n=require("solid-js"),r=require("@opencode/plugin/rpc");var i={type:`number`},a={type:`object`,properties:{rows:{type:`array`,items:{type:`object`,properties:{providerID:{type:`string`},modelID:{type:`string`},name:{type:`string`},provider:{type:`string`},tag:{type:`string`},input:i,output:i,context:i,credit:i,promo:i,peak:i,int:i,tps:i,measured:i,enabled:{type:`boolean`}},required:[`providerID`,`modelID`,`name`,`provider`,`tag`,`credit`,`enabled`],additionalProperties:!1}},favorites:{type:`array`,items:{type:`string`}},offers:i,withSpeed:i,credits:{type:`boolean`},problems:{type:`array`,items:{type:`string`}}},required:[`rows`,`favorites`,`offers`,`withSpeed`,`credits`],additionalProperties:!1},o=r.Rpc.define({id:`models`,methods:{snapshot:{input:{type:`object`,properties:{force:{type:`boolean`}},additionalProperties:!1},output:a},setFavorite:{input:{type:`object`,properties:{providerID:{type:`string`},modelID:{type:`string`},on:{type:`boolean`}},required:[`providerID`,`modelID`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`},favorites:{type:`array`,items:{type:`string`}}},required:[`ok`,`favorites`],additionalProperties:!1}}},events:{}});function s(e){if(e.input!==void 0)return e.input/(e.credit||1)}function c(e){return e.input===0&&e.output===0}function l(e){return e.measured??e.tps}var u={effective:{label:`effective price`,short:`effective↑`,value:s},input:{label:`input price`,short:`in↑`,value:e=>e.input},output:{label:`output price`,short:`out↑`,value:e=>e.output},inputSum:{label:`input + output`,short:`in+out↑`,value:e=>e.input===void 0||e.output===void 0?void 0:(e.input+e.output)/(e.credit||1)},context:{label:`context window`,short:`ctx↓`,desc:!0,value:e=>e.context},int:{label:`intelligence`,short:`int↓`,desc:!0,value:e=>e.int},speed:{label:`speed`,short:`speed↓`,desc:!0,value:e=>l(e)},recent:{label:`recently used`,short:`recent`,value:(e,t)=>t.recent.get(e.providerID+`/`+e.modelID)},provider:{label:`provider`,short:`provider`,value:()=>0},name:{label:`name`,short:`name`,value:()=>0}},d=[`effective`,`input`,`output`,`inputSum`,`context`,`int`,`speed`,`recent`,`provider`,`name`];function f(e){return u[e].desc?-1:1}var p=e=>e.providerID+`/`+e.modelID;function m(e,t,n,r,i=f(e)){if(e===`name`||e===`provider`){let r=e===`name`?t.name:t.provider,a=e===`name`?n.name:n.provider,o=r.localeCompare(a);return o===0?p(t).localeCompare(p(n)):o*i}let a=u[e].value(t,r),o=u[e].value(n,r);return a===void 0||o===void 0?a===o?t.name.localeCompare(n.name):a===void 0?1:-1:a===o?t.name.localeCompare(n.name):(a<o?-1:1)*i}function h(e,t){let n=t.inUse===void 0?void 0:e.find(e=>p(e)===t.inUse);if(!t.grouped){let t=n?e.filter(e=>e!==n):e,r=t.filter(e=>!c(e)),i=t.filter(e=>c(e));return[...n?[{rows:[n]}]:[],...r.length?[{rows:r}]:[],...i.length?[{title:`Free`,rows:i}]:[]]}let r=new Map;for(let t of e){let e=r.get(t.providerID)??{title:t.provider,rows:[]},n=e.rows.findIndex(e=>c(e)&&!c(t));n===-1?e.rows.push(t):e.rows.splice(n,0,t),r.set(t.providerID,e)}let i=[...r.entries()].sort(([e,t],[n,r])=>(t.title??``).localeCompare(r.title??``)||e.localeCompare(n)).map(([,e])=>e);if(n){let e=i.indexOf(r.get(n.providerID));e>0&&i.unshift(...i.splice(e,1));let t=i[0];t&&(t.rows=[n,...t.rows.filter(e=>e!==n)])}return i}if(typeof process<`u`&&process.argv?.[2]===`--self-check`){let e=(e={})=>({name:`Model`,providerID:`p`,modelID:`m`,provider:`Provider`,tag:`PR`,credit:1,enabled:!0,...e}),t={recent:new Map([[`p/m`,0]])},n=(e,t)=>{if(!e)throw Error(`self-check: `+t)};n(s(e())===void 0,`an unknown input price is not zero`),n(s(e({input:.8,credit:8}))===.1,`the credit divides the input price`),n(s(e({input:.8,credit:0}))===.8,`a zero credit must not divide by zero`),n(!c(e()),`a missing price is not free`),n(!c(e({input:0,output:2})),`a zero input with a paid output is not free`),n(c(e({input:0,output:0})),`both at zero is free`),n(l(e({tps:40,measured:12}))===12,`a measured speed outranks a borrowed one`),n(l(e())===void 0,`no speed is no speed`);let r=(e,n)=>[...n].sort((n,r)=>m(e,n,r,t)),i=e({name:`cheap`,input:1,output:1}),a=e({name:`dear`,input:10,output:10}),o=e({name:`unknown`});n(r(`input`,[o,a,i]).map(e=>e.name)[0]===`cheap`,`in↑ starts at the cheapest`),n(r(`input`,[o,a,i]).map(e=>e.name)[2]===`unknown`,`an unknown price sorts last`);let u=[e({name:`few`,context:1e3,int:10}),e({name:`many`,context:1e5,int:50}),o];n(r(`context`,u)[0].name===`many`,`ctx↓ starts at the widest window`),n(r(`context`,u)[2].name===`unknown`,`a missing window sorts last, not first`),n(r(`int`,u)[0].name===`many`,`int↓ starts at the highest score`),n(r(`int`,u)[2].name===`unknown`,`a missing score sorts last, not first`),n(r(`speed`,u)[2].name===`unknown`,`a missing speed sorts last, not first`);let p=(e,n)=>[...n].sort((n,r)=>m(e,n,r,t,-1));n(p(`input`,[o,a,i])[2].name===`unknown`,`in↓ puts an unknown price last too`),n(p(`input`,[o,a,i])[0].name===`dear`,`in↓ starts at the dearest`),n(p(`context`,u)[2].name===`unknown`,`ctx↑ puts a missing window last too`),n(p(`int`,u)[2].name===`unknown`,`int↑ puts a missing score last too`),n(p(`speed`,u)[2].name===`unknown`,`speed↑ puts a missing speed last too`),n(p(`effective`,[o,a,i])[2].name===`unknown`,`effective↓ puts an unknown price last too`);let g={recent:new Map([[`p/fresh`,0]])},_=e({name:`fresh`,modelID:`fresh`});n([e({name:`stale`,modelID:`stale`}),_].sort((e,t)=>m(`recent`,e,t,g))[0].name===`fresh`,`the recent list orders by its index`),n(m(`input`,i,a,t,-1)>0,`the sign the caller passes flips the order`),n(m(`name`,e({name:`a`}),e({name:`b`}),t)<0,`name compares text`),n(m(`provider`,e({provider:`a`}),e({provider:`b`}),t)<0,`provider compares text`),n(f(`effective`)===1&&f(`int`)===-1,`the default direction follows the order`);let v=e({name:`a`,modelID:`a`,providerID:`pa`,provider:`A`,input:2,output:2}),y=e({name:`b`,modelID:`b`,providerID:`pa`,provider:`A`,input:0,output:0}),b=e({name:`c`,modelID:`c`,providerID:`pb`,provider:`B`,input:3,output:3}),x=h([v,y,b],{inUse:`pb/c`,grouped:!0});n(x.map(e=>e.title).join()===`B,A`,`the provider in use leads the list`),n(x[0].rows[0]===b,`and its model leads inside it`),n(x[1].rows.map(e=>e.name).join()===`a,b`,`paying before free inside a group`);let S=h([v,y,b],{inUse:`pa/b`,grouped:!0});n(S[0].title===`A`&&S[0].rows[0]===y,`the in-use row leads inside a group that has others`),n(S[0].rows.map(e=>e.name).join()===`b,a`,`and the rest of the group keeps its order`);let C=h([v,y,b],{inUse:`pa/b`,grouped:!1});n(C[0].rows[0]===y,`flat: a free model in use still leads`),n(!C.some(e=>e.title===`Free`),`flat: and is not repeated in the free bucket`);let w=h([b],{inUse:`pb/c`,grouped:!0});n(w.length===1&&w[0].title===`B`,`the only row a filter kept still has its own group`);let T=h([y,b,v],{inUse:`pb/c`,grouped:!1});n(T[0].rows[0]===b,`flat: the model in use leads the list`),n(T[T.length-1].title===`Free`,`flat: the free bucket is last`),n(T[T.length-1].rows[0]===y,`flat: and holds the free row`);let E=e({name:`twin`,modelID:`twin`,providerID:`p2`,provider:`A`,input:4,output:4}),D=h([v,E],{inUse:`p2/twin`,grouped:!0});n(D.length===2,`two providers sharing a display name stay two groups`),n(D[0].rows[0]===E,`and the one in use leads by id, not by the shared name`),console.log(`self-check ok: `+d.length+` orders, unknown figures last in every one of them`)}var g=(0,e.template)(`<button>`),_=(0,e.template)(`<span class=omp-dot>●`),v=(0,e.template)(`<div class=omp-row><div class=omp-name><button class=omp-fav></button><span class=omp-tag></span></div><div class=omp-num></div><div class="omp-num omp-out"></div><div class="omp-num omp-ctx"></div><div class="omp-num omp-int"></div><div class="omp-num omp-tps"></div><div class="omp-num omp-credit"></div><div class="omp-num omp-note">`),y=(0,e.template)(`<div class=omp-warn>`),b=(0,e.template)(`<div class=omp-warn>Go credit unknown: the plan page could not be read.`),x=(0,e.template)(`<div class=omp-empty>`),S=(0,e.template)(`<div class=omp><div class=omp-bar><div class=omp-scopes><button class=omp-scope>groups</button></div></div><div class=omp-head><span class="omp-num omp-credit">×</span><span class="omp-num omp-note">note</span></div><div class=omp-body></div><div class=omp-fine>*N = the tariff at peak · +N = a promotion`),C=(0,e.template)(`<button class=omp-scope>`),w=(0,e.template)(`<div class=omp-group>`),T=3e5,E=15e3,D=(e,t)=>new Promise((n,r)=>{let i=setTimeout(()=>r(Error(`rpc timed out after ${t}ms`)),t);e.then(e=>{clearTimeout(i),n(e)},e=>{clearTimeout(i),r(e)})}),O=`
2
+ .omp { container-type: inline-size; display: flex; flex-direction: column; height: 100%; min-height: 0;
3
+ font-size: 12px; }
4
+ .omp-bar { display: flex; align-items: center; gap: 8px; padding: 6px 8px; flex-wrap: wrap; }
5
+ .omp-scopes { display: flex; gap: 2px; margin-left: auto; }
6
+ .omp-scope { background: none; border: 0; border-radius: 4px; color: inherit; cursor: pointer; font: inherit;
7
+ opacity: .55; padding: 2px 6px; }
8
+ .omp-scope[data-on] { background: color-mix(in srgb, currentColor 12%, transparent); opacity: 1; }
9
+ .omp-head, .omp-row { display: grid; align-items: center; gap: 6px; padding: 2px 8px;
10
+ grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 3.4rem 2.4rem 2.4rem 2.6rem 2.2rem; }
11
+ .omp-head { border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); opacity: .7;
12
+ padding-right: calc(8px + var(--omp-gutter, 0px)); }
13
+ .omp-head button { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 0;
14
+ text-align: left; }
15
+ .omp-head .omp-num { text-align: right; }
16
+ .omp-body { overflow: auto; min-height: 0; }
17
+ .omp-group { padding: 8px 8px 4px; text-transform: uppercase; letter-spacing: .04em; font-size: 10px;
18
+ position: sticky; top: 0; z-index: 1;
19
+ background: var(--v2-background-bg-base, Canvas);
20
+ color: color-mix(in srgb, currentColor 55%, transparent);
21
+ border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); }
22
+ .omp-row { cursor: pointer; border-radius: 4px; }
23
+ .omp-row:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
24
+ .omp-row[data-mine] { background: color-mix(in srgb, currentColor 12%, transparent); }
25
+ /* A figure wider than its track is cut, as the terminal cuts every cell: it must never push into the
26
+ column beside it and read as that column's number. */
27
+ .omp-num { text-align: right; font-variant-numeric: tabular-nums; overflow: hidden;
28
+ text-overflow: ellipsis; white-space: nowrap; }
29
+ .omp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+ .omp-tag { opacity: .5; margin-right: 6px; font-variant-numeric: tabular-nums; }
31
+ .omp-mine { opacity: .9; }
32
+ .omp-dot { opacity: .8; margin-right: 4px; }
33
+ .omp-fav { background: none; border: 0; cursor: pointer; color: inherit; font: inherit; opacity: .35;
34
+ padding: 0 4px; }
35
+ .omp-fav[data-on] { opacity: 1; }
36
+ .omp-empty { padding: 12px 8px; opacity: .6; }
37
+ .omp-warn { padding: 4px 8px; opacity: .7; }
38
+ .omp-fine { padding: 4px 8px; opacity: .55; font-size: 11px;
39
+ border-top: 1px solid color-mix(in srgb, currentColor 8%, transparent); }
40
+ /* The terminal's cascade, in pixels: the name shrinks first (the track is elastic), then whole columns
41
+ go in the order fit.ts uses — tok/s, then context, then intelligence. The three price figures never
42
+ go: they are what the list is for. Below the last rung the name truncates instead of scrolling. */
43
+ @container (max-width: 560px) {
44
+ .omp-tps, .omp-note { display: none; }
45
+ .omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 3.4rem 2.4rem 2.6rem; }
46
+ }
47
+ @container (max-width: 500px) {
48
+ .omp-ctx { display: none; }
49
+ .omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 2.4rem 2.6rem; }
50
+ }
51
+ @container (max-width: 440px) {
52
+ .omp-int { display: none; }
53
+ .omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 2.6rem; }
54
+ }
55
+ @container (max-width: 390px) {
56
+ .omp-head, .omp-row { grid-template-columns: minmax(0, 1fr) 2.8rem 3.4rem 2.6rem; }
57
+ }
58
+ `,k=new Set([`name`,`input`,`output`,`context`,`int`,`speed`]),A=e=>e===void 0?``:e===0?`free`:String(Math.round(e*1e3)/1e3),j=e=>e===void 0?``:e>=1e6?(e/1e6).toFixed(e%1e6==0?0:1)+`M`:e>=1e3?Math.round(e/1e3)+`K`:String(e),M=e=>e.peak?`*`+e.peak:e.promo?`+`+e.promo:``,N=e=>e.providerID+`/`+e.modelID,P=t.Plugin.define({setup(r){let[i,a]=(0,n.createSignal)(void 0),[s,l]=(0,n.createSignal)(``),[p,P]=(0,n.createSignal)(`effective`),[F,I]=(0,n.createSignal)(!0),[L,R]=(0,n.createSignal)(`all`),[z,ee]=(0,n.createSignal)(!0),[B,V]=(0,n.createSignal)([]),[H,U]=(0,n.createSignal)(!1),W,G=async e=>{if(H())return;let t=K()?.server.client;if(t){U(!0);try{a(await D(t.rpc(o).snapshot(e?{force:!0}:{}),E))}catch(e){r.toast.show({title:`Models`,description:e instanceof Error?e.message:String(e),variant:`error`})}finally{U(!1)}}},K=()=>r.sessions.current(),q=()=>{let e=r.screen.current()?.composer.model();return e?e.providerID+`/`+e.modelID:void 0},J=(0,n.createMemo)(()=>{let e=i();if(!e)return[];let t=new Set(e.favorites),n=q(),r=s().trim().toLowerCase(),a=e.rows.filter(e=>!e.enabled||L()===`favorites`&&!t.has(N(e))&&N(e)!==n||L()===`paying`&&c(e)&&N(e)!==n?!1:!r||(e.name+` `+e.provider+` `+e.modelID).toLowerCase().includes(r)),o=k.has(p())&&!F()?-1:1,l={recent:new Map(B().map((e,t)=>[e,t]))};return[...a].sort((e,t)=>m(p(),e,t,l,o))}),Y=(0,n.createMemo)(()=>h(J(),{inUse:q(),grouped:z()})),X=e=>{let t=r.screen.current()?.composer;if(!t)return;let n=N(e);t.setModel({providerID:e.providerID,modelID:e.modelID})?V([n,...B().filter(e=>e!==n)].slice(0,20)):r.toast.show({title:`Models`,description:`${e.name} is not available on this server`,variant:`error`})},Z=e=>{let t=N(e),n=i()?.favorites.includes(t)??!1,s=K()?.server.client;s&&D(s.rpc(o).setFavorite({providerID:e.providerID,modelID:e.modelID,on:!n}),E).then(e=>{let t=e;if(!t.ok){r.toast.show({title:`Models`,description:`model.json could not be written`,variant:`error`});return}let n=i();n&&a({...n,favorites:t.favorites})}).catch(()=>{r.toast.show({title:`Models`,description:`the favourite did not reach the server`,variant:`error`})})},Q=(t,n,r=``)=>(()=>{var i=g();return i.$$click=()=>{p()===t?I(!F()):(P(t),I(f(t)===1))},(0,e.className)(i,r),(0,e.insert)(i,n,null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>p()===t);return()=>n()?F()?` ↑`:` ↓`:``})(),null),i})(),te=t=>{let r=t.row,a=N(r),o=()=>i()?.favorites.includes(a)??!1,s=()=>a===q();return(()=>{var t=v(),i=t.firstChild,a=i.firstChild,c=a.nextSibling,l=i.nextSibling,u=l.nextSibling,d=u.nextSibling,f=d.nextSibling,p=f.nextSibling,m=p.nextSibling,h=m.nextSibling;return t.$$click=()=>X(r),a.$$click=e=>{e.stopPropagation(),Z(r)},(0,e.insert)(a,()=>o()?`★`:`☆`),(0,e.insert)(i,(0,e.createComponent)(n.Show,{get when(){return s()},get children(){return _()}}),c),(0,e.insert)(c,()=>r.tag),(0,e.insert)(i,()=>r.name,null),(0,e.insert)(l,()=>A(r.input)),(0,e.insert)(u,()=>A(r.output)),(0,e.insert)(d,()=>j(r.context)),(0,e.insert)(f,(()=>{var t=(0,e.memo)(()=>r.int!==void 0);return()=>t()?r.int.toFixed(1):``})()),(0,e.insert)(p,(()=>{var t=(0,e.memo)(()=>r.measured!==void 0);return()=>t()?Math.round(r.measured):(0,e.memo)(()=>r.tps!==void 0)()?`~`+Math.round(r.tps):``})()),(0,e.insert)(m,(()=>{var t=(0,e.memo)(()=>r.credit!==1);return()=>t()?`×`+r.credit:``})()),(0,e.insert)(h,()=>M(r)),(0,e.effect)(n=>{var r=s()?``:void 0,c=!!s(),l=o()?``:void 0;return r!==n.e&&(0,e.setAttribute)(t,`data-mine`,n.e=r),c!==n.t&&i.classList.toggle(`omp-mine`,n.t=c),l!==n.a&&(0,e.setAttribute)(a,`data-on`,n.a=l),n},{e:void 0,t:void 0,a:void 0}),t})()},ne=()=>{let t=()=>i(),a,o=()=>{let e=a?.querySelector(`.omp-body`);a&&e&&a.style.setProperty(`--omp-gutter`,e.offsetWidth-e.clientWidth+`px`)};return(0,n.createEffect)(()=>{Y(),o()}),(0,n.createEffect)(()=>{let e=new ResizeObserver(o);a&&e.observe(a),(0,n.onCleanup)(()=>e.disconnect())}),(()=>{var i=S(),o=i.firstChild,c=o.firstChild,m=c.firstChild,h=o.nextSibling,g=h.firstChild,_=h.nextSibling;return(0,e.use)(e=>a=e,i),(0,e.insert)(o,(0,e.createComponent)(r.ui.TextInput,{label:`Search models`,placeholder:`Search models`,type:`search`,get value(){return s()},onInput:l}),c),(0,e.insert)(o,(0,e.createComponent)(r.ui.Menu,{trigger:`button`,get label(){return`${u[p()].label}${k.has(p())?F()?` ↑`:` ↓`:``}`},get items(){return d.map(e=>({label:u[e].label,icon:p()===e?`check`:void 0,onSelect:()=>{p()===e?k.has(e)&&I(!F()):(P(e),I(f(e)===1))}}))}}),c),(0,e.insert)(o,(0,e.createComponent)(r.ui.IconButton,{icon:`refresh`,label:`Refresh`,get busy(){return H()},onClick:()=>void G(!0)}),c),(0,e.insert)(c,(0,e.createComponent)(n.For,{each:[`all`,`favorites`,`paying`],children:t=>(()=>{var n=C();return n.$$click=()=>R(t),(0,e.insert)(n,t),(0,e.effect)(()=>(0,e.setAttribute)(n,`data-on`,L()===t?``:void 0)),n})()}),m),m.$$click=()=>ee(!z()),(0,e.insert)(i,(0,e.createComponent)(n.Show,{get when(){return t()?.problems?.length},get children(){var n=y();return(0,e.insert)(n,()=>t()?.problems?.join(` · `)),n}}),h),(0,e.insert)(i,(0,e.createComponent)(n.Show,{get when(){return(0,e.memo)(()=>!!t())()&&!t().credits},get children(){return b()}}),h),(0,e.insert)(h,()=>Q(`name`,`model`),g),(0,e.insert)(h,()=>Q(`input`,`$/M in`,`omp-num`),g),(0,e.insert)(h,()=>Q(`output`,`$/M out`,`omp-num omp-out`),g),(0,e.insert)(h,()=>Q(`context`,`context`,`omp-num omp-ctx`),g),(0,e.insert)(h,()=>Q(`int`,`int`,`omp-num omp-int`),g),(0,e.insert)(h,()=>Q(`speed`,`tok/s`,`omp-num omp-tps`),g),(0,e.insert)(_,(0,e.createComponent)(n.Show,{get when(){return Y().length===0},get children(){var n=x();return(0,e.insert)(n,()=>t()?`no model matches`:`reading the catalogue…`),n}}),null),(0,e.insert)(_,(0,e.createComponent)(n.For,{get each(){return Y()},children:t=>[(0,e.createComponent)(n.Show,{get when(){return t.title},get children(){var n=w();return(0,e.insert)(n,()=>t.title),n}}),(0,e.createComponent)(n.For,{get each(){return t.rows},children:t=>(0,e.createComponent)(te,{row:t})})]}),null),(0,e.effect)(()=>(0,e.setAttribute)(m,`data-on`,z()?``:void 0)),i})()},re={id:`main`,get title(){return`Models`},closable:`hover`};r.add(t.Style,O),r.add(t.Panel,{id:`main`,region:`side`,wide:!0,list:e=>e.open.includes(`main`)?[re]:[],render:()=>(0,e.createComponent)(ne,{}),focus:()=>void G(!1)});let $=()=>{let e=K();e&&(r.layout.toggle(`${r.id}:main`,e),G(!1))};return r.add(t.Command,()=>({id:`open`,title:`Models: the table`,description:`Prices, context, intelligence and the Go credit, side by side`,keywords:`model price context credit list`,section:`model`,bind:`mod+shift+m`,slash:{name:`models-pro`},featured:!0,enabled:!!K(),run:$})),r.add(t.MenuItem,()=>{let e=K();return{menu:`session.panel`,id:`models`,title:`Models`,icon:`models`,description:`compare and switch`,keybind:`${r.id}.open`,opened:!!e&&r.layout.state(`${r.id}:main`,e)!==`closed`,run:$}}),W=setInterval(()=>{let e=K();e&&r.layout.state(`${r.id}:main`,e)!==`closed`&&G(!1)},T),()=>{W&&clearInterval(W)}}});(0,e.delegateEvents)([`click`]),exports.default=P;
package/index.ts CHANGED
@@ -1,7 +1,148 @@
1
- // oc-models-pro — server half. Empty on purpose: the model list is TUI-only.
1
+ // oc-models-pro — server half, for the desktop.
2
+ //
3
+ // The terminal reads the catalogue and the four sources in-process. A plugin window cannot: `bun:sqlite`,
4
+ // `node:fs` and the three fetches are all outside its reach — and the AA key lives in this process's
5
+ // environment, which is where the server has it and the window does not. So the whole table is assembled
6
+ // here and the window asks for it over `models.snapshot`.
7
+ //
8
+ // The rows are the terminal's, field for field, so the two views can be compared: same catalogue, same
9
+ // credits, same speeds. Only the rendering differs.
2
10
  import { Plugin } from "@opencode/plugin"
3
11
 
12
+ import { aaIndex } from "./aa.ts"
13
+ import { benchFor, benchIndex, benchKey, type Bench } from "./bench.ts"
14
+ import { credits, goPlan, key as creditKey, nativePeak, type Credits } from "./multipliers.ts"
15
+ import { prefKey, readFavourites, toggleFavorite } from "./prefs.ts"
16
+ import { tagsFor } from "./tags.ts"
17
+ import { speedIndex } from "./tps.ts"
18
+ import { Models, type ModelsSnapshot } from "./rpc.ts"
19
+ import type { Row } from "./rows.ts"
20
+
21
+ /**
22
+ * The fetch-backed sources refresh on their own TTLs (6 h, 1 day, 6 h); the measured speeds cost about a
23
+ * second here and are worth keeping for a minute, which is also what keeps a poll cheap.
24
+ */
25
+ const CACHE_MS = 60_000
26
+
27
+ const isGo = (providerID: string) => providerID === "opencode-go"
28
+
29
+ interface Sources {
30
+ bench?: Map<string, Bench>
31
+ credits?: Map<string, Credits>
32
+ creditsKnown: boolean
33
+ estimated?: Map<string, number>
34
+ measured?: Map<string, number>
35
+ }
36
+
4
37
  export default Plugin.define({
5
38
  id: "oc-models-pro",
6
- async setup() {},
39
+ async setup(ctx) {
40
+ let cache: { at: number; value: ModelsSnapshot } | undefined
41
+
42
+ /** The four sources, each failure named instead of swallowed: a thin column must look thin. */
43
+ async function sources(problems: string[]): Promise<Sources> {
44
+ const [plan, bench, estimates, speeds] = await Promise.allSettled([
45
+ goPlan(),
46
+ benchIndex(),
47
+ aaIndex(),
48
+ speedIndex(),
49
+ ])
50
+ const out: Sources = { creditsKnown: false }
51
+ if (plan.status === "fulfilled" && plan.value) {
52
+ out.credits = credits(plan.value)
53
+ out.creditsKnown = true
54
+ } else problems.push("go plan: no credit multipliers")
55
+ if (bench.status === "fulfilled") out.bench = bench.value
56
+ else problems.push("bench: no intelligence")
57
+ if (estimates.status === "fulfilled") out.estimated = estimates.value
58
+ else problems.push("aa: no borrowed speed")
59
+ if (speeds.status === "fulfilled")
60
+ out.measured = new Map([...speeds.value.speeds].map(([key, speed]) => [key, speed.tps]))
61
+ else problems.push("measured: the session store could not be read")
62
+ return out
63
+ }
64
+
65
+ async function build(force: boolean): Promise<ModelsSnapshot> {
66
+ if (!force && cache && Date.now() - cache.at < CACHE_MS) return cache.value
67
+
68
+ const problems: string[] = []
69
+ const [models, providers, src] = await Promise.all([
70
+ ctx.model.list({}),
71
+ ctx.provider.list({}),
72
+ sources(problems),
73
+ ])
74
+ const offers: any[] = (models as any)?.data ?? (models as any) ?? []
75
+ const names = new Map<string, string>(
76
+ (((providers as any)?.data ?? (providers as any) ?? []) as any[]).map((provider) => [
77
+ provider.id,
78
+ provider.name,
79
+ ]),
80
+ )
81
+ const tags = tagsFor([...names].map(([id, name]) => ({ id, name })))
82
+
83
+ const rows: Row[] = []
84
+ let withSpeed = 0
85
+ for (const model of offers) {
86
+ const cost = (model.cost ?? []).find((entry: any) => !entry.tier) ?? model.cost?.[0]
87
+ const limit = model.limit ?? {}
88
+ const score = benchFor(src.bench ?? new Map(), model.name, model.modelID)
89
+ // Three sources, one order: what this machine measured, then Artificial Analysis, then the
90
+ // catalogue. A measured figure is never displaced by an estimate.
91
+ const mine = src.measured?.get(model.providerID + "/" + model.modelID)
92
+ const theirs =
93
+ mine === undefined
94
+ ? (src.estimated?.get(benchKey(model.name)) ?? src.estimated?.get(benchKey(model.modelID)) ?? score?.tps)
95
+ : undefined
96
+ if (mine !== undefined || theirs !== undefined) withSpeed += 1
97
+ // The measured ratio from the Go plan page first, the native rule second: one is data, the
98
+ // other is the fact no table reports (the catalogue lists a single DeepSeek price).
99
+ const planPeak = isGo(model.providerID) ? src.credits?.get(creditKey(model.modelID))?.peak : undefined
100
+ const peak = planPeak ?? nativePeak(model.providerID)
101
+ rows.push({
102
+ providerID: model.providerID,
103
+ modelID: model.modelID,
104
+ name: model.name,
105
+ provider: (names.get(model.providerID) ?? model.providerID).replace(/^(.+?) \/ (.+)$/, "$2 ($1)"),
106
+ tag: tags.get(model.providerID) ?? model.providerID.slice(0, 2).toUpperCase(),
107
+ ...(typeof cost?.input === "number" ? { input: cost.input } : {}),
108
+ ...(typeof cost?.output === "number" ? { output: cost.output } : {}),
109
+ ...(typeof limit.context === "number" ? { context: limit.context } : {}),
110
+ credit: isGo(model.providerID) ? (src.credits?.get(creditKey(model.modelID))?.credit ?? 1) : 1,
111
+ ...(isGo(model.providerID) && src.credits?.get(creditKey(model.modelID))?.promo !== undefined
112
+ ? { promo: src.credits.get(creditKey(model.modelID))!.promo }
113
+ : {}),
114
+ ...(peak !== undefined ? { peak } : {}),
115
+ ...(score?.int !== undefined ? { int: score.int } : {}),
116
+ ...(theirs !== undefined ? { tps: theirs } : {}),
117
+ ...(mine !== undefined ? { measured: mine } : {}),
118
+ enabled: model.enabled !== false,
119
+ })
120
+ }
121
+
122
+ const favourites = readFavourites()
123
+ if (!favourites) problems.push("model.json: favourites unreadable")
124
+ const value: ModelsSnapshot = {
125
+ rows,
126
+ favorites: (favourites ?? []).map((entry) => prefKey(entry.providerID, entry.modelID)),
127
+ offers: offers.length,
128
+ withSpeed,
129
+ credits: src.creditsKnown,
130
+ ...(problems.length ? { problems } : {}),
131
+ }
132
+ cache = { at: Date.now(), value }
133
+ return value
134
+ }
135
+
136
+ await ctx.rpc.register(Models, {
137
+ snapshot: async (input: any) => build(input?.force === true),
138
+ setFavorite: async (input: any) => {
139
+ const on = toggleFavorite(String(input.providerID), String(input.modelID), input.on)
140
+ // The rows carry no favourite flag, so the cache stays usable; the list is the only thing that moved.
141
+ const favourites = readFavourites() ?? []
142
+ const favorites = favourites.map((entry) => prefKey(entry.providerID, entry.modelID))
143
+ if (cache) cache = { at: cache.at, value: { ...cache.value, favorites } }
144
+ return { ok: on !== undefined, favorites }
145
+ },
146
+ })
147
+ },
7
148
  })
package/multipliers.ts CHANGED
@@ -54,6 +54,15 @@ export interface Credits {
54
54
  peak?: number
55
55
  }
56
56
 
57
+ /** The native DeepSeek tariff doubles at peak, and the catalogue lists the **off-peak** figure: measured
58
+ * 2026-10-28 against DeepSeek's own pricing page — `deepseek-flash` $0.15 off-peak / $0.30 peak, and the
59
+ * catalogue carries 0.15. Duplicated from `oc-budget/peak.ts` on purpose: the two packages are published
60
+ * apart and neither may import the other, so each carries the one fact it needs. That copy also owns the
61
+ * clock and the wallet; this one only knows which *list* price doubles, so the note stays static. */
62
+ export function nativePeak(providerID: string): number | undefined {
63
+ return providerID === "deepseek" ? 2 : undefined
64
+ }
65
+
57
66
  const CACHE_DIR = process.env.XDG_CACHE_HOME || join(homedir(), ".cache")
58
67
  const CACHE = join(CACHE_DIR, "oc-models-pro", "go-limits.json")
59
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizal72/oc-models-pro",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode v2 model picker with prices, context, intelligence, tok/s, Go credit and the cost of a request",
6
6
  "repository": {
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "*.ts",
13
13
  "tui.js",
14
- "README.md"
14
+ "README.md",
15
+ "dist"
15
16
  ],
16
17
  "keywords": [
17
18
  "opencode",
@@ -25,6 +26,8 @@
25
26
  },
26
27
  "exports": {
27
28
  ".": "./index.ts",
28
- "./tui": "./tui.js"
29
+ "./tui": "./tui.js",
30
+ "./rpc": "./rpc.ts",
31
+ "./gui-experimental": "./dist/gui-experimental.cjs"
29
32
  }
30
33
  }
package/prefs.ts ADDED
@@ -0,0 +1,92 @@
1
+ // oc-models-pro/prefs.ts — the picker's own state file, `~/.local/state/opencode/model.json`.
2
+ //
3
+ // Both halves need it: the terminal draws the list and toggles a favourite from the panel, the desktop
4
+ // writes the same file through its server half. It lives here so the recipe exists once — a second copy
5
+ // is a second way to lose `recent[]` or `variant{}`.
6
+ //
7
+ // The host watches this path and `model.json.*`. The write is `.model.json.tmp` then rename: the leading
8
+ // dot keeps the temp name outside that glob, so the host never sees half a file.
9
+ import { readFileSync, renameSync, writeFileSync } from "node:fs"
10
+ import { homedir } from "node:os"
11
+ import { join } from "node:path"
12
+
13
+ /** The picker's state file. Exported for the message a failed write shows. */
14
+ export const PREFS_FILE = join(homedir(), ".local/state/opencode/model.json")
15
+ const TEMP_FILE = join(homedir(), ".local/state/opencode", ".model.json.tmp")
16
+
17
+ export const prefKey = (providerID: string, modelID: string): string => providerID + "/" + modelID
18
+
19
+ /** `favorite[]` and `recent[]` are `{ providerID, modelID }`. */
20
+ export interface Entry {
21
+ providerID: string
22
+ modelID: string
23
+ }
24
+
25
+ export interface Prefs {
26
+ favorite: Set<string>
27
+ recent: Map<string, number>
28
+ /** `variant{ "provider/model": "high" }`: a saved variant per model, "default" included. */
29
+ variants: Map<string, string>
30
+ }
31
+
32
+ /** The host's `favorite[]` as it is in the file: a key cannot be split back — a model id may hold a slash. */
33
+ export function readFavourites(): Entry[] | undefined {
34
+ try {
35
+ const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"))
36
+ if (!Array.isArray(data.favorite)) return []
37
+ return data.favorite.filter(
38
+ (entry: any) => typeof entry?.providerID === "string" && typeof entry?.modelID === "string",
39
+ )
40
+ } catch (error) {
41
+ // A missing file is not an error: starting empty creates it on the write. Anything else is.
42
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return []
43
+ console.error("[oc-models-pro] favourites: " + (error instanceof Error ? error.message : String(error)))
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ /** Rewrites `favorite[]` with the rest of the file untouched. */
49
+ export function writeFavourites(list: Entry[]): boolean {
50
+ try {
51
+ let raw: any
52
+ try {
53
+ raw = JSON.parse(readFileSync(PREFS_FILE, "utf8"))
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error
56
+ raw = {}
57
+ }
58
+ raw.favorite = list
59
+ writeFileSync(TEMP_FILE, JSON.stringify(raw))
60
+ renameSync(TEMP_FILE, PREFS_FILE)
61
+ return true
62
+ } catch (error) {
63
+ console.error("[oc-models-pro] favourites: " + (error instanceof Error ? error.message : String(error)))
64
+ return false
65
+ }
66
+ }
67
+
68
+ /** Adds or removes one model; `undefined` when the file could not be read or written. */
69
+ export function toggleFavorite(providerID: string, modelID: string, on?: boolean): boolean | undefined {
70
+ const key = prefKey(providerID, modelID)
71
+ const list = readFavourites()
72
+ if (!list) return undefined
73
+ const kept = list.filter((entry) => prefKey(entry.providerID, entry.modelID) !== key)
74
+ const next = on ?? kept.length === list.length
75
+ return writeFavourites(next ? [...kept, { providerID, modelID }] : kept) ? next : undefined
76
+ }
77
+
78
+ export function readPrefs(): Prefs {
79
+ const parsed: Prefs = { favorite: new Set(), recent: new Map(), variants: new Map() }
80
+ try {
81
+ const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"))
82
+ for (const entry of data.favorite ?? []) parsed.favorite.add(prefKey(entry.providerID, entry.modelID))
83
+ for (const [index, entry] of (data.recent ?? []).entries())
84
+ parsed.recent.set(prefKey(entry.providerID, entry.modelID), index)
85
+ for (const [model, variant] of Object.entries(data.variant ?? {}))
86
+ if (typeof variant === "string") parsed.variants.set(model, variant)
87
+ } catch (error) {
88
+ // A corrupt state file empties the favourites and the recent list: say so instead of pretending.
89
+ console.error("[oc-models-pro] prefs unreadable: " + (error instanceof Error ? error.message : String(error)))
90
+ }
91
+ return parsed
92
+ }
package/rows.ts ADDED
@@ -0,0 +1,301 @@
1
+ // oc-models-pro/rows.ts — the facts both surfaces read the same way.
2
+ //
3
+ // The terminal draws a monospace table and the desktop draws a grid, but what a row *means* is not the
4
+ // renderer's business: the effective price, what counts as free, and the ten orders are one definition
5
+ // here. Two copies would drift the way they already did once — the terminal kept free models out of the
6
+ // price order and the desktop did not.
7
+ //
8
+ // Runnable standalone for its self-check: node plugins/oc-models-pro/rows.ts --self-check
9
+
10
+ export interface Row {
11
+ name: string
12
+ providerID: string
13
+ modelID: string
14
+ provider: string
15
+ /** Two letters from `tags.ts`: the provider's, unique in the catalogue. */
16
+ tag: string
17
+ /** Undefined when the catalogue has no price for the model: an unknown figure, not a free one. */
18
+ input?: number
19
+ output?: number
20
+ /** Undefined when the model carries no limit. */
21
+ context?: number
22
+ /** Monthly limit ÷ the $10 plan: the ordering multiplier, shown so the order explains itself. */
23
+ credit: number
24
+ /** Allowance ÷ its normal one, when the page marks a promotion. */
25
+ promo?: number
26
+ /** Peak price ÷ off-peak price, for a model with two tariffs. */
27
+ peak?: number
28
+ /** Stencil's intelligence score and its speed; absent when the catalogue has no row for the model. */
29
+ int?: number
30
+ tps?: number
31
+ /** Tokens per second measured here, on this provider. Outranks `tps` and carries no tilde. */
32
+ measured?: number
33
+ enabled: boolean
34
+ }
35
+
36
+ /** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
37
+ * `undefined`, not infinity, so an order can tell "not measured" from "very expensive". */
38
+ export function effective(row: Row): number | undefined {
39
+ if (row.input === undefined) return undefined
40
+ return row.input / (row.credit || 1)
41
+ }
42
+
43
+ /** A missing price is not a free one: both figures must be there, and be zero. */
44
+ export function isFree(row: Row): boolean {
45
+ return row.input === 0 && row.output === 0
46
+ }
47
+
48
+ /** The speed to sort and print: what this machine measured first, the borrowed figure after. */
49
+ export function speedOf(row: Row): number | undefined {
50
+ return row.measured ?? row.tps
51
+ }
52
+
53
+ export type SortKey =
54
+ | "effective"
55
+ | "input"
56
+ | "output"
57
+ | "inputSum"
58
+ | "context"
59
+ | "int"
60
+ | "speed"
61
+ | "recent"
62
+ | "provider"
63
+ | "name"
64
+
65
+ export interface SortContext {
66
+ /** `providerID/modelID` → its place in the recent list; smaller is more recent. */
67
+ recent: Map<string, number>
68
+ }
69
+
70
+ export interface Order {
71
+ /** The long name: a footer, a menu, a title. */
72
+ label: string
73
+ /** The short name the terminal prints in its title bar. */
74
+ short: string
75
+ /** True when more is better, so the first row is the largest figure. */
76
+ desc?: true
77
+ /** `undefined` when the order cannot measure this row — the caller sorts it last, in either direction. */
78
+ value(row: Row, context: SortContext): number | undefined
79
+ }
80
+
81
+ /** Whatever an order cannot measure sorts last, never first, and in either direction: an unknown price is
82
+ * not the cheapest thing on the list, and a missing score is not the highest. */
83
+
84
+ export const ORDERS: Record<SortKey, Order> = {
85
+ effective: { label: "effective price", short: "effective↑", value: effective },
86
+ input: { label: "input price", short: "in↑", value: (row) => row.input },
87
+ output: { label: "output price", short: "out↑", value: (row) => row.output },
88
+ inputSum: {
89
+ label: "input + output",
90
+ short: "in+out↑",
91
+ value: (row) =>
92
+ row.input === undefined || row.output === undefined ? undefined : (row.input + row.output) / (row.credit || 1),
93
+ },
94
+ context: { label: "context window", short: "ctx↓", desc: true, value: (row) => row.context },
95
+ // A missing score sorts last, never first: Pi's rule, and the honest one.
96
+ int: { label: "intelligence", short: "int↓", desc: true, value: (row) => row.int },
97
+ speed: { label: "speed", short: "speed↓", desc: true, value: (row) => speedOf(row) },
98
+ recent: {
99
+ label: "recently used",
100
+ short: "recent",
101
+ value: (row, context) => context.recent.get(row.providerID + "/" + row.modelID),
102
+ },
103
+ provider: { label: "provider", short: "provider", value: () => 0 },
104
+ name: { label: "name", short: "name", value: () => 0 },
105
+ }
106
+
107
+ /** The orders in the cycle the terminal walks with `ctrl+o`, in the same sequence. */
108
+ export const ORDER: SortKey[] = [
109
+ "effective",
110
+ "input",
111
+ "output",
112
+ "inputSum",
113
+ "context",
114
+ "int",
115
+ "speed",
116
+ "recent",
117
+ "provider",
118
+ "name",
119
+ ]
120
+
121
+ /** The direction an order reads in when nothing else says otherwise. */
122
+ export function direction(key: SortKey): 1 | -1 {
123
+ return ORDERS[key].desc ? -1 : 1
124
+ }
125
+
126
+ const id = (row: Row) => row.providerID + "/" + row.modelID
127
+
128
+ /**
129
+ * The comparator both surfaces sort with. The two label orders compare text; every other order compares
130
+ * a number, and the direction is passed in because the desktop lets the reader flip it. Ties fall back to
131
+ * the name so the list never reshuffles between two reads of the same data.
132
+ */
133
+ export function compare(key: SortKey, a: Row, b: Row, context: SortContext, sign: 1 | -1 = direction(key)): number {
134
+ if (key === "name" || key === "provider") {
135
+ const left = key === "name" ? a.name : a.provider
136
+ const right = key === "name" ? b.name : b.provider
137
+ const delta = left.localeCompare(right)
138
+ return delta !== 0 ? delta * sign : id(a).localeCompare(id(b))
139
+ }
140
+ const left = ORDERS[key].value(a, context)
141
+ const right = ORDERS[key].value(b, context)
142
+ // The direction flips what can be measured, never what cannot: an unknown figure goes last when the
143
+ // arrow points up and when it points down, or flipping a column would head the list with blanks.
144
+ if (left === undefined || right === undefined) {
145
+ if (left === right) return a.name.localeCompare(b.name)
146
+ return left === undefined ? 1 : -1
147
+ }
148
+ if (left === right) return a.name.localeCompare(b.name)
149
+ return (left < right ? -1 : 1) * sign
150
+ }
151
+
152
+ /** What a list of rows looks like once it is drawn: one section per provider, or the flat blocks. */
153
+ export interface Section {
154
+ /** The provider's name, or `Free` for the free bucket. `undefined` when the block needs no label: the
155
+ * leading one, and the paying block after it in the flat layout. */
156
+ title?: string
157
+ rows: Row[]
158
+ }
159
+
160
+ /**
161
+ * The order the surfaces draw, which is not the order they sort: free models never enter a price order,
162
+ * so they follow their provider's paying rows; and the model in use is never buried — it leads its
163
+ * provider, and its provider leads the list. Both surfaces held a copy of this (`gui.tsx` and
164
+ * `tui.tsx`), and the copies drifted the moment a filter was added.
165
+ */
166
+ export function arrange(list: Row[], options: { inUse?: string; grouped: boolean }): Section[] {
167
+ const used = options.inUse === undefined ? undefined : list.find((row) => id(row) === options.inUse)
168
+
169
+ if (!options.grouped) {
170
+ const rest = used ? list.filter((row) => row !== used) : list
171
+ const paying = rest.filter((row) => !isFree(row))
172
+ const free = rest.filter((row) => isFree(row))
173
+ return [
174
+ ...(used ? [{ rows: [used] }] : []),
175
+ ...(paying.length ? [{ rows: paying }] : []),
176
+ ...(free.length ? [{ title: "Free", rows: free }] : []),
177
+ ]
178
+ }
179
+
180
+ // Built from the whole list, the in-use row included: leaving it out left its provider with no group
181
+ // at all when it was the only row a filter kept, and the row surfaced under the first header.
182
+ const groups = new Map<string, Section>()
183
+ for (const row of list) {
184
+ const group = groups.get(row.providerID) ?? { title: row.provider, rows: [] }
185
+ // Paying first, then free: `isFree` sorts false before true.
186
+ const at = group.rows.findIndex((entry) => isFree(entry) && !isFree(row))
187
+ if (at === -1) group.rows.push(row)
188
+ else group.rows.splice(at, 0, row)
189
+ groups.set(row.providerID, group)
190
+ }
191
+ // By name, and by id when two providers share one: the id decides, the name only labels.
192
+ const sections = [...groups.entries()]
193
+ .sort(([idA, a], [idB, b]) => (a.title ?? "").localeCompare(b.title ?? "") || idA.localeCompare(idB))
194
+ .map(([, section]) => section)
195
+ if (used) {
196
+ // The in-use provider leads, and its model leads inside it.
197
+ const at = sections.indexOf(groups.get(used.providerID)!)
198
+ if (at > 0) sections.unshift(...sections.splice(at, 1))
199
+ const first = sections[0]
200
+ if (first) first.rows = [used, ...first.rows.filter((row) => row !== used)]
201
+ }
202
+ return sections
203
+ }
204
+
205
+ // Self-check: node plugins/oc-models-pro/rows.ts --self-check
206
+ // Guarded: this module is also bundled into the desktop window, where `process` does not exist.
207
+ if (typeof process !== "undefined" && process.argv?.[2] === "--self-check") {
208
+ const row = (over: Partial<Row> = {}): Row => ({
209
+ name: "Model",
210
+ providerID: "p",
211
+ modelID: "m",
212
+ provider: "Provider",
213
+ tag: "PR",
214
+ credit: 1,
215
+ enabled: true,
216
+ ...over,
217
+ })
218
+ const context: SortContext = { recent: new Map([["p/m", 0]]) }
219
+ const check = (ok: boolean, message: string) => {
220
+ if (!ok) throw new Error("self-check: " + message)
221
+ }
222
+
223
+ check(effective(row()) === undefined, "an unknown input price is not zero")
224
+ check(effective(row({ input: 0.8, credit: 8 })) === 0.1, "the credit divides the input price")
225
+ check(effective(row({ input: 0.8, credit: 0 })) === 0.8, "a zero credit must not divide by zero")
226
+ check(!isFree(row()), "a missing price is not free")
227
+ check(!isFree(row({ input: 0, output: 2 })), "a zero input with a paid output is not free")
228
+ check(isFree(row({ input: 0, output: 0 })), "both at zero is free")
229
+ check(speedOf(row({ tps: 40, measured: 12 })) === 12, "a measured speed outranks a borrowed one")
230
+ check(speedOf(row()) === undefined, "no speed is no speed")
231
+
232
+ // The price orders put an unknown figure last, and the descending ones put a missing figure last too.
233
+ const sorts = (key: SortKey, rows: Row[]) => [...rows].sort((a, b) => compare(key, a, b, context))
234
+ const cheap = row({ name: "cheap", input: 1, output: 1 })
235
+ const dear = row({ name: "dear", input: 10, output: 10 })
236
+ const unknown = row({ name: "unknown" })
237
+ check(sorts("input", [unknown, dear, cheap]).map((r) => r.name)[0] === "cheap", "in↑ starts at the cheapest")
238
+ check(sorts("input", [unknown, dear, cheap]).map((r) => r.name)[2] === "unknown", "an unknown price sorts last")
239
+ const winning: Row[] = [row({ name: "few", context: 1000, int: 10 }), row({ name: "many", context: 100_000, int: 50 }), unknown]
240
+ check(sorts("context", winning)[0].name === "many", "ctx↓ starts at the widest window")
241
+ check(sorts("context", winning)[2].name === "unknown", "a missing window sorts last, not first")
242
+ check(sorts("int", winning)[0].name === "many", "int↓ starts at the highest score")
243
+ check(sorts("int", winning)[2].name === "unknown", "a missing score sorts last, not first")
244
+ check(sorts("speed", winning)[2].name === "unknown", "a missing speed sorts last, not first")
245
+ // Flipping a column is the reader's right, and it must never bring the blanks to the top: this is the
246
+ // state two clicks reach, and the one the sentinels used to get wrong.
247
+ const flipped = (key: SortKey, rows: Row[]) => [...rows].sort((a, b) => compare(key, a, b, context, -1))
248
+ check(flipped("input", [unknown, dear, cheap])[2].name === "unknown", "in↓ puts an unknown price last too")
249
+ check(flipped("input", [unknown, dear, cheap])[0].name === "dear", "in↓ starts at the dearest")
250
+ check(flipped("context", winning)[2].name === "unknown", "ctx↑ puts a missing window last too")
251
+ check(flipped("int", winning)[2].name === "unknown", "int↑ puts a missing score last too")
252
+ check(flipped("speed", winning)[2].name === "unknown", "speed↑ puts a missing speed last too")
253
+ check(flipped("effective", [unknown, dear, cheap])[2].name === "unknown", "effective↓ puts an unknown price last too")
254
+ const recentContext: SortContext = { recent: new Map([["p/fresh", 0]]) }
255
+ const fresh = row({ name: "fresh", modelID: "fresh" })
256
+ const stale = row({ name: "stale", modelID: "stale" })
257
+ check(
258
+ [stale, fresh].sort((a, b) => compare("recent", a, b, recentContext))[0].name === "fresh",
259
+ "the recent list orders by its index",
260
+ )
261
+ check(compare("input", cheap, dear, context, -1) > 0, "the sign the caller passes flips the order")
262
+ check(compare("name", row({ name: "a" }), row({ name: "b" }), context) < 0, "name compares text")
263
+ check(compare("provider", row({ provider: "a" }), row({ provider: "b" }), context) < 0, "provider compares text")
264
+ check(direction("effective") === 1 && direction("int") === -1, "the default direction follows the order")
265
+
266
+ // The order the surfaces draw: the cases that each cost a bug.
267
+ const a = row({ name: "a", modelID: "a", providerID: "pa", provider: "A", input: 2, output: 2 })
268
+ const b = row({ name: "b", modelID: "b", providerID: "pa", provider: "A", input: 0, output: 0 })
269
+ const c = row({ name: "c", modelID: "c", providerID: "pb", provider: "B", input: 3, output: 3 })
270
+
271
+ const grouped = arrange([a, b, c], { inUse: "pb/c", grouped: true })
272
+ check(grouped.map((s) => s.title).join() === "B,A", "the provider in use leads the list")
273
+ check(grouped[0].rows[0] === c, "and its model leads inside it")
274
+ check(grouped[1].rows.map((r) => r.name).join() === "a,b", "paying before free inside a group")
275
+
276
+ // The in-use row shares its group here: hoisting the provider is not enough, the row itself must move,
277
+ // and this is the shape the panel depends on (its sort does not pre-hoist, unlike the terminal's).
278
+ const inner = arrange([a, b, c], { inUse: "pa/b", grouped: true })
279
+ check(inner[0].title === "A" && inner[0].rows[0] === b, "the in-use row leads inside a group that has others")
280
+ check(inner[0].rows.map((r) => r.name).join() === "b,a", "and the rest of the group keeps its order")
281
+
282
+ // Free in use: it leads the list in both layouts, and never falls into the free bucket.
283
+ const freeUsed = arrange([a, b, c], { inUse: "pa/b", grouped: false })
284
+ check(freeUsed[0].rows[0] === b, "flat: a free model in use still leads")
285
+ check(!freeUsed.some((s) => s.title === "Free"), "flat: and is not repeated in the free bucket")
286
+
287
+ const solo = arrange([c], { inUse: "pb/c", grouped: true })
288
+ check(solo.length === 1 && solo[0].title === "B", "the only row a filter kept still has its own group")
289
+
290
+ const flat = arrange([b, c, a], { inUse: "pb/c", grouped: false })
291
+ check(flat[0].rows[0] === c, "flat: the model in use leads the list")
292
+ check(flat[flat.length - 1].title === "Free", "flat: the free bucket is last")
293
+ check(flat[flat.length - 1].rows[0] === b, "flat: and holds the free row")
294
+
295
+ const twin = row({ name: "twin", modelID: "twin", providerID: "p2", provider: "A", input: 4, output: 4 })
296
+ const twins = arrange([a, twin], { inUse: "p2/twin", grouped: true })
297
+ check(twins.length === 2, "two providers sharing a display name stay two groups")
298
+ check(twins[0].rows[0] === twin, "and the one in use leads by id, not by the shared name")
299
+
300
+ console.log("self-check ok: " + ORDER.length + " orders, unknown figures last in every one of them")
301
+ }
package/rpc.ts ADDED
@@ -0,0 +1,108 @@
1
+ // What the desktop's panel reads: a plugin window has no Node built-ins, so every file and every fetch
2
+ // stays in the server half and the window asks for the finished table.
3
+ import { Rpc } from "@opencode/plugin/rpc"
4
+
5
+ import type { Row } from "./rows.ts"
6
+
7
+ const Count = { type: "number" }
8
+
9
+ const Row = {
10
+ type: "object",
11
+ properties: {
12
+ providerID: { type: "string" },
13
+ modelID: { type: "string" },
14
+ name: { type: "string" },
15
+ /** The provider's display name, account first as the host writes it. */
16
+ provider: { type: "string" },
17
+ /** Two letters from `tags.ts`. */
18
+ tag: { type: "string" },
19
+ /** Prices per million tokens; absent when the catalogue has none — an unknown figure, not a free one. */
20
+ input: Count,
21
+ output: Count,
22
+ context: Count,
23
+ /** Monthly limit ÷ the $10 plan. 1 when the plan is unknown. */
24
+ credit: Count,
25
+ promo: Count,
26
+ peak: Count,
27
+ int: Count,
28
+ /** Borrowed from Artificial Analysis, or the catalogue's own when it still carries one. */
29
+ tps: Count,
30
+ /** Measured on this machine, on this provider. Outranks `tps` and carries no tilde. */
31
+ measured: Count,
32
+ enabled: { type: "boolean" },
33
+ },
34
+ required: ["providerID", "modelID", "name", "provider", "tag", "credit", "enabled"],
35
+ additionalProperties: false,
36
+ } as const
37
+
38
+ const Snapshot = {
39
+ type: "object",
40
+ properties: {
41
+ rows: { type: "array", items: Row },
42
+ /** `providerID/modelID` of the models the host keeps as favourites, from `model.json`. */
43
+ favorites: { type: "array", items: { type: "string" } },
44
+ /** How many offers the catalogue holds, and how many came out with a speed — the pair that tells a
45
+ * thin source from a broken key. */
46
+ offers: Count,
47
+ withSpeed: Count,
48
+ /** False when the Go plan could not be read and no cache was there: every credit is 1. */
49
+ credits: { type: "boolean" },
50
+ /** What failed, in the words the panel shows. Empty when everything came back. */
51
+ problems: { type: "array", items: { type: "string" } },
52
+ },
53
+ required: ["rows", "favorites", "offers", "withSpeed", "credits"],
54
+ additionalProperties: false,
55
+ } as const
56
+
57
+ export const Models = Rpc.define({
58
+ id: "models",
59
+ methods: {
60
+ snapshot: {
61
+ input: {
62
+ type: "object",
63
+ properties: { force: { type: "boolean" } },
64
+ additionalProperties: false,
65
+ },
66
+ output: Snapshot,
67
+ },
68
+ setFavorite: {
69
+ input: {
70
+ type: "object",
71
+ properties: {
72
+ providerID: { type: "string" },
73
+ modelID: { type: "string" },
74
+ /** Absent toggles. */
75
+ on: { type: "boolean" },
76
+ },
77
+ required: ["providerID", "modelID"],
78
+ additionalProperties: false,
79
+ },
80
+ output: {
81
+ type: "object",
82
+ properties: {
83
+ /** False when the file could not be read or written: the panel says so instead of pretending. */
84
+ ok: { type: "boolean" },
85
+ favorites: { type: "array", items: { type: "string" } },
86
+ },
87
+ required: ["ok", "favorites"],
88
+ additionalProperties: false,
89
+ },
90
+ },
91
+ },
92
+ // Present (empty) on purpose: a missing events map fails the whole plugin.
93
+ events: {},
94
+ })
95
+
96
+ /** The wire form is the row both surfaces sort: one shape, declared once. */
97
+ export type ModelRow = Row
98
+
99
+ export interface ModelsSnapshot {
100
+ rows: ModelRow[]
101
+ favorites: string[]
102
+ offers: number
103
+ withSpeed: number
104
+ credits: boolean
105
+ problems?: string[]
106
+ }
107
+
108
+ export default Models
package/tui.js CHANGED
@@ -18,139 +18,24 @@ import { createElement as _$createElement } from "@opentui/solid";
18
18
  // is the first row whatever it costs: the free/paying split never buries it.
19
19
  import { Plugin } from "@opencode/plugin/tui";
20
20
  import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
21
- import { appendFileSync, readFileSync, renameSync, watch, writeFileSync } from "node:fs";
21
+ import { appendFileSync, readFileSync, watch, writeFileSync } from "node:fs";
22
22
  import { homedir, tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { benchFor, benchIndex, benchKey } from "./bench.ts";
25
25
  import { aaIndex } from "./aa.ts";
26
26
  import { speedIndex } from "./tps.ts";
27
- import { credits, goPlan, key } from "./multipliers.ts";
27
+ import { credits, goPlan, key, nativePeak } from "./multipliers.ts";
28
28
  import { LEGEND_MIN, fit, hintLines, rowWidth } from "./fit.ts";
29
29
  import { tagsFor } from "./tags.ts";
30
+ import { prefKey, readFavourites, readPrefs, toggleFavorite, writeFavourites, PREFS_FILE } from "./prefs.ts";
31
+ import { ORDER, ORDERS, arrange, compare, isFree } from "./rows.ts";
30
32
  const SCOPES = ["all", "favorites", "paying"];
31
- const prefKey = (providerID, modelID) => providerID + "/" + modelID;
32
-
33
- /** The picker's own state file: `favorite[]` and `recent[]` are `{ providerID, modelID }`. */
34
-
35
- const PREFS_FILE = join(homedir(), ".local/state/opencode/model.json");
36
- /** Same directory, a leading dot: the host watches `model.json.*`, not this. */
37
- const TEMP_FILE = join(homedir(), ".local/state/opencode", ".model.json.tmp");
38
33
  let prefs = {
39
34
  favorite: new Set(),
40
35
  recent: new Map(),
41
36
  variants: new Map()
42
37
  };
43
38
 
44
- /** The host's `favorite[]` as it is in the file: a key cannot be split back — a model id may hold a slash. */
45
- function readFavourites() {
46
- try {
47
- const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
48
- if (!Array.isArray(data.favorite)) return [];
49
- return data.favorite.filter(entry => typeof entry?.providerID === "string" && typeof entry?.modelID === "string");
50
- } catch (error) {
51
- // A missing file is not an error: starting empty creates it on the write. Anything else is.
52
- if (error?.code === "ENOENT") return [];
53
- trace("favourites: " + (error instanceof Error ? error.message : String(error)));
54
- return undefined;
55
- }
56
- }
57
-
58
- /** Rewrites `favorite[]` with the rest of the file untouched. The host watches this path and
59
- * `model.json.*`, so the atomic rename is seen and the native picker, `model.cycle_favorite` and this
60
- * list stay in step; a dot in front keeps the temp name outside that glob. */
61
- function writeFavourites(list) {
62
- try {
63
- let raw;
64
- try {
65
- raw = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
66
- } catch (error) {
67
- if (error?.code !== "ENOENT") throw error;
68
- raw = {};
69
- }
70
- raw.favorite = list;
71
- writeFileSync(TEMP_FILE, JSON.stringify(raw));
72
- renameSync(TEMP_FILE, PREFS_FILE);
73
- return true;
74
- } catch (error) {
75
- trace("favourites: " + (error instanceof Error ? error.message : String(error)));
76
- return false;
77
- }
78
- }
79
- function toggleFavorite(providerID, modelID) {
80
- const key = prefKey(providerID, modelID);
81
- const list = readFavourites();
82
- if (!list) return undefined;
83
- const kept = list.filter(entry => prefKey(entry.providerID, entry.modelID) !== key);
84
- const on = kept.length === list.length;
85
- return writeFavourites(on ? [...kept, {
86
- providerID,
87
- modelID
88
- }] : kept) ? on : undefined;
89
- }
90
- function readPrefs() {
91
- const parsed = {
92
- favorite: new Set(),
93
- recent: new Map(),
94
- variants: new Map()
95
- };
96
- try {
97
- const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
98
- for (const entry of data.favorite ?? []) parsed.favorite.add(prefKey(entry.providerID, entry.modelID));
99
- for (const [index, entry] of (data.recent ?? []).entries()) parsed.recent.set(prefKey(entry.providerID, entry.modelID), index);
100
- for (const [model, variant] of Object.entries(data.variant ?? {})) if (typeof variant === "string") parsed.variants.set(model, variant);
101
- } catch (error) {
102
- // A corrupt state file empties the favourites and the recent list: say so instead of pretending.
103
- trace("prefs unreadable: " + (error instanceof Error ? error.message : String(error)));
104
- }
105
- return parsed;
106
- }
107
-
108
- /** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
109
- * not zero — it sorts last, or it would sit at the top of the cheapest list forever. */
110
- function effective(row) {
111
- if (row.input === undefined) return Number.POSITIVE_INFINITY;
112
- return row.input / (row.credit || 1);
113
- }
114
- const SORTS = [{
115
- label: "effective price",
116
- short: "effective↑",
117
- value: effective
118
- }, {
119
- label: "input price",
120
- short: "in↑",
121
- value: row => row.input ?? Number.POSITIVE_INFINITY
122
- }, {
123
- label: "output price",
124
- short: "out↑",
125
- value: row => row.output ?? Number.POSITIVE_INFINITY
126
- }, {
127
- label: "input + output",
128
- short: "in+out↑",
129
- value: row => row.input === undefined || row.output === undefined ? Number.POSITIVE_INFINITY : (row.input + row.output) / (row.credit || 1)
130
- }, {
131
- label: "context window",
132
- short: "ctx↓",
133
- value: row => row.context === undefined ? Number.POSITIVE_INFINITY : -row.context
134
- },
135
- // A missing score sorts last, never first: Pi's rule, and the honest one.
136
- {
137
- label: "intelligence",
138
- short: "int↓",
139
- value: row => -(row.int ?? -1)
140
- }, {
141
- label: "speed",
142
- short: "speed↓",
143
- value: row => -(row.measured ?? row.tps ?? -1)
144
- }, {
145
- label: "recently used",
146
- short: "recent",
147
- value: row => prefs.recent.get(prefKey(row.providerID, row.modelID)) ?? 1e9
148
- }, {
149
- label: "name",
150
- short: "name",
151
- value: row => 0
152
- }];
153
-
154
39
  /** Fallbacks, used only when the theme defines none of the candidates. */
155
40
  const FALLBACK_DIM = "#8a8a8a";
156
41
  const FALLBACK_ACCENT = "#5a9fd4";
@@ -193,9 +78,6 @@ function price(value, width) {
193
78
  function isGo(providerID) {
194
79
  return providerID === "opencode-go";
195
80
  }
196
- function free(row) {
197
- return row.input === 0 && row.output === 0;
198
- }
199
81
  function headerRow(f) {
200
82
  return [" ", " ", "model".padEnd(f.name), cell("in", 6), cell("out", 6), ...(f.ctx ? [cell("ctx", 5)] : []), ...(f.int ? [cell("int", 5)] : []), ...(f.tkps ? [cell("tok/s", 6)] : []), cell("cred", 4), ...(f.note ? [cell("note", 6)] : [])].join(" ");
201
83
  }
@@ -289,7 +171,7 @@ export default Plugin.define({
289
171
  let creditsKnown = false;
290
172
  // Signals, not plain variables: the header shows them, and a plain variable left the title
291
173
  // saying "effective price" whatever the list was actually sorted by.
292
- const [sort, setSort] = createSignal(0);
174
+ const [sort, setSort] = createSignal("effective");
293
175
  const [grouped, setGrouped] = createSignal(true);
294
176
  const [scope, setScope] = createSignal("all");
295
177
  function fail(label, error) {
@@ -349,7 +231,9 @@ export default Plugin.define({
349
231
  // "DeepSeek V4.1 Flash" inherited the Go credit.
350
232
  credit: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.credit ?? 1 : 1,
351
233
  promo: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.promo : undefined,
352
- peak: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined,
234
+ // Measured on the Go plan first, then the native rule: the catalogue lists one
235
+ // DeepSeek price, so only the second can say it doubles.
236
+ peak: (isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined) ?? nativePeak(model.providerID),
353
237
  int: score?.int,
354
238
  tps: theirs,
355
239
  measured: mine,
@@ -502,66 +386,35 @@ export default Plugin.define({
502
386
  const needle = query().trim().toLowerCase();
503
387
  let list = rows().filter(row => row.enabled);
504
388
  if (scope() === "favorites") list = list.filter(row => prefs.favorite.has(prefKey(row.providerID, row.modelID)) || prefKey(row.providerID, row.modelID) === mine);
505
- if (scope() === "paying") list = list.filter(row => !free(row) || prefKey(row.providerID, row.modelID) === mine);
389
+ if (scope() === "paying") list = list.filter(row => !isFree(row) || prefKey(row.providerID, row.modelID) === mine);
506
390
  if (needle) list = list.filter(row => (row.name + " " + row.provider + " " + row.providerID).toLowerCase().includes(needle));
507
391
  list = [...list].sort((a, b) => {
508
392
  const mineA = prefKey(a.providerID, a.modelID) === mine;
509
393
  const mineB = prefKey(b.providerID, b.modelID) === mine;
510
394
  if (mineA !== mineB) return mineA ? -1 : 1;
511
- if (SORTS[sort()].label !== "name") {
512
- const delta = SORTS[sort()].value(a) - SORTS[sort()].value(b);
513
- if (delta) return delta;
514
- }
515
- return a.name.localeCompare(b.name);
395
+ return compare(sort(), a, b, {
396
+ recent: prefs.recent
397
+ });
516
398
  });
517
399
 
518
400
  // Free models never enter the price order: they are not cheap, they are unmetered, and at
519
401
  // the top they hide everything that costs something. Grouped, each one still sits under
520
402
  // its own provider, after that provider's paying rows — the group answers "who serves
521
403
  // this", and a global "Free" bucket would answer it wrong. Flat, they keep one bucket.
404
+ // What is drawn, and in which order, is `arrange`'s business — the same function the
405
+ // desktop panel calls. This half only turns sections into the headers and rows a
406
+ // terminal list wants.
522
407
  const items = [];
523
- if (grouped()) {
524
- const groups = new Map();
525
- for (const row of list) {
526
- const bucket = groups.get(row.provider) ?? [];
527
- bucket.push(row);
528
- groups.set(row.provider, bucket);
529
- }
530
- // Two bits: the model in use first, then the active order inside each of the two classes.
531
- // It leads its group even when it is free — it is the state of the session, not a price.
532
- for (const bucket of groups.values()) bucket.sort((a, b) => {
533
- const mineA = prefKey(a.providerID, a.modelID) === mine;
534
- const mineB = prefKey(b.providerID, b.modelID) === mine;
535
- if (mineA !== mineB) return mineA ? -1 : 1;
536
- return Number(free(a)) - Number(free(b));
408
+ for (const section of arrange(list, {
409
+ inUse: mine,
410
+ grouped: grouped()
411
+ })) {
412
+ if (section.title) items.push({
413
+ header: section.title
537
414
  });
538
- const names = [...groups.keys()].sort((a, b) => a.localeCompare(b));
539
- // The provider of the model in use comes first, like the native picker.
540
- const mineProvider = list.find(row => prefKey(row.providerID, row.modelID) === mine)?.provider;
541
- if (mineProvider) names.sort((a, b) => a === mineProvider ? -1 : b === mineProvider ? 1 : 0);
542
- for (const name of names) {
543
- items.push({
544
- header: name
545
- });
546
- for (const row of groups.get(name)) items.push({
547
- row
548
- });
549
- }
550
- } else {
551
- // The model in use leads the list, free or not: it is the state of the session, not a
552
- // price to compare. The other free models keep the bucket at the end.
553
- for (const row of list) if (!free(row) || prefKey(row.providerID, row.modelID) === mine) items.push({
415
+ for (const row of section.rows) items.push({
554
416
  row
555
417
  });
556
- const freeRows = list.filter(row => free(row) && prefKey(row.providerID, row.modelID) !== mine);
557
- if (freeRows.length) {
558
- items.push({
559
- header: "Free"
560
- });
561
- for (const row of freeRows) items.push({
562
- row
563
- });
564
- }
565
418
  }
566
419
  return items;
567
420
  });
@@ -713,10 +566,10 @@ export default Plugin.define({
713
566
  trace("variant set: " + chosen + " -> " + applied);
714
567
  }
715
568
  function cycleSort() {
716
- setSort((sort() + 1) % SORTS.length);
569
+ setSort(ORDER[(ORDER.indexOf(sort()) + 1) % ORDER.length]);
717
570
  }
718
571
  function recentSort() {
719
- setSort(SORTS.findIndex(entry => entry.label === "recently used"));
572
+ setSort("recent");
720
573
  }
721
574
  function toggleGroups() {
722
575
  setGrouped(!grouped());
@@ -873,7 +726,7 @@ export default Plugin.define({
873
726
  _$insertNode(_el$6, _el$8);
874
727
  _$setProp(_el$6, "fg", DIM);
875
728
  _$setProp(_el$6, "wrapMode", "none");
876
- _$insert(_el$6, () => "sort: " + SORTS[sort()].short, _el$8);
729
+ _$insert(_el$6, () => "sort: " + ORDERS[sort()].short, _el$8);
877
730
  _$insertNode(_el$9, _el$0);
878
731
  _$setProp(_el$9, "fg", BASE);
879
732
  _$setProp(_el$9, "wrapMode", "none");
package/vite.config.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite"
2
+ import { gui } from "@opencode/plugin/gui/vite"
3
+
4
+ export default defineConfig({
5
+ plugins: [gui({ entry: "gui.tsx", outFile: "dist/gui-experimental.cjs" })],
6
+ })