Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) }')
50 changes: 0 additions & 50 deletions build.mjs

This file was deleted.

29 changes: 0 additions & 29 deletions check-compatibility.mjs

This file was deleted.

4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<directory>/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 `<directory>/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
{
Expand All @@ -27,15 +31,15 @@ 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.

The host also picks up plugins from a `plugin` or `plugins` directory in the config directory, but those receive no options, so use a path entry when you need them.

## 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.

Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
46 changes: 46 additions & 0 deletions options.schema.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -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)`)
}
Loading