opencode2-tps 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ When OpenCode reports terminal usage, the token count becomes exact while TPS re
12
12
 
13
13
  ## Install
14
14
 
15
- 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.
15
+ Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`. 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, then [open an issue](https://github.com/P-Theo/opencode2-tps/issues).
16
16
 
17
17
  Install it with the CLI's plugin command, which adds the entry to `~/.config/opencode/cli.json` for you:
18
18
 
@@ -43,15 +43,15 @@ To set options, use the object form:
43
43
 
44
44
  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.
45
45
 
46
- 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:
46
+ The host reuses the newest installed generation without contacting the registry, so a restart alone may not pick up a new release. Delete the spec's cache directory and restart to upgrade:
47
47
 
48
48
  ```sh
49
49
  rm -rf ~/.cache/opencode/npm/opencode2-tps@latest
50
50
  ```
51
51
 
52
- To pin a version instead, put the range in the entry — `"opencode2-tps@0.1.0"`. Every distinct entry gets its own cache directory.
52
+ Put the range in the entry to pin a version instead — `"opencode2-tps@0.1.0"`. Every distinct entry gets its own cache directory.
53
53
 
54
- The plugin ID is `opencode2.tps`. To switch it off without losing the entry and its options, add `"-opencode2.tps"` after it:
54
+ The plugin ID is `opencode2.tps`. Add `"-opencode2.tps"` after it to switch it off without losing the entry and its options:
55
55
 
56
56
  ```json
