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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ jobs:
# The compatibility check needs the CLI, but running its postinstall would
# restore dependency lifecycle-script execution. The runner is Linux x64.
- name: Prepare the OpenCode CLI
run: install -m 755 node_modules/@opencode-ai/cli-linux-x64/bin/opencode2 node_modules/@opencode-ai/cli/bin/opencode2.exe
run: install -m 755 node_modules/@opencode/cli-linux-x64/bin/opencode2 node_modules/@opencode/cli/bin/opencode2.exe

- 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-ai/cli"]')
version=$(node -p 'require("./package.json").devDependencies["@opencode/cli"]')
gh api "repos/anomalyco/opencode-beta/releases/tags/v${version}" --silent

- run: npm run lint
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ When OpenCode reports terminal usage, the token count becomes exact while TPS re

## Install

Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`; the latest tested beta is `0.0.0-beta-17639`. The TUI plugin API is still moving, so a much newer or older build may drop the indicator without an error. If the figure never appears, check your version first.
Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`; the latest tested beta is `0.0.0-beta-19381`. The TUI plugin API is still moving, so a much newer or older build may drop the indicator without an error. If the figure never appears, check your version first.

Add the package to `~/.config/opencode/cli.json`:
Install it with the CLI's plugin command, which adds the entry to `~/.config/opencode/cli.json` for you:

```sh
opencode2 plugin add opencode2-tps
```

Or add the package to `~/.config/opencode/cli.json` yourself:

```json
{
Expand All @@ -35,12 +41,12 @@ To set options, use the object form:
}
```

A running TUI picks up `cli.json` changes immediately. On first use it installs the package into its own cache, `~/.cache/opencode/packages/opencode2-tps/` on Linux.
A running TUI picks up `cli.json` changes immediately. On first use it installs the package into its own cache under `~/.cache/opencode/npm/` on Linux — one millisecond-timestamped generation per spec, for example `opencode2-tps@latest/<generation>/`, with the newest generation winning.

That cache is keyed on the entry text and is never refreshed once it exists, so a restart alone will not pick up a new release. To upgrade, delete the directory and restart:
The host reuses the newest installed generation without contacting the registry, so a restart alone may not pick up a new release. To upgrade, delete the spec's cache directory and restart:

```sh
rm -rf ~/.cache/opencode/packages/opencode2-tps
rm -rf ~/.cache/opencode/npm/opencode2-tps@latest
```

To pin a version instead, put the range in the entry — `"opencode2-tps@0.1.0"`. Every distinct entry gets its own cache directory.
Expand All @@ -67,7 +73,9 @@ The defaults are usable as they are. For the full option list, the ranges and mo

While output streams, the plugin estimates tokens from observable UTF-8 bytes at a default of 4.75 bytes per token and calculates a bounded rolling delivery rate. Complete text, reasoning, and tool-input events reconcile buffered or missed deltas without creating artificial live-rate spikes.

At the end of each model step, OpenCode's reported output and reasoning usage replaces the byte estimate. Settled TPS divides those exact tokens by observed step spans ending at the final model-content boundary, which excludes later local tool execution and time between model calls.
At the end of each model step, OpenCode's reported output and reasoning usage replaces the byte estimate. Settled TPS divides those exact tokens by observed step spans ending at `session.step.streamed`, the host's authoritative end of the model stream, published before local tools join. Hosts that do not publish the event fall back to the final model-content boundary. Either way, local tool execution and time between model calls are excluded.

OpenCode's built-in assistant-footer t/s divides visible output tokens by the same step spans, leaving hidden reasoning out of its numerator. This plugin counts output plus reasoning, so on reasoning models its settled figure reads higher than the built-in one — those tokens were generated too.

TPS is always approximate (`~`) because OpenCode does not expose token-level timestamps. Proprietary reasoning may be encrypted or represented only by a short summary, and some providers buffer tool arguments until completion. During those opaque intervals the live rate holds or becomes unavailable instead of continuously falling. Opaque provider state is never counted by byte length.

