Skip to content

Latest commit

 

History

History
97 lines (68 loc) · 8.1 KB

File metadata and controls

97 lines (68 loc) · 8.1 KB

Development

Setup

npm ci          # install dependencies
npm run lint    # oxlint
npm run check   # tsc --noEmit
npm test        # bun test
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.

  • tests/tracker.test.ts covers the throughput tracker.
  • tests/options.test.ts covers the option parsing and label formatting.
  • tests/debug.test.ts covers the debug switch.
  • tests/plugin.test.ts covers 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.
  • 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 src/plugin.tsx, transforms the source, and watches it — saving a file under src/ reloads the plugin without a restart.

{
  "plugins": [
    {
      "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 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

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, because the host only applies the Solid transform outside node_modules and an installed package lives inside it. 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.

Debug logging

Logging is off by default. Two ways to turn it on:

  • "debug": true in the options of the cli.json entry.
  • TPS_DEBUG=1 or TPS_DEBUG=true in the TUI's environment. Use this when the host gives the plugin no options.

The log lands in <tmpdir>/tps-debug-<pid>/tps.log. The directory is created with mode 0700, because the log records session IDs. If the name is already taken, the plugin reuses it only when it is a real directory rather than a symlink, has mode 0700, and belongs to your user; otherwise it falls back to tps-debug-<pid>-XXXXXX.

That means one directory and one log per PID. Hot reloads append to the same file, and so does a later process that the OS hands the same PID — the timestamps tell them apart.

Architecture

  • The plugin listens to session events to start a run, end a run, and collect the model's output.
  • 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. At the session.step.streamed boundary the clock stops entirely. The live estimate is then held across tool execution and between steps, and only new observable bytes resume it.
  • 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 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 when the stream boundary is known, when a step settles without one, or when the live stale tail expires. A held rate cannot change with time, so lifecycle events schedule one final dirty repaint without keeping the timer running.
  • 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.

Read src/ for the event names and the formulas.

Example run

One user prompt becomes a stream of events; the tracker does the bookkeeping below for each step.

What happens Event Tracker action
A new user prompt starts a run session.execution.started reset settled tokens, observed duration, and partial state
The model begins a step of generation session.step.started (m1) record the step timestamp and assistant-message ID
An output block begins session.*.started (m1) create an idempotent text, reasoning, or tool-input block
Observable output streams session.*.delta (m1) add UTF-8 bytes and a rolling-rate sample
The complete block becomes available session.*.ended (m1) reconcile its full byte count and record the model-content boundary
The provider stream exits session.step.streamed (m1) record the authoritative span end, before local tools join, and capture the live rate there
The model step settles, possibly after a tool runs session.step.ended / failed (m1) replace the estimate with reported usage; add duration through the span end; capture the held rate there
The whole execution finishes session.execution.succeeded / failed / idle freeze exact settled tokens plus any explicitly estimated partial output

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

Release

Publishing is a separate flow. See Release.