57
57
  {
@@ -71,9 +71,9 @@ The defaults are usable as they are. For the full option list, the ranges and mo
71
71
 
72
72
  ## How it works
73
73
 
74
- 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.
74
+ 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 while output streams. Complete text, reasoning, and tool-input events reconcile buffered or missed deltas without creating artificial live-rate spikes.
75
75
 
76
- 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.
76
+ OpenCode's reported output and reasoning usage replaces the byte estimate at the end of each model step. 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.
77
77
 
78
78
  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.
79
79
 
@@ -85,7 +85,7 @@ For more detail, see [Architecture](docs/development.md#architecture).
85
85
 
86
86
  Every output event carries the ID of the session that produced it, so each session is measured on its own.
87
87
 
88
- A sub-agent streams under its own child session ID. While it works, the orchestrator's number stops moving and holds the average of the output the orchestrator produced before delegating. Open the sub-agent's session to watch its live throughput.
88
+ A sub-agent streams under its own child session ID. The orchestrator's number stops moving while it works and holds the average of the output the orchestrator produced before delegating. Open the sub-agent's session to watch its live throughput.
89
89
 
90
90
  <p align="center">
91
91
  <img src="docs/screenshots/subagent_tps.png" width="750" alt="Sub-agent session showing its own live throughput indicator" />
package/dist/debug.js ADDED
@@ -0,0 +1,79 @@
1
+ import { appendFileSync, lstatSync, mkdirSync, mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // debug
7
+ //
8
+ // Off unless asked for: an unconfigured install must never touch disk.
9
+ //
10
+ // The log goes inside an owner-only directory instead of straight into the
11
+ // shared temp directory. A guessable path there (the PID is a small, enumerable
12
+ // number) can be pre-created by another local user as a symlink, which
13
+ // appendFileSync would happily follow into a file of their choosing; a
14
+ // world-readable log would also hand them the session IDs it records.
15
+
16
+ export const DEBUG_DIR_PREFIX = "tps-debug-";
17
+ const debugState = {
18
+ enabled: false,
19
+ file: ""
20
+ };
21
+
22
+ /** True only for a real directory that belongs to us and to no one else. */
23
+ function isOwnPrivateDir(path) {
24
+ try {
25
+ const stats = lstatSync(path); // lstat, not stat: a planted symlink must not pass
26
+
27
+ if (!stats.isDirectory()) return false;
28
+ const uid = process.getuid?.();
29
+
30
+ // Windows has no uid and a per-user temp directory, so there is nothing to check.
31
+ if (uid === undefined) return true;
32
+ return stats.uid === uid && (stats.mode & 0o777) === 0o700;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The 0700 directory to log into. Named after the PID so the process's own hot
40
+ * reloads keep appending to one file, and only reused when it really is ours —
41
+ * anything else squatting on the name gets sidestepped via mkdtemp.
42
+ */
43
+ function debugDir() {
44
+ const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`);
45
+ try {
46
+ mkdirSync(preferred, {
47
+ mode: 0o700
48
+ });
49
+ return preferred;
50
+ } catch {
51
+ if (isOwnPrivateDir(preferred)) return preferred;
52
+ return mkdtempSync(`${preferred}-`);
53
+ }
54
+ }
55
+ export function configureDebug(enabled) {
56
+ debugState.enabled = enabled;
57
+ if (!enabled || debugState.file) return;
58
+ try {
59
+ debugState.file = join(debugDir(), "tps.log");
60
+ } catch {
61
+ debugState.enabled = false; // no usable temp directory: stay silent
62
+ }
63
+ }
64
+
65
+ /** Truthy spellings only: `TPS_DEBUG=0` must not start writing to disk. */
66
+ export function isEnvEnabled(value) {
67
+ if (value === undefined) return false;
68
+ const normalized = value.trim().toLowerCase();
69
+ return normalized === "1" || normalized === "true";
70
+ }
71
+ export function mark(line) {
72
+ if (!debugState.enabled) return;
73
+ try {
74
+ const safeLine = line.replace(/\p{Cc}/gu, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
75
+ appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`);
76
+ } catch {
77
+ // debug only; never break the host
78
+ }
79
+ }
@@ -0,0 +1,53 @@
1
+ // ---------------------------------------------------------------------------
2
+ // options
3
+ //
4
+ // `ctx.options` is host-supplied JSON (Record<string, any>), so this is a real
5
+ // parsing boundary: every value is validated and clamped, and anything invalid
6
+ // falls back to the default rather than propagating NaN into the arithmetic.
7
+ // `options.schema.json` documents the same enumerations, defaults, and bounds
8
+ // for editors and agents; `tests/options-schema.test.ts` keeps the two in
9
+ // agreement.
10
+
11
+ import { BYTES_PER_TOKEN_MAX, BYTES_PER_TOKEN_MIN, DEFAULT_CONFIG, formatTps } from "./tracker.js";
12
+ export const DISPLAY_MODES = ["both", "tokens", "tps"];
13
+ export const REFRESH_HZ_MIN = 1;
14
+ export const REFRESH_HZ_MAX = 60;
15
+
16
+ /**
17
+ * A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
18
+ * Named so the option boundary has a real input contract to validate against.
19
+ */
20
+
21
+ /** The option surface, exactly as documented in the README, before validation. */
22
+
23
+ export const DEFAULT_OPTIONS = {
24
+ ...DEFAULT_CONFIG,
25
+ display: "both",
26
+ refreshHz: 8,
27
+ debug: false
28
+ };
29
+ function isFiniteNumber(value) {
30
+ return Number.isFinite(value);
31
+ }
32
+ function isDisplayMode(value) {
33
+ return DISPLAY_MODES.some(mode => mode === value);
34
+ }
35
+ function clampNumber(value, fallback, min, max) {
36
+ if (!isFiniteNumber(value)) return fallback;
37
+ return Math.min(Math.max(value, min), max);
38
+ }
39
+ export function resolveOptions(raw) {
40
+ return {
41
+ display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
42
+ refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, REFRESH_HZ_MIN, REFRESH_HZ_MAX),
43
+ bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
44
+ debug: raw.debug === true
45
+ };
46
+ }
47
+ export function formatLabel(value, display) {
48
+ const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`;
49
+ const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`;
50
+ if (display === "tokens") return tokens;
51
+ if (display === "tps") return tps ?? "— t/s";
52
+ return tps === null ? tokens : `${tokens} · ${tps}`;
53
+ }
@@ -0,0 +1,263 @@
1
+ // ---------------------------------------------------------------------------
2
+ // tracker (UI-free)
3
+ //
4
+ // Measures the rate of the observable model stream: bytes to a rolling
5
+ // estimate while output arrives, exact step usage once the host reports it,
6
+ // and a frozen average after the run ends.
7
+
8
+ import { mark } from "./debug.js";
9
+ export const DEFAULT_CONFIG = {
10
+ bytesPerToken: 4.75
11
+ };
12
+ // The frozen final average stays visible until the next prompt starts a new run.
13
+
14
+ export const BYTES_PER_TOKEN_MIN = 1;
15
+ export const BYTES_PER_TOKEN_MAX = 16;
16
+ const LIVE_WINDOW_MS = 5_000;
17
+ const LIVE_STALE_MS = 1_500;
18
+ const LIVE_MIN_DURATION_MS = 250;
19
+ function estimateTokens(bytes, bytesPerToken) {
20
+ return Math.ceil(bytes / bytesPerToken);
21
+ }
22
+ export function formatTps(value) {
23
+ if (value < 10) return value.toFixed(2);
24
+ if (value < 100) return value.toFixed(1);
25
+ return Math.round(value).toString();
26
+ }
27
+ // A finished run keeps its frozen average indefinitely (it is what the composer
28
+ // still shows), so the map is bounded instead: past this many tracked sessions,
29
+ // the least recently started *finished* runs are dropped. Running ones are never
30
+ // touched. Entries are tiny, so this is hygiene for a long-lived TUI, not a
31
+ // memory fix.
32
+ const MAX_TRACKED_RUNS = 64;
33
+ export class TpsTracker {
34
+ // Insertion order is kept equal to run-start recency (see beginRun), which is
35
+ // what makes eviction from the front drop the stalest session.
36
+ runs = new Map();
37
+ constructor(config = DEFAULT_CONFIG) {
38
+ this.config = config;
39
+ }
40
+ state(sessionID) {
41
+ let st = this.runs.get(sessionID);
42
+ if (!st) {
43
+ st = {
44
+ phase: "ended",
45
+ settledTokens: 0,
46
+ settledDurationMs: 0,
47
+ tokensEstimated: false,
48
+ partial: false,
49
+ activeStep: null,
50
+ settledSteps: new Set(),
51
+ frozen: null
52
+ };
53
+ this.runs.set(sessionID, st);
54
+ }
55
+ return st;
56
+ }
57
+ beginRun(sessionID) {
58
+ const st = this.state(sessionID);
59
+ st.phase = "running";
60
+ st.settledTokens = 0;
61
+ st.settledDurationMs = 0;
62
+ st.tokensEstimated = false;
63
+ st.partial = false;
64
+ st.activeStep = null;
65
+ st.settledSteps.clear();
66
+ st.frozen = null;
67
+ // Re-insert so this session becomes the newest in iteration order. Every
68
+ // entry is created through here, so the cap is checked on the one path that
69
+ // can grow the map.
70
+ this.runs.delete(sessionID);
71
+ this.runs.set(sessionID, st);
72
+ this.evictStale();
73
+ }
74
+ evictStale() {
75
+ if (this.runs.size <= MAX_TRACKED_RUNS) return;
76
+ for (const [sessionID, st] of this.runs) {
77
+ if (this.runs.size <= MAX_TRACKED_RUNS) return;
78
+ if (st.phase === "running") continue;
79
+ this.dropSession(sessionID);
80
+ }
81
+ }
82
+ ensureStep(sessionID, assistantMessageID, now, replace = false) {
83
+ const st = this.state(sessionID);
84
+ if (st.settledSteps.has(assistantMessageID) || st.phase === "ended" && st.frozen !== null) return null;
85
+ if (st.phase !== "running") this.beginRun(sessionID);
86
+ const running = this.state(sessionID);
87
+ if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep;
88
+ if (running.activeStep && !replace) return null;
89
+ if (running.activeStep) this.settleActiveStep(running, undefined);
90
+ const step = {
91
+ assistantMessageID,
92
+ startedAt: now,
93
+ streamedAt: null,
94
+ lastBoundaryAt: null,
95
+ observableBytes: 0,
96
+ blocks: new Map(),
97
+ samples: []
98
+ };
99
+ running.activeStep = step;
100
+ running.frozen = null;
101
+ return step;
102
+ }
103
+ beginStep(sessionID, assistantMessageID, now = Date.now()) {
104
+ const st = this.state(sessionID);
105
+ if (st.phase !== "running") {
106
+ if (st.settledSteps.has(assistantMessageID)) return;
107
+ this.beginRun(sessionID);
108
+ }
109
+ if (st.activeStep?.assistantMessageID === assistantMessageID) return;
110
+ this.ensureStep(sessionID, assistantMessageID, now, true);
111
+ }
112
+ beginBlock(sessionID, assistantMessageID, blockID, now) {
113
+ const step = this.ensureStep(sessionID, assistantMessageID, now);
114
+ if (!step) return;
115
+ if (!step.blocks.has(blockID)) step.blocks.set(blockID, {
116
+ streamedBytes: 0,
117
+ finalBytes: null
118
+ });
119
+ }
120
+ push(sessionID, delta, now, assistantMessageID = "implicit", blockID = "implicit") {
121
+ if (!delta) return;
122
+ const step = this.ensureStep(sessionID, assistantMessageID, now);
123
+ if (!step) return;
124
+ let block = step.blocks.get(blockID);
125
+ if (!block) {
126
+ block = {
127
+ streamedBytes: 0,
128
+ finalBytes: null
129
+ };
130
+ step.blocks.set(blockID, block);
131
+ }
132
+ if (block.finalBytes !== null) return;
133
+ const bytes = Buffer.byteLength(delta, "utf8");
134
+ block.streamedBytes += bytes;
135
+ step.observableBytes += bytes;
136
+ step.samples.push({
137
+ bytes,
138
+ timestamp: now
139
+ });
140
+ const oldest = now - LIVE_WINDOW_MS;
141
+ while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift();
142
+ }
143
+ finishBlock(sessionID, assistantMessageID, blockID, text, now) {
144
+ const st = this.runs.get(sessionID);
145
+ const step = st?.activeStep;
146
+ if (!step || step.assistantMessageID !== assistantMessageID) return;
147
+ let block = step.blocks.get(blockID);
148
+ if (!block) {
149
+ block = {
150
+ streamedBytes: 0,
151
+ finalBytes: null
152
+ };
153
+ step.blocks.set(blockID, block);
154
+ }
155
+ if (block.finalBytes !== null) return;
156
+ block.finalBytes = Buffer.byteLength(text, "utf8");
157
+ step.observableBytes += block.finalBytes - block.streamedBytes;
158
+ step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now);
159
+ }
160
+
161
+ /**
162
+ * The host's authoritative end of the model stream, published after the
163
+ * provider stream exits and before local tools join. Assigned rather than
164
+ * maxed so a retried attempt reusing the message ID moves the boundary to its
165
+ * own completion.
166
+ */
167
+ markStreamed(sessionID, assistantMessageID, now) {
168
+ const st = this.runs.get(sessionID);
169
+ const step = st?.activeStep;
170
+ if (!step || step.assistantMessageID !== assistantMessageID) return;
171
+ step.streamedAt = now;
172
+ }
173
+ settleActiveStep(st, generatedTokens) {
174
+ const step = st.activeStep;
175
+ if (!step) return;
176
+ const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0;
177
+ st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken);
178
+ if (!exact) {
179
+ st.tokensEstimated = true;
180
+ st.partial = true;
181
+ }
182
+
183
+ // `session.step.streamed` is the exact stream end; the last content boundary
184
+ // remains the fallback for hosts that do not publish it.
185
+ const end = step.streamedAt ?? step.lastBoundaryAt;
186
+ if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt);
187
+ st.settledSteps.add(step.assistantMessageID);
188
+ st.activeStep = null;
189
+ }
190
+ finishStep(sessionID, assistantMessageID, generatedTokens, _now) {
191
+ const st = this.runs.get(sessionID);
192
+ if (st?.activeStep?.assistantMessageID !== assistantMessageID) return;
193
+ this.settleActiveStep(st, generatedTokens);
194
+ }
195
+ finish(sessionID, _now) {
196
+ const st = this.runs.get(sessionID);
197
+ if (!st || st.phase === "ended") return;
198
+ if (st.activeStep) this.settleActiveStep(st, undefined);
199
+ st.phase = "ended";
200
+ const tokens = st.settledTokens;
201
+ if (tokens <= 0) {
202
+ this.evictStale();
203
+ return;
204
+ }
205
+ const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null;
206
+ st.frozen = {
207
+ tps,
208
+ tokens,
209
+ tokensEstimated: st.tokensEstimated,
210
+ partial: st.partial
211
+ };
212
+ mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`);
213
+ this.evictStale();
214
+ }
215
+ dropSession(sessionID) {
216
+ this.runs.delete(sessionID);
217
+ }
218
+ evict(sessionID) {
219
+ this.dropSession(sessionID);
220
+ }
221
+ hasRunning(now = Date.now()) {
222
+ for (const st of this.runs.values()) {
223
+ const last = st.activeStep?.samples.at(-1);
224
+ if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true;
225
+ }
226
+ return false;
227
+ }
228
+ liveTps(step, now) {
229
+ const last = step.samples.at(-1);
230
+ if (!last) return null;
231
+ const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS);
232
+ const oldest = effectiveNow - LIVE_WINDOW_MS;
233
+ const samples = step.samples.filter(sample => sample.timestamp >= oldest);
234
+ const first = samples[0];
235
+ if (!first) return null;
236
+ const bytes = samples.reduce((total, sample) => total + sample.bytes, 0);
237
+ const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS);
238
+ return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000);
239
+ }
240
+ value(sessionID, now) {
241
+ const st = this.runs.get(sessionID);
242
+ if (!st) return null;
243
+ if (st.frozen) return {
244
+ ...st.frozen,
245
+ frozen: true,
246
+ tpsEstimated: true
247
+ };
248
+ if (st.phase !== "running") return null;
249
+ const active = st.activeStep;
250
+ const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0;
251
+ const tokens = st.settledTokens + activeTokens;
252
+ if (tokens <= 0) return null;
253
+ const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null;
254
+ return {
255
+ tps: active ? this.liveTps(active, now) ?? settledTps : settledTps,
256
+ tokens,
257
+ frozen: false,
258
+ tokensEstimated: st.tokensEstimated || active !== null,
259
+ tpsEstimated: true,
260
+ partial: st.partial
261
+ };
262
+ }
263
+ }
package/dist/tui.js CHANGED
@@ -7,393 +7,9 @@ import { createComponent as _$createComponent } from "@opentui/solid";
7
7
  /** @jsxImportSource @opentui/solid */
8
8
 
9
9
  import { createMemo, createSignal, Show } from "solid-js";
10
- import { appendFileSync, lstatSync, mkdirSync, mkdtempSync } from "node:fs";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
13
-
14
- // ---------------------------------------------------------------------------
15
- // debug
16
- //
17
- // Off unless asked for: an unconfigured install must never touch disk.
18
- //
19
- // The log goes inside an owner-only directory instead of straight into the
20
- // shared temp directory. A guessable path there (the PID is a small, enumerable
21
- // number) can be pre-created by another local user as a symlink, which
22
- // appendFileSync would happily follow into a file of their choosing; a
23
- // world-readable log would also hand them the session IDs it records.
24
-
25
- export const DEBUG_DIR_PREFIX = "tps-debug-";
26
- const debugState = {
27
- enabled: false,
28
- file: ""
29
- };
30
-
31
- /** True only for a real directory that belongs to us and to no one else. */
32
- function isOwnPrivateDir(path) {
33
- try {
34
- const stats = lstatSync(path); // lstat, not stat: a planted symlink must not pass
35
- if (!stats.isDirectory()) return false;
36
- const uid = process.getuid?.();
37
- // Windows has no uid and a per-user temp directory, so there is nothing to check.
38
- if (uid === undefined) return true;
39
- return stats.uid === uid && (stats.mode & 0o777) === 0o700;
40
- } catch {
41
- return false;
42
- }
43
- }
44
-
45
- /**
46
- * The 0700 directory to log into. Named after the PID so the process's own hot
47
- * reloads keep appending to one file, and only reused when it really is ours —
48
- * anything else squatting on the name gets sidestepped via mkdtemp.
49
- */
50
- function debugDir() {
51
- const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`);
52
- try {
53
- mkdirSync(preferred, {
54
- mode: 0o700
55
- });
56
- return preferred;
57
- } catch {
58
- if (isOwnPrivateDir(preferred)) return preferred;
59
- return mkdtempSync(`${preferred}-`);
60
- }
61
- }
62
- function configureDebug(enabled) {
63
- debugState.enabled = enabled;
64
- if (!enabled || debugState.file) return;
65
- try {
66
- debugState.file = join(debugDir(), "tps.log");
67
- } catch {
68
- debugState.enabled = false; // no usable temp directory: stay silent
69
- }
70
- }
71
-
72
- /** Truthy spellings only: `TPS_DEBUG=0` must not start writing to disk. */
73
- export function isEnvEnabled(value) {
74
- if (value === undefined) return false;
75
- const normalized = value.trim().toLowerCase();
76
- return normalized === "1" || normalized === "true";
77
- }
78
- function mark(line) {
79
- if (!debugState.enabled) return;
80
- try {
81
- const safeLine = line.replace(/\p{Cc}/gu, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
82
- appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`);
83
- } catch {
84
- // debug only; never break the host
85
- }
86
- }
87
-
88
- // ---------------------------------------------------------------------------
89
- // tuning
90
-
91
- export const DEFAULT_CONFIG = {
92
- bytesPerToken: 4.75
93
- };
94
- // The frozen final average stays visible until the next prompt starts a new run.
95
-
96
- const BYTES_PER_TOKEN_MIN = 1;
97
- const BYTES_PER_TOKEN_MAX = 16;
98
- const LIVE_WINDOW_MS = 5_000;
99
- const LIVE_STALE_MS = 1_500;
100
- const LIVE_MIN_DURATION_MS = 250;
101
- function estimateTokens(bytes, bytesPerToken) {
102
- return Math.ceil(bytes / bytesPerToken);
103
- }
104
- function formatTps(value) {
105
- if (value < 10) return value.toFixed(2);
106
- if (value < 100) return value.toFixed(1);
107
- return Math.round(value).toString();
108
- }
109
-
110
- // ---------------------------------------------------------------------------
111
- // tracker (UI-free)
112
-
113
- // A finished run keeps its frozen average indefinitely (it is what the composer
114
- // still shows), so the map is bounded instead: past this many tracked sessions,
115
- // the least recently started *finished* runs are dropped. Running ones are never
116
- // touched. Entries are tiny, so this is hygiene for a long-lived TUI, not a
117
- // memory fix.
118
- const MAX_TRACKED_RUNS = 64;
119
- export class TpsTracker {
120
- // Insertion order is kept equal to run-start recency (see beginRun), which is
121
- // what makes eviction from the front drop the stalest session.
122
- runs = new Map();
123
- constructor(config = DEFAULT_CONFIG) {
124
- this.config = config;
125
- }
126
- state(sessionID) {
127
- let st = this.runs.get(sessionID);
128
- if (!st) {
129
- st = {
130
- phase: "ended",
131
- settledTokens: 0,
132
- settledDurationMs: 0,
133
- tokensEstimated: false,
134
- partial: false,
135
- activeStep: null,
136
- settledSteps: new Set(),
137
- frozen: null
138
- };
139
- this.runs.set(sessionID, st);
140
- }
141
- return st;
142
- }
143
- beginRun(sessionID) {
144
- const st = this.state(sessionID);
145
- st.phase = "running";
146
- st.settledTokens = 0;
147
- st.settledDurationMs = 0;
148
- st.tokensEstimated = false;
149
- st.partial = false;
150
- st.activeStep = null;
151
- st.settledSteps.clear();
152
- st.frozen = null;
153
- // Re-insert so this session becomes the newest in iteration order. Every
154
- // entry is created through here, so the cap is checked on the one path that
155
- // can grow the map.
156
- this.runs.delete(sessionID);
157
- this.runs.set(sessionID, st);
158
- this.evictStale();
159
- }
160
- evictStale() {
161
- if (this.runs.size <= MAX_TRACKED_RUNS) return;
162
- for (const [sessionID, st] of this.runs) {
163
- if (this.runs.size <= MAX_TRACKED_RUNS) return;
164
- if (st.phase === "running") continue;
165
- this.dropSession(sessionID);
166
- }
167
- }
168
- ensureStep(sessionID, assistantMessageID, now, replace = false) {
169
- const st = this.state(sessionID);
170
- if (st.settledSteps.has(assistantMessageID) || st.phase === "ended" && st.frozen !== null) return null;
171
- if (st.phase !== "running") this.beginRun(sessionID);
172
- const running = this.state(sessionID);
173
- if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep;
174
- if (running.activeStep && !replace) return null;
175
- if (running.activeStep) this.settleActiveStep(running, undefined);
176
- const step = {
177
- assistantMessageID,
178
- startedAt: now,
179
- streamedAt: null,
180
- lastBoundaryAt: null,
181
- observableBytes: 0,
182
- blocks: new Map(),
183
- samples: []
184
- };
185
- running.activeStep = step;
186
- running.frozen = null;
187
- return step;
188
- }
189
- beginStep(sessionID, assistantMessageID, now = Date.now()) {
190
- const st = this.state(sessionID);
191
- if (st.phase !== "running") {
192
- if (st.settledSteps.has(assistantMessageID)) return;
193
- this.beginRun(sessionID);
194
- }
195
- if (st.activeStep?.assistantMessageID === assistantMessageID) return;
196
- this.ensureStep(sessionID, assistantMessageID, now, true);
197
- }
198
- beginBlock(sessionID, assistantMessageID, blockID, now) {
199
- const step = this.ensureStep(sessionID, assistantMessageID, now);
200
- if (!step) return;
201
- if (!step.blocks.has(blockID)) step.blocks.set(blockID, {
202
- streamedBytes: 0,
203
- finalBytes: null
204
- });
205
- }
206
- push(sessionID, delta, now, assistantMessageID = "implicit", blockID = "implicit") {
207
- if (!delta) return;
208
- const step = this.ensureStep(sessionID, assistantMessageID, now);
209
- if (!step) return;
210
- let block = step.blocks.get(blockID);
211
- if (!block) {
212
- block = {
213
- streamedBytes: 0,
214
- finalBytes: null
215
- };
216
- step.blocks.set(blockID, block);
217
- }
218
- if (block.finalBytes !== null) return;
219
- const bytes = Buffer.byteLength(delta, "utf8");
220
- block.streamedBytes += bytes;
221
- step.observableBytes += bytes;
222
- step.samples.push({
223
- bytes,
224
- timestamp: now
225
- });
226
- const oldest = now - LIVE_WINDOW_MS;
227
- while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift();
228
- }
229
- finishBlock(sessionID, assistantMessageID, blockID, text, now) {
230
- const st = this.runs.get(sessionID);
231
- const step = st?.activeStep;
232
- if (!step || step.assistantMessageID !== assistantMessageID) return;
233
- let block = step.blocks.get(blockID);
234
- if (!block) {
235
- block = {
236
- streamedBytes: 0,
237
- finalBytes: null
238
- };
239
- step.blocks.set(blockID, block);
240
- }
241
- if (block.finalBytes !== null) return;
242
- block.finalBytes = Buffer.byteLength(text, "utf8");
243
- step.observableBytes += block.finalBytes - block.streamedBytes;
244
- step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now);
245
- }
246
-
247
- /**
248
- * The host's authoritative end of the model stream, published after the
249
- * provider stream exits and before local tools join. Assigned rather than
250
- * maxed so a retried attempt reusing the message ID moves the boundary to its
251
- * own completion.
252
- */
253
- markStreamed(sessionID, assistantMessageID, now) {
254
- const st = this.runs.get(sessionID);
255
- const step = st?.activeStep;
256
- if (!step || step.assistantMessageID !== assistantMessageID) return;
257
- step.streamedAt = now;
258
- }
259
- settleActiveStep(st, generatedTokens) {
260
- const step = st.activeStep;
261
- if (!step) return;
262
- const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0;
263
- st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken);
264
- if (!exact) {
265
- st.tokensEstimated = true;
266
- st.partial = true;
267
- }
268
- // `session.step.streamed` is the exact stream end; the last content boundary
269
- // remains the fallback for hosts that do not publish it.
270
- const end = step.streamedAt ?? step.lastBoundaryAt;
271
- if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt);
272
- st.settledSteps.add(step.assistantMessageID);
273
- st.activeStep = null;
274
- }
275
- finishStep(sessionID, assistantMessageID, generatedTokens, _now) {
276
- const st = this.runs.get(sessionID);
277
- if (st?.activeStep?.assistantMessageID !== assistantMessageID) return;
278
- this.settleActiveStep(st, generatedTokens);
279
- }
280
- finish(sessionID, _now) {
281
- const st = this.runs.get(sessionID);
282
- if (!st || st.phase === "ended") return;
283
- if (st.activeStep) this.settleActiveStep(st, undefined);
284
- st.phase = "ended";
285
- const tokens = st.settledTokens;
286
- if (tokens <= 0) {
287
- this.evictStale();
288
- return;
289
- }
290
- const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null;
291
- st.frozen = {
292
- tps,
293
- tokens,
294
- tokensEstimated: st.tokensEstimated,
295
- partial: st.partial
296
- };
297
- mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`);
298
- this.evictStale();
299
- }
300
- dropSession(sessionID) {
301
- this.runs.delete(sessionID);
302
- }
303
- evict(sessionID) {
304
- this.dropSession(sessionID);
305
- }
306
- hasRunning(now = Date.now()) {
307
- for (const st of this.runs.values()) {
308
- const last = st.activeStep?.samples.at(-1);
309
- if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true;
310
- }
311
- return false;
312
- }
313
- liveTps(step, now) {
314
- const last = step.samples.at(-1);
315
- if (!last) return null;
316
- const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS);
317
- const oldest = effectiveNow - LIVE_WINDOW_MS;
318
- const samples = step.samples.filter(sample => sample.timestamp >= oldest);
319
- const first = samples[0];
320
- if (!first) return null;
321
- const bytes = samples.reduce((total, sample) => total + sample.bytes, 0);
322
- const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS);
323
- return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000);
324
- }
325
- value(sessionID, now) {
326
- const st = this.runs.get(sessionID);
327
- if (!st) return null;
328
- if (st.frozen) return {
329
- ...st.frozen,
330
- frozen: true,
331
- tpsEstimated: true
332
- };
333
- if (st.phase !== "running") return null;
334
- const active = st.activeStep;
335
- const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0;
336
- const tokens = st.settledTokens + activeTokens;
337
- if (tokens <= 0) return null;
338
- const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null;
339
- return {
340
- tps: active ? this.liveTps(active, now) ?? settledTps : settledTps,
341
- tokens,
342
- frozen: false,
343
- tokensEstimated: st.tokensEstimated || active !== null,
344
- tpsEstimated: true,
345
- partial: st.partial
346
- };
347
- }
348
- }
349
-
350
- // ---------------------------------------------------------------------------
351
- // options
352
- //
353
- // `ctx.options` is host-supplied JSON (Record<string, any>), so this is a real
354
- // parsing boundary: every value is validated and clamped, and anything invalid
355
- // falls back to the default rather than propagating NaN into the arithmetic.
356
-
357
- const DISPLAY_MODES = ["both", "tokens", "tps"];
358
-
359
- /**
360
- * A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
361
- * Named so the option boundary has a real input contract to validate against.
362
- */
363
-
364
- /** The option surface, exactly as documented in the README, before validation. */
365
-
366
- export const DEFAULT_OPTIONS = {
367
- ...DEFAULT_CONFIG,
368
- display: "both",
369
- refreshHz: 8,
370
- debug: false
371
- };
372
- function isFiniteNumber(value) {
373
- return Number.isFinite(value);
374
- }
375
- function isDisplayMode(value) {
376
- return DISPLAY_MODES.some(mode => mode === value);
377
- }
378
- function clampNumber(value, fallback, min, max) {
379
- if (!isFiniteNumber(value)) return fallback;
380
- return Math.min(Math.max(value, min), max);
381
- }
382
- export function resolveOptions(raw) {
383
- return {
384
- display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
385
- refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, 1, 60),
386
- bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
387
- debug: raw.debug === true
388
- };
389
- }
390
- export function formatLabel(value, display) {
391
- const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`;
392
- const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`;
393
- if (display === "tokens") return tokens;
394
- if (display === "tps") return tps ?? "— t/s";
395
- return tps === null ? tokens : `${tokens} · ${tps}`;
396
- }
10
+ import { configureDebug, isEnvEnabled, mark } from "./debug.js";
11
+ import { TpsTracker } from "./tracker.js";
12
+ import { formatLabel, resolveOptions } from "./options.js";
397
13
 
