opencode2-tps 0.1.0-rc.1
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/LICENSE +7 -0
- package/README.md +86 -0
- package/dist/tui.js +456 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 P-Theo
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# opencode2-tps
|
|
2
|
+
|
|
3
|
+
Live token-throughput indicator for the OpenCode 2 TUI prompt composer.
|
|
4
|
+
|
|
5
|
+
While a session streams, the top right of the composer shows the estimated tokens and the throughput of the current run:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
1712 tok · 51.5 t/s
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
When the run ends, the number freezes at the run average and stays there until the next run starts. Nothing is shown on the home screen, or before the first token of a run.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Built against the OpenCode 2 preview and tested on `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: a renamed event stops arriving, and an unknown composer slot gets quietly rerouted. If the figure never appears, check your CLI version first.
|
|
16
|
+
|
|
17
|
+
Add the package to `~/.config/opencode/cli.json`:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"plugins": ["opencode2-tps"]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To set options, use the object form:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"plugins": [
|
|
30
|
+
{
|
|
31
|
+
"package": "opencode2-tps",
|
|
32
|
+
"options": { "display": "tps", "refreshHz": 12 }
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
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:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
rm -rf ~/.cache/opencode/packages/opencode2-tps
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To pin a version instead, put the range in the entry — `"opencode2-tps@0.1.0"`. Every distinct entry gets its own cache directory.
|
|
47
|
+
|
|
48
|
+
The plugin ID is `opencode2.tps`. To switch it off without losing the entry and its options, add `"-opencode2.tps"` after it:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"plugins": [
|
|
53
|
+
{
|
|
54
|
+
"package": "opencode2-tps",
|
|
55
|
+
"options": { "display": "tps", "refreshHz": 12 }
|
|
56
|
+
},
|
|
57
|
+
"-opencode2.tps"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
The defaults are usable as they are. For the full option list, the ranges and more examples, see [Configuration](docs/configuration.md).
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
The plugin counts the output bytes of a run and estimates tokens from the byte total. It also accumulates the run's active time, capping each gap between two outputs, so time spent in a tool call is not charged to the model's rate.
|
|
69
|
+
|
|
70
|
+
The live figure comes from a rolling window over recent output. When that window holds nothing recent, the indicator falls back to the average of the run so far.
|
|
71
|
+
|
|
72
|
+
For more detail, see [Architecture](docs/development.md#architecture).
|
|
73
|
+
|
|
74
|
+
## Sub-agents
|
|
75
|
+
|
|
76
|
+
Every output event carries the ID of the session that produced it, so each session is measured on its own.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
To work on the plugin, see [Development](docs/development.md). To publish a new version, see [Release](docs/release.md).
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
2
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
3
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
4
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
5
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
6
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
7
|
+
/** @jsxImportSource @opentui/solid */
|
|
8
|
+
|
|
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
|
+
appendFileSync(debugState.file, `${new Date().toISOString()} ${line}\n`);
|
|
82
|
+
} catch {
|
|
83
|
+
// debug only; never break the host
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// tuning
|
|
89
|
+
|
|
90
|
+
const BYTES_PER_TOKEN = 5;
|
|
91
|
+
export const DEFAULT_CONFIG = {
|
|
92
|
+
sampleWindowMs: 5_000,
|
|
93
|
+
liveStaleMs: 1_500,
|
|
94
|
+
singleSampleMinMs: 250,
|
|
95
|
+
singleSampleMaxMs: 1_000,
|
|
96
|
+
tailMaxMs: 1_000,
|
|
97
|
+
gapCapMs: 2_000
|
|
98
|
+
};
|
|
99
|
+
// The frozen final average stays visible until the next prompt starts a new run.
|
|
100
|
+
|
|
101
|
+
function estimateTokens(bytes) {
|
|
102
|
+
return Math.ceil(bytes / BYTES_PER_TOKEN);
|
|
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
|
+
samples: [],
|
|
132
|
+
bytes: 0,
|
|
133
|
+
activeMs: 0,
|
|
134
|
+
lastSampleAt: null,
|
|
135
|
+
frozen: null
|
|
136
|
+
};
|
|
137
|
+
this.runs.set(sessionID, st);
|
|
138
|
+
}
|
|
139
|
+
return st;
|
|
140
|
+
}
|
|
141
|
+
beginRun(sessionID) {
|
|
142
|
+
const st = this.state(sessionID);
|
|
143
|
+
st.phase = "running";
|
|
144
|
+
st.samples = [];
|
|
145
|
+
st.bytes = 0;
|
|
146
|
+
st.activeMs = 0;
|
|
147
|
+
st.lastSampleAt = null;
|
|
148
|
+
st.frozen = null;
|
|
149
|
+
// Re-insert so this session becomes the newest in iteration order. Every
|
|
150
|
+
// entry is created through here, so the cap is checked on the one path that
|
|
151
|
+
// can grow the map.
|
|
152
|
+
this.runs.delete(sessionID);
|
|
153
|
+
this.runs.set(sessionID, st);
|
|
154
|
+
this.evictStale();
|
|
155
|
+
}
|
|
156
|
+
evictStale() {
|
|
157
|
+
if (this.runs.size <= MAX_TRACKED_RUNS) return;
|
|
158
|
+
for (const [sessionID, st] of this.runs) {
|
|
159
|
+
if (this.runs.size <= MAX_TRACKED_RUNS) return;
|
|
160
|
+
if (st.phase === "running") continue;
|
|
161
|
+
this.runs.delete(sessionID);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
push(sessionID, delta, now) {
|
|
165
|
+
if (!delta) return;
|
|
166
|
+
const st = this.state(sessionID);
|
|
167
|
+
if (st.phase !== "running") this.beginRun(sessionID); // execution.started missed
|
|
168
|
+
st.frozen = null;
|
|
169
|
+
// Bytes accumulate; tokens are derived from the run total, so providers
|
|
170
|
+
// that emit 1-3 byte deltas are not rounded up on every single one.
|
|
171
|
+
const bytes = Buffer.byteLength(delta, "utf8");
|
|
172
|
+
st.samples.push({
|
|
173
|
+
bytes,
|
|
174
|
+
timestamp: now
|
|
175
|
+
});
|
|
176
|
+
st.bytes += bytes;
|
|
177
|
+
// The first sample of a run contributes no elapsed time; the floor is
|
|
178
|
+
// applied once, where the average is computed.
|
|
179
|
+
if (st.lastSampleAt !== null) st.activeMs += Math.min(Math.max(0, now - st.lastSampleAt), this.config.gapCapMs);
|
|
180
|
+
st.lastSampleAt = now;
|
|
181
|
+
}
|
|
182
|
+
finish(sessionID, now) {
|
|
183
|
+
const st = this.runs.get(sessionID);
|
|
184
|
+
if (!st || st.phase === "ended") return;
|
|
185
|
+
st.phase = "ended";
|
|
186
|
+
if (st.lastSampleAt !== null) {
|
|
187
|
+
st.activeMs += Math.min(Math.max(0, now - st.lastSampleAt), this.config.tailMaxMs);
|
|
188
|
+
st.lastSampleAt = null;
|
|
189
|
+
}
|
|
190
|
+
const tokens = estimateTokens(st.bytes);
|
|
191
|
+
if (tokens <= 0) {
|
|
192
|
+
this.runs.delete(sessionID);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const seconds = Math.max(st.activeMs, this.config.singleSampleMinMs) / 1000;
|
|
196
|
+
st.frozen = {
|
|
197
|
+
tps: tokens / seconds,
|
|
198
|
+
tokens
|
|
199
|
+
};
|
|
200
|
+
st.samples = [];
|
|
201
|
+
mark(`finish sid=${sessionID} tokens=${tokens} activeMs=${st.activeMs} tps=${st.frozen.tps.toFixed(1)}`);
|
|
202
|
+
}
|
|
203
|
+
evict(sessionID) {
|
|
204
|
+
this.runs.delete(sessionID);
|
|
205
|
+
}
|
|
206
|
+
hasRunning() {
|
|
207
|
+
for (const st of this.runs.values()) if (st.phase === "running") return true;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Throughput over the rolling window, or -1 when the window holds nothing
|
|
212
|
+
// recent enough to be called "live".
|
|
213
|
+
live(st, now) {
|
|
214
|
+
const cutoff = now - this.config.sampleWindowMs;
|
|
215
|
+
const active = st.samples.filter(s => s.timestamp >= cutoff);
|
|
216
|
+
const last = active.at(-1);
|
|
217
|
+
const first = active[0];
|
|
218
|
+
if (!last || !first || now - last.timestamp > this.config.liveStaleMs) return -1;
|
|
219
|
+
let bytes = 0;
|
|
220
|
+
for (const s of active) bytes += s.bytes;
|
|
221
|
+
const tokens = bytes / BYTES_PER_TOKEN;
|
|
222
|
+
let durationMs;
|
|
223
|
+
if (active.length < 2) {
|
|
224
|
+
durationMs = Math.max(this.config.singleSampleMinMs, Math.min(now - first.timestamp, this.config.singleSampleMaxMs));
|
|
225
|
+
} else {
|
|
226
|
+
// Gaps are capped exactly as in push(): a pause for tool execution must
|
|
227
|
+
// not be charged to the model's throughput.
|
|
228
|
+
let gaps = 0;
|
|
229
|
+
let prev = first.timestamp;
|
|
230
|
+
for (const s of active) {
|
|
231
|
+
gaps += Math.min(Math.max(0, s.timestamp - prev), this.config.gapCapMs);
|
|
232
|
+
prev = s.timestamp;
|
|
233
|
+
}
|
|
234
|
+
gaps += Math.min(now - last.timestamp, this.config.tailMaxMs);
|
|
235
|
+
durationMs = Math.max(gaps, this.config.singleSampleMinMs);
|
|
236
|
+
}
|
|
237
|
+
return tokens / durationMs * 1000;
|
|
238
|
+
}
|
|
239
|
+
value(sessionID, now) {
|
|
240
|
+
const st = this.runs.get(sessionID);
|
|
241
|
+
if (!st) return null;
|
|
242
|
+
if (st.frozen) return {
|
|
243
|
+
tps: st.frozen.tps,
|
|
244
|
+
tokens: st.frozen.tokens,
|
|
245
|
+
frozen: true
|
|
246
|
+
};
|
|
247
|
+
if (st.phase !== "running") return null;
|
|
248
|
+
const tokens = estimateTokens(st.bytes);
|
|
249
|
+
if (tokens <= 0) return null;
|
|
250
|
+
const live = this.live(st, now);
|
|
251
|
+
if (live >= 0) return {
|
|
252
|
+
tps: live,
|
|
253
|
+
tokens,
|
|
254
|
+
frozen: false
|
|
255
|
+
};
|
|
256
|
+
// Live window empty (long tool call, sub-agent turn): keep showing the
|
|
257
|
+
// run-so-far average instead of blanking. It uses the same elapsed-time
|
|
258
|
+
// formula finish() will use, so freezing does not make the number jump.
|
|
259
|
+
const tail = st.lastSampleAt === null ? 0 : Math.min(Math.max(0, now - st.lastSampleAt), this.config.tailMaxMs);
|
|
260
|
+
const seconds = Math.max(st.activeMs + tail, this.config.singleSampleMinMs) / 1000;
|
|
261
|
+
return {
|
|
262
|
+
tps: tokens / seconds,
|
|
263
|
+
tokens,
|
|
264
|
+
frozen: false
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
prune(now) {
|
|
268
|
+
const cutoff = now - this.config.sampleWindowMs;
|
|
269
|
+
for (const st of this.runs.values()) {
|
|
270
|
+
// Finished runs keep their frozen average until the next run replaces
|
|
271
|
+
// them, so only the live sample window needs trimming.
|
|
272
|
+
const oldest = st.samples[0];
|
|
273
|
+
if (oldest && oldest.timestamp < cutoff) st.samples = st.samples.filter(s => s.timestamp >= cutoff);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// options
|
|
280
|
+
//
|
|
281
|
+
// `ctx.options` is host-supplied JSON (Record<string, any>), so this is a real
|
|
282
|
+
// parsing boundary: every value is validated and clamped, and anything invalid
|
|
283
|
+
// falls back to the default rather than propagating NaN into the arithmetic.
|
|
284
|
+
|
|
285
|
+
const DISPLAY_MODES = ["both", "tokens", "tps"];
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
|
|
289
|
+
* Named so the option boundary has a real input contract to validate against.
|
|
290
|
+
*/
|
|
291
|
+
|
|
292
|
+
/** The option surface, exactly as documented in the README, before validation. */
|
|
293
|
+
|
|
294
|
+
export const DEFAULT_OPTIONS = {
|
|
295
|
+
...DEFAULT_CONFIG,
|
|
296
|
+
display: "both",
|
|
297
|
+
refreshHz: 8,
|
|
298
|
+
debug: false
|
|
299
|
+
};
|
|
300
|
+
function isFiniteNumber(value) {
|
|
301
|
+
return Number.isFinite(value);
|
|
302
|
+
}
|
|
303
|
+
function isDisplayMode(value) {
|
|
304
|
+
return DISPLAY_MODES.some(mode => mode === value);
|
|
305
|
+
}
|
|
306
|
+
function clampNumber(value, fallback, min, max) {
|
|
307
|
+
if (!isFiniteNumber(value)) return fallback;
|
|
308
|
+
return Math.min(Math.max(value, min), max);
|
|
309
|
+
}
|
|
310
|
+
export function resolveOptions(raw) {
|
|
311
|
+
const singleSampleMinMs = clampNumber(raw.singleSampleMinMs, DEFAULT_OPTIONS.singleSampleMinMs, 50, 5_000);
|
|
312
|
+
return {
|
|
313
|
+
display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
|
|
314
|
+
refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, 1, 60),
|
|
315
|
+
sampleWindowMs: clampNumber(raw.sampleWindowMs, DEFAULT_OPTIONS.sampleWindowMs, 1_000, 60_000),
|
|
316
|
+
liveStaleMs: clampNumber(raw.liveStaleMs, DEFAULT_OPTIONS.liveStaleMs, 250, 30_000),
|
|
317
|
+
singleSampleMinMs,
|
|
318
|
+
// The ceiling can never sit below the floor, whatever the user wrote.
|
|
319
|
+
singleSampleMaxMs: Math.max(singleSampleMinMs, clampNumber(raw.singleSampleMaxMs, DEFAULT_OPTIONS.singleSampleMaxMs, 50, 10_000)),
|
|
320
|
+
tailMaxMs: clampNumber(raw.tailMaxMs, DEFAULT_OPTIONS.tailMaxMs, 0, 10_000),
|
|
321
|
+
gapCapMs: clampNumber(raw.gapCapMs, DEFAULT_OPTIONS.gapCapMs, 100, 30_000),
|
|
322
|
+
debug: raw.debug === true
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export function formatLabel(value, display) {
|
|
326
|
+
if (display === "tokens") return `${value.tokens} tok`;
|
|
327
|
+
if (display === "tps") return `${formatTps(value.tps)} t/s`;
|
|
328
|
+
return `${value.tokens} tok · ${formatTps(value.tps)} t/s`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// plugin
|
|
333
|
+
|
|
334
|
+
// Event payloads are taken from the SDK's own union (via the non-generic
|
|
335
|
+
// `data.listen` signature) rather than restated structurally: handlers are
|
|
336
|
+
// contravariant, so hand-written shapes keep typechecking after a field rename.
|
|
337
|
+
|
|
338
|
+
const definition = {
|
|
339
|
+
id: "opencode2.tps",
|
|
340
|
+
setup(ctx) {
|
|
341
|
+
// Generation guard: the host may start a new generation of this plugin
|
|
342
|
+
// without disposing the previous one (observed on server (re)attach), and
|
|
343
|
+
// hot reload shares `storage.memory` across generations. Only the newest
|
|
344
|
+
// generation may count tokens or render.
|
|
345
|
+
const [gen, setGen] = ctx.storage.memory("generation", {
|
|
346
|
+
initial: {
|
|
347
|
+
active: 0
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
const mine = gen.active + 1;
|
|
351
|
+
setGen(d => {
|
|
352
|
+
d.active = mine;
|
|
353
|
+
});
|
|
354
|
+
const isActive = () => gen.active === mine;
|
|
355
|
+
const options = resolveOptions(ctx.options);
|
|
356
|
+
configureDebug(options.debug || isEnvEnabled(process.env["TPS_DEBUG"]));
|
|
357
|
+
const tracker = new TpsTracker(options);
|
|
358
|
+
const [version, setVersion] = createSignal(0);
|
|
359
|
+
mark(`setup ok app=${ctx.app.version} gen=${mine} display=${options.display} refreshHz=${options.refreshHz}`);
|
|
360
|
+
|
|
361
|
+
// Rendering is throttled: deltas arrive at 100-200/s, and every bump costs
|
|
362
|
+
// a memo recompute plus a terminal repaint to move a number no one can read
|
|
363
|
+
// faster than ~10 Hz. Handlers only set a flag; the timer does the work,
|
|
364
|
+
// and it only runs while a session is actually streaming.
|
|
365
|
+
let dirty = false;
|
|
366
|
+
let timer;
|
|
367
|
+
const flush = () => {
|
|
368
|
+
// A superseded generation stops ticking even if its cleanup never ran.
|
|
369
|
+
if (!isActive()) {
|
|
370
|
+
stopTimer();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
tracker.prune(now);
|
|
375
|
+
const running = tracker.hasRunning();
|
|
376
|
+
// While running, the label is a function of `now` (window ageing, tail
|
|
377
|
+
// gap), so it is republished every tick regardless of new deltas.
|
|
378
|
+
if (dirty || running) {
|
|
379
|
+
dirty = false;
|
|
380
|
+
setVersion(v => v + 1);
|
|
381
|
+
}
|
|
382
|
+
if (!running) stopTimer();
|
|
383
|
+
};
|
|
384
|
+
function stopTimer() {
|
|
385
|
+
if (timer === undefined) return;
|
|
386
|
+
clearInterval(timer);
|
|
387
|
+
timer = undefined;
|
|
388
|
+
}
|
|
389
|
+
const touch = () => {
|
|
390
|
+
dirty = true;
|
|
391
|
+
if (timer !== undefined) return;
|
|
392
|
+
timer = setInterval(flush, Math.round(1000 / options.refreshHz));
|
|
393
|
+
timer.unref?.();
|
|
394
|
+
};
|
|
395
|
+
const onDelta = e => {
|
|
396
|
+
if (!isActive()) return;
|
|
397
|
+
tracker.push(e.data.sessionID, e.data.delta, Date.now());
|
|
398
|
+
touch();
|
|
399
|
+
};
|
|
400
|
+
const onFinish = e => {
|
|
401
|
+
if (!isActive()) return;
|
|
402
|
+
tracker.finish(e.data.sessionID, Date.now());
|
|
403
|
+
touch();
|
|
404
|
+
};
|
|
405
|
+
const unsubs = [ctx.data.on("session.execution.started", e => {
|
|
406
|
+
if (!isActive()) return;
|
|
407
|
+
tracker.beginRun(e.data.sessionID);
|
|
408
|
+
touch();
|
|
409
|
+
}), ctx.data.on("session.text.delta", onDelta), ctx.data.on("session.reasoning.delta", onDelta),
|
|
410
|
+
// Tool arguments are model output too: without this the indicator blanks
|
|
411
|
+
// partway through every large write/edit while generation is at full rate.
|
|
412
|
+
ctx.data.on("session.tool.input.delta", onDelta), 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 => {
|
|
413
|
+
if (!isActive()) return;
|
|
414
|
+
tracker.evict(e.data.sessionID);
|
|
415
|
+
touch();
|
|
416
|
+
})];
|
|
417
|
+
const unslot = ctx.ui.slot({
|
|
418
|
+
append: "session.composer.top",
|
|
419
|
+
render: input => {
|
|
420
|
+
const label = createMemo(() => {
|
|
421
|
+
version();
|
|
422
|
+
if (!isActive()) return null;
|
|
423
|
+
const v = tracker.value(input.sessionID, Date.now());
|
|
424
|
+
if (!v) return null;
|
|
425
|
+
return formatLabel(v, options.display);
|
|
426
|
+
});
|
|
427
|
+
return _$createComponent(Show, {
|
|
428
|
+
get when() {
|
|
429
|
+
return label();
|
|
430
|
+
},
|
|
431
|
+
children: text => (() => {
|
|
432
|
+
var _el$ = _$createElement("box"),
|
|
433
|
+
_el$2 = _$createElement("text");
|
|
434
|
+
_$insertNode(_el$, _el$2);
|
|
435
|
+
_$setProp(_el$, "width", "100%");
|
|
436
|
+
_$setProp(_el$, "flexDirection", "row");
|
|
437
|
+
_$setProp(_el$, "justifyContent", "flex-end");
|
|
438
|
+
_$insert(_el$2, () => `${text()} `);
|
|
439
|
+
_$effect(_$p => _$setProp(_el$2, "fg", ctx.theme.text.subdued, _$p));
|
|
440
|
+
return _el$;
|
|
441
|
+
})()
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
return () => {
|
|
446
|
+
for (const unsub of unsubs) unsub();
|
|
447
|
+
unslot();
|
|
448
|
+
stopTimer();
|
|
449
|
+
if (gen.active === mine) setGen(d => {
|
|
450
|
+
d.active = 0;
|
|
451
|
+
});
|
|
452
|
+
mark(`cleanup ok gen=${mine}`);
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
export default definition;
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode2-tps",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Live token-throughput indicator for the OpenCode 2 TUI prompt composer",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "P-Theo",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/P-Theo/opencode2-tps.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/P-Theo/opencode2-tps#readme",
|
|
12
|
+
"bugs": "https://github.com/P-Theo/opencode2-tps/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"opencode2",
|
|
17
|
+
"tui",
|
|
18
|
+
"plugin",
|
|
19
|
+
"tps",
|
|
20
|
+
"tokens",
|
|
21
|
+
"throughput"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./dist/tui.js",
|
|
25
|
+
"./tui": "./dist/tui.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@opentui/solid": ">=0.5.3",
|
|
32
|
+
"solid-js": "^1.9.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"@opentui/solid": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"solid-js": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"overrides": {
|
|
43
|
+
"@opentui/solid": {
|
|
44
|
+
"@babel/core": "^7.29.7"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@babel/core": "^7.29.7",
|
|
49
|
+
"@babel/preset-typescript": "^7.29.7",
|
|
50
|
+
"@opencode-ai/plugin": "0.0.0-beta-17595",
|
|
51
|
+
"@opencode-ai/theme": "0.0.0-beta-17595",
|
|
52
|
+
"@opentui/core": "^0.5.3",
|
|
53
|
+
"@opentui/solid": "^0.5.3",
|
|
54
|
+
"@oxlint/plugins": "^1.78.0",
|
|
55
|
+
"@types/bun": "^1.3.14",
|
|
56
|
+
"@types/node": "^24.0.0",
|
|
57
|
+
"babel-preset-solid": "^1.9.12",
|
|
58
|
+
"oxlint": "^1.78.0",
|
|
59
|
+
"solid-js": "^1.9.0",
|
|
60
|
+
"typescript": "^5.9.0"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "node build.mjs",
|
|
64
|
+
"check": "tsc --noEmit",
|
|
65
|
+
"lint": "oxlint",
|
|
66
|
+
"test": "bun test",
|
|
67
|
+
"prepack": "npm run lint && npm run check && npm test && npm run build"
|
|
68
|
+
}
|
|
69
|
+
}
|