diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c81661b..40124bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,13 @@ 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 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 +34,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 @@ -51,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 '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" + (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/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/check-compatibility.mjs b/check-compatibility.mjs deleted file mode 100644 index 9c126ed..0000000 --- a/check-compatibility.mjs +++ /dev/null @@ -1,29 +0,0 @@ -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 packages = ["@opencode/cli", "@opencode/plugin", "@opencode/theme"] - -const versions = packages.map((name) => packageJson.devDependencies[name]) - -const version = versions[0] - -if (!/^0\.0\.0-beta-\d{5,6}$/.test(version)) { - throw new Error(`OpenCode 2 compatibility version has an unexpected format: ${version}`) -} - -if (!versions.every((candidate) => candidate === version)) { - throw new Error(`OpenCode 2 packages must use one exact version: ${versions.join(", ")}`) -} - -const executable = resolve("node_modules", ".bin", process.platform === "win32" ? "opencode2.cmd" : "opencode2") - -const reported = execFileSync(executable, ["--version"], { encoding: "utf8" }).trim() - -if (reported !== `opencode2 v${version}`) { - throw new Error(`Expected opencode2 v${version}, got ${reported}`) -} - -console.log(`OpenCode 2 compatibility target: ${version}`) 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..f2ba676 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,14 +7,18 @@ 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. + +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 `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 +31,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 +39,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 +63,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-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 bd3543f..be671b9 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", @@ -53,16 +55,16 @@ "@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", "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/scripts/check-compatibility.mjs b/scripts/check-compatibility.mjs new file mode 100644 index 0000000..cd2cd25 --- /dev/null +++ b/scripts/check-compatibility.mjs @@ -0,0 +1,52 @@ +// 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" +import { fileURLToPath } from "node:url" + +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) + +const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8") + +const packages = ["@opencode/cli", "@opencode/plugin", "@opencode/theme"] + +const versions = packages.map((name) => packageJson.devDependencies[name]) + +const version = versions[0] + +if (!/^0\.0\.0-beta-\d{5,6}$/.test(version)) { + throw new Error(`OpenCode 2 compatibility version has an unexpected format: ${version}`) +} + +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}`) +} + +// 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() + +if (reported !== `opencode2 v${version}`) { + throw new Error(`Expected opencode2 v${version}, got ${reported}`) +} + +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 92% rename from entrypoint.test.tsx rename to tests/entrypoint.test.tsx index 5e2c3db..a00d1bf 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 }) @@ -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/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..ee70360 --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,300 @@ +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 + // 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 + // 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 + }) as typeof globalThis.setInterval + + 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"