@rizal72/oc-tps-meter 0.0.0-stage → 0.0.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,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # oc-tps-meter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Tokens per second, step by step: how fast each answer in the session came, in a panel of the desktop app.
4
+ A bar per step — the session scrolls sideways when it outgrows the panel — the model behind it,
5
+ and what the session spent in tokens.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ opencode plugin add @rizal72/oc-tps-meter
11
+ ```
12
+
13
+ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
14
+
15
+ ```jsonc
16
+ "plugins": ["@rizal72/oc-tps-meter"]
17
+ ```
18
+
19
+ ## Update
20
+
21
+ V2 checks package plugins at startup and never applies the update, so a package only moves when you ask:
22
+
23
+ ```sh
24
+ opencode plugin check # which of the installed packages are behind
25
+ opencode plugin update # apply them, and let the server reload
26
+ ```
27
+
28
+ Or install [`@rizal72/oc-plugin-updates`](https://www.npmjs.com/package/@rizal72/oc-plugin-updates): the
29
+ count appears in a pill above the composer, with the update one click away.
30
+
31
+ ## GUI
32
+
33
+ A tab in the session's side panel: the `+` menu beside the tabs, the command palette, or `/tps`. It opens
34
+ 360 px wide every time; drag it wider when a model name needs the room.
35
+
36
+ Top to bottom: the speed of the last step it could measure, with the model that produced it and the wait for
37
+ the first token; the histogram, one bar per step, newest on the right, a dashed line at the median and one at
38
+ the top of the drawing, where the tallest bar ends — and, when the peak is beyond the scale, a bright cap on
39
+ the bars that cross it — and a gap where a step could not be measured. Pointing at a bar turns the block at the
40
+ top into that step, its time in place of the label. Then a pair of lines per model with how many of its steps
41
+ were measured, its median and its peak; and the totals, cache included.
42
+
43
+ The chart scrolls sideways: two fingers on the trackpad, shift and the wheel, or the scrollbar, and the small
44
+ hint between the two times says which way there is more. A time carries its date once it is not today's, at
45
+ the ends of the axis and in the label alike. A bar turns red when that step waited more than ten seconds for
46
+ its first token — and the wait in the block above turns red too — because that wait, not the speed, is what
47
+ says you are queued. Bar heights follow the fastest step — only a step more than twice the 95th percentile is
48
+ cut at the top edge, and it keeps its real figure in the block above when it is pointed at — so one exceptional
49
+ step does not flatten the rest.
50
+
51
+ ## What it leaves out
52
+
53
+ No live speed: a step counts once it has finished, so nothing moves while a reply is streaming. No subagents:
54
+ they run in their own session. No cost and no quota — the app's own header shows what the session cost, and
55
+ [`oc-budget`](../oc-budget/README.md) shows what is left to spend. No history: the session on screen is the
56
+ only thing it measures.
57
+
58
+ ## How it works
59
+
60
+ It reads the session's own messages through the app every two seconds while the tab is open, and measures each
61
+ step between its first token and the end of the stream. Nothing is stored, nothing leaves the machine. The
62
+ measurement, its thresholds and the traps behind it: [`docs/oc-tps-meter.md`](../../docs/oc-tps-meter.md).
63
+
64
+ Requires OpenCode v2. TypeScript, no server half, no runtime dependencies.
@@ -0,0 +1,49 @@
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");var r=e=>e!==void 0&&e>1e4,i=e=>typeof e==`number`&&Number.isFinite(e)?e:0,a=e=>typeof e==`number`&&Number.isFinite(e)?e:void 0,o=e=>e.time?.completed!==void 0,s=e=>{let t=a(e.time?.firstToken);if(t!==void 0)return t;let n;for(let t of e.content??[]){if(t?.type!==`reasoning`)continue;let e=a(t.time?.created);e!==void 0&&(n===void 0||e<n)&&(n=e)}return n},c=e=>{let t=i(e.tokens?.output)+i(e.tokens?.reasoning),n=i(e.time?.created),r=s(e),o=a(e.time?.streamed),c=r!==void 0&&o!==void 0?o-r:void 0,l=c!==void 0&&c>=200&&t>=20,u=e.model?.providerID&&e.model?.id?e.model.providerID+`/`+e.model.id:``;return{id:e.id??``,created:n,tokens:t,tps:l&&c!==void 0?t/(c/1e3):void 0,ttft:r!==void 0&&n!==0?r-n:void 0,model:u,measured:l}},l=e=>{if(e.length===0)return;let t=[...e].sort((e,t)=>e-t),n=Math.floor(t.length/2);return t.length%2?t[n]:(t[n-1]+t[n])/2},u=e=>{if(e.length===0)return;let t=[...e].sort((e,t)=>e-t);return t[Math.max(0,Math.ceil(t.length*.95)-1)]},d=e=>{if(e.length===0)return;let t=Math.max(...e);return Math.min(t,2*(u(e)??t))},f=(e,t)=>{let n=t?e.findIndex(e=>e.id===t):-1;return n===-1?{messages:[...e],found:!1}:{messages:e.slice(0,n),found:!0}},ee=e=>{let t=new Map;for(let n of e){if(n.tps===void 0)continue;let e=t.get(n.model)??[];e.push(n.tps),t.set(n.model,e)}return[...t].map(([e,t])=>({model:e,steps:t.length,median:l(t),peak:Math.max(...t)})).sort((e,t)=>t.steps-e.steps||e.model.localeCompare(t.model))},te=(e,t,n)=>{if(e<=0)return{first:0,last:-1};let r=Math.max(0,Math.min(e-1,Math.floor(t/4)));return{first:r,last:Math.min(e-1,Math.max(r,Math.ceil((t+n)/4)-1))}},p=(e,t,n)=>t-n-e<=1,m=(e,t)=>t.user?p(t.scrollLeft,t.scrollWidth,t.clientWidth):e,ne=e=>{let t=e.filter(o),n=t.map(c).sort((e,t)=>e.created-t.created),r={input:0,output:0,reasoning:0,cacheRead:0,cacheWrite:0};for(let e of t)r.input+=i(e.tokens?.input),r.output+=i(e.tokens?.output),r.reasoning+=i(e.tokens?.reasoning),r.cacheRead+=i(e.tokens?.cache?.read),r.cacheWrite+=i(e.tokens?.cache?.write);let a=r.input+r.cacheRead+r.cacheWrite;a>0&&(r.cacheHit=r.cacheRead/a);let s=n.flatMap(e=>e.tps===void 0?[]:[e.tps]),u=n.filter(e=>e.measured);return{steps:n,totals:r,measured:u.length,median:l(s),peak:s.length?Math.max(...s):void 0,scale:d(s),last:u[u.length-1]}},h=(0,e.template)(`<div class=tpsm-total><span></span><span class=tpsm-amount>`),g=(0,e.template)(`<span class=tpsm-num>`),re=(0,e.template)(`<div class=tpsm-block><div class=tpsm-label></div><div class=tpsm-figure><span class=tpsm-unit> t/s</span></div><div class=tpsm-sub><span class=tpsm-smodel>`),ie=(0,e.template)(`<svg><line x1=0 stroke=currentColor stroke-width=1 stroke-dasharray="3 3"opacity=0.3></svg>`,!1,!0,!1),ae=(0,e.template)(`<svg><line x1=0 y1=0.5 y2=0.5 stroke=currentColor stroke-width=1 stroke-dasharray="3 3"opacity=0.3></svg>`,!1,!0,!1),oe=(0,e.template)(`<div class=tpsm-warn>the revert point is older than the messages read: undone steps may be counted`),se=(0,e.template)(`<div class=tpsm-block><div class=tpsm-chart><svg class=tpsm-hist height=120 role=img></svg></div><div class=tpsm-axis><span></span><span class=tpsm-pan></span><span class=tpsm-end></span></div><div class=tpsm-counts></div><div class=tpsm-models><div class=tpsm-label>by model`),ce=(0,e.template)(`<div class=tpsm-block><div class=tpsm-label>totals`),le=(0,e.template)(`<div class=tpsm>`),ue=(0,e.template)(`<div class=tpsm-block><div class=tpsm-label>stale</div><div class=tpsm-sub>read <!> ago, the last attempt failed: `),de=(0,e.template)(`<div class=tpsm-note>`),fe=(0,e.template)(`<div class=tpsm-note>The messages could not be read: `),pe=(0,e.template)(`<div class=tpsm-note>No assistant step yet.`),me=(0,e.template)(`<span>`),he=(0,e.template)(`<svg><rect fill=currentColor></svg>`,!1,!0,!1),ge=(0,e.template)(`<svg><rect y=0 height=2 fill=currentColor opacity=1></svg>`,!1,!0,!1),_=(0,e.template)(`<div class=tpsm-mrow><div class=tpsm-mname></div><div class=tpsm-mnum>`),_e=2e3,v=360,y=120,b=200,x=2e3,S=`
2
+ /* Muted small capitals for labels, one large figure, hairlines between blocks, no borders and no cards. */
3
+ .tpsm { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: auto; font-size: 12px;
4
+ color: var(--v2-text-text-base); }
5
+ .tpsm-block { padding: 14px 12px; }
6
+ .tpsm-block + .tpsm-block { border-top: 1px solid color-mix(in srgb, var(--v2-text-text-base) 8%, transparent); }
7
+ .tpsm-label { color: var(--v2-text-text-faint); text-transform: uppercase; letter-spacing: .06em; font-size: 11px;
8
+ font-weight: 600; margin-bottom: 6px; font-variant-numeric: tabular-nums; }
9
+ .tpsm-figure { font-size: 28px; line-height: 1.15; font-weight: 700; font-variant-numeric: tabular-nums; }
10
+ /* The unit is a label, not part of the figure: bold digits, a quiet "t/s" beside them. */
11
+ .tpsm-unit { font-size: 14px; font-weight: 400; opacity: .7; }
12
+ /* One line of room kept even when a step carries neither model nor time, so the chart never jumps a row. */
13
+ .tpsm-sub { color: var(--v2-text-text-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis;
14
+ white-space: nowrap; min-height: 1.4em; }
15
+ /* The chart pans sideways and nothing else: a step is a fixed 4 px, so a long session scrolls natively. */
16
+ .tpsm-chart { display: flex; overflow-x: auto; overflow-y: hidden; }
17
+ .tpsm-hist { display: block; flex: 0 0 auto; margin-left: auto; height: ${y}px;
18
+ color: var(--v2-text-text-base); }
19
+ .tpsm-axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 6px; align-items: baseline; margin-top: 4px;
20
+ color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
21
+ /* The way to the earlier steps, between the two dates: the only place it belongs, and silent when it all fits. */
22
+ .tpsm-pan { font-size: 10px; letter-spacing: .04em; opacity: .8; }
23
+ .tpsm-end { text-align: right; }
24
+ /* The model of the step being described: white, so a name keeps its own against the figure above it. */
25
+ .tpsm-smodel { color: var(--v2-text-text-base); }
26
+ /* A figure the line exists for — a median, a peak, a time to first token: white and bold, the words around
27
+ it keeping the faint they had. */
28
+ .tpsm-num { color: var(--v2-text-text-base); font-weight: 600; }
29
+ /* A slow step wears the theme's danger at the weight it had at 45% alpha — mixed with the surface, because the
30
+ bar's opacity is 1 — and the pointer raises a bar to the danger itself. The figure above wears that danger
31
+ unmixed: eleven pixels of text at the muted tone would not be readable, and the figure is what is read.
32
+ Kept after the num rule on purpose: same specificity, so the later one wins. */
33
+ .tpsm-slow { color: var(--v2-state-fg-danger); }
34
+ .tpsm-hot { fill: color-mix(in srgb, var(--v2-state-fg-danger) 45%, var(--v2-background-bg-base)); }
35
+ .tpsm-hot-on { fill: var(--v2-state-fg-danger); }
36
+ .tpsm-counts { margin-top: 6px; color: var(--v2-text-text-faint); line-height: 1.4; font-variant-numeric: tabular-nums; }
37
+ .tpsm-warn { margin-top: 4px; color: var(--v2-text-text-faint); line-height: 1.4; }
38
+ .tpsm-models { margin-top: 6px; }
39
+ /* One model per block, two lines read as one unit: the same rhythm as the block above, and the space that
40
+ tells two models apart on the pair's top only, never between a name and its numbers. */
41
+ .tpsm-mrow { line-height: 1.4; }
42
+ .tpsm-mrow + .tpsm-mrow { padding-top: 6px; }
43
+ .tpsm-mname { overflow-wrap: anywhere; }
44
+ .tpsm-mnum { color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
45
+ .tpsm-total { display: flex; justify-content: space-between; gap: 12px; line-height: 1.4; }
46
+ .tpsm-total > :first-child { color: var(--v2-text-text-faint); }
47
+ .tpsm-amount { font-variant-numeric: tabular-nums; }
48
+ .tpsm-note { padding: 12px; color: var(--v2-text-text-faint); }
49
+ `,C=e=>e>=100?String(Math.round(e)):e.toFixed(1),ve=e=>e.toLocaleString(),w=(e,t)=>new Date(e).toDateString()===new Date(t).toDateString(),T=(e,t)=>t?new Date(e).toLocaleString(void 0,{month:`numeric`,day:`numeric`,hour:`2-digit`,minute:`2-digit`}):new Date(e).toLocaleTimeString(void 0,{hour:`2-digit`,minute:`2-digit`}),ye=e=>e<6e4?Math.round(e/1e3)+`s`:Math.round(e/6e4)+`m`,be=(e,t)=>t?new Date(e).toLocaleString(void 0,{month:`numeric`,day:`numeric`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}):new Date(e).toLocaleTimeString(void 0,{hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),xe=e=>e.map(e=>typeof e==`string`?e:e.figure).join(``),E=e=>e===void 0?`—`:C(e),Se=async e=>{let t=[],n;for(;;){let r=await e.server.client.message.list({sessionID:e.id,limit:b,...n?{cursor:n}:{order:`desc`}});t.push(...r.data);let i=r.cursor?.next??void 0;if(!i)return{messages:t.reverse(),truncated:!1};if(t.length>=x)return{messages:t.reverse(),truncated:!0};n=i}},Ce=async e=>{try{return(await e.server.client.session.get({sessionID:e.id})).revert?.messageID}catch(e){throw Error(`the revert point could not be read (`+(e instanceof Error?e.message:String(e))+`)`)}},D=t=>(()=>{var n=h(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.label),(0,e.insert)(i,()=>t.value),n})(),O=t=>(0,e.createComponent)(n.For,{get each(){return t.pieces},children:t=>typeof t==`string`?t:(()=>{var n=g();return(0,e.insert)(n,()=>t.figure),n})()}),k=t.Plugin.define({setup(i){let a=()=>i.sessions.current(),o=()=>{let[o,s]=(0,n.createSignal)(void 0),[c,l]=(0,n.createSignal)(void 0),[u,d]=(0,n.createSignal)(!1),[h,g]=(0,n.createSignal)(Date.now()),[b,x]=(0,n.createSignal)(0),[S,k]=(0,n.createSignal)(0),[A,j]=(0,n.createSignal)(void 0),M,N=!0,P,F=0;(0,t.createKeyed)(()=>a()?.key,e=>{let t=!0,r=0;s(void 0),l(void 0),d(!0),j(void 0),k(0),N=!0;let i=async()=>{let n=a();if(!n||n.key!==e)return;let i=r+=1,o=()=>t&&i===r&&a()?.key===e;try{let[e,t]=await Promise.all([Se(n),Ce(n)]);if(!o())return;let r=f(e.messages,t),i=r.messages.filter(e=>e.type===`assistant`);F=performance.now()+150,s({readAt:Date.now(),summary:ne(i),truncated:e.truncated,revertLost:t!==void 0&&!r.found&&e.truncated}),l(void 0),requestAnimationFrame(()=>G())}catch(e){if(!o())return;l({at:Date.now(),error:e instanceof Error?e.message:String(e)})}finally{t&&i===r&&d(!1)}};i();let o=setInterval(()=>{g(Date.now()),i()},_e);(0,n.onCleanup)(()=>{t=!1,clearInterval(o)})},{otherwise:()=>{s(void 0),l(void 0),d(!1),j(void 0),k(0),N=!0}});let I=()=>o(),L=()=>o()?.summary,R=()=>{let e=o(),t=c();return e&&t&&t.at>e.readAt?t:void 0},z=()=>L()?.steps??[],we=()=>L()?.last,Te=()=>L()?.median,B=()=>L()?.scale??1,V=e=>Math.max(0,y-e/(B()||1)*y),Ee=e=>e>B(),H=()=>z().length*4,U=()=>Math.max(H(),b()),W=()=>Math.max(0,U()-H()),De=()=>te(z().length,S(),b());(0,n.onMount)(()=>{i.layout.side.width(`tps`)!==v&&i.layout.side.resize(`tps`,v)});let G=()=>{let e=M;if(!e)return;F=performance.now()+150;let t=Math.max(0,e.scrollWidth-e.clientWidth),n=N?t:Math.min(e.scrollLeft,t);n!==e.scrollLeft&&(P=n,e.scrollLeft=n),k(n)},Oe=()=>{let e=M;if(!e)return;if(k(e.scrollLeft),j(void 0),P!==void 0&&Math.abs(e.scrollLeft-P)<=1){P=void 0;return}let t=performance.now()>F;N=m(N,{user:t,scrollLeft:e.scrollLeft,scrollWidth:e.scrollWidth,clientWidth:e.clientWidth})},K,ke=e=>{M=e,K?.disconnect(),K=new ResizeObserver(()=>{x(e.clientWidth),requestAnimationFrame(G)}),K.observe(e),x(e.clientWidth),requestAnimationFrame(G)};(0,n.onCleanup)(()=>K?.disconnect());let Ae=e=>{let t=z();if(!t.length)return;let n=e.currentTarget.getBoundingClientRect().left,r=Math.floor((e.clientX-n-W())/4);j(Math.max(0,Math.min(t.length-1,r)))},q=()=>{let e=A();return e!==void 0&&e<z().length?e:void 0},J=()=>{let e=q();return e===void 0?we():z()[e]},je=()=>{let e=J();return q()!==void 0&&e?be(e.created,!w(e.created,h())):`last measured`},Me=()=>ee(z()),Y=()=>{let e=Te();return e===void 0?void 0:V(e)},Ne=()=>{let e=J();return e?.tps===void 0?`—`:C(e.tps)},X=()=>J()?.model??``,Pe=()=>{let e=J()?.ttft;if(e!==void 0)return e<1e3?[String(Math.round(e)),` ms`]:[(e/1e3).toFixed(1),` s`]},Z=()=>{let e=z(),{first:t,last:n}=De(),r=e[t]?.created,i=e[n]?.created;if(r===void 0||i===void 0)return[``,``];let a=!w(r,i),o=h();return[T(r,a||!w(r,o)),T(i,a||!w(i,o))]},Fe=()=>{if(H()<=b())return``;let e=p(S(),H(),b());return S()<=1?`scroll →`:e?`← scroll`:`← scroll →`},Q=()=>{let e=L();if(!e)return[];let t=[e.steps.length+` steps · `+e.measured+` measured · median `,{figure:E(e.median)},` t/s · peak `,{figure:E(e.peak)},` t/s`];return o()?.truncated&&t.push(` · truncated`),t},Ie=()=>xe(Q()),Le=e=>[(e.steps===1?`1 step`:e.steps+` steps`)+` · med `,{figure:E(e.median)},` t/s · peak `,{figure:E(e.peak)},` t/s`],$=e=>{let t=L()?.totals;return t?ve(e(t)):``},Re=()=>{let e=L()?.totals.cacheHit;return e===void 0?`—`:Math.round(e*100)+`%`};return(()=>{var t=le();return(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return R()},children:t=>(()=>{var n=ue(),r=n.firstChild.nextSibling,i=r.firstChild.nextSibling;return i.nextSibling,(0,e.insert)(r,()=>ye(h()-(I()?.readAt??h())),i),(0,e.insert)(r,()=>t().error,null),n})()}),null),(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return I()},get fallback(){return(0,e.createComponent)(n.Show,{get when(){return c()},get fallback(){return(()=>{var t=de();return(0,e.insert)(t,()=>u()?`reading this session's messages…`:`No session is routed.`),t})()},children:t=>(()=>{var n=fe();return n.firstChild,(0,e.insert)(n,()=>t().error,null),n})()})},get children(){return(0,e.createComponent)(n.Show,{get when(){return z().length>0},get fallback(){return pe()},get children(){return[(()=>{var t=re(),i=t.firstChild,a=i.nextSibling,o=a.firstChild,s=a.nextSibling,c=s.firstChild;return(0,e.insert)(i,je),(0,e.insert)(a,Ne,o),(0,e.insert)(c,X),(0,e.insert)(s,(0,e.createComponent)(n.Show,{get when(){return Pe()},children:t=>[(0,e.memo)(()=>X()?` · `:``),`TTFT`,` `,(()=>{var n=me();return(0,e.insert)(n,()=>t()[0]),(0,e.effect)(()=>(0,e.className)(n,r(J()?.ttft)?`tpsm-num tpsm-slow`:`tpsm-num`)),n})(),(0,e.memo)(()=>t()[1])]}),null),t})(),(()=>{var t=se(),i=t.firstChild,a=i.firstChild,s=i.nextSibling,c=s.firstChild,l=c.nextSibling,u=l.nextSibling,d=s.nextSibling,f=d.nextSibling;return f.firstChild,i.addEventListener(`scroll`,Oe),(0,e.use)(ke,i),a.addEventListener(`pointerleave`,()=>j(void 0)),a.$$pointermove=Ae,(0,e.insert)(a,(0,e.createComponent)(n.For,{get each(){return z()},children:(t,i)=>{let a=t.tps;return a===void 0?null:[(()=>{var n=he();return(0,e.setAttribute)(n,`width`,3),(0,e.effect)(o=>{var s=W()+i()*4,c=V(a),l=y-V(a),u=r(t.ttft)?i()===A()?`tpsm-hot tpsm-hot-on`:`tpsm-hot`:void 0,d=r(t.ttft)||i()===A()?1:i()===z().length-1?.9:.45;return s!==o.e&&(0,e.setAttribute)(n,`x`,o.e=s),c!==o.t&&(0,e.setAttribute)(n,`y`,o.t=c),l!==o.a&&(0,e.setAttribute)(n,`height`,o.a=l),u!==o.o&&(0,e.setAttribute)(n,`class`,o.o=u),d!==o.i&&(0,e.setAttribute)(n,`opacity`,o.i=d),o},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),n})(),(0,e.createComponent)(n.Show,{get when(){return Ee(a)},get children(){var t=ge();return(0,e.setAttribute)(t,`width`,3),(0,e.effect)(()=>(0,e.setAttribute)(t,`x`,W()+i()*4)),t}})]}}),null),(0,e.insert)(a,(0,e.createComponent)(n.Show,{get when(){return Y()!==void 0},get children(){var t=ie();return(0,e.effect)(n=>{var r=U(),i=Y(),a=Y();return r!==n.e&&(0,e.setAttribute)(t,`x2`,n.e=r),i!==n.t&&(0,e.setAttribute)(t,`y1`,n.t=i),a!==n.a&&(0,e.setAttribute)(t,`y2`,n.a=a),n},{e:void 0,t:void 0,a:void 0}),t}}),null),(0,e.insert)(a,(0,e.createComponent)(n.Show,{get when(){return L()?.scale},get children(){var t=ae();return(0,e.effect)(()=>(0,e.setAttribute)(t,`x2`,U())),t}}),null),(0,e.insert)(c,()=>Z()[0]),(0,e.insert)(l,Fe),(0,e.insert)(u,()=>Z()[1]),(0,e.insert)(d,(0,e.createComponent)(O,{get pieces(){return Q()}})),(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return o()?.revertLost},get children(){return oe()}}),f),(0,e.insert)(f,(0,e.createComponent)(n.For,{get each(){return Me()},children:t=>(()=>{var n=_(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.model||`—`),(0,e.insert)(i,(0,e.createComponent)(O,{get pieces(){return Le(t)}})),n})()}),null),(0,e.effect)(t=>{var n=U(),r=Ie();return n!==t.e&&(0,e.setAttribute)(a,`width`,t.e=n),r!==t.t&&(0,e.setAttribute)(a,`aria-label`,t.t=r),t},{e:void 0,t:void 0}),t})(),(()=>{var t=ce();return t.firstChild,(0,e.insert)(t,(0,e.createComponent)(D,{label:`input`,get value(){return $(e=>e.input)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`output`,get value(){return $(e=>e.output)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`reasoning`,get value(){return $(e=>e.reasoning)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`cache read`,get value(){return $(e=>e.cacheRead)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`cache write`,get value(){return $(e=>e.cacheWrite)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`cache hit`,get value(){return Re()}}),null),t})()]}})}}),null),t})()},s={id:`tps`,get title(){return`TPS`},closable:`hover`};i.add(t.Style,S),i.add(t.Panel,{id:`tps`,region:`side`,width:v,list:e=>e.open.includes(`tps`)?[s]:[],render:()=>(0,e.createComponent)(o,{})});let c=()=>{let e=a();e&&i.layout.toggle(`${i.id}:tps`,e)};i.add(t.Command,()=>({id:`open`,title:`TPS Meter`,description:`Decode speed of every closed step in this session`,keywords:`tps speed tokens decode throughput latency ttft`,section:`session`,slash:{name:`tps`},enabled:!!a(),run:c})),i.add(t.MenuItem,()=>{let e=a();return{menu:`session.panel`,id:`tps`,title:`TPS Meter`,icon:`window-analytics`,keybind:`${i.id}.open`,opened:!!e&&i.layout.state(`${i.id}:tps`,e)!==`closed`,run:c}})}});(0,e.delegateEvents)([`pointermove`]),exports.default=k;
package/gui.tsx ADDED
@@ -0,0 +1,655 @@
1
+ // oc-tps-meter — GUI half: a side panel drawing tokens per second for every closed step of the session on
2
+ // screen, as a histogram, with the figures around it. The metric is `metrics.ts`.
3
+ import { Command, MenuItem, Panel, Plugin, Style, createKeyed, type MountedSession, type PanelTab } from "@opencode/plugin/gui"
4
+ import { For, Show, createSignal, onCleanup, onMount } from "solid-js"
5
+
6
+ import {
7
+ BAR_PX,
8
+ atRightEnd,
9
+ followAfter,
10
+ byModel,
11
+ isSlow,
12
+ revertCut,
13
+ STEP_PX,
14
+ summarise,
15
+ visibleRange,
16
+ type Step,
17
+ type StepMessage,
18
+ type ModelStats,
19
+ type Summary,
20
+ } from "./metrics.ts"
21
+
22
+ const REFRESH_MS = 2_000
23
+ /** The panel's width, re-applied at every open. */
24
+ const PANEL_WIDTH = 360
25
+ /** The drawing's height in pixels, where a bar's top is measured from the baseline of a 0-based scale. */
26
+ const HEIGHT = 120
27
+ /** What a read asks for in one page, and the most it will accumulate before saying so. */
28
+ const PAGE = 200
29
+ const MAX_MESSAGES = 2_000
30
+
31
+ const CSS = `
32
+ /* Muted small capitals for labels, one large figure, hairlines between blocks, no borders and no cards. */
33
+ .tpsm { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: auto; font-size: 12px;
34
+ color: var(--v2-text-text-base); }
35
+ .tpsm-block { padding: 14px 12px; }
36
+ .tpsm-block + .tpsm-block { border-top: 1px solid color-mix(in srgb, var(--v2-text-text-base) 8%, transparent); }
37
+ .tpsm-label { color: var(--v2-text-text-faint); text-transform: uppercase; letter-spacing: .06em; font-size: 11px;
38
+ font-weight: 600; margin-bottom: 6px; font-variant-numeric: tabular-nums; }
39
+ .tpsm-figure { font-size: 28px; line-height: 1.15; font-weight: 700; font-variant-numeric: tabular-nums; }
40
+ /* The unit is a label, not part of the figure: bold digits, a quiet "t/s" beside them. */
41
+ .tpsm-unit { font-size: 14px; font-weight: 400; opacity: .7; }
42
+ /* One line of room kept even when a step carries neither model nor time, so the chart never jumps a row. */
43
+ .tpsm-sub { color: var(--v2-text-text-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis;
44
+ white-space: nowrap; min-height: 1.4em; }
45
+ /* The chart pans sideways and nothing else: a step is a fixed 4 px, so a long session scrolls natively. */
46
+ .tpsm-chart { display: flex; overflow-x: auto; overflow-y: hidden; }
47
+ .tpsm-hist { display: block; flex: 0 0 auto; margin-left: auto; height: ${HEIGHT}px;
48
+ color: var(--v2-text-text-base); }
49
+ .tpsm-axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 6px; align-items: baseline; margin-top: 4px;
50
+ color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
51
+ /* The way to the earlier steps, between the two dates: the only place it belongs, and silent when it all fits. */
52
+ .tpsm-pan { font-size: 10px; letter-spacing: .04em; opacity: .8; }
53
+ .tpsm-end { text-align: right; }
54
+ /* The model of the step being described: white, so a name keeps its own against the figure above it. */
55
+ .tpsm-smodel { color: var(--v2-text-text-base); }
56
+ /* A figure the line exists for — a median, a peak, a time to first token: white and bold, the words around
57
+ it keeping the faint they had. */
58
+ .tpsm-num { color: var(--v2-text-text-base); font-weight: 600; }
59
+ /* A slow step wears the theme's danger at the weight it had at 45% alpha — mixed with the surface, because the
60
+ bar's opacity is 1 — and the pointer raises a bar to the danger itself. The figure above wears that danger
61
+ unmixed: eleven pixels of text at the muted tone would not be readable, and the figure is what is read.
62
+ Kept after the num rule on purpose: same specificity, so the later one wins. */
63
+ .tpsm-slow { color: var(--v2-state-fg-danger); }
64
+ .tpsm-hot { fill: color-mix(in srgb, var(--v2-state-fg-danger) 45%, var(--v2-background-bg-base)); }
65
+ .tpsm-hot-on { fill: var(--v2-state-fg-danger); }
66
+ .tpsm-counts { margin-top: 6px; color: var(--v2-text-text-faint); line-height: 1.4; font-variant-numeric: tabular-nums; }
67
+ .tpsm-warn { margin-top: 4px; color: var(--v2-text-text-faint); line-height: 1.4; }
68
+ .tpsm-models { margin-top: 6px; }
69
+ /* One model per block, two lines read as one unit: the same rhythm as the block above, and the space that
70
+ tells two models apart on the pair's top only, never between a name and its numbers. */
71
+ .tpsm-mrow { line-height: 1.4; }
72
+ .tpsm-mrow + .tpsm-mrow { padding-top: 6px; }
73
+ .tpsm-mname { overflow-wrap: anywhere; }
74
+ .tpsm-mnum { color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
75
+ .tpsm-total { display: flex; justify-content: space-between; gap: 12px; line-height: 1.4; }
76
+ .tpsm-total > :first-child { color: var(--v2-text-text-faint); }
77
+ .tpsm-amount { font-variant-numeric: tabular-nums; }
78
+ .tpsm-note { padding: 12px; color: var(--v2-text-text-faint); }
79
+ `
80
+
81
+ type Reading = {
82
+ readAt: number
83
+ summary: Summary
84
+ truncated: boolean
85
+ /** A revert was staged and its message was older than what the read reached. */
86
+ revertLost: boolean
87
+ }
88
+
89
+ type Failure = { at: number; error: string }
90
+
91
+ /** A speed that reads at a glance: whole numbers once they are three digits. */
92
+ const speed = (value: number) => (value >= 100 ? String(Math.round(value)) : value.toFixed(1))
93
+
94
+ const count = (value: number) => value.toLocaleString()
95
+
96
+ /** Whether two instants fall on the same local day: what decides if a time needs its date beside it. */
97
+ const sameDay = (a: number, b: number) => new Date(a).toDateString() === new Date(b).toDateString()
98
+
99
+ const clock = (at: number, withDay: boolean) =>
100
+ withDay
101
+ ? new Date(at).toLocaleString(undefined, { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })
102
+ : new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
103
+
104
+ const age = (ms: number) => (ms < 60_000 ? Math.round(ms / 1000) + "s" : Math.round(ms / 60_000) + "m")
105
+
106
+ /** `HH:MM:SS`, the granularity one step's instant needs, behind its date when that is not today. */
107
+ const stamp = (at: number, withDay: boolean) =>
108
+ withDay
109
+ ? new Date(at).toLocaleString(undefined, {
110
+ month: "numeric",
111
+ day: "numeric",
112
+ hour: "2-digit",
113
+ minute: "2-digit",
114
+ second: "2-digit",
115
+ })
116
+ : new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
117
+
118
+ /** A line as the words around a `figure`: the words stay faint, a figure is drawn bright, because it is the
119
+ * number the line exists for. One shape for the median, the peak and the time to first token, so they read
120
+ * the same wherever they appear. */
121
+ type Piece = string | { figure: string }
122
+
123
+ const piecesText = (pieces: Piece[]) => pieces.map((piece) => (typeof piece === "string" ? piece : piece.figure)).join("")
124
+
125
+ /** A speed, or the dash a line shows where it has none. */
126
+ const tps = (value: number | undefined) => (value === undefined ? "—" : speed(value))
127
+
128
+ /** Every message of the session, assistant and otherwise, handed back oldest first: the revert cut has to find
129
+ * the message it points at, which is usually a user one. */
130
+ const readAll = async (session: MountedSession): Promise<{ messages: StepMessage[]; truncated: boolean }> => {
131
+ const messages: StepMessage[] = []
132
+ let cursor: string | undefined
133
+ for (;;) {
134
+ // The order belongs to the first request only: a cursor carries its own direction.
135
+ const page = await session.server.client.message.list({
136
+ sessionID: session.id,
137
+ limit: PAGE,
138
+ ...(cursor ? { cursor } : { order: "desc" as const }),
139
+ })
140
+ messages.push(...page.data)
141
+ const next = page.cursor?.next ?? undefined
142
+ if (!next) return { messages: messages.reverse(), truncated: false }
143
+ if (messages.length >= MAX_MESSAGES) return { messages: messages.reverse(), truncated: true }
144
+ cursor = next
145
+ }
146
+ }
147
+
148
+ /** The revert point, when the session carries a staged revert. A failure is not a blank panel: it is thrown so
149
+ * the read keeps the figures it has, and the note says this is why. */
150
+ const revertOf = async (session: MountedSession): Promise<string | undefined> => {
151
+ try {
152
+ const info = await session.server.client.session.get({ sessionID: session.id })
153
+ return info.revert?.messageID
154
+ } catch (error) {
155
+ throw new Error("the revert point could not be read (" + (error instanceof Error ? error.message : String(error)) + ")")
156
+ }
157
+ }
158
+
159
+ const Total = (props: { label: string; value: string }) => (
160
+ <div class="tpsm-total">
161
+ <span>{props.label}</span>
162
+ <span class="tpsm-amount">{props.value}</span>
163
+ </div>
164
+ )
165
+
166
+ const Figures = (props: { pieces: Piece[] }) => (
167
+ <For each={props.pieces}>
168
+ {(piece) => (typeof piece === "string" ? piece : <span class="tpsm-num">{piece.figure}</span>)}
169
+ </For>
170
+ )
171
+
172
+ export default Plugin.define({
173
+ setup(ctx) {
174
+ const current = () => ctx.sessions.current()
175
+
176
+ const View = () => {
177
+ const [reading, setReading] = createSignal<Reading | undefined>(undefined)
178
+ const [failure, setFailure] = createSignal<Failure | undefined>(undefined)
179
+ const [loading, setLoading] = createSignal(false)
180
+ /** The instant the note about age is read against, bumped by the poll so it never freezes. */
181
+ const [now, setNow] = createSignal(Date.now())
182
+ const [viewport, setViewport] = createSignal(0)
183
+ const [scrolled, setScrolled] = createSignal(0)
184
+ const [hovered, setHovered] = createSignal<number | undefined>(undefined)
185
+ let box: HTMLDivElement | undefined
186
+ /** True while the window sits at the right end. Written by the user's own scrolling only, so a read
187
+ * that lands while they are reading back does not yank them forward. */
188
+ let follow = true
189
+ /** Where this plugin last scrolled to itself: its own event must not touch `follow`. */
190
+ let ownScroll: number | undefined
191
+ /** Until this instant every scroll event is the layout's doing — a clamp, a resize, our own settle —
192
+ * never the user's word on where they want to be. */
193
+ let programmaticUntil = 0
194
+
195
+ // Keyed by `session.key`: another session means another read, and it starts blank so no figure of
196
+ // the session before is ever drawn under this one's name. Each run owns its timer and its
197
+ // `live` flag, so a late reply for the key it replaced is dropped twice over.
198
+ createKeyed(
199
+ () => current()?.key,
200
+ (key: string) => {
201
+ let live = true
202
+ /** This key's reads, in the order they started: only the latest may write, errors included. */
203
+ let sequence = 0
204
+ setReading(undefined)
205
+ setFailure(undefined)
206
+ setLoading(true)
207
+ // A new session inherits no offset: the right end, no hover, and following back on.
208
+ setHovered(undefined)
209
+ setScrolled(0)
210
+ follow = true
211
+
212
+ const read = async () => {
213
+ const session = current()
214
+ if (!session || session.key !== key) return
215
+ const mine = (sequence += 1)
216
+ const latest = () => live && mine === sequence && current()?.key === key
217
+ try {
218
+ const [page, revert] = await Promise.all([readAll(session), revertOf(session)])
219
+ if (!latest()) return
220
+ const cut = revertCut(page.messages, revert)
221
+ // Assistant messages only are steps; the user and compaction ones existed for the cut.
222
+ const kept = cut.messages.filter((message) => message.type === "assistant")
223
+ // The content is about to change width: any scroll event until the layout settles is
224
+ // the browser's, not the user's.
225
+ programmaticUntil = performance.now() + 150
226
+ setReading({
227
+ readAt: Date.now(),
228
+ summary: summarise(kept),
229
+ truncated: page.truncated,
230
+ revertLost: revert !== undefined && !cut.found && page.truncated,
231
+ })
232
+ setFailure(undefined)
233
+ requestAnimationFrame(() => settle())
234
+ } catch (error) {
235
+ if (!latest()) return
236
+ setFailure({ at: Date.now(), error: error instanceof Error ? error.message : String(error) })
237
+ } finally {
238
+ if (live && mine === sequence) setLoading(false)
239
+ }
240
+ }
241
+ void read()
242
+ // The app pushes no change event for message data, so the panel asks on a clock; the same tick
243
+ // moves the instant the stale age is read against.
244
+ const timer = setInterval(() => {
245
+ setNow(Date.now())
246
+ void read()
247
+ }, REFRESH_MS)
248
+ onCleanup(() => {
249
+ live = false
250
+ clearInterval(timer)
251
+ })
252
+ },
253
+ {
254
+ otherwise: () => {
255
+ setReading(undefined)
256
+ setFailure(undefined)
257
+ setLoading(false)
258
+ setHovered(undefined)
259
+ setScrolled(0)
260
+ follow = true
261
+ },
262
+ },
263
+ )
264
+
265
+ const value = () => reading()
266
+ const summary = () => reading()?.summary
267
+ /** A failed read after a good one: the figures stay, their age says they are old. */
268
+ const stale = () => {
269
+ const last = reading()
270
+ const missed = failure()
271
+ return last && missed && missed.at > last.readAt ? missed : undefined
272
+ }
273
+
274
+ const steps = () => summary()?.steps ?? []
275
+ const lastStep = () => summary()?.last
276
+ const medianTps = () => summary()?.median
277
+ // The drawing stops at the peak, or at twice the 95th percentile when one step is beyond both: that
278
+ // step is cut at the top edge instead of flattening all the others. A short session's p95 is its peak.
279
+ const scale = () => summary()?.scale ?? 1
280
+ const y = (tps: number) => Math.max(0, HEIGHT - (tps / (scale() || 1)) * HEIGHT)
281
+ const clipped = (tps: number) => tps > scale()
282
+
283
+ // The chart is one bar per step in real pixels, so it can be wider than the panel and scrolls sideways.
284
+ const contentWidth = () => steps().length * STEP_PX
285
+ const chartWidth = () => Math.max(contentWidth(), viewport())
286
+ const chartLeft = () => Math.max(0, chartWidth() - contentWidth())
287
+ const range = () => visibleRange(steps().length, scrolled(), viewport())
288
+
289
+ // The width is re-applied once at mount, never reactively, so a drag is not fought.
290
+ onMount(() => {
291
+ if (ctx.layout.side.width("tps") !== PANEL_WIDTH) ctx.layout.side.resize("tps", PANEL_WIDTH)
292
+ })
293
+ /** After a render: follow the newest only when the window was already at the right end, and clamp an
294
+ * offset the content no longer has. */
295
+ const settle = () => {
296
+ const element = box
297
+ if (!element) return
298
+ programmaticUntil = performance.now() + 150
299
+ const end = Math.max(0, element.scrollWidth - element.clientWidth)
300
+ const target = follow ? end : Math.min(element.scrollLeft, end)
301
+ if (target !== element.scrollLeft) {
302
+ ownScroll = target
303
+ element.scrollLeft = target
304
+ }
305
+ setScrolled(target)
306
+ }
307
+ const onScroll = () => {
308
+ const element = box
309
+ if (!element) return
310
+ setScrolled(element.scrollLeft)
311
+ // The bar the pointer was over is no longer where it pointed, so the block goes back to the newest
312
+ // measured step rather than describe a bar that has moved.
313
+ setHovered(undefined)
314
+ // The scroll this plugin just made arrives as an event too, and it is not the user's word.
315
+ if (ownScroll !== undefined && Math.abs(element.scrollLeft - ownScroll) <= 1) {
316
+ ownScroll = undefined
317
+ return
318
+ }
319
+ // Only a scroll the user made may move the latch: a clamp after the content shrank arrives as a
320
+ // scroll event too, and it always lands at the right end (docs/oc-tps-meter.md, Traps).
321
+ const user = performance.now() > programmaticUntil
322
+ follow = followAfter(follow, {
323
+ user,
324
+ scrollLeft: element.scrollLeft,
325
+ scrollWidth: element.scrollWidth,
326
+ clientWidth: element.clientWidth,
327
+ })
328
+ }
329
+ /** The chart's own wrapper, which the observer has to follow: it comes and goes with the steps. */
330
+ let observer: ResizeObserver | undefined
331
+ const watch = (element: HTMLDivElement) => {
332
+ box = element
333
+ observer?.disconnect()
334
+ observer = new ResizeObserver(() => {
335
+ setViewport(element.clientWidth)
336
+ requestAnimationFrame(settle)
337
+ })
338
+ observer.observe(element)
339
+ setViewport(element.clientWidth)
340
+ requestAnimationFrame(settle)
341
+ }
342
+ onCleanup(() => observer?.disconnect())
343
+ /** A step's slot is hoverable whether or not it was measured: the block above says which it was. */
344
+ const point = (event: PointerEvent & { currentTarget: SVGSVGElement }) => {
345
+ const list = steps()
346
+ if (!list.length) return
347
+ const left = event.currentTarget.getBoundingClientRect().left
348
+ const index = Math.floor((event.clientX - left - chartLeft()) / STEP_PX)
349
+ setHovered(Math.max(0, Math.min(list.length - 1, index)))
350
+ }
351
+ /** The bar under the pointer, when the list still holds it: a poll appends, a revert cuts the tail, and
352
+ * either can leave the stored index past the end. */
353
+ const over = (): number | undefined => {
354
+ const index = hovered()
355
+ return index !== undefined && index < steps().length ? index : undefined
356
+ }
357
+ /** The step the block describes: the one under the pointer, else the newest measured one. Nothing
358
+ * hovered means the panel keeps its headline, so panning never moves the big figure. */
359
+ const shown = (): Step | undefined => {
360
+ const index = over()
361
+ return index === undefined ? lastStep() : steps()[index]
362
+ }
363
+ /** What the block is describing: the hovered step's time, or the headline's own label. */
364
+ const blockLabel = () => {
365
+ const step = shown()
366
+ return over() !== undefined && step ? stamp(step.created, !sameDay(step.created, now())) : "last measured"
367
+ }
368
+ const models = () => byModel(steps())
369
+ const medianLine = () => {
370
+ const value = medianTps()
371
+ return value === undefined ? undefined : y(value)
372
+ }
373
+ const figure = () => {
374
+ const step = shown()
375
+ return step?.tps === undefined ? "—" : speed(step.tps)
376
+ }
377
+ const model = () => shown()?.model ?? ""
378
+ const ttft = () => {
379
+ const value = shown()?.ttft
380
+ // Milliseconds below a second, where the digits mean something; seconds above, where they do not.
381
+ if (value === undefined) return undefined
382
+ return value < 1000 ? ([String(Math.round(value)), " ms"] as const) : ([(value / 1000).toFixed(1), " s"] as const)
383
+ }
384
+ /** Absolute local times at the two ends of the visible window. A window that crosses midnight dates
385
+ * both ends — 23:58 and 00:08 alone would not say the day changed — and an end that is not today's
386
+ * carries its date, so panned back onto yesterday the axis cannot be mistaken for this morning. */
387
+ const axis = () => {
388
+ const list = steps()
389
+ const { first, last } = range()
390
+ const start = list[first]?.created
391
+ const end = list[last]?.created
392
+ if (start === undefined || end === undefined) return ["", ""]
393
+ const crosses = !sameDay(start, end)
394
+ const today = now()
395
+ return [clock(start, crosses || !sameDay(start, today)), clock(end, crosses || !sameDay(end, today))]
396
+ }
397
+ /** Which way there is more to see, between the two dates: nothing when the whole session fits. */
398
+ const pan = () => {
399
+ if (contentWidth() <= viewport()) return ""
400
+ const right = atRightEnd(scrolled(), contentWidth(), viewport())
401
+ if (scrolled() <= 1) return "scroll →"
402
+ return right ? "← scroll" : "← scroll →"
403
+ }
404
+ const countsLine = (): Piece[] => {
405
+ const value = summary()
406
+ if (!value) return []
407
+ const pieces: Piece[] = [
408
+ value.steps.length + " steps · " + value.measured + " measured · median ",
409
+ { figure: tps(value.median) },
410
+ " t/s · peak ",
411
+ { figure: tps(value.peak) },
412
+ " t/s",
413
+ ]
414
+ if (reading()?.truncated) pieces.push(" · truncated")
415
+ return pieces
416
+ }
417
+ const counts = () => piecesText(countsLine())
418
+ const modelLine = (row: ModelStats): Piece[] => [
419
+ (row.steps === 1 ? "1 step" : row.steps + " steps") + " · med ",
420
+ { figure: tps(row.median) },
421
+ " t/s · peak ",
422
+ { figure: tps(row.peak) },
423
+ " t/s",
424
+ ]
425
+ const amount = (pick: (totals: Summary["totals"]) => number) => {
426
+ const totals = summary()?.totals
427
+ return totals ? count(pick(totals)) : ""
428
+ }
429
+ const hit = () => {
430
+ const value = summary()?.totals.cacheHit
431
+ return value === undefined ? "—" : Math.round(value * 100) + "%"
432
+ }
433
+
434
+ return (
435
+ <div class="tpsm">
436
+ <Show when={stale()}>
437
+ {(missed: () => Failure) => (
438
+ <div class="tpsm-block">
439
+ <div class="tpsm-label">stale</div>
440
+ <div class="tpsm-sub">
441
+ read {age(now() - (value()?.readAt ?? now()))} ago, the last attempt failed:{" "}
442
+ {missed().error}
443
+ </div>
444
+ </div>
445
+ )}
446
+ </Show>
447
+ <Show when={value()} fallback={
448
+ <Show
449
+ when={failure()}
450
+ fallback={
451
+ <div class="tpsm-note">
452
+ {loading() ? "reading this session's messages…" : "No session is routed."}
453
+ </div>
454
+ }
455
+ >
456
+ {(missed: () => Failure) => (
457
+ <div class="tpsm-note">The messages could not be read: {missed().error}</div>
458
+ )}
459
+ </Show>
460
+ }>
461
+ <Show when={steps().length > 0} fallback={<div class="tpsm-note">No assistant step yet.</div>}>
462
+ <div class="tpsm-block">
463
+ <div class="tpsm-label">{blockLabel()}</div>
464
+ <div class="tpsm-figure">{figure()}<span class="tpsm-unit"> t/s</span></div>
465
+ <div class="tpsm-sub">
466
+ <span class="tpsm-smodel">{model()}</span>
467
+ <Show when={ttft()}>
468
+ {(pair) => (
469
+ <>
470
+ {model() ? " · " : ""}TTFT{" "}
471
+ <span class={isSlow(shown()?.ttft) ? "tpsm-num tpsm-slow" : "tpsm-num"}>
472
+ {pair()[0]}
473
+ </span>
474
+ {pair()[1]}
475
+ </>
476
+ )}
477
+ </Show>
478
+ </div>
479
+ </div>
480
+ <div class="tpsm-block">
481
+ <div class="tpsm-chart" ref={watch} onScroll={onScroll}>
482
+ <svg
483
+ class="tpsm-hist"
484
+ width={chartWidth()}
485
+ height={HEIGHT}
486
+ role="img"
487
+ aria-label={counts()}
488
+ onPointerMove={point}
489
+ onPointerLeave={() => setHovered(undefined)}
490
+ >
491
+ {/* One drawing for every step, in real pixels: an unmeasured step leaves its slot empty, and the
492
+ two dashed lines are strokes at one pixel over the whole width. */}
493
+ <For each={steps()}>
494
+ {(step: Step, index) => {
495
+ const tps = step.tps
496
+ if (tps === undefined) return null
497
+ return (
498
+ <>
499
+ {/* Red when that step waited too long for its first token: the bar's height is the
500
+ speed, so the colour carries the wait, which is the other reason to switch provider.
501
+ A slow bar is filled with the muted red and keeps full opacity, so the pointer
502
+ has to show itself through the fill instead — hence the brighter class below. */}
503
+ <rect
504
+ x={chartLeft() + index() * STEP_PX}
505
+ y={y(tps)}
506
+ width={BAR_PX}
507
+ height={HEIGHT - y(tps)}
508
+ fill="currentColor"
509
+ class={
510
+ isSlow(step.ttft)
511
+ ? index() === hovered()
512
+ ? "tpsm-hot tpsm-hot-on"
513
+ : "tpsm-hot"
514
+ : undefined
515
+ }
516
+ opacity={
517
+ isSlow(step.ttft) || index() === hovered()
518
+ ? 1
519
+ : index() === steps().length - 1
520
+ ? 0.9
521
+ : 0.45
522
+ }
523
+ />
524
+ {/* Cut at the top: a bright cap says the bar goes on, and the block above
525
+ still carries its real figure when pointed at. */}
526
+ <Show when={clipped(tps)}>
527
+ <rect
528
+ x={chartLeft() + index() * STEP_PX}
529
+ y={0}
530
+ width={BAR_PX}
531
+ height={2}
532
+ fill="currentColor"
533
+ opacity={1}
534
+ />
535
+ </Show>
536
+ </>
537
+ )
538
+ }}
539
+ </For>
540
+ <Show when={medianLine() !== undefined}>
541
+ <line
542
+ x1="0"
543
+ x2={chartWidth()}
544
+ y1={medianLine()}
545
+ y2={medianLine()}
546
+ stroke="currentColor"
547
+ stroke-width="1"
548
+ stroke-dasharray="3 3"
549
+ opacity="0.3"
550
+ />
551
+ </Show>
552
+ {/* The top of the drawing, drawn whatever the data does: the block's padding is black on
553
+ black, so without this line the edge is invisible and a cut bar has nothing to be cut
554
+ against. The tallest bar reaches it, unless the fence cut it. */}
555
+ <Show when={summary()?.scale}>
556
+ <line
557
+ x1="0"
558
+ x2={chartWidth()}
559
+ y1="0.5"
560
+ y2="0.5"
561
+ stroke="currentColor"
562
+ stroke-width="1"
563
+ stroke-dasharray="3 3"
564
+ opacity="0.3"
565
+ />
566
+ </Show>
567
+ </svg>
568
+ </div>
569
+ <div class="tpsm-axis">
570
+ <span>{axis()[0]}</span>
571
+ <span class="tpsm-pan">{pan()}</span>
572
+ <span class="tpsm-end">{axis()[1]}</span>
573
+ </div>
574
+ <div class="tpsm-counts"><Figures pieces={countsLine()} /></div>
575
+ <Show when={reading()?.revertLost}>
576
+ <div class="tpsm-warn">
577
+ the revert point is older than the messages read: undone steps may be counted
578
+ </div>
579
+ </Show>
580
+ <div class="tpsm-models">
581
+ <div class="tpsm-label">by model</div>
582
+ <For each={models()}>
583
+ {(row) => (
584
+ <div class="tpsm-mrow">
585
+ <div class="tpsm-mname">{row.model || "—"}</div>
586
+ <div class="tpsm-mnum"><Figures pieces={modelLine(row)} /></div>
587
+ </div>
588
+ )}
589
+ </For>
590
+ </div>
591
+ </div>
592
+ <div class="tpsm-block">
593
+ <div class="tpsm-label">totals</div>
594
+ <Total label="input" value={amount((totals) => totals.input)} />
595
+ <Total label="output" value={amount((totals) => totals.output)} />
596
+ <Total label="reasoning" value={amount((totals) => totals.reasoning)} />
597
+ <Total label="cache read" value={amount((totals) => totals.cacheRead)} />
598
+ <Total label="cache write" value={amount((totals) => totals.cacheWrite)} />
599
+ <Total label="cache hit" value={hit()} />
600
+ </div>
601
+ </Show>
602
+ </Show>
603
+ </div>
604
+ )
605
+ }
606
+
607
+ const tab: PanelTab = {
608
+ id: "tps",
609
+ get title() {
610
+ return "TPS"
611
+ },
612
+ closable: "hover",
613
+ }
614
+
615
+ ctx.add(Style, CSS)
616
+ ctx.add(Panel, {
617
+ id: "tps",
618
+ region: "side",
619
+ // The declared width puts the panel on the app's per-panel width store, which is what a drag writes.
620
+ width: PANEL_WIDTH,
621
+ list: (input) => (input.open.includes("tps") ? [tab] : []),
622
+ render: () => <View />,
623
+ })
624
+
625
+ const toggle = () => {
626
+ const session = current()
627
+ if (!session) return
628
+ ctx.layout.toggle(`${ctx.id}:tps`, session)
629
+ }
630
+
631
+ ctx.add(Command, (): Command => ({
632
+ id: "open",
633
+ title: "TPS Meter",
634
+ description: "Decode speed of every closed step in this session",
635
+ keywords: "tps speed tokens decode throughput latency ttft",
636
+ section: "session",
637
+ slash: { name: "tps" },
638
+ enabled: !!current(),
639
+ run: toggle,
640
+ }))
641
+
642
+ ctx.add(MenuItem, (): MenuItem => {
643
+ const session = current()
644
+ return {
645
+ menu: "session.panel",
646
+ id: "tps",
647
+ title: "TPS Meter",
648
+ icon: "window-analytics",
649
+ keybind: `${ctx.id}.open`,
650
+ opened: !!session && ctx.layout.state(`${ctx.id}:tps`, session) !== "closed",
651
+ run: toggle,
652
+ }
653
+ })
654
+ },
655
+ })
package/metrics.ts ADDED
@@ -0,0 +1,229 @@
1
+ // oc-tps-meter — the metric, as pure functions over the messages the client returns. No imports: the test
2
+ // reads exactly what the panel reads. A step's speed is its own decode window, first token to last, and a
3
+ // step without a usable one is unmeasured, never zero.
4
+
5
+ export const MIN_TOKENS = 20
6
+ export const MIN_SPAN_MS = 200
7
+
8
+ /** The wait for a first token above which a step is read as queued rather than merely slow: above the 95th
9
+ * percentile of everything measured on this machine, so a flagged step is never ordinary slowness. The
10
+ * measurement behind it is in `docs/oc-tps-meter.md`, *a red bar is a wait* — the number is not repeated here. */
11
+ export const SLOW_TTFT_MS = 10_000
12
+
13
+ export const isSlow = (ttft: number | undefined) => ttft !== undefined && ttft > SLOW_TTFT_MS
14
+
15
+ export type MessageTime = {
16
+ created?: number
17
+ firstToken?: number
18
+ streamed?: number
19
+ completed?: number
20
+ }
21
+
22
+ export type MessageTokens = {
23
+ input?: number
24
+ output?: number
25
+ reasoning?: number
26
+ cache?: { read?: number; write?: number }
27
+ }
28
+
29
+ export type StepMessage = {
30
+ id?: string
31
+ /** `assistant`, `user`, `compaction`… Only the assistant ones are steps, but the revert cut needs them all. */
32
+ type?: string
33
+ time?: MessageTime
34
+ tokens?: MessageTokens
35
+ model?: { providerID?: string; id?: string; variant?: string }
36
+ content?: readonly { type?: string; time?: null | { created?: number } }[]
37
+ }
38
+
39
+ export type Step = {
40
+ id: string
41
+ /** Epoch ms. */
42
+ created: number
43
+ tokens: number
44
+ /** Only on a measured step. */
45
+ tps?: number
46
+ ttft?: number
47
+ /** `providerID/modelID`, or empty when the message carried neither. */
48
+ model: string
49
+ measured: boolean
50
+ }
51
+
52
+ export type Totals = {
53
+ input: number
54
+ output: number
55
+ reasoning: number
56
+ cacheRead: number
57
+ cacheWrite: number
58
+ /** `cache.read / (input + cache.read + cache.write)`; undefined when all three are zero. */
59
+ cacheHit?: number
60
+ }
61
+
62
+ export type Summary = {
63
+ /** Oldest first, whether the messages arrived oldest or newest first. */
64
+ steps: Step[]
65
+ /** Sums over the same messages, measured or not. */
66
+ totals: Totals
67
+ measured: number
68
+ /** The median of the measured speeds, not the mean. */
69
+ median?: number
70
+ peak?: number
71
+ /** The top of the drawing: the 95th percentile, so one freak step cannot flatten the other 95%. */
72
+ scale?: number
73
+ /** The newest measured step. */
74
+ last?: Step
75
+ }
76
+
77
+ const number = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0)
78
+
79
+ /** A finite instant, or nothing: a non-finite one cannot measure a window. */
80
+ const instant = (value: number | undefined) =>
81
+ typeof value === "number" && Number.isFinite(value) ? value : undefined
82
+
83
+ /** A step is closed only once `completed` is there: a message still streaming is not history. */
84
+ export const closed = (message: StepMessage) => message.time?.completed !== undefined
85
+
86
+ /** The instant generation started: the provider's own first token, and for the messages recorded before that
87
+ * field existed, the first **reasoning** part. Only reasoning: a tool part's time is when the call begins,
88
+ * after the model has already generated, and using it collapses the window — on real rows it turned 365 tokens
89
+ * into 1388 t/s. A text part carries no time at all, so an old text-only or tool-only message stays a gap. */
90
+ const started = (message: StepMessage): number | undefined => {
91
+ const firstToken = instant(message.time?.firstToken)
92
+ if (firstToken !== undefined) return firstToken
93
+ let earliest: number | undefined
94
+ for (const part of message.content ?? []) {
95
+ if (part?.type !== "reasoning") continue
96
+ const at = instant(part.time?.created)
97
+ if (at !== undefined && (earliest === undefined || at < earliest)) earliest = at
98
+ }
99
+ return earliest
100
+ }
101
+
102
+ export const stepOf = (message: StepMessage): Step => {
103
+ const tokens = number(message.tokens?.output) + number(message.tokens?.reasoning)
104
+ const created = number(message.time?.created)
105
+ const firstToken = started(message)
106
+ const streamed = instant(message.time?.streamed)
107
+ const span = firstToken !== undefined && streamed !== undefined ? streamed - firstToken : undefined
108
+ const measured = span !== undefined && span >= MIN_SPAN_MS && tokens >= MIN_TOKENS
109
+ const model =
110
+ message.model?.providerID && message.model?.id ? message.model.providerID + "/" + message.model.id : ""
111
+ return {
112
+ id: message.id ?? "",
113
+ created,
114
+ tokens,
115
+ tps: measured && span !== undefined ? tokens / (span / 1000) : undefined,
116
+ ttft: firstToken !== undefined && created !== 0 ? firstToken - created : undefined,
117
+ model,
118
+ measured,
119
+ }
120
+ }
121
+
122
+ /** The middle value, averaging the two middles on an even count. Undefined for no value at all. */
123
+ export const median = (values: readonly number[]): number | undefined => {
124
+ if (values.length === 0) return undefined
125
+ const sorted = [...values].sort((a, b) => a - b)
126
+ const middle = Math.floor(sorted.length / 2)
127
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
128
+ }
129
+
130
+ /** The value 95% of the speeds sit at or below, by nearest rank. */
131
+ export const p95 = (values: readonly number[]): number | undefined => {
132
+ if (values.length === 0) return undefined
133
+ const sorted = [...values].sort((a, b) => a - b)
134
+ return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)]
135
+ }
136
+
137
+ /** The top of the drawing: the peak itself, unless one step is more than twice the 95th percentile — then the
138
+ * scale stops at that fence, so a freak bar is cut instead of flattening every other one. With no such step
139
+ * this is exactly the peak. */
140
+ export const scaleOf = (values: readonly number[]): number | undefined => {
141
+ if (values.length === 0) return undefined
142
+ const max = Math.max(...values)
143
+ return Math.min(max, 2 * (p95(values) ?? max))
144
+ }
145
+
146
+ /** A staged revert leaves the messages it undid stored but no longer part of the session: the message it points
147
+ * at and everything newer are dropped. The list is oldest first. `found` says whether that message was in it:
148
+ * an id from outside the window cuts nothing, and the caller has to say so. */
149
+ export type RevertCut = {
150
+ messages: StepMessage[]
151
+ found: boolean
152
+ }
153
+
154
+ export const revertCut = (messages: readonly StepMessage[], revertMessageID: string | undefined): RevertCut => {
155
+ const stop = revertMessageID ? messages.findIndex((message) => message.id === revertMessageID) : -1
156
+ return stop === -1 ? { messages: [...messages], found: false } : { messages: messages.slice(0, stop), found: true }
157
+ }
158
+
159
+ /** One row per model with at least one measured step, the busiest first and ties by name. The counts are of
160
+ * measured steps, which is what the row's speeds speak about. */
161
+ export type ModelStats = { model: string; steps: number; median?: number; peak?: number }
162
+
163
+ export const byModel = (steps: readonly Step[]): ModelStats[] => {
164
+ const held = new Map<string, number[]>()
165
+ for (const step of steps) {
166
+ if (step.tps === undefined) continue
167
+ const speeds = held.get(step.model) ?? []
168
+ speeds.push(step.tps)
169
+ held.set(step.model, speeds)
170
+ }
171
+ return [...held]
172
+ .map(([model, speeds]) => ({ model, steps: speeds.length, median: median(speeds), peak: Math.max(...speeds) }))
173
+ .sort((a, b) => b.steps - a.steps || a.model.localeCompare(b.model))
174
+ }
175
+
176
+ /** A bar is one step: 3 px of bar and the 1 px gap after it, so the chart is four pixels per step wide. */
177
+ export const BAR_PX = 3
178
+ export const STEP_PX = 4
179
+
180
+ /** The steps the window shows, as inclusive indices: the slot the left edge falls in and the one the right edge
181
+ * reaches. Nothing in view is `last < first`. */
182
+ export const visibleRange = (count: number, scrollLeft: number, viewport: number): { first: number; last: number } => {
183
+ if (count <= 0) return { first: 0, last: -1 }
184
+ const first = Math.max(0, Math.min(count - 1, Math.floor(scrollLeft / STEP_PX)))
185
+ const last = Math.min(count - 1, Math.max(first, Math.ceil((scrollLeft + viewport) / STEP_PX) - 1))
186
+ return { first, last }
187
+ }
188
+
189
+ /** Whether the window sits at its right end, within the native scroll's 1 px — and true when nothing overflows. */
190
+ export const atRightEnd = (scrollLeft: number, scrollWidth: number, clientWidth: number) =>
191
+ scrollWidth - clientWidth - scrollLeft <= 1
192
+
193
+ /** Whether the window follows the newest step after a scroll event: only a scroll the user made can move that
194
+ * latch. A clamp after the content shrank, a resize and our own settle all arrive as scroll events too, and
195
+ * every one of them lands at the right end — reading them as intent would drag the user forward on the next
196
+ * read, which is exactly what the latch exists to prevent. */
197
+ export const followAfter = (
198
+ current: boolean,
199
+ event: { user: boolean; scrollLeft: number; scrollWidth: number; clientWidth: number },
200
+ ): boolean =>
201
+ event.user ? atRightEnd(event.scrollLeft, event.scrollWidth, event.clientWidth) : current
202
+
203
+ export const summarise = (messages: readonly StepMessage[]): Summary => {
204
+ const history = messages.filter(closed)
205
+ const steps = history.map(stepOf).sort((a, b) => a.created - b.created)
206
+ const totals: Totals = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
207
+ for (const message of history) {
208
+ totals.input += number(message.tokens?.input)
209
+ totals.output += number(message.tokens?.output)
210
+ totals.reasoning += number(message.tokens?.reasoning)
211
+ totals.cacheRead += number(message.tokens?.cache?.read)
212
+ totals.cacheWrite += number(message.tokens?.cache?.write)
213
+ }
214
+ const spent = totals.input + totals.cacheRead + totals.cacheWrite
215
+ if (spent > 0) totals.cacheHit = totals.cacheRead / spent
216
+
217
+ const speeds = steps.flatMap((step) => (step.tps === undefined ? [] : [step.tps]))
218
+ const measured = steps.filter((step) => step.measured)
219
+ return {
220
+ steps,
221
+ totals,
222
+ measured: measured.length,
223
+ median: median(speeds),
224
+ peak: speeds.length ? Math.max(...speeds) : undefined,
225
+ // A session with few steps gets its peak back: 95% of ten values is still nearly all of them.
226
+ scale: scaleOf(speeds),
227
+ last: measured[measured.length - 1],
228
+ }
229
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
- "name": "@rizal72/oc-tps-meter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@rizal72/oc-tps-meter",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Tokens per second for every closed step of an OpenCode v2 session: a histogram in the desktop side panel, with the median, the peak, TTFT and the token totals",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rizal72/opencode-plugins.git"
9
+ },
10
+ "license": "MIT",
11
+ "files": [
12
+ "*.ts",
13
+ "*.tsx",
14
+ "README.md",
15
+ "dist",
16
+ "!*.test.ts"
17
+ ],
18
+ "keywords": [
19
+ "opencode",
20
+ "opencode-plugin",
21
+ "tps",
22
+ "tokens",
23
+ "throughput"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ "./gui-experimental": "./dist/gui-experimental.cjs"
30
+ }
31
+ }
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
+ })