Expand Down
2 changes: 1 addition & 1 deletion check-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { resolve } from "node:path"

const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"))
const readme = readFileSync(new URL("./README.md", import.meta.url), "utf8")
const packages = ["@opencode-ai/cli", "@opencode-ai/plugin", "@opencode-ai/theme"]
const packages = ["@opencode/cli", "@opencode/plugin", "@opencode/theme"]
const versions = packages.map((name) => packageJson.devDependencies[name])
const version = versions[0]

Expand Down
15 changes: 9 additions & 6 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,25 @@ npm run build # write dist/tui.js

## Run from source

Point a path entry in `cli.json` at the source file. The host watches it and reloads the plugin whenever you save `tps.tsx`.
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.

```json
{
"plugins": [
{ "package": "/absolute/path/to/tps.tsx", "options": { "debug": true } }
{ "package": "/absolute/path/to/opencode2-tps", "options": { "debug": true } }
]
}
```

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.

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

`solid-js` and `@opentui/solid` are optional peer dependencies; the host supplies its own copies.

Expand All @@ -49,7 +51,7 @@ That means one directory and one log per PID. Hot reloads append to the same fil
- It estimates live tokens from observable UTF-8 bytes at 4.75 bytes per token by default. Complete block values reconcile buffered or missed deltas.
- Live TPS is a bounded rolling rate over observable deltas. Its denominator stops after a short stale tail because silence may be encrypted reasoning or buffered tool input rather than inactivity.
- A completed model step reports exact generated usage as `tokens.output + tokens.reasoning`. This replaces that step's byte estimate.
- Settled TPS sums exact step tokens and divides once by the sum of observed step spans. Each span runs from `session.step.started` 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.
- 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`.
Expand All @@ -68,10 +70,11 @@ One user prompt becomes a stream of events; the tracker does the bookkeeping bel
| An output block begins | `session.*.started` (m1) | create an idempotent text, reasoning, or tool-input block |
| Observable output streams | `session.*.delta` (m1) | add UTF-8 bytes and a rolling-rate sample |
| The complete block becomes available | `session.*.ended` (m1) | reconcile its full byte count and record the model-content boundary |
| The model step settles, possibly after a tool runs | `session.step.ended` / `failed` (m1) | replace the estimate with reported usage when available; add duration only through the last content boundary |
| The provider stream exits | `session.step.streamed` (m1) | record the authoritative span end, before local tools join |
| The model step settles, possibly after a tool runs | `session.step.ended` / `failed` (m1) | replace the estimate with reported usage when available; add duration through the streamed boundary |
| The whole execution finishes | `session.execution.succeeded` / `failed` / `idle` | freeze exact settled tokens plus any explicitly estimated partial output |

OpenCode beta versions may omit `session.tool.input.delta` entirely and provide only the complete `session.tool.input.ended` text. Newer versions may stream both. Ended-value reconciliation supports both without double-counting.
Current betas no longer publish `session.tool.input.delta`; tool arguments arrive only as the complete `session.tool.input.ended` text. Older betas streamed both, and the plugin still subscribes to the delta event for them. Ended-value reconciliation supports either without double-counting.

## Release

Expand Down
35 changes: 20 additions & 15 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Maintainer runbook.

A path entry loads `tps.tsx` and lets the host transform it; an installed package loads the pre-built `dist/tui.js`. Those are two different code paths, so the bundle gets tested before it goes out.
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.

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.

Expand All @@ -16,25 +16,28 @@ npm pack --pack-destination /tmp

The tarball holds `dist/tui.js`, `package.json`, `README.md` and `LICENSE`. The host entry point is `exports["./tui"]`.

## 2. Install it somewhere clean
## 2. Install the tarball into the host's cache

The host resolves a bare-name entry from its own cache, `~/.cache/opencode/npm/<name>@<spec>/<generation>/`, where the newest generation wins. Stage the tarball as a new generation:

```sh
mkdir -p /tmp/tps-verify && cd /tmp/tps-verify && npm init -y
npm i /tmp/opencode2-tps-<version>.tgz
gen=~/.cache/opencode/npm/opencode2-tps@latest/$(date +%s%3N)
mkdir -p "$gen" && cd "$gen"
npm init -y && npm i /tmp/opencode2-tps-<version>.tgz
```

## 3. Load the bundle

Point `cli.json` at the installed file, start the TUI and send a prompt.
Keep the bare-name entry in `cli.json` the same entry a registry install uses — start the TUI and send a prompt. The host resolves the bare name to the newest generation and loads `dist/tui.js` through `exports["./tui"]`, the same resolution a registry install gets, minus the registry.

```json
{
"plugins": [
{ "package": "/tmp/tps-verify/node_modules/opencode2-tps/dist/tui.js" }
]
"plugins": ["opencode2-tps"]
}
```

The host reuses the newest generation without contacting the registry, so the staged build stays in place across restarts. Delete the generation directory to fall back to the published version.

## 4. Publish a prerelease

Use a version such as `0.1.0-rc.1`.
Expand All @@ -47,12 +50,14 @@ Always pass a tag. An untagged publish becomes `latest`, prerelease version or n

## 5. Install from the registry

Install the prerelease, then reference the plugin by name in `cli.json`. This is the only step that exercises registry resolution, the `exports` subpath, and the bare-string entry form.
Add the prerelease with the CLI's plugin command, which installs it into the host cache and adds the entry to `cli.json` (remove any `latest` entry first — the last entry wins):

```sh
npm i opencode2-tps@next
opencode2 plugin add opencode2-tps@next
```

This is the only step that exercises registry resolution, the plugin-add routing, the `exports` subpath, and the bare-string entry form. Restart the TUI afterwards.

## 6. Publish the release

`npm version` refuses a dirty tree, so commit the work first. It writes the version, commits it, and tags it `v<version>` to match the existing tags.
Expand All @@ -79,16 +84,16 @@ Skip this for a prerelease. Those ship under `--tag next` and are not announceme

## 8. Invalidate the plugin cache

The host caches an installed plugin under `~/.cache/opencode/packages/<entry>/` on Linux, keyed on the entry text, and never refreshes it. A maintainer who verified a prerelease is still running that build. Delete the directories and restart the TUI.
The host installs a registry plugin under `~/.cache/opencode/npm/<name>@<spec>/<generation>/` on Linux. A generation is a millisecond-timestamped directory with a full `node_modules` inside; the newest generation wins, and the host reuses it without contacting the registry. A restart alone may therefore not pick up a freshly published version. Delete the spec's cache directory and restart the TUI:

```sh
rm -rf ~/.cache/opencode/packages/opencode2-tps ~/.cache/opencode/packages/'opencode2-tps@next'
rm -rf ~/.cache/opencode/npm/opencode2-tps@latest ~/.cache/opencode/npm/opencode2-tps@next
```

Restart, then confirm the host reinstalled the version that was just published.
Restart, then confirm the host installed the version that was just published:

```sh
node -p "require(process.env.HOME + '/.cache/opencode/packages/opencode2-tps/node_modules/opencode2-tps/package.json').version"
grep -h '"version"' ~/.cache/opencode/npm/opencode2-tps@latest/*/node_modules/opencode2-tps/package.json
```

Dropping the `@next` directory too keeps a stale prerelease from being loaded by an old `cli.json` entry.
One line prints per generation; the last one is the newest. Dropping the `@next` directory too keeps a stale prerelease from being loaded by an old `cli.json` entry. Old generations are pruned when a plugin update runs — only the two newest are kept, and anything older is removed after seven days — so manual cleanup is only needed to force a re-install.
Loading