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/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/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/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..6d56669 --- /dev/null +++ b/entrypoint.test.tsx @@ -0,0 +1,309 @@ +// 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 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 +}) + +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/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" - ] - } - ] -} 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"] }