398
14
  // ---------------------------------------------------------------------------
399
15
  // plugin
@@ -452,6 +68,7 @@ const definition = {
452
68
  return;
453
69
  }
454
70
  const running = tracker.hasRunning(Date.now());
71
+
455
72
  // The observable live rate decays only through a short stale tail. Opaque
456
73
  // provider work after that is not charged to a numerator we cannot see.
457
74
  if (dirty || running) {
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/P-Theo/opencode2-tps/main/options.schema.json",
4
+ "title": "opencode2-tps plugin options",
5
+ "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.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "display": {
10
+ "enum": ["both", "tokens", "tps"],
11
+ "default": "both",
12
+ "description": "Which parts of the label to show."
13
+ },
14
+ "refreshHz": {
15
+ "type": "number",
16
+ "minimum": 1,
17
+ "maximum": 60,
18
+ "default": 8,
19
+ "description": "How often the label updates while a session streams, clamped to 1-60."
20
+ },
21
+ "bytesPerToken": {
22
+ "type": "number",
23
+ "minimum": 1,
24
+ "maximum": 16,
25
+ "default": 4.75,
26
+ "description": "Bytes per token used for live and partial-output estimates, clamped to 1-16. Completed steps use OpenCode's reported token usage instead."
27
+ },
28
+ "debug": {
29
+ "type": "boolean",
30
+ "default": false,
31
+ "description": "Writes a debug log. Only `true` enables it."
32
+ }
33
+ },
34
+ "examples": [
35
+ {
36
+ "display": "tps",
37
+ "refreshHz": 12
38
+ },
39
+ {
40
+ "display": "both",
41
+ "refreshHz": 8,
42
+ "bytesPerToken": 4.75,
43
+ "debug": false
44
+ }
45
+ ]
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-tps",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Live token-throughput indicator for the OpenCode 2 TUI prompt composer",
5
5
  "license": "MIT",
