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.
Files changed (4) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +86 -0
  3. package/dist/tui.js +456 -0
  4. 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
+ }