From b3652b27d06fd2001d109727c0bf164e117d0545 Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Wed, 16 Sep 2026 13:33:18 +0300
Subject: [PATCH 1/2] refactor: adopt src/tests/scripts layout and ship
options.schema.json
---
.github/workflows/ci.yml | 11 +-
build.mjs | 50 --
docs/configuration.md | 4 +
docs/development.md | 16 +-
docs/release.md | 4 +-
options.schema.json | 46 +
package.json | 10 +-
scripts/build.mjs | 68 ++
.../check-compatibility.mjs | 19 +-
src/debug.ts | 88 ++
src/options.ts | 80 ++
src/plugin.tsx | 249 ++++++
src/tracker.ts | 382 +++++++++
tests/debug.test.ts | 11 +
.../entrypoint.test.tsx | 12 +-
tests/options-schema.test.ts | 62 ++
tests/options.test.ts | 63 ++
tests/plugin.test.ts | 296 +++++++
tps.test.ts => tests/tracker.test.ts | 362 +-------
tps.tsx | 786 ------------------
tsconfig.json | 3 +-
tui.tsx | 5 +-
22 files changed, 1399 insertions(+), 1228 deletions(-)
delete mode 100644 build.mjs
create mode 100644 options.schema.json
create mode 100644 scripts/build.mjs
rename check-compatibility.mjs => scripts/check-compatibility.mjs (51%)
create mode 100644 src/debug.ts
create mode 100644 src/options.ts
create mode 100644 src/plugin.tsx
create mode 100644 src/tracker.ts
create mode 100644 tests/debug.test.ts
rename entrypoint.test.tsx => tests/entrypoint.test.tsx (95%)
create mode 100644 tests/options-schema.test.ts
create mode 100644 tests/options.test.ts
create mode 100644 tests/plugin.test.ts
rename tps.test.ts => tests/tracker.test.ts (58%)
delete mode 100644 tps.tsx
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c81661b..cc62b0b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
node-version: 24
cache: npm
- # tsc, oxlint and build.mjs run on node; the test script is `bun test`.
+ # tsc, oxlint and scripts/build.mjs run on node; the test script is `bun test`.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.12
@@ -33,13 +33,6 @@ jobs:
- run: npm run check:compatibility
- - name: Verify the official OpenCode 2 beta release
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- version=$(node -p 'require("./package.json").devDependencies["@opencode/cli"]')
- gh api "repos/anomalyco/opencode-beta/releases/tags/v${version}" --silent
-
- run: npm run lint
- run: npm run check
- run: npm test
@@ -54,4 +47,4 @@ jobs:
opentui=$(node -p 'require("./node_modules/@opentui/solid/package.json").version')
solid=$(node -p 'require("./node_modules/solid-js/package.json").version')
npm install --ignore-scripts --prefix "$RUNNER_TEMP/tps-install" "$RUNNER_TEMP"/tps-pack/opencode2-tps-*.tgz "@opentui/solid@${opentui}" "solid-js@${solid}"
- node --input-type=module -e 'await import(process.argv[1])' "file://$RUNNER_TEMP/tps-install/node_modules/opencode2-tps/dist/tui.js"
+ node --input-type=module -e 'const m = await import(process.argv[1]); if (m.default?.id !== "opencode2.tps") { console.error("unexpected entrypoint export:", m.default); process.exit(1) }' "file://$RUNNER_TEMP/tps-install/node_modules/opencode2-tps/dist/tui.js"
diff --git a/build.mjs b/build.mjs
deleted file mode 100644
index 4cbb734..0000000
--- a/build.mjs
+++ /dev/null
@@ -1,50 +0,0 @@
-// Precompiles tps.tsx into the published entrypoint.
-//
-// The host only applies its Solid/Babel transform to files *outside*
-// node_modules (filter: /^(?!.*[/\\]node_modules[/\\]).*\.[cm]?[jt]sx$/ in
-// @opentui/solid's bun plugin), and an installed package always lives inside
-// node_modules. Untransformed JSX would still load — @opentui/solid ships a
-// runtime jsx-runtime, and the host rewires runtime imports for node_modules
-// ESM — but props and children would be evaluated once, so the indicator would
-// render a single frozen value. Hence: transform here, ship JS.
-//
-// The preset options mirror @opentui/solid/scripts/solid-transform.js so the
-// published output is what a locally-loaded source file would have become.
-// Imports stay bare (`@opentui/solid`, `solid-js`); the host rewrites them to
-// its own runtime copies, which is what keeps the plugin on the same reactive
-// graph and renderer as the TUI.
-
-import { transformAsync } from "@babel/core"
-import ts from "@babel/preset-typescript"
-import solid from "babel-preset-solid"
-import { mkdir, readFile, writeFile } from "node:fs/promises"
-import { dirname, join } from "node:path"
-import { fileURLToPath } from "node:url"
-
-const root = dirname(fileURLToPath(import.meta.url))
-
-const source = join(root, "tps.tsx")
-
-const outDir = join(root, "dist")
-
-const out = join(outDir, "tui.js")
-
-const code = await readFile(source, "utf8")
-
-const result = await transformAsync(code, {
- filename: source,
- configFile: false,
- babelrc: false,
- // Presets apply in reverse order: TypeScript first, then Solid's JSX transform.
- presets: [[solid, { moduleName: "@opentui/solid", generate: "universal" }], [ts]],
-})
-
-if (!result?.code) throw new Error("babel produced no output")
-
-const output = `${result.code}\n`
-
-await mkdir(outDir, { recursive: true })
-
-await writeFile(out, output, "utf8")
-
-console.log(`built ${out} (${Buffer.byteLength(output)} bytes)`)
diff --git a/docs/configuration.md b/docs/configuration.md
index 2ee2162..ae24d9d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -15,6 +15,10 @@ One extra rule:
- `debug` only accepts `true`. Anything else leaves logging off.
+## Options schema
+
+[`options.schema.json`](../options.schema.json) publishes the same enumerations, defaults, bounds and descriptions this page documents. It ships in the package and is exported at `opencode2-tps/options.schema.json`, so editors and agents can validate options without the repository. Unknown keys are rejected by the schema on purpose, because the runtime ignores them silently.
+
## Example
```json
diff --git a/docs/development.md b/docs/development.md
index 84f7871..5933bf7 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -7,14 +7,16 @@ npm ci # install dependencies
npm run lint # oxlint
npm run check # tsc --noEmit
npm test # bun test
-npm run build # write dist/tui.js
+npm run build # write dist/tui.js and its sibling modules
```
-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.
+Tests run through `bun test`. `bunfig.toml` preloads `@opentui/solid/preload` so tests use Solid's client build and can observe reactive updates. `tests/tracker.test.ts` covers the throughput tracker, `tests/options.test.ts` the option parsing and label formatting, `tests/debug.test.ts` the debug switch, `tests/plugin.test.ts` the event wiring through a fake context and a patched `setInterval`, `tests/options-schema.test.ts` pins `options.schema.json` and the docs to the same constants the parser uses, and `tests/entrypoint.test.tsx` runs `scripts/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.
+
+`scripts/*.mjs` run under plain node and stay outside the `tsconfig.json` typecheck — they are exercised by CI and the entrypoint test instead.
## 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.
+Point a path entry in `cli.json` at this repository's directory. The loader resolves `/tui.tsx`, which re-exports the plugin definition from `src/plugin.tsx`, transforms the source, and watches it — saving a file under `src/` reloads the plugin without a restart.
```json
{
@@ -27,7 +29,7 @@ Point a path entry in `cli.json` at this repository's directory. The loader reso
}
```
-The entry must be a directory containing a `tui.tsx` entry file. Current betas skip entries that point at a file, so pointing at `tps.tsx` or `dist/tui.js` directly loads nothing.
+The entry must be a directory containing a `tui.tsx` entry file. Current betas skip entries that point at a file, so pointing at `src/plugin.tsx` or `dist/tui.js` directly loads nothing.
`package` takes an absolute path, a `file://` URL, or a relative path that starts with `./` or `../` and resolves against the directory holding `cli.json`. Anything else is read as a package name.
@@ -35,7 +37,7 @@ The host also picks up plugins from a `plugin` or `plugins` directory in the con
## Build
-The host only applies the Solid transform outside `node_modules`, and an installed package lives inside it, so `build.mjs` runs the transform ahead of time and writes `dist/tui.js`. See `build.mjs` and the `exports` and `files` fields in `package.json`. The tarball ships only `dist`, so `tui.tsx` never reaches the package — it exists only for path entries.
+The host only applies the Solid transform outside `node_modules`, and an installed package lives inside it, so `scripts/build.mjs` runs the transform ahead of time and writes `dist/tui.js` plus its sibling modules (`dist/tracker.js`, `dist/options.js`, `dist/debug.js`), which the entrypoint imports relatively. See `scripts/build.mjs` and the `exports` and `files` fields in `package.json`. The tarball ships only `dist` and `options.schema.json`, so `tui.tsx` never reaches the package — it exists only for path entries.
`solid-js` and `@opentui/solid` are optional peer dependencies; the host supplies its own copies.
@@ -59,10 +61,10 @@ That means one directory and one log per PID. Hot reloads append to the same fil
- Settled TPS sums exact step tokens and divides once by the sum of observed step spans. Each span runs from `session.step.started` to `session.step.streamed`, the host's authoritative end of the model stream, published after the provider stream exits and before local tools join. Hosts that do not publish `session.step.streamed` fall back to the final `session.text.ended`, `session.reasoning.ended`, or `session.tool.input.ended` boundary. Delayed step settlement, local tool execution, and time between model steps are excluded.
- TPS remains approximate because the host does not expose token-level provider timestamps. Encrypted content, signatures, and other opaque provider state are never byte-counted.
- A single timer draws the label, and it stops after the live stale tail or when a step settles.
-- A finished run keeps its state until the next run replaces it, and the number of tracked sessions is bounded. See `MAX_TRACKED_RUNS` in `tps.tsx`.
+- A finished run keeps its state until the next run replaces it, and the number of tracked sessions is bounded. See `MAX_TRACKED_RUNS` in `src/tracker.ts`.
- A generation guard makes sure only the newest generation of the plugin counts tokens and renders.
-For the event names and the formulas, read `tps.tsx`.
+For the event names and the formulas, read `src/`.
## Example run
diff --git a/docs/release.md b/docs/release.md
index dce2ff2..89ae06c 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -4,6 +4,8 @@ Maintainer runbook.
A path entry loads the `tui.tsx` source through the host's transform; an installed package loads the pre-built `dist/tui.js` through the `exports` subpath. Those are two different code paths, so the bundle gets tested before it goes out.
+The pinned `@opencode/*` beta is the build and test baseline. Move it in the same pull request as the fix or feature that needs it; CI validates the pin format, the README compatibility floor, and that the installed CLI reports the pinned version.
+
A docs-only release skips steps 1 to 5. The bundle is unchanged, and CI already packs and imports the artifact on every push to `main`. Go straight to step 6. npmjs.com only refreshes the rendered README when a new version is published, so a README change that matters on the package page needs a patch release to reach it.
## 1. Build the tarball
@@ -14,7 +16,7 @@ A docs-only release skips steps 1 to 5. The bundle is unchanged, and CI already
npm pack --pack-destination /tmp
```
-The tarball holds `dist/tui.js`, `package.json`, `README.md` and `LICENSE`. The host entry point is `exports["./tui"]`.
+The tarball holds `dist/`, `options.schema.json`, `package.json`, `README.md` and `LICENSE`. The host entry point is `exports["./tui"]`.
## 2. Install the tarball into the host's cache
diff --git a/options.schema.json b/options.schema.json
new file mode 100644
index 0000000..33d6f26
--- /dev/null
+++ b/options.schema.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/P-Theo/opencode2-tps/main/options.schema.json",
+ "title": "opencode2-tps plugin options",
+ "description": "Options for the opencode2-tps OpenCode 2 TUI plugin, as they appear in the plugin entry's `options` object in `~/.config/opencode/cli.json` (or `$XDG_CONFIG_HOME/opencode/cli.json`). The runtime parser is tolerant — an unusable value falls back to the documented default and an out-of-range number is clamped — but agents should write canonical values and validate against this schema. Unknown keys are rejected here on purpose: the runtime ignores them silently, so a typo in an option is otherwise invisible.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "display": {
+ "enum": ["both", "tokens", "tps"],
+ "default": "both",
+ "description": "Which parts of the label to show."
+ },
+ "refreshHz": {
+ "type": "number",
+ "minimum": 1,
+ "maximum": 60,
+ "default": 8,
+ "description": "How often the label updates while a session streams, clamped to 1-60."
+ },
+ "bytesPerToken": {
+ "type": "number",
+ "minimum": 1,
+ "maximum": 16,
+ "default": 4.75,
+ "description": "Bytes per token used for live and partial-output estimates, clamped to 1-16. Completed steps use OpenCode's reported token usage instead."
+ },
+ "debug": {
+ "type": "boolean",
+ "default": false,
+ "description": "Writes a debug log. Only `true` enables it."
+ }
+ },
+ "examples": [
+ {
+ "display": "tps",
+ "refreshHz": 12
+ },
+ {
+ "display": "both",
+ "refreshHz": 8,
+ "bytesPerToken": 4.75,
+ "debug": false
+ }
+ ]
+}
diff --git a/package.json b/package.json
index bd3543f..41a862d 100644
--- a/package.json
+++ b/package.json
@@ -21,10 +21,12 @@
"throughput"
],
"exports": {
- "./tui": "./dist/tui.js"
+ "./tui": "./dist/tui.js",
+ "./options.schema.json": "./options.schema.json"
},
"files": [
- "dist"
+ "dist",
+ "options.schema.json"
],
"peerDependencies": {
"@opentui/solid": ">=0.5.4",
@@ -60,9 +62,9 @@
"typescript": "^5.9.0"
},
"scripts": {
- "build": "node build.mjs",
+ "build": "node scripts/build.mjs",
"check": "tsc --noEmit",
- "check:compatibility": "node check-compatibility.mjs",
+ "check:compatibility": "node scripts/check-compatibility.mjs",
"lint": "oxlint",
"test": "bun test",
"prepack": "npm run lint && npm run check && npm test && npm run build"
diff --git a/scripts/build.mjs b/scripts/build.mjs
new file mode 100644
index 0000000..b70c44a
--- /dev/null
+++ b/scripts/build.mjs
@@ -0,0 +1,68 @@
+// Precompiles the runtime modules in src/ into the published entrypoints.
+//
+// The host only applies its Solid/Babel transform to files *outside*
+// node_modules (filter: /^(?!.*[/\\]node_modules[/\\]).*\.[cm]?[jt]sx$/ in
+// @opentui/solid's bun plugin), and an installed package always lives inside
+// node_modules. Untransformed JSX would still load — @opentui/solid ships a
+// runtime jsx-runtime, and the host rewires runtime imports for node_modules
+// ESM — but props and children would be evaluated once, so the indicator would
+// render a single frozen value. Hence: transform here, ship JS.
+//
+// The preset options mirror @opentui/solid/scripts/solid-transform.js so the
+// published output is what a locally-loaded source file would have become.
+// Imports stay bare (`@opentui/solid`, `solid-js`); the host rewrites them to
+// its own runtime copies, which is what keeps the plugin on the same reactive
+// graph and renderer as the TUI. Relative imports between the modules stay
+// relative (`./tracker.js`), so every compiled file ships and dist/ resolves
+// the same graph the source has.
+
+import { transformAsync } from "@babel/core"
+import ts from "@babel/preset-typescript"
+import solid from "babel-preset-solid"
+import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
+import { dirname, join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..")
+
+const src = join(root, "src")
+
+const targets = []
+
+for (const source of (await readdir(src)).sort()) {
+ if (!/\.tsx?$/.test(source) || /\.(test|d)\.tsx?$/.test(source)) continue
+ targets.push({
+ source,
+ out: `dist/${source === "plugin.tsx" ? "tui.js" : source.replace(/\.tsx?$/, ".js")}`,
+ // Presets apply in reverse order: TypeScript first, then Solid's JSX transform.
+ presets: source.endsWith(".tsx")
+ ? [[solid, { moduleName: "@opentui/solid", generate: "universal" }], [ts]]
+ : [[ts]],
+ })
+}
+
+if (targets.length === 0) throw new Error("no source modules found in src/")
+
+// dist is generated as a unit; removed source modules must not remain in tarballs.
+await rm(join(root, "dist"), { recursive: true, force: true })
+
+for (const target of targets) {
+ const source = join(src, target.source)
+ const out = join(root, target.out)
+ const code = await readFile(source, "utf8")
+
+ const result = await transformAsync(code, {
+ filename: source,
+ configFile: false,
+ babelrc: false,
+ presets: target.presets,
+ })
+
+ if (!result?.code) throw new Error(`babel produced no output for ${target.source}`)
+
+ const output = `${result.code}\n`
+
+ await mkdir(dirname(out), { recursive: true })
+ await writeFile(out, output, "utf8")
+ console.log(`built ${out} (${Buffer.byteLength(output)} bytes)`)
+}
diff --git a/check-compatibility.mjs b/scripts/check-compatibility.mjs
similarity index 51%
rename from check-compatibility.mjs
rename to scripts/check-compatibility.mjs
index 9c126ed..97d7fa3 100644
--- a/check-compatibility.mjs
+++ b/scripts/check-compatibility.mjs
@@ -1,8 +1,13 @@
+// Verifies the OpenCode 2 compatibility pin in package.json and the floor
+// stated in the README. The pinned beta is the build and test baseline: move
+// it in the same pull request as the fix or feature that needs it.
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
-const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"))
+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"]
@@ -18,6 +23,16 @@ if (!versions.every((candidate) => candidate === version)) {
throw new Error(`OpenCode 2 packages must use one exact version: ${versions.join(", ")}`)
}
+const floor = /earliest known compatible beta is\s+`(0\.0\.0-beta-\d{5,6})`/i.exec(readme)?.[1]
+
+if (floor === undefined) {
+ throw new Error("README does not state the earliest known compatible beta")
+}
+
+if (Number(floor.replace("0.0.0-beta-", "")) > Number(version.replace("0.0.0-beta-", ""))) {
+ throw new Error(`README floor ${floor} is newer than the pinned compatibility target ${version}`)
+}
+
const executable = resolve("node_modules", ".bin", process.platform === "win32" ? "opencode2.cmd" : "opencode2")
const reported = execFileSync(executable, ["--version"], { encoding: "utf8" }).trim()
@@ -26,4 +41,4 @@ if (reported !== `opencode2 v${version}`) {
throw new Error(`Expected opencode2 v${version}, got ${reported}`)
}
-console.log(`OpenCode 2 compatibility target: ${version}`)
+console.log(`OpenCode 2 compatibility target: ${version} (floor ${floor})`)
diff --git a/src/debug.ts b/src/debug.ts
new file mode 100644
index 0000000..254af38
--- /dev/null
+++ b/src/debug.ts
@@ -0,0 +1,88 @@
+import { appendFileSync, lstatSync, mkdirSync, mkdtempSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+
+// ---------------------------------------------------------------------------
+// debug
+//
+// Off unless asked for: an unconfigured install must never touch disk.
+//
+// The log goes inside an owner-only directory instead of straight into the
+// shared temp directory. A guessable path there (the PID is a small, enumerable
+// number) can be pre-created by another local user as a symlink, which
+// appendFileSync would happily follow into a file of their choosing; a
+// world-readable log would also hand them the session IDs it records.
+
+export const DEBUG_DIR_PREFIX = "tps-debug-"
+
+const debugState = { enabled: false, file: "" }
+
+/** True only for a real directory that belongs to us and to no one else. */
+function isOwnPrivateDir(path: string): boolean {
+ try {
+ const stats = lstatSync(path) // lstat, not stat: a planted symlink must not pass
+
+ if (!stats.isDirectory()) return false
+ const uid = process.getuid?.()
+
+ // Windows has no uid and a per-user temp directory, so there is nothing to check.
+ if (uid === undefined) return true
+
+ return stats.uid === uid && (stats.mode & 0o777) === 0o700
+ } catch {
+ return false
+ }
+}
+
+/**
+ * The 0700 directory to log into. Named after the PID so the process's own hot
+ * reloads keep appending to one file, and only reused when it really is ours —
+ * anything else squatting on the name gets sidestepped via mkdtemp.
+ */
+function debugDir(): string {
+ const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`)
+
+ try {
+ mkdirSync(preferred, { mode: 0o700 })
+
+ return preferred
+ } catch {
+ if (isOwnPrivateDir(preferred)) return preferred
+
+ return mkdtempSync(`${preferred}-`)
+ }
+}
+
+export function configureDebug(enabled: boolean): void {
+ debugState.enabled = enabled
+
+ if (!enabled || debugState.file) return
+
+ try {
+ debugState.file = join(debugDir(), "tps.log")
+ } catch {
+ debugState.enabled = false // no usable temp directory: stay silent
+ }
+}
+
+/** Truthy spellings only: `TPS_DEBUG=0` must not start writing to disk. */
+export function isEnvEnabled(value: string | undefined): boolean {
+ if (value === undefined) return false
+ const normalized = value.trim().toLowerCase()
+
+ return normalized === "1" || normalized === "true"
+}
+
+export function mark(line: string): void {
+ if (!debugState.enabled) return
+
+ try {
+ const safeLine = line.replace(/\p{Cc}/gu, (character) =>
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
+ )
+
+ appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`)
+ } catch {
+ // debug only; never break the host
+ }
+}
diff --git a/src/options.ts b/src/options.ts
new file mode 100644
index 0000000..d6efdac
--- /dev/null
+++ b/src/options.ts
@@ -0,0 +1,80 @@
+// ---------------------------------------------------------------------------
+// options
+//
+// `ctx.options` is host-supplied JSON (Record), so this is a real
+// parsing boundary: every value is validated and clamped, and anything invalid
+// falls back to the default rather than propagating NaN into the arithmetic.
+// `options.schema.json` documents the same enumerations, defaults, and bounds
+// for editors and agents; `tests/options-schema.test.ts` keeps the two in
+// agreement.
+
+import { BYTES_PER_TOKEN_MAX, BYTES_PER_TOKEN_MIN, DEFAULT_CONFIG, formatTps, type TpsConfig, type TpsValue } from "./tracker.js"
+
+export const DISPLAY_MODES = ["both", "tokens", "tps"] as const
+
+export type DisplayMode = (typeof DISPLAY_MODES)[number]
+
+export const REFRESH_HZ_MIN = 1
+
+export const REFRESH_HZ_MAX = 60
+
+/**
+ * A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
+ * Named so the option boundary has a real input contract to validate against.
+ */
+export type OptionValue = string | number | boolean | null | readonly OptionValue[] | { readonly [key: string]: OptionValue }
+
+/** The option surface, exactly as documented in the README, before validation. */
+export interface TpsOptionsInput {
+ readonly display?: OptionValue
+ readonly refreshHz?: OptionValue
+ readonly bytesPerToken?: OptionValue
+ readonly debug?: OptionValue
+}
+
+export interface TpsOptions extends TpsConfig {
+ readonly display: DisplayMode
+ readonly refreshHz: number
+ readonly debug: boolean
+}
+
+export const DEFAULT_OPTIONS: TpsOptions = {
+ ...DEFAULT_CONFIG,
+ display: "both",
+ refreshHz: 8,
+ debug: false,
+}
+
+function isFiniteNumber(value: OptionValue | undefined): value is number {
+ return Number.isFinite(value)
+}
+
+function isDisplayMode(value: OptionValue | undefined): value is DisplayMode {
+ return DISPLAY_MODES.some((mode) => mode === value)
+}
+
+function clampNumber(value: OptionValue | undefined, fallback: number, min: number, max: number): number {
+ if (!isFiniteNumber(value)) return fallback
+
+ return Math.min(Math.max(value, min), max)
+}
+
+export function resolveOptions(raw: TpsOptionsInput): TpsOptions {
+ return {
+ display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
+ refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, REFRESH_HZ_MIN, REFRESH_HZ_MAX),
+ bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
+ debug: raw.debug === true,
+ }
+}
+
+export function formatLabel(value: TpsValue, display: DisplayMode): string {
+ const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`
+ const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`
+
+ if (display === "tokens") return tokens
+
+ if (display === "tps") return tps ?? "— t/s"
+
+ return tps === null ? tokens : `${tokens} · ${tps}`
+}
diff --git a/src/plugin.tsx b/src/plugin.tsx
new file mode 100644
index 0000000..38cc501
--- /dev/null
+++ b/src/plugin.tsx
@@ -0,0 +1,249 @@
+/** @jsxImportSource @opentui/solid */
+import type { Plugin } from "@opencode/plugin/tui"
+import { createMemo, createSignal, Show } from "solid-js"
+import { configureDebug, isEnvEnabled, mark } from "./debug.js"
+import { TpsTracker } from "./tracker.js"
+import { formatLabel, resolveOptions } from "./options.js"
+
+// ---------------------------------------------------------------------------
+// plugin
+
+// Event payloads are taken from the SDK's own union (via the non-generic
+// `data.listen` signature) rather than restated structurally: handlers are
+// contravariant, so hand-written shapes keep typechecking after a field rename.
+type PluginContext = Parameters[0]
+
+type AnyEvent = Parameters[0]>[0]["details"]
+
+type EventOf = Extract
+
+type DeltaEvent = EventOf<"session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta">
+
+type BlockStartedEvent = EventOf<
+ "session.text.started" | "session.reasoning.started" | "session.tool.input.started"
+>
+
+type BlockEndedEvent = EventOf<"session.text.ended" | "session.reasoning.ended" | "session.tool.input.ended">
+
+type FinishEvent = EventOf<
+ "session.execution.succeeded" | "session.execution.failed" | "session.execution.interrupted" | "session.idle"
+>
+
+type StepStartedEvent = EventOf<"session.step.started">
+
+type StepStreamedEvent = EventOf<"session.step.streamed">
+
+type StepFinishedEvent = EventOf<"session.step.ended" | "session.step.failed">
+
+function blockID(e: DeltaEvent | BlockStartedEvent | BlockEndedEvent): string {
+ if (e.type === "session.tool.input.delta" || e.type === "session.tool.input.started" || e.type === "session.tool.input.ended")
+ return `tool:${e.data.id}`
+
+ return `${e.type.startsWith("session.text.") ? "text" : "reasoning"}:${e.data.ordinal}`
+}
+
+const definition: Plugin.Definition = {
+ id: "opencode2.tps",
+ setup(ctx) {
+ // Generation guard: the host may start a new generation of this plugin
+ // without disposing the previous one (observed on server (re)attach), and
+ // hot reload shares `storage.memory` across generations. Only the newest
+ // generation may count tokens or render.
+ const [gen, setGen] = ctx.storage.memory("generation", { initial: { active: 0 } })
+ const mine = gen.active + 1
+ setGen((d) => {
+ d.active = mine
+ })
+ const isActive = () => gen.active === mine
+
+ const options = resolveOptions(ctx.options)
+ configureDebug(options.debug || isEnvEnabled(process.env["TPS_DEBUG"]))
+
+ const tracker = new TpsTracker(options)
+ const [version, setVersion] = createSignal(0)
+ const seenEventIDs = new Set()
+
+ const isNewEvent = (e: AnyEvent): boolean => {
+ if (seenEventIDs.has(e.id)) return false
+ seenEventIDs.add(e.id)
+
+ if (seenEventIDs.size > 4_096) {
+ const oldest = seenEventIDs.values().next().value
+
+ if (oldest !== undefined) seenEventIDs.delete(oldest)
+ }
+
+ return true
+ }
+
+ mark(`setup ok app=${ctx.app.version} gen=${mine} display=${options.display} refreshHz=${options.refreshHz}`)
+
+ // Rendering is throttled: deltas arrive at 100-200/s, and every bump costs
+ // a memo recompute plus a terminal repaint to move a number no one can read
+ // faster than ~10 Hz. Handlers only set a flag; the timer does the work,
+ // and it only runs while a session is actually streaming.
+ let dirty = false
+ let timer: ReturnType | undefined
+
+ const flush = () => {
+ // A superseded generation stops ticking even if its cleanup never ran.
+ if (!isActive()) {
+ stopTimer()
+
+ return
+ }
+
+ const running = tracker.hasRunning(Date.now())
+
+ // The observable live rate decays only through a short stale tail. Opaque
+ // provider work after that is not charged to a numerator we cannot see.
+ if (dirty || running) {
+ dirty = false
+ setVersion((v) => v + 1)
+ }
+
+ if (!running) stopTimer()
+ }
+
+ function stopTimer(): void {
+ if (timer === undefined) return
+ clearInterval(timer)
+ timer = undefined
+ }
+
+ const touch = () => {
+ dirty = true
+
+ if (timer !== undefined) return
+ timer = setInterval(flush, Math.round(1000 / options.refreshHz))
+ timer.unref?.()
+ }
+
+ const onDelta = (e: DeltaEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.push(e.data.sessionID, e.data.delta, e.created, e.data.assistantMessageID, blockID(e))
+ touch()
+ }
+
+ const onBlockStarted = (e: BlockStartedEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.beginBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.created)
+ }
+
+ const onBlockEnded = (e: BlockEndedEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.finishBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.data.text, e.created)
+ touch()
+ }
+
+ const onFinish = (e: FinishEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.finish(e.data.sessionID, e.created)
+ touch()
+ }
+
+ const onStepStarted = (e: StepStartedEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.beginStep(e.data.sessionID, e.data.assistantMessageID, e.created)
+ touch()
+ }
+
+ const onStepStreamed = (e: StepStreamedEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.markStreamed(e.data.sessionID, e.data.assistantMessageID, e.created)
+ touch()
+ }
+
+ const onStepFinished = (e: StepFinishedEvent) => {
+ if (!isActive() || !isNewEvent(e)) return
+ const tokens = e.data.tokens
+
+ const generatedTokens =
+ tokens !== undefined &&
+ Number.isFinite(tokens.output) &&
+ tokens.output >= 0 &&
+ Number.isFinite(tokens.reasoning) &&
+ tokens.reasoning >= 0
+ ? tokens.output + tokens.reasoning
+ : undefined
+
+ tracker.finishStep(
+ e.data.sessionID,
+ e.data.assistantMessageID,
+ generatedTokens,
+ e.created,
+ )
+ touch()
+ }
+
+ const unsubs = [
+ ctx.data.on("session.execution.started", (e) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.beginRun(e.data.sessionID)
+ touch()
+ }),
+ ctx.data.on("session.text.delta", onDelta),
+ ctx.data.on("session.reasoning.delta", onDelta),
+ ctx.data.on("session.tool.input.delta", onDelta),
+ ctx.data.on("session.text.started", onBlockStarted),
+ ctx.data.on("session.reasoning.started", onBlockStarted),
+ ctx.data.on("session.tool.input.started", onBlockStarted),
+ ctx.data.on("session.text.ended", onBlockEnded),
+ ctx.data.on("session.reasoning.ended", onBlockEnded),
+ ctx.data.on("session.tool.input.ended", onBlockEnded),
+ ctx.data.on("session.step.started", onStepStarted),
+ ctx.data.on("session.step.streamed", onStepStreamed),
+ ctx.data.on("session.step.ended", onStepFinished),
+ ctx.data.on("session.step.failed", onStepFinished),
+ ctx.data.on("session.execution.succeeded", onFinish),
+ ctx.data.on("session.execution.failed", onFinish),
+ ctx.data.on("session.execution.interrupted", onFinish),
+ ctx.data.on("session.idle", onFinish),
+ ctx.data.on("session.deleted", (e) => {
+ if (!isActive() || !isNewEvent(e)) return
+ tracker.evict(e.data.sessionID)
+ touch()
+ }),
+ ]
+
+ const unslot = ctx.ui.slot({
+ append: "session.composer.top",
+ render: (input) => {
+ const label = createMemo(() => {
+ version()
+
+ if (!isActive()) return null
+ const v = tracker.value(input.sessionID, Date.now())
+
+ if (!v) return null
+
+ return formatLabel(v, options.display)
+ })
+
+ return (
+
+ {(text: () => string) => (
+
+ {`${text()} `}
+
+ )}
+
+ )
+ },
+ })
+
+ return () => {
+ for (const unsub of unsubs) unsub()
+ unslot()
+ stopTimer()
+
+ if (gen.active === mine)
+ setGen((d) => {
+ d.active = 0
+ })
+ mark(`cleanup ok gen=${mine}`)
+ }
+ },
+}
+
+export default definition
diff --git a/src/tracker.ts b/src/tracker.ts
new file mode 100644
index 0000000..f950d8d
--- /dev/null
+++ b/src/tracker.ts
@@ -0,0 +1,382 @@
+// ---------------------------------------------------------------------------
+// tracker (UI-free)
+//
+// Measures the rate of the observable model stream: bytes to a rolling
+// estimate while output arrives, exact step usage once the host reports it,
+// and a frozen average after the run ends.
+
+import { mark } from "./debug.js"
+
+export interface TpsConfig {
+ readonly bytesPerToken: number // live and partial-output estimate only
+}
+
+export const DEFAULT_CONFIG: TpsConfig = {
+ bytesPerToken: 4.75,
+}
+// The frozen final average stays visible until the next prompt starts a new run.
+
+export const BYTES_PER_TOKEN_MIN = 1
+
+export const BYTES_PER_TOKEN_MAX = 16
+
+const LIVE_WINDOW_MS = 5_000
+
+const LIVE_STALE_MS = 1_500
+
+const LIVE_MIN_DURATION_MS = 250
+
+function estimateTokens(bytes: number, bytesPerToken: number): number {
+ return Math.ceil(bytes / bytesPerToken)
+}
+
+export function formatTps(value: number): string {
+ if (value < 10) return value.toFixed(2)
+
+ if (value < 100) return value.toFixed(1)
+
+ return Math.round(value).toString()
+}
+
+interface Frozen {
+ readonly tps: number | null
+ readonly tokens: number
+ readonly tokensEstimated: boolean
+ readonly partial: boolean
+}
+
+interface LiveSample {
+ readonly bytes: number
+ readonly timestamp: number
+}
+
+interface OutputBlock {
+ streamedBytes: number
+ finalBytes: number | null
+}
+
+interface StepState {
+ readonly assistantMessageID: string
+ readonly startedAt: number
+ streamedAt: number | null
+ lastBoundaryAt: number | null
+ observableBytes: number
+ readonly blocks: Map
+ readonly samples: LiveSample[]
+}
+
+interface RunState {
+ phase: "running" | "ended"
+ settledTokens: number
+ settledDurationMs: number
+ tokensEstimated: boolean
+ partial: boolean
+ activeStep: StepState | null
+ readonly settledSteps: Set
+ frozen: Frozen | null
+}
+
+export interface TpsValue {
+ readonly tps: number | null
+ readonly tokens: number
+ readonly frozen: boolean
+ readonly tokensEstimated: boolean
+ readonly tpsEstimated: true
+ readonly partial: boolean
+}
+
+// A finished run keeps its frozen average indefinitely (it is what the composer
+// still shows), so the map is bounded instead: past this many tracked sessions,
+// the least recently started *finished* runs are dropped. Running ones are never
+// touched. Entries are tiny, so this is hygiene for a long-lived TUI, not a
+// memory fix.
+const MAX_TRACKED_RUNS = 64
+
+export class TpsTracker {
+ // Insertion order is kept equal to run-start recency (see beginRun), which is
+ // what makes eviction from the front drop the stalest session.
+ private readonly runs = new Map()
+ private readonly config: TpsConfig
+
+ constructor(config: TpsConfig = DEFAULT_CONFIG) {
+ this.config = config
+ }
+
+ private state(sessionID: string): RunState {
+ let st = this.runs.get(sessionID)
+
+ if (!st) {
+ st = {
+ phase: "ended",
+ settledTokens: 0,
+ settledDurationMs: 0,
+ tokensEstimated: false,
+ partial: false,
+ activeStep: null,
+ settledSteps: new Set(),
+ frozen: null,
+ }
+ this.runs.set(sessionID, st)
+ }
+
+ return st
+ }
+
+ beginRun(sessionID: string): void {
+ const st = this.state(sessionID)
+ st.phase = "running"
+ st.settledTokens = 0
+ st.settledDurationMs = 0
+ st.tokensEstimated = false
+ st.partial = false
+ st.activeStep = null
+ st.settledSteps.clear()
+ st.frozen = null
+ // Re-insert so this session becomes the newest in iteration order. Every
+ // entry is created through here, so the cap is checked on the one path that
+ // can grow the map.
+ this.runs.delete(sessionID)
+ this.runs.set(sessionID, st)
+ this.evictStale()
+ }
+
+ private evictStale(): void {
+ if (this.runs.size <= MAX_TRACKED_RUNS) return
+
+ for (const [sessionID, st] of this.runs) {
+ if (this.runs.size <= MAX_TRACKED_RUNS) return
+
+ if (st.phase === "running") continue
+ this.dropSession(sessionID)
+ }
+ }
+
+ private ensureStep(sessionID: string, assistantMessageID: string, now: number, replace = false): StepState | null {
+ const st = this.state(sessionID)
+
+ if (st.settledSteps.has(assistantMessageID) || (st.phase === "ended" && st.frozen !== null)) return null
+
+ if (st.phase !== "running") this.beginRun(sessionID)
+ const running = this.state(sessionID)
+
+ if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep
+
+ if (running.activeStep && !replace) return null
+
+ if (running.activeStep) this.settleActiveStep(running, undefined)
+
+ const step: StepState = {
+ assistantMessageID,
+ startedAt: now,
+ streamedAt: null,
+ lastBoundaryAt: null,
+ observableBytes: 0,
+ blocks: new Map(),
+ samples: [],
+ }
+
+ running.activeStep = step
+ running.frozen = null
+
+ return step
+ }
+
+ beginStep(sessionID: string, assistantMessageID: string, now = Date.now()): void {
+ const st = this.state(sessionID)
+
+ if (st.phase !== "running") {
+ if (st.settledSteps.has(assistantMessageID)) return
+ this.beginRun(sessionID)
+ }
+
+ if (st.activeStep?.assistantMessageID === assistantMessageID) return
+ this.ensureStep(sessionID, assistantMessageID, now, true)
+ }
+
+ beginBlock(sessionID: string, assistantMessageID: string, blockID: string, now: number): void {
+ const step = this.ensureStep(sessionID, assistantMessageID, now)
+
+ if (!step) return
+
+ if (!step.blocks.has(blockID)) step.blocks.set(blockID, { streamedBytes: 0, finalBytes: null })
+ }
+
+ push(
+ sessionID: string,
+ delta: string,
+ now: number,
+ assistantMessageID = "implicit",
+ blockID = "implicit",
+ ): void {
+ if (!delta) return
+ const step = this.ensureStep(sessionID, assistantMessageID, now)
+
+ if (!step) return
+ let block = step.blocks.get(blockID)
+
+ if (!block) {
+ block = { streamedBytes: 0, finalBytes: null }
+ step.blocks.set(blockID, block)
+ }
+
+ if (block.finalBytes !== null) return
+ const bytes = Buffer.byteLength(delta, "utf8")
+ block.streamedBytes += bytes
+ step.observableBytes += bytes
+ step.samples.push({ bytes, timestamp: now })
+ const oldest = now - LIVE_WINDOW_MS
+
+ while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift()
+ }
+
+ finishBlock(
+ sessionID: string,
+ assistantMessageID: string,
+ blockID: string,
+ text: string,
+ now: number,
+ ): void {
+ const st = this.runs.get(sessionID)
+ const step = st?.activeStep
+
+ if (!step || step.assistantMessageID !== assistantMessageID) return
+ let block = step.blocks.get(blockID)
+
+ if (!block) {
+ block = { streamedBytes: 0, finalBytes: null }
+ step.blocks.set(blockID, block)
+ }
+
+ if (block.finalBytes !== null) return
+ block.finalBytes = Buffer.byteLength(text, "utf8")
+ step.observableBytes += block.finalBytes - block.streamedBytes
+ step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now)
+ }
+
+ /**
+ * The host's authoritative end of the model stream, published after the
+ * provider stream exits and before local tools join. Assigned rather than
+ * maxed so a retried attempt reusing the message ID moves the boundary to its
+ * own completion.
+ */
+ markStreamed(sessionID: string, assistantMessageID: string, now: number): void {
+ const st = this.runs.get(sessionID)
+ const step = st?.activeStep
+
+ if (!step || step.assistantMessageID !== assistantMessageID) return
+ step.streamedAt = now
+ }
+
+ private settleActiveStep(st: RunState, generatedTokens: number | undefined): void {
+ const step = st.activeStep
+
+ if (!step) return
+ const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0
+ st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken)
+
+ if (!exact) {
+ st.tokensEstimated = true
+ st.partial = true
+ }
+
+ // `session.step.streamed` is the exact stream end; the last content boundary
+ // remains the fallback for hosts that do not publish it.
+ const end = step.streamedAt ?? step.lastBoundaryAt
+
+ if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt)
+ st.settledSteps.add(step.assistantMessageID)
+ st.activeStep = null
+ }
+
+ finishStep(sessionID: string, assistantMessageID: string, generatedTokens: number | undefined, _now: number): void {
+ const st = this.runs.get(sessionID)
+
+ if (st?.activeStep?.assistantMessageID !== assistantMessageID) return
+ this.settleActiveStep(st, generatedTokens)
+ }
+
+ finish(sessionID: string, _now: number): void {
+ const st = this.runs.get(sessionID)
+
+ if (!st || st.phase === "ended") return
+
+ if (st.activeStep) this.settleActiveStep(st, undefined)
+ st.phase = "ended"
+ const tokens = st.settledTokens
+
+ if (tokens <= 0) {
+ this.evictStale()
+
+ return
+ }
+
+ const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null
+ st.frozen = { tps, tokens, tokensEstimated: st.tokensEstimated, partial: st.partial }
+ mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`)
+ this.evictStale()
+ }
+
+ private dropSession(sessionID: string): void {
+ this.runs.delete(sessionID)
+ }
+
+ evict(sessionID: string): void {
+ this.dropSession(sessionID)
+ }
+
+ hasRunning(now = Date.now()): boolean {
+ for (const st of this.runs.values()) {
+ const last = st.activeStep?.samples.at(-1)
+
+ if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true
+ }
+
+ return false
+ }
+
+ private liveTps(step: StepState, now: number): number | null {
+ const last = step.samples.at(-1)
+
+ if (!last) return null
+ const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS)
+ const oldest = effectiveNow - LIVE_WINDOW_MS
+ const samples = step.samples.filter((sample) => sample.timestamp >= oldest)
+ const first = samples[0]
+
+ if (!first) return null
+ const bytes = samples.reduce((total, sample) => total + sample.bytes, 0)
+ const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS)
+
+ return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000)
+ }
+
+ value(sessionID: string, now: number): TpsValue | null {
+ const st = this.runs.get(sessionID)
+
+ if (!st) return null
+
+ if (st.frozen)
+ return {
+ ...st.frozen,
+ frozen: true,
+ tpsEstimated: true,
+ }
+
+ if (st.phase !== "running") return null
+ const active = st.activeStep
+ const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0
+ const tokens = st.settledTokens + activeTokens
+
+ if (tokens <= 0) return null
+ const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null
+
+ return {
+ tps: active ? (this.liveTps(active, now) ?? settledTps) : settledTps,
+ tokens,
+ frozen: false,
+ tokensEstimated: st.tokensEstimated || active !== null,
+ tpsEstimated: true,
+ partial: st.partial,
+ }
+ }
+}
diff --git a/tests/debug.test.ts b/tests/debug.test.ts
new file mode 100644
index 0000000..7360b72
--- /dev/null
+++ b/tests/debug.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, test } from "bun:test"
+import { isEnvEnabled } from "../src/debug.ts"
+
+describe("isEnvEnabled", () => {
+ test("accepts only explicit truthy spellings", () => {
+ for (const value of ["1", "true", "TRUE", " true "]) expect(isEnvEnabled(value)).toBe(true)
+
+ // A shell script exporting TPS_DEBUG=0 must not start writing to disk.
+ for (const value of [undefined, "", "0", "false", "no", "off"]) expect(isEnvEnabled(value)).toBe(false)
+ })
+})
diff --git a/entrypoint.test.tsx b/tests/entrypoint.test.tsx
similarity index 95%
rename from entrypoint.test.tsx
rename to tests/entrypoint.test.tsx
index 5e2c3db..5ab9678 100644
--- a/entrypoint.test.tsx
+++ b/tests/entrypoint.test.tsx
@@ -1,5 +1,5 @@
// 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
+// export, not src/plugin.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
@@ -11,11 +11,11 @@ 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"
+import type { TpsOptionsInput } from "../src/options.ts"
-const root = fileURLToPath(new URL(".", import.meta.url))
+const root = fileURLToPath(new URL("..", import.meta.url))
-const distEntry = new URL("./dist/tui.js", import.meta.url).href
+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)
@@ -26,9 +26,9 @@ 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" })
+ const build = spawnSync("node", ["scripts/build.mjs"], { cwd: root, encoding: "utf8" })
- if (build.status !== 0) throw new Error(`node build.mjs failed:\n${build.stderr || build.stdout}`)
+ if (build.status !== 0) throw new Error(`node scripts/build.mjs failed:\n${build.stderr || build.stdout}`)
plugin = (await import(distEntry)).default
})
diff --git a/tests/options-schema.test.ts b/tests/options-schema.test.ts
new file mode 100644
index 0000000..6fabf6a
--- /dev/null
+++ b/tests/options-schema.test.ts
@@ -0,0 +1,62 @@
+// Keeps options.schema.json honest against the runtime parser and the README.
+// The schema is the contract agents validate against; the parser is what
+// actually runs; a drift between them is a bug in one of them, so both are
+// pinned to the same exported constants.
+
+import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import optionsSchema from "../options.schema.json"
+import packageJson from "../package.json"
+import {
+ DEFAULT_OPTIONS,
+ DISPLAY_MODES,
+ REFRESH_HZ_MAX,
+ REFRESH_HZ_MIN,
+} from "../src/options.ts"
+import { BYTES_PER_TOKEN_MAX, BYTES_PER_TOKEN_MIN, DEFAULT_CONFIG } from "../src/tracker.ts"
+
+const docs = readFileSync(new URL("../docs/configuration.md", import.meta.url), "utf8")
+
+describe("options.schema.json agreement", () => {
+ const { display, refreshHz, bytesPerToken, debug } = optionsSchema.properties
+
+ test("documents the same enumerations as runtime parsing", () => {
+ expect(display.enum).toEqual([...DISPLAY_MODES])
+ })
+
+ test("documents the same defaults as runtime parsing", () => {
+ expect(display.default).toBe(DEFAULT_OPTIONS.display)
+ expect(refreshHz.default).toBe(DEFAULT_OPTIONS.refreshHz)
+ expect(bytesPerToken.default).toBe(DEFAULT_OPTIONS.bytesPerToken)
+ expect(debug.default).toBe(DEFAULT_OPTIONS.debug)
+ expect(DEFAULT_CONFIG.bytesPerToken).toBe(DEFAULT_OPTIONS.bytesPerToken)
+ })
+
+ test("documents the same bounds as the runtime clamp", () => {
+ expect(refreshHz.minimum).toBe(REFRESH_HZ_MIN)
+ expect(refreshHz.maximum).toBe(REFRESH_HZ_MAX)
+ expect(bytesPerToken.minimum).toBe(BYTES_PER_TOKEN_MIN)
+ expect(bytesPerToken.maximum).toBe(BYTES_PER_TOKEN_MAX)
+ })
+
+ test("documents every option the parser reads, and no others", () => {
+ expect(Object.keys(optionsSchema.properties).sort()).toEqual(["bytesPerToken", "debug", "display", "refreshHz"])
+ })
+
+ test("names the plugin and its options location consistently", () => {
+ expect(optionsSchema.title).toBe(`${packageJson.name} plugin options`)
+ expect(optionsSchema.description).toContain(packageJson.name)
+ })
+})
+
+describe("docs agreement", () => {
+ test("states the defaults the plugin ships with", () => {
+ expect(docs).toContain(`"${DEFAULT_OPTIONS.display}"`)
+ expect(docs).toContain(String(DEFAULT_OPTIONS.refreshHz))
+ expect(docs).toContain(String(DEFAULT_OPTIONS.bytesPerToken))
+ })
+
+ test("documents each option by name", () => {
+ for (const name of ["display", "refreshHz", "bytesPerToken", "debug"]) expect(docs).toContain(`\`${name}\``)
+ })
+})
diff --git a/tests/options.test.ts b/tests/options.test.ts
new file mode 100644
index 0000000..5ed062e
--- /dev/null
+++ b/tests/options.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { DEFAULT_OPTIONS, formatLabel, resolveOptions } from "../src/options.ts"
+
+describe("formatLabel", () => {
+ const value = {
+ tps: 6.5,
+ tokens: 41,
+ frozen: false,
+ tokensEstimated: true,
+ tpsEstimated: true as const,
+ partial: false,
+ }
+
+ test("both", () => expect(formatLabel(value, "both")).toBe("~41 tok · ~6.50 t/s"))
+ test("tokens", () => expect(formatLabel(value, "tokens")).toBe("~41 tok"))
+ test("tps", () => expect(formatLabel(value, "tps")).toBe("~6.50 t/s"))
+
+ test("scales precision with magnitude", () => {
+ expect(formatLabel({ ...value, tps: 62.44 }, "tps")).toBe("~62.4 t/s")
+ expect(formatLabel({ ...value, tps: 184.6 }, "tps")).toBe("~185 t/s")
+ })
+
+ test("shows exact settled tokens and omits unavailable TPS", () => {
+ const settled = { ...value, tps: null, tokensEstimated: false, frozen: true }
+ expect(formatLabel(settled, "both")).toBe("41 tok")
+ expect(formatLabel(settled, "tps")).toBe("— t/s")
+ })
+})
+
+describe("resolveOptions", () => {
+ test("empty options yield the defaults", () => {
+ expect(resolveOptions({})).toEqual(DEFAULT_OPTIONS)
+ })
+
+ test("accepts valid values", () => {
+ const options = resolveOptions({ display: "tps", refreshHz: 20, bytesPerToken: 5, debug: true })
+ expect(options.display).toBe("tps")
+ expect(options.refreshHz).toBe(20)
+ expect(options.bytesPerToken).toBe(5)
+ expect(options.debug).toBe(true)
+ })
+
+ test("clamps numbers into their supported range", () => {
+ expect(resolveOptions({ refreshHz: 0 }).refreshHz).toBe(1)
+ expect(resolveOptions({ refreshHz: 1000 }).refreshHz).toBe(60)
+ expect(resolveOptions({ bytesPerToken: 0 }).bytesPerToken).toBe(1)
+ expect(resolveOptions({ bytesPerToken: 100 }).bytesPerToken).toBe(16)
+ })
+
+ test("rejects invalid values", () => {
+ const options = resolveOptions({ refreshHz: "12", bytesPerToken: null, display: "fancy", debug: "yes" })
+ expect(options.refreshHz).toBe(DEFAULT_OPTIONS.refreshHz)
+ expect(options.bytesPerToken).toBe(DEFAULT_OPTIONS.bytesPerToken)
+ expect(options.display).toBe("both")
+ expect(options.debug).toBe(false)
+ })
+
+ test("tolerates hostile shapes", () => {
+ expect(resolveOptions({ display: {}, refreshHz: Number.NaN, bytesPerToken: Number.POSITIVE_INFINITY })).toEqual(
+ DEFAULT_OPTIONS,
+ )
+ })
+})
diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts
new file mode 100644
index 0000000..a094577
--- /dev/null
+++ b/tests/plugin.test.ts
@@ -0,0 +1,296 @@
+import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"
+import { existsSync, readdirSync, rmSync, statSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { DEBUG_DIR_PREFIX } from "../src/debug.ts"
+import type { TpsOptionsInput } from "../src/options.ts"
+import definition from "../src/plugin.tsx"
+
+// ---------------------------------------------------------------------------
+// setup wiring: the reactive path cannot be rendered headlessly, but the parts
+// that matter (which events are subscribed, and when the render timer runs) are
+// observable through a fake context and a patched setInterval.
+
+/** The event fields `setup` reads; the harness emits nothing else. */
+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 TimerSpy {
+ callback: (() => void) | undefined
+ intervalMs: number
+ cleared: number
+ created: number
+}
+
+interface Generation {
+ active: number
+}
+
+/**
+ * The slice of the host context `setup` actually touches: an approximation of
+ * the real `Context`, not a checked subset of it. The `unknown` narrowing
+ * below switches the type checker off, so an SDK rename is caught by `tsc` on
+ * `src/` (which is fully typed) rather than here — this fake only records what
+ * `setup` does with the members it is given.
+ */
+interface FakeContext {
+ readonly options: TpsOptionsInput
+ readonly app: { readonly version: string }
+ readonly theme: { readonly text: { readonly subdued: string } }
+ readonly storage: {
+ 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: () => () => void }
+}
+
+// SAFETY: `FakeContext` covers every context member `setup` touches; the host
+// members it omits are unreachable on this path. TypeScript cannot express
+// "partial implementation of a foreign interface", so the parameter is narrowed
+// through `unknown` — a test-double limitation, not a production cast.
+// oxlint-disable-next-line anti-slop/no-chained-type-assertions
+const setupWithFakeContext = definition.setup as unknown as (
+ context: FakeContext,
+) => ReturnType
+
+function createHarness(options: TpsOptionsInput = {}) {
+ const handlers = new Map void)[]>()
+ const generation: Generation = { active: 0 }
+ const timer: TimerSpy = { callback: undefined, intervalMs: 0, cleared: 0, created: 0 }
+ let eventID = 0
+
+ const realSetInterval = globalThis.setInterval
+ const realClearInterval = globalThis.clearInterval
+ globalThis.setInterval = (fn: () => void, ms?: number) => {
+ timer.callback = fn
+ timer.intervalMs = ms ?? 0
+ timer.created += 1
+ // 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 = () => {
+ timer.cleared += 1
+ timer.callback = undefined
+ }
+
+ const ctx: 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: () => () => {} },
+ }
+
+ // `setup` is declared as possibly async and possibly cleanup-less; ours is
+ // neither, and the timer assertions fail loudly if that ever changes.
+ const started = setupWithFakeContext(ctx)
+ const cleanup = started instanceof Function ? started : () => {}
+
+ return {
+ timer,
+ subscribed: (type: string) => handlers.has(type),
+ subscribedTypes: () => [...handlers.keys()].sort(),
+ emit: (type: string, data: FakeEvent["data"], created = Date.now(), id = `evt_${eventID++}`) => {
+ for (const handler of handlers.get(type) ?? []) handler({ id, type, created, data })
+ },
+ tick: () => timer.callback?.(),
+ cleanup,
+ restore: () => {
+ globalThis.setInterval = realSetInterval
+ globalThis.clearInterval = realClearInterval
+ },
+ }
+}
+
+/**
+ * Every debug directory this process could have created: the preferred name, or
+ * a mkdtemp fallback. Matched exactly, not by prefix — `afterEach` deletes these,
+ * and a prefix would make PID 12 claim (and remove) a live PID 123 log directory.
+ */
+function debugDirs(): string[] {
+ const own = `${DEBUG_DIR_PREFIX}${process.pid}`
+
+ return readdirSync(tmpdir())
+ .filter((entry) => entry === own || entry.startsWith(`${own}-`))
+ .map((entry) => join(tmpdir(), entry))
+}
+
+describe("plugin setup", () => {
+ // The debug switch is also readable from the environment. A developer's
+ // shell must not decide what these tests assert.
+ const savedDebugEnv = process.env["TPS_DEBUG"]
+ beforeAll(() => {
+ delete process.env["TPS_DEBUG"]
+ })
+ afterAll(() => {
+ if (savedDebugEnv === undefined) delete process.env["TPS_DEBUG"]
+ else process.env["TPS_DEBUG"] = savedDebugEnv
+ })
+ afterEach(() => {
+ for (const dir of debugDirs()) rmSync(dir, { recursive: true, force: true })
+ })
+
+ test("subscribes to exactly the events the tracker needs", () => {
+ const h = createHarness()
+
+ const expected = [
+ "session.execution.started",
+ "session.text.delta",
+ "session.reasoning.delta",
+ "session.tool.input.delta",
+ "session.text.started",
+ "session.reasoning.started",
+ "session.tool.input.started",
+ "session.text.ended",
+ "session.reasoning.ended",
+ "session.tool.input.ended",
+ "session.step.started",
+ "session.step.streamed",
+ "session.step.ended",
+ "session.step.failed",
+ "session.execution.succeeded",
+ "session.execution.failed",
+ "session.execution.interrupted",
+ "session.idle",
+ "session.deleted",
+ ]
+
+ for (const type of expected) expect(h.subscribed(type)).toBe(true)
+ // Exact set, not just subset: an added or dropped subscription fails here.
+ expect(h.subscribedTypes()).toEqual([...expected].sort())
+
+ h.cleanup()
+ h.restore()
+ })
+
+ test("runs the timer only while a model step is producing output", () => {
+ const h = createHarness()
+ expect(h.timer.created).toBe(0)
+
+ h.emit("session.step.started", { sessionID: "s", assistantMessageID: "m1" })
+ h.emit("session.text.delta", { sessionID: "s", assistantMessageID: "m1", ordinal: 0, delta: "hello" })
+ expect(h.timer.created).toBe(1)
+ expect(h.timer.intervalMs).toBe(125) // 8 Hz default
+
+ h.tick() // still streaming: keeps ticking
+ expect(h.timer.cleared).toBe(0)
+
+ h.emit("session.step.ended", {
+ sessionID: "s",
+ assistantMessageID: "m1",
+ tokens: { output: 1, reasoning: 0 },
+ })
+ h.tick() // publishes the step average, then stops during tool execution
+ expect(h.timer.cleared).toBe(1)
+
+ h.emit("session.step.started", { sessionID: "s", assistantMessageID: "m2" })
+ h.emit("session.text.delta", { sessionID: "s", assistantMessageID: "m2", ordinal: 0, delta: "again" })
+ expect(h.timer.created).toBe(2)
+ h.cleanup()
+ h.restore()
+ })
+
+ test("honours refreshHz", () => {
+ const h = createHarness({ refreshHz: 20 })
+ h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
+ expect(h.timer.intervalMs).toBe(50)
+ h.cleanup()
+ h.restore()
+ })
+
+ test("cleanup stops the timer", () => {
+ const h = createHarness()
+ h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
+ h.cleanup()
+ expect(h.timer.cleared).toBe(1)
+ h.restore()
+ })
+
+ // Ordered before the negative test: `afterEach` removes the directory, and the
+ // module keeps its resolved path, so re-enabling debug afterwards would write
+ // into a directory that no longer exists.
+ test("debug logs into a private directory, not a guessable temp path", async () => {
+ const h = createHarness({ debug: true })
+ const sessionID = "s\n\u001b[31mforged"
+ h.emit("session.execution.started", { sessionID }, 1_000)
+ h.emit("session.step.started", { sessionID, assistantMessageID: "m1" }, 2_000)
+ h.emit("session.text.delta", { sessionID, assistantMessageID: "m1", ordinal: 0, delta: "a".repeat(4_000) }, 2_100)
+ h.emit("session.text.ended", {
+ sessionID,
+ assistantMessageID: "m1",
+ ordinal: 0,
+ text: "a".repeat(4_000),
+ }, 3_000)
+ h.emit("session.step.streamed", { sessionID, assistantMessageID: "m1" }, 3_200, "evt_streamed")
+ // A replayed delivery of the same event must not move the boundary.
+ h.emit("session.step.streamed", { sessionID, assistantMessageID: "m1" }, 500_000, "evt_streamed")
+ h.emit("session.step.ended", {
+ sessionID,
+ assistantMessageID: "m1",
+ tokens: { output: 700, reasoning: 100 },
+ }, 4_000)
+ h.emit("session.idle", { sessionID }, 4_100)
+ h.tick()
+ h.cleanup()
+ h.restore()
+
+ const dir = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`)
+ expect(debugDirs()).toEqual([dir])
+ // Owner-only: the log carries session IDs, so other local users must not
+ // even be able to list it.
+ expect(statSync(dir).mode & 0o777).toBe(0o700)
+ expect(existsSync(join(dir, "tps.log"))).toBe(true)
+ const log = Bun.file(join(dir, "tps.log"))
+ expect(log.size).toBeGreaterThan(0)
+ const text = await log.text()
+ expect(text).toContain("finish sid=s\\u000a\\u001b[31mforged tokens=800 observedMs=1200 tps=666.7")
+ expect(text).not.toContain(sessionID)
+ // The old predictable path must stay unused.
+ expect(existsSync(join(tmpdir(), `tps-debug-${process.pid}.log`))).toBe(false)
+ })
+
+ test("writes nothing to disk without the debug option", () => {
+ const h = createHarness()
+ h.emit("session.execution.started", { sessionID: "s" })
+ h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
+ h.emit("session.idle", { sessionID: "s" })
+ h.tick()
+ h.cleanup()
+ h.restore()
+ expect(debugDirs()).toHaveLength(0)
+ expect(existsSync(join(tmpdir(), `tps-debug-${process.pid}.log`))).toBe(false)
+ })
+})
diff --git a/tps.test.ts b/tests/tracker.test.ts
similarity index 58%
rename from tps.test.ts
rename to tests/tracker.test.ts
index a0eeceb..fb006ae 100644
--- a/tps.test.ts
+++ b/tests/tracker.test.ts
@@ -1,16 +1,6 @@
-import { afterEach, describe, expect, test } from "bun:test"
-import { existsSync, readdirSync, rmSync, statSync } from "node:fs"
-import { tmpdir } from "node:os"
-import { join } from "node:path"
-import definition, {
- DEBUG_DIR_PREFIX,
- DEFAULT_OPTIONS,
- formatLabel,
- isEnvEnabled,
- resolveOptions,
- TpsTracker,
- type TpsOptionsInput,
-} from "./tps.tsx"
+import { describe, expect, test } from "bun:test"
+import { DEFAULT_OPTIONS } from "../src/options.ts"
+import { TpsTracker } from "../src/tracker.ts"
// 50 ASCII bytes -> ceil(50/4.75) = 11 estimated tokens at the default ratio
const DELTA = "a".repeat(50)
@@ -451,349 +441,3 @@ describe("TpsTracker", () => {
expect(tracker.value("s0", 100)).toBeNull()
})
})
-
-describe("formatLabel", () => {
- const value = {
- tps: 6.5,
- tokens: 41,
- frozen: false,
- tokensEstimated: true,
- tpsEstimated: true as const,
- partial: false,
- }
-
- test("both", () => expect(formatLabel(value, "both")).toBe("~41 tok · ~6.50 t/s"))
- test("tokens", () => expect(formatLabel(value, "tokens")).toBe("~41 tok"))
- test("tps", () => expect(formatLabel(value, "tps")).toBe("~6.50 t/s"))
-
- test("scales precision with magnitude", () => {
- expect(formatLabel({ ...value, tps: 62.44 }, "tps")).toBe("~62.4 t/s")
- expect(formatLabel({ ...value, tps: 184.6 }, "tps")).toBe("~185 t/s")
- })
-
- test("shows exact settled tokens and omits unavailable TPS", () => {
- const settled = { ...value, tps: null, tokensEstimated: false, frozen: true }
- expect(formatLabel(settled, "both")).toBe("41 tok")
- expect(formatLabel(settled, "tps")).toBe("— t/s")
- })
-})
-
-// ---------------------------------------------------------------------------
-// setup wiring: the reactive path cannot be rendered headlessly, but the parts
-// that matter (which events are subscribed, and when the render timer runs) are
-// observable through a fake context and a patched setInterval.
-
-/** The event fields `setup` reads; the harness emits nothing else. */
-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 TimerSpy {
- callback: (() => void) | undefined
- intervalMs: number
- cleared: number
- created: number
-}
-
-interface Generation {
- active: number
-}
-
-/**
- * The slice of the host context `setup` actually touches. Member signatures
- * mirror the SDK's, so the real `Context` remains assignable to this and a
- * renamed or re-shaped host member fails to compile instead of being erased.
- */
-interface FakeContext {
- readonly options: TpsOptionsInput
- readonly app: { readonly version: string }
- readonly theme: { readonly text: { readonly subdued: string } }
- readonly storage: {
- 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: () => () => void }
-}
-
-// SAFETY: `FakeContext` covers every context member `setup` touches; the host
-// members it omits are unreachable on this path. TypeScript cannot express
-// "partial implementation of a foreign interface", so the parameter is narrowed
-// through `unknown` — a test-double limitation, not a production cast.
-// oxlint-disable-next-line anti-slop/no-chained-type-assertions
-const setupWithFakeContext = definition.setup as unknown as (
- context: FakeContext,
-) => ReturnType
-
-function createHarness(options: TpsOptionsInput = {}) {
- const handlers = new Map void)[]>()
- const generation: Generation = { active: 0 }
- const timer: TimerSpy = { callback: undefined, intervalMs: 0, cleared: 0, created: 0 }
- let eventID = 0
-
- const realSetInterval = globalThis.setInterval
- const realClearInterval = globalThis.clearInterval
- globalThis.setInterval = (fn: () => void, ms?: number) => {
- timer.callback = fn
- timer.intervalMs = ms ?? 0
- timer.created += 1
- // 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 = () => {
- timer.cleared += 1
- timer.callback = undefined
- }
-
- const ctx: 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: () => () => {} },
- }
-
- // `setup` is declared as possibly async and possibly cleanup-less; ours is
- // neither, and the timer assertions fail loudly if that ever changes.
- const started = setupWithFakeContext(ctx)
- const cleanup = started instanceof Function ? started : () => {}
-
- return {
- timer,
- subscribed: (type: string) => handlers.has(type),
- emit: (type: string, data: FakeEvent["data"], created = Date.now(), id = `evt_${eventID++}`) => {
- for (const handler of handlers.get(type) ?? []) handler({ id, type, created, data })
- },
- tick: () => timer.callback?.(),
- cleanup,
- restore: () => {
- globalThis.setInterval = realSetInterval
- globalThis.clearInterval = realClearInterval
- },
- }
-}
-
-/**
- * Every debug directory this process could have created: the preferred name, or
- * a mkdtemp fallback. Matched exactly, not by prefix — `afterEach` deletes these,
- * and a prefix would make PID 12 claim (and remove) a live PID 123 log directory.
- */
-function debugDirs(): string[] {
- const own = `${DEBUG_DIR_PREFIX}${process.pid}`
-
- return readdirSync(tmpdir())
- .filter((entry) => entry === own || entry.startsWith(`${own}-`))
- .map((entry) => join(tmpdir(), entry))
-}
-
-describe("plugin setup", () => {
- afterEach(() => {
- for (const dir of debugDirs()) rmSync(dir, { recursive: true, force: true })
- })
-
- test("subscribes to the events the tracker needs", () => {
- const h = createHarness()
-
- for (const type of [
- "session.execution.started",
- "session.text.delta",
- "session.reasoning.delta",
- "session.tool.input.delta",
- "session.text.started",
- "session.reasoning.started",
- "session.tool.input.started",
- "session.text.ended",
- "session.reasoning.ended",
- "session.tool.input.ended",
- "session.step.started",
- "session.step.streamed",
- "session.step.ended",
- "session.step.failed",
- "session.execution.succeeded",
- "session.execution.failed",
- "session.execution.interrupted",
- "session.idle",
- "session.deleted",
- ]) {
- expect(h.subscribed(type)).toBe(true)
- }
-
- h.cleanup()
- h.restore()
- })
-
- test("runs the timer only while a model step is producing output", () => {
- const h = createHarness()
- expect(h.timer.created).toBe(0)
-
- h.emit("session.step.started", { sessionID: "s", assistantMessageID: "m1" })
- h.emit("session.text.delta", { sessionID: "s", assistantMessageID: "m1", ordinal: 0, delta: "hello" })
- expect(h.timer.created).toBe(1)
- expect(h.timer.intervalMs).toBe(125) // 8 Hz default
-
- h.tick() // still streaming: keeps ticking
- expect(h.timer.cleared).toBe(0)
-
- h.emit("session.step.ended", {
- sessionID: "s",
- assistantMessageID: "m1",
- tokens: { output: 1, reasoning: 0 },
- })
- h.tick() // publishes the step average, then stops during tool execution
- expect(h.timer.cleared).toBe(1)
-
- h.emit("session.step.started", { sessionID: "s", assistantMessageID: "m2" })
- h.emit("session.text.delta", { sessionID: "s", assistantMessageID: "m2", ordinal: 0, delta: "again" })
- expect(h.timer.created).toBe(2)
- h.cleanup()
- h.restore()
- })
-
- test("honours refreshHz", () => {
- const h = createHarness({ refreshHz: 20 })
- h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
- expect(h.timer.intervalMs).toBe(50)
- h.cleanup()
- h.restore()
- })
-
- test("cleanup stops the timer", () => {
- const h = createHarness()
- h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
- h.cleanup()
- expect(h.timer.cleared).toBe(1)
- h.restore()
- })
-
- // Ordered before the negative test: `afterEach` removes the directory, and the
- // module keeps its resolved path, so re-enabling debug afterwards would write
- // into a directory that no longer exists.
- test("debug logs into a private directory, not a guessable temp path", async () => {
- delete process.env["TPS_DEBUG"]
- const h = createHarness({ debug: true })
- const sessionID = "s\n\u001b[31mforged"
- h.emit("session.execution.started", { sessionID }, 1_000)
- h.emit("session.step.started", { sessionID, assistantMessageID: "m1" }, 2_000)
- h.emit("session.text.delta", { sessionID, assistantMessageID: "m1", ordinal: 0, delta: "a".repeat(4_000) }, 2_100)
- h.emit("session.text.ended", {
- sessionID,
- assistantMessageID: "m1",
- ordinal: 0,
- text: "a".repeat(4_000),
- }, 3_000)
- h.emit("session.step.streamed", { sessionID, assistantMessageID: "m1" }, 3_200, "evt_streamed")
- // A replayed delivery of the same event must not move the boundary.
- h.emit("session.step.streamed", { sessionID, assistantMessageID: "m1" }, 500_000, "evt_streamed")
- h.emit("session.step.ended", {
- sessionID,
- assistantMessageID: "m1",
- tokens: { output: 700, reasoning: 100 },
- }, 4_000)
- h.emit("session.idle", { sessionID }, 4_100)
- h.tick()
- h.cleanup()
- h.restore()
-
- const dir = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`)
- expect(debugDirs()).toEqual([dir])
- // Owner-only: the log carries session IDs, so other local users must not
- // even be able to list it.
- expect(statSync(dir).mode & 0o777).toBe(0o700)
- expect(existsSync(join(dir, "tps.log"))).toBe(true)
- const log = Bun.file(join(dir, "tps.log"))
- expect(log.size).toBeGreaterThan(0)
- const text = await log.text()
- expect(text).toContain("finish sid=s\\u000a\\u001b[31mforged tokens=800 observedMs=1200 tps=666.7")
- expect(text).not.toContain(sessionID)
- // The old predictable path must stay unused.
- expect(existsSync(join(tmpdir(), `tps-debug-${process.pid}.log`))).toBe(false)
- })
-
- test("writes nothing to disk without the debug option", () => {
- delete process.env["TPS_DEBUG"]
- const h = createHarness()
- h.emit("session.execution.started", { sessionID: "s" })
- h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
- h.emit("session.idle", { sessionID: "s" })
- h.tick()
- h.cleanup()
- h.restore()
- expect(debugDirs()).toHaveLength(0)
- expect(existsSync(join(tmpdir(), `tps-debug-${process.pid}.log`))).toBe(false)
- })
-})
-
-describe("isEnvEnabled", () => {
- test("accepts only explicit truthy spellings", () => {
- for (const value of ["1", "true", "TRUE", " true "]) expect(isEnvEnabled(value)).toBe(true)
-
- // A shell script exporting TPS_DEBUG=0 must not start writing to disk.
- for (const value of [undefined, "", "0", "false", "no", "off"]) expect(isEnvEnabled(value)).toBe(false)
- })
-})
-
-describe("resolveOptions", () => {
- test("empty options yield the defaults", () => {
- expect(resolveOptions({})).toEqual(DEFAULT_OPTIONS)
- })
-
- test("accepts valid values", () => {
- const options = resolveOptions({ display: "tps", refreshHz: 20, bytesPerToken: 5, debug: true })
- expect(options.display).toBe("tps")
- expect(options.refreshHz).toBe(20)
- expect(options.bytesPerToken).toBe(5)
- expect(options.debug).toBe(true)
- })
-
- test("clamps numbers into their supported range", () => {
- expect(resolveOptions({ refreshHz: 0 }).refreshHz).toBe(1)
- expect(resolveOptions({ refreshHz: 1000 }).refreshHz).toBe(60)
- expect(resolveOptions({ bytesPerToken: 0 }).bytesPerToken).toBe(1)
- expect(resolveOptions({ bytesPerToken: 100 }).bytesPerToken).toBe(16)
- })
-
- test("rejects invalid values", () => {
- const options = resolveOptions({ refreshHz: "12", bytesPerToken: null, display: "fancy", debug: "yes" })
- expect(options.refreshHz).toBe(DEFAULT_OPTIONS.refreshHz)
- expect(options.bytesPerToken).toBe(DEFAULT_OPTIONS.bytesPerToken)
- expect(options.display).toBe("both")
- expect(options.debug).toBe(false)
- })
-
- test("tolerates hostile shapes", () => {
- expect(resolveOptions({ display: {}, refreshHz: Number.NaN, bytesPerToken: Number.POSITIVE_INFINITY })).toEqual(
- DEFAULT_OPTIONS,
- )
- })
-})
diff --git a/tps.tsx b/tps.tsx
deleted file mode 100644
index 165a8df..0000000
--- a/tps.tsx
+++ /dev/null
@@ -1,786 +0,0 @@
-/** @jsxImportSource @opentui/solid */
-import type { Plugin } from "@opencode/plugin/tui"
-import { createMemo, createSignal, Show } from "solid-js"
-import { appendFileSync, lstatSync, mkdirSync, mkdtempSync } from "node:fs"
-import { tmpdir } from "node:os"
-import { join } from "node:path"
-
-// ---------------------------------------------------------------------------
-// debug
-//
-// Off unless asked for: an unconfigured install must never touch disk.
-//
-// The log goes inside an owner-only directory instead of straight into the
-// shared temp directory. A guessable path there (the PID is a small, enumerable
-// number) can be pre-created by another local user as a symlink, which
-// appendFileSync would happily follow into a file of their choosing; a
-// world-readable log would also hand them the session IDs it records.
-
-export const DEBUG_DIR_PREFIX = "tps-debug-"
-
-const debugState = { enabled: false, file: "" }
-
-/** True only for a real directory that belongs to us and to no one else. */
-function isOwnPrivateDir(path: string): boolean {
- try {
- const stats = lstatSync(path) // lstat, not stat: a planted symlink must not pass
-
- if (!stats.isDirectory()) return false
- const uid = process.getuid?.()
-
- // Windows has no uid and a per-user temp directory, so there is nothing to check.
- if (uid === undefined) return true
-
- return stats.uid === uid && (stats.mode & 0o777) === 0o700
- } catch {
- return false
- }
-}
-
-/**
- * The 0700 directory to log into. Named after the PID so the process's own hot
- * reloads keep appending to one file, and only reused when it really is ours —
- * anything else squatting on the name gets sidestepped via mkdtemp.
- */
-function debugDir(): string {
- const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`)
-
- try {
- mkdirSync(preferred, { mode: 0o700 })
-
- return preferred
- } catch {
- if (isOwnPrivateDir(preferred)) return preferred
-
- return mkdtempSync(`${preferred}-`)
- }
-}
-
-function configureDebug(enabled: boolean): void {
- debugState.enabled = enabled
-
- if (!enabled || debugState.file) return
-
- try {
- debugState.file = join(debugDir(), "tps.log")
- } catch {
- debugState.enabled = false // no usable temp directory: stay silent
- }
-}
-
-/** Truthy spellings only: `TPS_DEBUG=0` must not start writing to disk. */
-export function isEnvEnabled(value: string | undefined): boolean {
- if (value === undefined) return false
- const normalized = value.trim().toLowerCase()
-
- return normalized === "1" || normalized === "true"
-}
-
-function mark(line: string): void {
- if (!debugState.enabled) return
-
- try {
- const safeLine = line.replace(/\p{Cc}/gu, (character) =>
- `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
- )
-
- appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`)
- } catch {
- // debug only; never break the host
- }
-}
-
-// ---------------------------------------------------------------------------
-// tuning
-
-export interface TpsConfig {
- readonly bytesPerToken: number // live and partial-output estimate only
-}
-
-export const DEFAULT_CONFIG: TpsConfig = {
- bytesPerToken: 4.75,
-}
-// The frozen final average stays visible until the next prompt starts a new run.
-
-const BYTES_PER_TOKEN_MIN = 1
-
-const BYTES_PER_TOKEN_MAX = 16
-
-const LIVE_WINDOW_MS = 5_000
-
-const LIVE_STALE_MS = 1_500
-
-const LIVE_MIN_DURATION_MS = 250
-
-function estimateTokens(bytes: number, bytesPerToken: number): number {
- return Math.ceil(bytes / bytesPerToken)
-}
-
-function formatTps(value: number): string {
- if (value < 10) return value.toFixed(2)
-
- if (value < 100) return value.toFixed(1)
-
- return Math.round(value).toString()
-}
-
-// ---------------------------------------------------------------------------
-// tracker (UI-free)
-
-interface Frozen {
- readonly tps: number | null
- readonly tokens: number
- readonly tokensEstimated: boolean
- readonly partial: boolean
-}
-
-interface LiveSample {
- readonly bytes: number
- readonly timestamp: number
-}
-
-interface OutputBlock {
- streamedBytes: number
- finalBytes: number | null
-}
-
-interface StepState {
- readonly assistantMessageID: string
- readonly startedAt: number
- streamedAt: number | null
- lastBoundaryAt: number | null
- observableBytes: number
- readonly blocks: Map
- readonly samples: LiveSample[]
-}
-
-interface RunState {
- phase: "running" | "ended"
- settledTokens: number
- settledDurationMs: number
- tokensEstimated: boolean
- partial: boolean
- activeStep: StepState | null
- readonly settledSteps: Set
- frozen: Frozen | null
-}
-
-export interface TpsValue {
- readonly tps: number | null
- readonly tokens: number
- readonly frozen: boolean
- readonly tokensEstimated: boolean
- readonly tpsEstimated: true
- readonly partial: boolean
-}
-
-// A finished run keeps its frozen average indefinitely (it is what the composer
-// still shows), so the map is bounded instead: past this many tracked sessions,
-// the least recently started *finished* runs are dropped. Running ones are never
-// touched. Entries are tiny, so this is hygiene for a long-lived TUI, not a
-// memory fix.
-const MAX_TRACKED_RUNS = 64
-
-export class TpsTracker {
- // Insertion order is kept equal to run-start recency (see beginRun), which is
- // what makes eviction from the front drop the stalest session.
- private readonly runs = new Map()
- private readonly config: TpsConfig
-
- constructor(config: TpsConfig = DEFAULT_CONFIG) {
- this.config = config
- }
-
- private state(sessionID: string): RunState {
- let st = this.runs.get(sessionID)
-
- if (!st) {
- st = {
- phase: "ended",
- settledTokens: 0,
- settledDurationMs: 0,
- tokensEstimated: false,
- partial: false,
- activeStep: null,
- settledSteps: new Set(),
- frozen: null,
- }
- this.runs.set(sessionID, st)
- }
-
- return st
- }
-
- beginRun(sessionID: string): void {
- const st = this.state(sessionID)
- st.phase = "running"
- st.settledTokens = 0
- st.settledDurationMs = 0
- st.tokensEstimated = false
- st.partial = false
- st.activeStep = null
- st.settledSteps.clear()
- st.frozen = null
- // Re-insert so this session becomes the newest in iteration order. Every
- // entry is created through here, so the cap is checked on the one path that
- // can grow the map.
- this.runs.delete(sessionID)
- this.runs.set(sessionID, st)
- this.evictStale()
- }
-
- private evictStale(): void {
- if (this.runs.size <= MAX_TRACKED_RUNS) return
-
- for (const [sessionID, st] of this.runs) {
- if (this.runs.size <= MAX_TRACKED_RUNS) return
-
- if (st.phase === "running") continue
- this.dropSession(sessionID)
- }
- }
-
- private ensureStep(sessionID: string, assistantMessageID: string, now: number, replace = false): StepState | null {
- const st = this.state(sessionID)
-
- if (st.settledSteps.has(assistantMessageID) || (st.phase === "ended" && st.frozen !== null)) return null
-
- if (st.phase !== "running") this.beginRun(sessionID)
- const running = this.state(sessionID)
-
- if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep
-
- if (running.activeStep && !replace) return null
-
- if (running.activeStep) this.settleActiveStep(running, undefined)
-
- const step: StepState = {
- assistantMessageID,
- startedAt: now,
- streamedAt: null,
- lastBoundaryAt: null,
- observableBytes: 0,
- blocks: new Map(),
- samples: [],
- }
-
- running.activeStep = step
- running.frozen = null
-
- return step
- }
-
- beginStep(sessionID: string, assistantMessageID: string, now = Date.now()): void {
- const st = this.state(sessionID)
-
- if (st.phase !== "running") {
- if (st.settledSteps.has(assistantMessageID)) return
- this.beginRun(sessionID)
- }
-
- if (st.activeStep?.assistantMessageID === assistantMessageID) return
- this.ensureStep(sessionID, assistantMessageID, now, true)
- }
-
- beginBlock(sessionID: string, assistantMessageID: string, blockID: string, now: number): void {
- const step = this.ensureStep(sessionID, assistantMessageID, now)
-
- if (!step) return
-
- if (!step.blocks.has(blockID)) step.blocks.set(blockID, { streamedBytes: 0, finalBytes: null })
- }
-
- push(
- sessionID: string,
- delta: string,
- now: number,
- assistantMessageID = "implicit",
- blockID = "implicit",
- ): void {
- if (!delta) return
- const step = this.ensureStep(sessionID, assistantMessageID, now)
-
- if (!step) return
- let block = step.blocks.get(blockID)
-
- if (!block) {
- block = { streamedBytes: 0, finalBytes: null }
- step.blocks.set(blockID, block)
- }
-
- if (block.finalBytes !== null) return
- const bytes = Buffer.byteLength(delta, "utf8")
- block.streamedBytes += bytes
- step.observableBytes += bytes
- step.samples.push({ bytes, timestamp: now })
- const oldest = now - LIVE_WINDOW_MS
-
- while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift()
- }
-
- finishBlock(
- sessionID: string,
- assistantMessageID: string,
- blockID: string,
- text: string,
- now: number,
- ): void {
- const st = this.runs.get(sessionID)
- const step = st?.activeStep
-
- if (!step || step.assistantMessageID !== assistantMessageID) return
- let block = step.blocks.get(blockID)
-
- if (!block) {
- block = { streamedBytes: 0, finalBytes: null }
- step.blocks.set(blockID, block)
- }
-
- if (block.finalBytes !== null) return
- block.finalBytes = Buffer.byteLength(text, "utf8")
- step.observableBytes += block.finalBytes - block.streamedBytes
- step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now)
- }
-
- /**
- * The host's authoritative end of the model stream, published after the
- * provider stream exits and before local tools join. Assigned rather than
- * maxed so a retried attempt reusing the message ID moves the boundary to its
- * own completion.
- */
- markStreamed(sessionID: string, assistantMessageID: string, now: number): void {
- const st = this.runs.get(sessionID)
- const step = st?.activeStep
-
- if (!step || step.assistantMessageID !== assistantMessageID) return
- step.streamedAt = now
- }
-
- private settleActiveStep(st: RunState, generatedTokens: number | undefined): void {
- const step = st.activeStep
-
- if (!step) return
- const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0
- st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken)
-
- if (!exact) {
- st.tokensEstimated = true
- st.partial = true
- }
-
- // `session.step.streamed` is the exact stream end; the last content boundary
- // remains the fallback for hosts that do not publish it.
- const end = step.streamedAt ?? step.lastBoundaryAt
-
- if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt)
- st.settledSteps.add(step.assistantMessageID)
- st.activeStep = null
- }
-
- finishStep(sessionID: string, assistantMessageID: string, generatedTokens: number | undefined, _now: number): void {
- const st = this.runs.get(sessionID)
-
- if (st?.activeStep?.assistantMessageID !== assistantMessageID) return
- this.settleActiveStep(st, generatedTokens)
- }
-
- finish(sessionID: string, _now: number): void {
- const st = this.runs.get(sessionID)
-
- if (!st || st.phase === "ended") return
-
- if (st.activeStep) this.settleActiveStep(st, undefined)
- st.phase = "ended"
- const tokens = st.settledTokens
-
- if (tokens <= 0) {
- this.evictStale()
-
- return
- }
-
- const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null
- st.frozen = { tps, tokens, tokensEstimated: st.tokensEstimated, partial: st.partial }
- mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`)
- this.evictStale()
- }
-
- private dropSession(sessionID: string): void {
- this.runs.delete(sessionID)
- }
-
- evict(sessionID: string): void {
- this.dropSession(sessionID)
- }
-
- hasRunning(now = Date.now()): boolean {
- for (const st of this.runs.values()) {
- const last = st.activeStep?.samples.at(-1)
-
- if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true
- }
-
- return false
- }
-
- private liveTps(step: StepState, now: number): number | null {
- const last = step.samples.at(-1)
-
- if (!last) return null
- const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS)
- const oldest = effectiveNow - LIVE_WINDOW_MS
- const samples = step.samples.filter((sample) => sample.timestamp >= oldest)
- const first = samples[0]
-
- if (!first) return null
- const bytes = samples.reduce((total, sample) => total + sample.bytes, 0)
- const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS)
-
- return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000)
- }
-
- value(sessionID: string, now: number): TpsValue | null {
- const st = this.runs.get(sessionID)
-
- if (!st) return null
-
- if (st.frozen)
- return {
- ...st.frozen,
- frozen: true,
- tpsEstimated: true,
- }
-
- if (st.phase !== "running") return null
- const active = st.activeStep
- const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0
- const tokens = st.settledTokens + activeTokens
-
- if (tokens <= 0) return null
- const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null
-
- return {
- tps: active ? (this.liveTps(active, now) ?? settledTps) : settledTps,
- tokens,
- frozen: false,
- tokensEstimated: st.tokensEstimated || active !== null,
- tpsEstimated: true,
- partial: st.partial,
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// options
-//
-// `ctx.options` is host-supplied JSON (Record), so this is a real
-// parsing boundary: every value is validated and clamped, and anything invalid
-// falls back to the default rather than propagating NaN into the arithmetic.
-
-const DISPLAY_MODES = ["both", "tokens", "tps"] as const
-
-export type DisplayMode = (typeof DISPLAY_MODES)[number]
-
-/**
- * A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
- * Named so the option boundary has a real input contract to validate against.
- */
-export type OptionValue = string | number | boolean | null | readonly OptionValue[] | { readonly [key: string]: OptionValue }
-
-/** The option surface, exactly as documented in the README, before validation. */
-export interface TpsOptionsInput {
- readonly display?: OptionValue
- readonly refreshHz?: OptionValue
- readonly bytesPerToken?: OptionValue
- readonly debug?: OptionValue
-}
-
-export interface TpsOptions extends TpsConfig {
- readonly display: DisplayMode
- readonly refreshHz: number
- readonly debug: boolean
-}
-
-export const DEFAULT_OPTIONS: TpsOptions = {
- ...DEFAULT_CONFIG,
- display: "both",
- refreshHz: 8,
- debug: false,
-}
-
-function isFiniteNumber(value: OptionValue | undefined): value is number {
- return Number.isFinite(value)
-}
-
-function isDisplayMode(value: OptionValue | undefined): value is DisplayMode {
- return DISPLAY_MODES.some((mode) => mode === value)
-}
-
-function clampNumber(value: OptionValue | undefined, fallback: number, min: number, max: number): number {
- if (!isFiniteNumber(value)) return fallback
-
- return Math.min(Math.max(value, min), max)
-}
-
-export function resolveOptions(raw: TpsOptionsInput): TpsOptions {
- return {
- display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
- refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, 1, 60),
- bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
- debug: raw.debug === true,
- }
-}
-
-export function formatLabel(value: TpsValue, display: DisplayMode): string {
- const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`
- const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`
-
- if (display === "tokens") return tokens
-
- if (display === "tps") return tps ?? "— t/s"
-
- return tps === null ? tokens : `${tokens} · ${tps}`
-}
-
-// ---------------------------------------------------------------------------
-// plugin
-
-// Event payloads are taken from the SDK's own union (via the non-generic
-// `data.listen` signature) rather than restated structurally: handlers are
-// contravariant, so hand-written shapes keep typechecking after a field rename.
-type PluginContext = Parameters[0]
-
-type AnyEvent = Parameters[0]>[0]["details"]
-
-type EventOf = Extract
-
-type DeltaEvent = EventOf<"session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta">
-
-type BlockStartedEvent = EventOf<
- "session.text.started" | "session.reasoning.started" | "session.tool.input.started"
->
-
-type BlockEndedEvent = EventOf<"session.text.ended" | "session.reasoning.ended" | "session.tool.input.ended">
-
-type FinishEvent = EventOf<
- "session.execution.succeeded" | "session.execution.failed" | "session.execution.interrupted" | "session.idle"
->
-
-type StepStartedEvent = EventOf<"session.step.started">
-
-type StepStreamedEvent = EventOf<"session.step.streamed">
-
-type StepFinishedEvent = EventOf<"session.step.ended" | "session.step.failed">
-
-function blockID(e: DeltaEvent | BlockStartedEvent | BlockEndedEvent): string {
- if (e.type === "session.tool.input.delta" || e.type === "session.tool.input.started" || e.type === "session.tool.input.ended")
- return `tool:${e.data.id}`
-
- return `${e.type.startsWith("session.text.") ? "text" : "reasoning"}:${e.data.ordinal}`
-}
-
-const definition: Plugin.Definition = {
- id: "opencode2.tps",
- setup(ctx) {
- // Generation guard: the host may start a new generation of this plugin
- // without disposing the previous one (observed on server (re)attach), and
- // hot reload shares `storage.memory` across generations. Only the newest
- // generation may count tokens or render.
- const [gen, setGen] = ctx.storage.memory("generation", { initial: { active: 0 } })
- const mine = gen.active + 1
- setGen((d) => {
- d.active = mine
- })
- const isActive = () => gen.active === mine
-
- const options = resolveOptions(ctx.options)
- configureDebug(options.debug || isEnvEnabled(process.env["TPS_DEBUG"]))
-
- const tracker = new TpsTracker(options)
- const [version, setVersion] = createSignal(0)
- const seenEventIDs = new Set()
-
- const isNewEvent = (e: AnyEvent): boolean => {
- if (seenEventIDs.has(e.id)) return false
- seenEventIDs.add(e.id)
-
- if (seenEventIDs.size > 4_096) {
- const oldest = seenEventIDs.values().next().value
-
- if (oldest !== undefined) seenEventIDs.delete(oldest)
- }
-
- return true
- }
-
- mark(`setup ok app=${ctx.app.version} gen=${mine} display=${options.display} refreshHz=${options.refreshHz}`)
-
- // Rendering is throttled: deltas arrive at 100-200/s, and every bump costs
- // a memo recompute plus a terminal repaint to move a number no one can read
- // faster than ~10 Hz. Handlers only set a flag; the timer does the work,
- // and it only runs while a session is actually streaming.
- let dirty = false
- let timer: ReturnType | undefined
-
- const flush = () => {
- // A superseded generation stops ticking even if its cleanup never ran.
- if (!isActive()) {
- stopTimer()
-
- return
- }
-
- const running = tracker.hasRunning(Date.now())
-
- // The observable live rate decays only through a short stale tail. Opaque
- // provider work after that is not charged to a numerator we cannot see.
- if (dirty || running) {
- dirty = false
- setVersion((v) => v + 1)
- }
-
- if (!running) stopTimer()
- }
-
- function stopTimer(): void {
- if (timer === undefined) return
- clearInterval(timer)
- timer = undefined
- }
-
- const touch = () => {
- dirty = true
-
- if (timer !== undefined) return
- timer = setInterval(flush, Math.round(1000 / options.refreshHz))
- timer.unref?.()
- }
-
- const onDelta = (e: DeltaEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.push(e.data.sessionID, e.data.delta, e.created, e.data.assistantMessageID, blockID(e))
- touch()
- }
-
- const onBlockStarted = (e: BlockStartedEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.beginBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.created)
- }
-
- const onBlockEnded = (e: BlockEndedEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.finishBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.data.text, e.created)
- touch()
- }
-
- const onFinish = (e: FinishEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.finish(e.data.sessionID, e.created)
- touch()
- }
-
- const onStepStarted = (e: StepStartedEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.beginStep(e.data.sessionID, e.data.assistantMessageID, e.created)
- touch()
- }
-
- const onStepStreamed = (e: StepStreamedEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.markStreamed(e.data.sessionID, e.data.assistantMessageID, e.created)
- touch()
- }
-
- const onStepFinished = (e: StepFinishedEvent) => {
- if (!isActive() || !isNewEvent(e)) return
- const tokens = e.data.tokens
-
- const generatedTokens =
- tokens !== undefined &&
- Number.isFinite(tokens.output) &&
- tokens.output >= 0 &&
- Number.isFinite(tokens.reasoning) &&
- tokens.reasoning >= 0
- ? tokens.output + tokens.reasoning
- : undefined
-
- tracker.finishStep(
- e.data.sessionID,
- e.data.assistantMessageID,
- generatedTokens,
- e.created,
- )
- touch()
- }
-
- const unsubs = [
- ctx.data.on("session.execution.started", (e) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.beginRun(e.data.sessionID)
- touch()
- }),
- ctx.data.on("session.text.delta", onDelta),
- ctx.data.on("session.reasoning.delta", onDelta),
- ctx.data.on("session.tool.input.delta", onDelta),
- ctx.data.on("session.text.started", onBlockStarted),
- ctx.data.on("session.reasoning.started", onBlockStarted),
- ctx.data.on("session.tool.input.started", onBlockStarted),
- ctx.data.on("session.text.ended", onBlockEnded),
- ctx.data.on("session.reasoning.ended", onBlockEnded),
- ctx.data.on("session.tool.input.ended", onBlockEnded),
- ctx.data.on("session.step.started", onStepStarted),
- ctx.data.on("session.step.streamed", onStepStreamed),
- ctx.data.on("session.step.ended", onStepFinished),
- ctx.data.on("session.step.failed", onStepFinished),
- ctx.data.on("session.execution.succeeded", onFinish),
- ctx.data.on("session.execution.failed", onFinish),
- ctx.data.on("session.execution.interrupted", onFinish),
- ctx.data.on("session.idle", onFinish),
- ctx.data.on("session.deleted", (e) => {
- if (!isActive() || !isNewEvent(e)) return
- tracker.evict(e.data.sessionID)
- touch()
- }),
- ]
-
- const unslot = ctx.ui.slot({
- append: "session.composer.top",
- render: (input) => {
- const label = createMemo(() => {
- version()
-
- if (!isActive()) return null
- const v = tracker.value(input.sessionID, Date.now())
-
- if (!v) return null
-
- return formatLabel(v, options.display)
- })
-
- return (
-
- {(text: () => string) => (
-
- {`${text()} `}
-
- )}
-
- )
- },
- })
-
- return () => {
- for (const unsub of unsubs) unsub()
- unslot()
- stopTimer()
-
- if (gen.active === mine)
- setGen((d) => {
- d.active = 0
- })
- mark(`cleanup ok gen=${mine}`)
- }
- },
-}
-
-export default definition
diff --git a/tsconfig.json b/tsconfig.json
index e8a9084..2892ca2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,8 @@
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
"types": ["node", "bun"]
},
- "include": ["tps.tsx", "tui.tsx", "tps.test.ts", "entrypoint.test.tsx"]
+ "include": ["tui.tsx", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
diff --git a/tui.tsx b/tui.tsx
index 2f405e6..583a504 100644
--- a/tui.tsx
+++ b/tui.tsx
@@ -1,3 +1,2 @@
-// Local path entries resolve `/tui.`; this re-exports the
-// plugin definition so a path entry at the repository root loads the source.
-export { default } from "./tps.tsx"
+// Local path entries resolve `/tui.`; this shim keeps that loader contract at the repository root while the implementation lives in `src/`.
+export { default } from "./src/plugin.tsx"
From 8d591b15ceaf45d14b0a1ab9312b44632330c496 Mon Sep 17 00:00:00 2001
From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 16:11:09 +0300
Subject: [PATCH 2/2] fix(ci): packed schema verification, native CLI exec,
Node 26 baseline (#8)
Verify the packed schema export and harden CI on the Node 26 baseline.
- Packed-artifact step asserts the tarball contains package/options.schema.json and that the opencode2-tps/options.schema.json export resolves through the exports map (with the JSON import attribute) with the expected title.
- scripts/check-compatibility.mjs resolves node_modules/.bin against the script location instead of cwd and execs @opencode/cli's real bin target (bin/opencode2.exe) instead of npm's .bin wrapper, dropping the process.platform branch.
- CI node-version moves 24 -> 26.4.0 to match @opentui/core's documented floor; @types/node follows to ^26.0.0 with regenerated lockfile.
- The two setInterval test doubles cast to typeof globalThis.setInterval for Node 26's conditional rest-args typing; no runtime source changes in src/.
---
.github/workflows/ci.yml | 5 ++++-
docs/development.md | 2 ++
package-lock.json | 16 ++++++++--------
package.json | 2 +-
scripts/check-compatibility.mjs | 10 +++++++++-
tests/entrypoint.test.tsx | 8 ++++++--
tests/plugin.test.ts | 8 ++++++--
7 files changed, 36 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cc62b0b..40124bd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,9 +14,10 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # 26.4.0 is @opentui/core's documented Node floor; the non-bun toolchain runs on it.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
- node-version: 24
+ node-version: 26.4.0
cache: npm
# tsc, oxlint and scripts/build.mjs run on node; the test script is `bun test`.
@@ -44,7 +45,9 @@ jobs:
run: |
mkdir -p "$RUNNER_TEMP/tps-pack" "$RUNNER_TEMP/tps-install"
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/tps-pack"
+ tar -tzf "$RUNNER_TEMP"/tps-pack/opencode2-tps-*.tgz | grep -qx 'package/options.schema.json' || { echo "options.schema.json missing from the tarball"; exit 1; }
opentui=$(node -p 'require("./node_modules/@opentui/solid/package.json").version')
solid=$(node -p 'require("./node_modules/solid-js/package.json").version')
npm install --ignore-scripts --prefix "$RUNNER_TEMP/tps-install" "$RUNNER_TEMP"/tps-pack/opencode2-tps-*.tgz "@opentui/solid@${opentui}" "solid-js@${solid}"
node --input-type=module -e 'const m = await import(process.argv[1]); if (m.default?.id !== "opencode2.tps") { console.error("unexpected entrypoint export:", m.default); process.exit(1) }' "file://$RUNNER_TEMP/tps-install/node_modules/opencode2-tps/dist/tui.js"
+ (cd "$RUNNER_TEMP/tps-install" && node --input-type=module -e 'const s = await import("opencode2-tps/options.schema.json", { with: { type: "json" } }); if (s.default.title !== "opencode2-tps plugin options") { console.error("unexpected schema export:", s.default.title); process.exit(1) }')
diff --git a/docs/development.md b/docs/development.md
index 5933bf7..f2ba676 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -14,6 +14,8 @@ Tests run through `bun test`. `bunfig.toml` preloads `@opentui/solid/preload` so
`scripts/*.mjs` run under plain node and stay outside the `tsconfig.json` typecheck — they are exercised by CI and the entrypoint test instead.
+CI runs the Node-side toolchain on Node 26.4, the `@opentui/core` documented floor; tests run under Bun either way.
+
## 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 `src/plugin.tsx`, transforms the source, and watches it — saving a file under `src/` reloads the plugin without a restart.
diff --git a/package-lock.json b/package-lock.json
index 39616b4..1ce21d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
"@opentui/solid": "^0.5.10",
"@oxlint/plugins": "^1.78.0",
"@types/bun": "^1.3.14",
- "@types/node": "^24.0.0",
+ "@types/node": "^26.0.0",
"babel-preset-solid": "^1.9.12",
"oxlint": "^1.78.0",
"solid-js": "^1.9.0",
@@ -3793,13 +3793,13 @@
}
},
"node_modules/@types/node": {
- "version": "24.13.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
- "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "version": "26.6.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
+ "integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "undici-types": "~8.9.0"
}
},
"node_modules/@types/ws": {
@@ -6739,9 +6739,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index 41a862d..be671b9 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,7 @@
"@opentui/solid": "^0.5.10",
"@oxlint/plugins": "^1.78.0",
"@types/bun": "^1.3.14",
- "@types/node": "^24.0.0",
+ "@types/node": "^26.0.0",
"babel-preset-solid": "^1.9.12",
"oxlint": "^1.78.0",
"solid-js": "^1.9.0",
diff --git a/scripts/check-compatibility.mjs b/scripts/check-compatibility.mjs
index 97d7fa3..cd2cd25 100644
--- a/scripts/check-compatibility.mjs
+++ b/scripts/check-compatibility.mjs
@@ -4,6 +4,7 @@
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
+import { fileURLToPath } from "node:url"
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
@@ -33,7 +34,14 @@ if (Number(floor.replace("0.0.0-beta-", "")) > Number(version.replace("0.0.0-bet
throw new Error(`README floor ${floor} is newer than the pinned compatibility target ${version}`)
}
-const executable = resolve("node_modules", ".bin", process.platform === "win32" ? "opencode2.cmd" : "opencode2")
+// Resolved against this script's location, not cwd, so the check works from any directory.
+const root = fileURLToPath(new URL("..", import.meta.url))
+
+// The package's own bin target, not npm's `.bin` wrapper: a .cmd shim cannot be
+// execFileSync'd on Windows without a shell, while the native binary runs
+// unshelled on every platform despite the .exe name. A normal install's
+// postinstall (or CI's prepare step with --ignore-scripts) puts it there.
+const executable = resolve(root, "node_modules", "@opencode", "cli", "bin", "opencode2.exe")
const reported = execFileSync(executable, ["--version"], { encoding: "utf8" }).trim()
diff --git a/tests/entrypoint.test.tsx b/tests/entrypoint.test.tsx
index 5ab9678..a00d1bf 100644
--- a/tests/entrypoint.test.tsx
+++ b/tests/entrypoint.test.tsx
@@ -106,7 +106,11 @@ function createHarness(options: TpsOptionsInput = {}): Harness {
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) => {
+ // SAFETY: Node 26's setInterval type has a conditional rest-args overload no
+ // two-parameter double can satisfy; the double ignores extra arguments by
+ // design, so the assignment is narrowed in one step — a test-double
+ // limitation, not a production cast.
+ 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.
@@ -114,7 +118,7 @@ function createHarness(options: TpsOptionsInput = {}): Harness {
realClearInterval(handle)
return handle
- }
+ }) as typeof globalThis.setInterval
globalThis.clearInterval = () => {
flush = undefined
diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts
index a094577..ee70360 100644
--- a/tests/plugin.test.ts
+++ b/tests/plugin.test.ts
@@ -78,7 +78,11 @@ function createHarness(options: TpsOptionsInput = {}) {
const realSetInterval = globalThis.setInterval
const realClearInterval = globalThis.clearInterval
- globalThis.setInterval = (fn: () => void, ms?: number) => {
+ // SAFETY: Node 26's setInterval type has a conditional rest-args overload no
+ // two-parameter double can satisfy; the double ignores extra arguments by
+ // design, so the assignment is narrowed in one step — a test-double
+ // limitation, not a production cast.
+ globalThis.setInterval = ((fn: () => void, ms?: number) => {
timer.callback = fn
timer.intervalMs = ms ?? 0
timer.created += 1
@@ -88,7 +92,7 @@ function createHarness(options: TpsOptionsInput = {}) {
realClearInterval(handle)
return handle
- }
+ }) as typeof globalThis.setInterval
globalThis.clearInterval = () => {
timer.cleared += 1