From ea25304c8eb3219747f0b7015d626539009f7ce7 Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Thu, 10 Sep 2026 19:26:09 +0300
Subject: [PATCH 1/3] chore: drop the unused Renovate config and the
latest-tested beta claim
---
README.md | 2 +-
check-compatibility.mjs | 4 ---
renovate.jsonc | 56 -----------------------------------------
3 files changed, 1 insertion(+), 61 deletions(-)
delete mode 100644 renovate.jsonc
diff --git a/README.md b/README.md
index a45c19c..d4baca3 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ When OpenCode reports terminal usage, the token count becomes exact while TPS re
## Install
-Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`; the latest tested beta is `0.0.0-beta-19381`. The TUI plugin API is still moving, so a much newer or older build may drop the indicator without an error. If the figure never appears, check your version first.
+Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`. The TUI plugin API is still moving, so a much newer or older build may drop the indicator without an error. If the figure never appears, check your version first, then [open an issue](https://github.com/P-Theo/opencode2-tps/issues).
Install it with the CLI's plugin command, which adds the entry to `~/.config/opencode/cli.json` for you:
diff --git a/check-compatibility.mjs b/check-compatibility.mjs
index db89a09..b269feb 100644
--- a/check-compatibility.mjs
+++ b/check-compatibility.mjs
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs"
import { resolve } from "node:path"
const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"))
-const readme = readFileSync(new URL("./README.md", import.meta.url), "utf8")
const packages = ["@opencode/cli", "@opencode/plugin", "@opencode/theme"]
const versions = packages.map((name) => packageJson.devDependencies[name])
const version = versions[0]
@@ -14,9 +13,6 @@ if (!/^0\.0\.0-beta-\d{5,6}$/.test(version)) {
if (!versions.every((candidate) => candidate === version)) {
throw new Error(`OpenCode 2 packages must use one exact version: ${versions.join(", ")}`)
}
-if (!readme.includes(`latest tested beta is \`${version}\``)) {
- throw new Error(`README latest tested beta does not match ${version}`)
-}
const executable = resolve("node_modules", ".bin", process.platform === "win32" ? "opencode2.cmd" : "opencode2")
const reported = execFileSync(executable, ["--version"], { encoding: "utf8" }).trim()
diff --git a/renovate.jsonc b/renovate.jsonc
deleted file mode 100644
index 2ac7db5..0000000
--- a/renovate.jsonc
+++ /dev/null
@@ -1,56 +0,0 @@
-{
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["config:recommended"],
- "draftPR": true,
- "automerge": false,
- "enabledManagers": ["npm", "custom.regex"],
- "customManagers": [
- {
- "customType": "regex",
- "managerFilePatterns": ["/^README\\.md$/"],
- "matchStrings": ["latest tested beta is `(?0\\.0\\.0-beta-\\d+)`"],
- "depNameTemplate": "anomalyco/opencode-beta",
- "datasourceTemplate": "github-releases",
- "versioningTemplate": "npm",
- "extractVersionTemplate": "^v(?.*)$"
- }
- ],
- "packageRules": [
- {
- "description": "Do not open unrelated npm dependency PRs",
- "matchManagers": ["npm"],
- "enabled": false
- },
- {
- "description": "Update the installable OpenCode 2 compatibility packages from their beta tag",
- "matchManagers": ["npm"],
- "matchDepNames": ["@opencode/cli", "@opencode/plugin", "@opencode/theme"],
- "enabled": true,
- "followTag": "beta",
- "ignoreUnstable": false,
- "minimumReleaseAge": "1 hour",
- "groupName": "OpenCode 2 beta compatibility",
- "semanticCommitType": "chore",
- "semanticCommitScope": "compatibility",
- "assignees": ["P-Theo"],
- "prBodyNotes": [
- "### Manual compatibility gate\n\nCI verifies the API types, tests, build, package import, exact `opencode2` version, and corresponding `anomalyco/opencode-beta` GitHub Release. Before marking this draft ready and merging it, verify:\n\n- [ ] TPS appears while normal text streams\n- [ ] TPS remains active during reasoning and tool-input streaming\n- [ ] The final average freezes after completion\n- [ ] A new prompt resets the figure\n- [ ] The orchestrator and sub-agent views retain their documented behavior"
- ]
- },
- {
- "description": "Keep every OpenCode 2 compatibility target in one manually verified PR",
- "matchDatasources": ["github-releases"],
- "matchPackageNames": ["anomalyco/opencode-beta"],
- "allowedVersions": "/^0\\.0\\.0-beta-\\d{5,6}$/",
- "ignoreUnstable": false,
- "minimumReleaseAge": "1 hour",
- "groupName": "OpenCode 2 beta compatibility",
- "semanticCommitType": "chore",
- "semanticCommitScope": "compatibility",
- "assignees": ["P-Theo"],
- "prBodyNotes": [
- "### Manual compatibility gate\n\nCI verifies the API types, tests, build, package import, exact `opencode2` version, and corresponding `anomalyco/opencode-beta` GitHub Release. Before marking this draft ready and merging it, verify:\n\n- [ ] TPS appears while normal text streams\n- [ ] TPS remains active during reasoning and tool-input streaming\n- [ ] The final average freezes after completion\n- [ ] A new prompt resets the figure\n- [ ] The orchestrator and sub-agent views retain their documented behavior"
- ]
- }
- ]
-}
From 21867f302a9ab6d4b58f8f6acef5f8889860754a Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Thu, 10 Sep 2026 19:26:09 +0300
Subject: [PATCH 2/3] test: render the built entrypoint in bun test
---
bunfig.toml | 5 +
docs/development.md | 27 ++--
entrypoint.test.tsx | 308 ++++++++++++++++++++++++++++++++++++++++++++
tsconfig.json | 2 +-
4 files changed, 330 insertions(+), 12 deletions(-)
create mode 100644 bunfig.toml
create mode 100644 entrypoint.test.tsx
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..6ee39d0
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,5 @@
+# Test files render through @opentui/solid. Without this preload, Bun resolves
+# solid-js to its server build (no reactivity), so render tests could not detect
+# a broken reactive update. The preload swaps in the client build.
+[test]
+preload = ["@opentui/solid/preload"]
diff --git a/docs/development.md b/docs/development.md
index ec3fa0e..84f7871 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -10,6 +10,8 @@ npm test # bun test
npm run build # write dist/tui.js
```
+Tests run through `bun test`. `bunfig.toml` preloads `@opentui/solid/preload` so tests use Solid's client build and can observe reactive updates. `tps.test.ts` covers the tracker and the event wiring, and `entrypoint.test.tsx` runs `build.mjs`, imports `dist/tui.js`, and renders the composer claim with `testRender` asserting the label appears while streaming, settles and freezes, resets for a new prompt, and stays per-session.
+
## Run from source
Point a path entry in `cli.json` at this repository's directory. The loader resolves `/tui.tsx`, which re-exports the plugin definition from `tps.tsx`, transforms the source, and watches it — saving `tps.tsx` reloads the plugin without a restart.
@@ -17,7 +19,10 @@ Point a path entry in `cli.json` at this repository's directory. The loader reso
```json
{
"plugins": [
- { "package": "/absolute/path/to/opencode2-tps", "options": { "debug": true } }
+ {
+ "package": "/absolute/path/to/opencode2-tps",
+ "options": { "debug": true }
+ }
]
}
```
@@ -63,16 +68,16 @@ For the event names and the formulas, read `tps.tsx`.
One user prompt becomes a stream of events; the tracker does the bookkeeping below for each step.
-| What happens | Event | Tracker action |
-| -------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
-| A new user prompt starts a run | `session.execution.started` | reset settled tokens, observed duration, and partial state |
-| The model begins a step of generation | `session.step.started` (m1) | record the step timestamp and assistant-message ID |
-| An output block begins | `session.*.started` (m1) | create an idempotent text, reasoning, or tool-input block |
-| Observable output streams | `session.*.delta` (m1) | add UTF-8 bytes and a rolling-rate sample |
-| The complete block becomes available | `session.*.ended` (m1) | reconcile its full byte count and record the model-content boundary |
-| The provider stream exits | `session.step.streamed` (m1) | record the authoritative span end, before local tools join |
-| The model step settles, possibly after a tool runs | `session.step.ended` / `failed` (m1) | replace the estimate with reported usage when available; add duration through the streamed boundary |
-| The whole execution finishes | `session.execution.succeeded` / `failed` / `idle` | freeze exact settled tokens plus any explicitly estimated partial output |
+| What happens | Event | Tracker action |
+| -------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| A new user prompt starts a run | `session.execution.started` | reset settled tokens, observed duration, and partial state |
+| The model begins a step of generation | `session.step.started` (m1) | record the step timestamp and assistant-message ID |
+| An output block begins | `session.*.started` (m1) | create an idempotent text, reasoning, or tool-input block |
+| Observable output streams | `session.*.delta` (m1) | add UTF-8 bytes and a rolling-rate sample |
+| The complete block becomes available | `session.*.ended` (m1) | reconcile its full byte count and record the model-content boundary |
+| The provider stream exits | `session.step.streamed` (m1) | record the authoritative span end, before local tools join |
+| The model step settles, possibly after a tool runs | `session.step.ended` / `failed` (m1) | replace the estimate with reported usage when available; add duration through the streamed boundary |
+| The whole execution finishes | `session.execution.succeeded` / `failed` / `idle` | freeze exact settled tokens plus any explicitly estimated partial output |
Current betas no longer publish `session.tool.input.delta`; tool arguments arrive only as the complete `session.tool.input.ended` text. Older betas streamed both, and the plugin still subscribes to the delta event for them. Ended-value reconciliation supports either without double-counting.
diff --git a/entrypoint.test.tsx b/entrypoint.test.tsx
new file mode 100644
index 0000000..730d955
--- /dev/null
+++ b/entrypoint.test.tsx
@@ -0,0 +1,308 @@
+// The published package loads the precompiled dist/tui.js through its `./tui`
+// export, not tps.tsx. Every other test imports the source, so this suite builds
+// the bundle with the production script, imports it, and renders the composer
+// claim with testRender: the transformed JSX mounts, the precompiled memo still
+// tracks the plugin's refresh signal, and the shipped label appears, settles and
+// freezes, resets for a new run, and stays per-session.
+
+import { beforeAll, describe, expect, test } from "bun:test"
+import { spawnSync } from "node:child_process"
+import { fileURLToPath } from "node:url"
+import { testRender } from "@opentui/solid"
+import type { JSX } from "@opentui/solid"
+import type { Plugin } from "@opencode/plugin/tui"
+import type { TpsOptionsInput } from "./tps.tsx"
+
+const root = fileURLToPath(new URL(".", import.meta.url))
+const distEntry = new URL("./dist/tui.js", import.meta.url).href
+
+// 95 bytes / 4.75 bytes-per-token = 20 estimated tokens.
+const DELTA = "a".repeat(95)
+
+let plugin: Plugin.Definition
+
+beforeAll(async () => {
+ // Spawn the real build so every run tests a fresh dist/tui.js, even from a
+ // clean checkout and across watch-mode reruns.
+ const build = spawnSync(process.execPath, ["build.mjs"], { cwd: root, encoding: "utf8" })
+ if (build.status !== 0) throw new Error(`build.mjs failed:\n${build.stderr || build.stdout}`)
+ plugin = (await import(distEntry)).default
+})
+
+interface FakeEvent {
+ readonly id: string
+ readonly type: string
+ readonly created: number
+ readonly data: {
+ readonly sessionID?: string
+ readonly assistantMessageID?: string
+ readonly id?: string
+ readonly delta?: string
+ readonly ordinal?: number
+ readonly text?: string
+ readonly tokens?: { readonly output: number; readonly reasoning: number }
+ }
+}
+
+interface Generation {
+ active: number
+}
+
+interface Claim {
+ readonly append: "session.composer.top"
+ readonly render: (input: { readonly sessionID: string }) => JSX.Element
+}
+
+interface FakeContext {
+ readonly options: TpsOptionsInput
+ readonly app: { readonly version: string }
+ readonly theme: { readonly text: { readonly subdued: string } }
+ readonly storage: {
+ readonly memory: (
+ key: string,
+ options: { readonly initial: Generation },
+ ) => readonly [Generation, (mutation: (draft: Generation) => void) => void]
+ }
+ readonly data: {
+ readonly on: (type: string, handler: (event: FakeEvent) => void) => () => void
+ }
+ readonly ui: {
+ readonly slot: (claim: Claim) => () => void
+ }
+}
+
+type FakeSetup = (context: FakeContext) => (() => void) | void
+
+function start(context: FakeContext): (() => void) | void {
+ // SAFETY: FakeContext covers every context member the compiled setup touches;
+ // TypeScript cannot express a partial implementation of a foreign interface,
+ // so the callable is narrowed through unknown — a test-double limitation, not
+ // a production cast.
+ // oxlint-disable-next-line anti-slop/no-chained-type-assertions
+ const setup = plugin.setup as unknown as FakeSetup
+ return setup(context)
+}
+
+interface Harness {
+ readonly claims: Claim[]
+ emit: (type: string, data: FakeEvent["data"], created: number) => void
+ tick: () => void
+ cleanup: () => void
+ restore: () => void
+}
+
+function createHarness(options: TpsOptionsInput = {}): Harness {
+ const claims: Claim[] = []
+ const handlers = new Map void)[]>()
+ const generation: Generation = { active: 0 }
+ let flush: (() => void) | undefined
+ let eventID = 0
+
+ const realSetInterval = globalThis.setInterval
+ const realClearInterval = globalThis.clearInterval
+ // Rendering is throttled by the plugin's own interval; capture the callback
+ // so the test can flush on demand instead of waiting on wall-clock time.
+ globalThis.setInterval = (callback: () => void, _ms?: number) => {
+ flush = callback
+ // A real (immediately cancelled) handle keeps the host's return type honest
+ // without leaving a live interval behind.
+ const handle = realSetInterval(() => {}, 60_000)
+ realClearInterval(handle)
+ return handle
+ }
+ globalThis.clearInterval = () => {
+ flush = undefined
+ }
+
+ const context: FakeContext = {
+ options,
+ app: { version: "test" },
+ theme: { text: { subdued: "#888888" } },
+ storage: {
+ memory: () => [generation, (mutation: (draft: Generation) => void) => mutation(generation)] as const,
+ },
+ data: {
+ on: (type: string, handler: (event: FakeEvent) => void) => {
+ const list = handlers.get(type) ?? []
+ list.push(handler)
+ handlers.set(type, list)
+ return () => handlers.delete(type)
+ },
+ },
+ ui: {
+ slot: (claim: Claim) => {
+ claims.push(claim)
+ return () => {}
+ },
+ },
+ }
+
+ // `setup` is declared as possibly async and possibly cleanup-less; ours is
+ // neither, and the render assertions fail loudly if that ever changes.
+ const started = start(context)
+ const cleanup = started instanceof Function ? started : () => {}
+
+ return {
+ claims,
+ emit: (type, data, created) => {
+ for (const handler of handlers.get(type) ?? []) handler({ id: `evt_${eventID++}`, type, created, data })
+ },
+ tick: () => flush?.(),
+ cleanup,
+ restore: () => {
+ globalThis.setInterval = realSetInterval
+ globalThis.clearInterval = realClearInterval
+ },
+ }
+}
+
+async function openApp(harness: Harness, sessionID: string) {
+ return testRender(() => harness.claims[0]!.render({ sessionID }), { width: 60, height: 4 })
+}
+
+describe("built entrypoint", () => {
+ test("renders the live label through text, reasoning, and tool-input streaming", async () => {
+ const h = createHarness()
+ const app = await openApp(h, "ses_test")
+ try {
+ await app.renderOnce()
+ expect(app.captureCharFrame()).not.toContain("tok")
+
+ const now = Date.now()
+ h.emit("session.execution.started", { sessionID: "ses_test" }, now)
+ h.emit("session.step.started", { sessionID: "ses_test", assistantMessageID: "m1" }, now)
+ h.emit("session.text.delta", { sessionID: "ses_test", assistantMessageID: "m1", ordinal: 0, delta: DELTA }, now)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("~20 tok")
+ expect(app.captureCharFrame()).toContain("t/s")
+
+ h.emit("session.reasoning.delta", { sessionID: "ses_test", assistantMessageID: "m1", ordinal: 0, delta: DELTA }, now)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("~40 tok")
+
+ h.emit("session.tool.input.started", { sessionID: "ses_test", assistantMessageID: "m1", id: "t1" }, now)
+ h.emit("session.tool.input.delta", { sessionID: "ses_test", assistantMessageID: "m1", id: "t1", delta: DELTA }, now)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("~60 tok")
+ } finally {
+ app.renderer.destroy()
+ h.cleanup()
+ h.restore()
+ }
+ })
+
+ test("settles exactly and freezes after completion", async () => {
+ const h = createHarness()
+ const app = await openApp(h, "ses_test")
+ try {
+ const t0 = Date.now()
+ h.emit("session.execution.started", { sessionID: "ses_test" }, t0)
+ h.emit("session.step.started", { sessionID: "ses_test", assistantMessageID: "m1" }, t0)
+ h.emit("session.text.delta", { sessionID: "ses_test", assistantMessageID: "m1", ordinal: 0, delta: DELTA }, t0)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("~20 tok")
+
+ h.emit("session.step.streamed", { sessionID: "ses_test", assistantMessageID: "m1" }, t0 + 1000)
+ h.emit(
+ "session.step.ended",
+ { sessionID: "ses_test", assistantMessageID: "m1", tokens: { output: 42, reasoning: 8 } },
+ t0 + 1050,
+ )
+ h.emit("session.execution.succeeded", { sessionID: "ses_test" }, t0 + 1100)
+ h.tick()
+ await app.renderOnce()
+ const frozen = app.captureCharFrame()
+ expect(frozen).toContain("50 tok")
+ expect(frozen).toContain("50.0 t/s")
+ expect(frozen).not.toContain("~50 tok")
+
+ // A late delta after completion must not move the frozen figure.
+ h.emit("session.text.delta", { sessionID: "ses_test", assistantMessageID: "m1", ordinal: 0, delta: "late" }, t0 + 2000)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toBe(frozen)
+ } finally {
+ app.renderer.destroy()
+ h.cleanup()
+ h.restore()
+ }
+ })
+
+ test("resets the figure when a new prompt starts", async () => {
+ const h = createHarness()
+ const app = await openApp(h, "ses_test")
+ try {
+ const t0 = Date.now()
+ h.emit("session.execution.started", { sessionID: "ses_test" }, t0)
+ h.emit("session.step.started", { sessionID: "ses_test", assistantMessageID: "m1" }, t0)
+ h.emit("session.text.delta", { sessionID: "ses_test", assistantMessageID: "m1", ordinal: 0, delta: DELTA }, t0)
+ h.emit("session.step.streamed", { sessionID: "ses_test", assistantMessageID: "m1" }, t0 + 1000)
+ h.emit(
+ "session.step.ended",
+ { sessionID: "ses_test", assistantMessageID: "m1", tokens: { output: 42, reasoning: 8 } },
+ t0 + 1050,
+ )
+ h.emit("session.execution.succeeded", { sessionID: "ses_test" }, t0 + 1100)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("50 tok")
+
+ const t1 = t0 + 5000
+ h.emit("session.execution.started", { sessionID: "ses_test" }, t1)
+ h.emit("session.step.started", { sessionID: "ses_test", assistantMessageID: "m2" }, t1)
+ h.emit("session.text.delta", { sessionID: "ses_test", assistantMessageID: "m2", ordinal: 0, delta: DELTA }, t1)
+ h.tick()
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("~20 tok")
+ expect(app.captureCharFrame()).not.toContain("50 tok")
+ } finally {
+ app.renderer.destroy()
+ h.cleanup()
+ h.restore()
+ }
+ })
+
+ test("keeps orchestrator and sub-agent sessions independent", async () => {
+ const h = createHarness()
+ const app = await testRender(
+ () => (
+
+ {h.claims[0]!.render({ sessionID: "ses_main" })}
+ {h.claims[0]!.render({ sessionID: "ses_sub" })}
+
+ ),
+ { width: 60, height: 8 },
+ )
+ try {
+ const now = Date.now()
+ h.emit("session.execution.started", { sessionID: "ses_sub" }, now)
+ h.emit("session.step.started", { sessionID: "ses_sub", assistantMessageID: "m1" }, now)
+ h.emit("session.text.delta", { sessionID: "ses_sub", assistantMessageID: "m1", ordinal: 0, delta: DELTA }, now)
+ h.tick()
+ await app.renderOnce()
+ let frame = app.captureCharFrame()
+ expect(frame).toContain("~20 tok")
+ expect(frame.match(/tok/g)?.length).toBe(1)
+
+ h.emit("session.step.started", { sessionID: "ses_main", assistantMessageID: "m2" }, now)
+ h.emit(
+ "session.text.delta",
+ { sessionID: "ses_main", assistantMessageID: "m2", ordinal: 0, delta: DELTA + DELTA },
+ now,
+ )
+ h.tick()
+ await app.renderOnce()
+ frame = app.captureCharFrame()
+ expect(frame).toContain("~20 tok")
+ expect(frame).toContain("~40 tok")
+ } finally {
+ app.renderer.destroy()
+ h.cleanup()
+ h.restore()
+ }
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index acc865f..e8a9084 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,5 +13,5 @@
"noUncheckedIndexedAccess": true,
"types": ["node", "bun"]
},
- "include": ["tps.tsx", "tui.tsx", "tps.test.ts"]
+ "include": ["tps.tsx", "tui.tsx", "tps.test.ts", "entrypoint.test.tsx"]
}
From 6c2d52c1f8aa24733dc0f89d4e1273d8f26b641c Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Thu, 10 Sep 2026 19:36:41 +0300
Subject: [PATCH 3/3] test: build the entrypoint artifact with node
---
entrypoint.test.tsx | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/entrypoint.test.tsx b/entrypoint.test.tsx
index 730d955..6d56669 100644
--- a/entrypoint.test.tsx
+++ b/entrypoint.test.tsx
@@ -22,10 +22,11 @@ const DELTA = "a".repeat(95)
let plugin: Plugin.Definition
beforeAll(async () => {
- // Spawn the real build so every run tests a fresh dist/tui.js, even from a
- // clean checkout and across watch-mode reruns.
- const build = spawnSync(process.execPath, ["build.mjs"], { cwd: root, encoding: "utf8" })
- if (build.status !== 0) throw new Error(`build.mjs failed:\n${build.stderr || build.stdout}`)
+ // Spawn the production build with Node — the interpreter `npm run build` uses
+ // — so every run tests a fresh dist/tui.js built exactly as the package
+ // ships, even from a clean checkout and across watch-mode reruns.
+ const build = spawnSync("node", ["build.mjs"], { cwd: root, encoding: "utf8" })
+ if (build.status !== 0) throw new Error(`node build.mjs failed:\n${build.stderr || build.stdout}`)
plugin = (await import(distEntry)).default
})