6
6
  "author": "P-Theo",
@@ -21,10 +21,12 @@
21
21
  "throughput"
22
22
  ],
23
23
  "exports": {
24
- "./tui": "./dist/tui.js"
24
+ "./tui": "./dist/tui.js",
25
+ "./options.schema.json": "./options.schema.json"
25
26
  },
26
27
  "files": [
27
- "dist"
28
+ "dist",
29
+ "options.schema.json"
28
30
  ],
29
31
  "peerDependencies": {
30
32
  "@opentui/solid": ">=0.5.4",
@@ -53,16 +55,16 @@
53
55
  "@opentui/solid": "^0.5.10",
54
56
  "@oxlint/plugins": "^1.78.0",
55
57
  "@types/bun": "^1.3.14",
56
- "@types/node": "^24.0.0",
58
+ "@types/node": "^26.0.0",
57
59
  "babel-preset-solid": "^1.9.12",
58
60
  "oxlint": "^1.78.0",
59
61
  "solid-js": "^1.9.0",
60
62
  "typescript": "^5.9.0"
61
63
  },
62
64
  "scripts": {
63
- "build": "node build.mjs",
65
+ "build": "node scripts/build.mjs",
64
66
  "check": "tsc --noEmit",
65
- "check:compatibility": "node check-compatibility.mjs",
67
+ "check:compatibility": "node scripts/check-compatibility.mjs",
66
68
  "lint": "oxlint",
67
69
  "test": "bun test",
68
70
  "prepack": "npm run lint && npm run check && npm test && npm run build"