opencode2-tps 0.1.1 → 0.2.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 (3) hide show
  1. package/README.md +3 -3
  2. package/dist/tui.js +161 -95
  3. package/package.json +7 -7
package/README.md CHANGED
@@ -16,7 +16,7 @@ When the run ends, the number freezes at the run average and stays there until t
16
16
 
17
17
  ## Install
18
18
 
19
- 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-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.
19
+ Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`; the latest tested beta is `0.0.0-beta-17639`. The TUI plugin API is still moving, so a much newer or older build may drop the indicator without an error: a renamed event stops arriving, and an unknown composer slot gets quietly rerouted. If the figure never appears, check your CLI version first.
20
20
 
21
21
  Add the package to `~/.config/opencode/cli.json`:
22
22
 
@@ -69,9 +69,9 @@ The defaults are usable as they are. For the full option list, the ranges and mo
69
69
 
70
70
  ## How it works
71
71
 
72
- 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.
72
+ The plugin counts the output bytes of a run and estimates tokens from the byte total. When the host reports the session's real token counts, the plugin measures its own bytes-per-token ratio for that session and switches to it until then it assumes 4.75 bytes per token. TPS is the run's estimated tokens divided by active model-generation time: timing starts at each step's first output and continues without pause for provider stalls, while tool-execution intervals and time between model steps are excluded.
73
73
 
74
- 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.
74
+ The measured ratio depends on the model and is calibrated once per session. Changing models mid-session can therefore make the token and t/s estimates inaccurate; start a new session after switching models for a fresh calibration. This also avoids the prompt-cache disruption associated with changing models in existing sessions.
75
75
 
76
76
  For more detail, see [Architecture](docs/development.md#architecture).
77
77
 
package/dist/tui.js CHANGED
@@ -87,19 +87,22 @@ function mark(line) {
87
87
  // ---------------------------------------------------------------------------
88
88
  // tuning
89
89
 
90
- const BYTES_PER_TOKEN = 5;
91
90
  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
91
+ bytesPerToken: 4.75
98
92
  };
99
93
  // The frozen final average stays visible until the next prompt starts a new run.
100
94
 
101
- function estimateTokens(bytes) {
102
- return Math.ceil(bytes / BYTES_PER_TOKEN);
95
+ // Step completion events carry the generated-token count for that model call.
96
+ // Pairing it with deltas from the same assistant message avoids contamination
97
+ // from concurrent title generation and other session-level usage updates.
98
+ export const CALIBRATION_MIN_BYTES = 2_048;
99
+ export const CALIBRATION_MIN_TOKENS = 50;
100
+ // Bounds shared by the option clamp and the calibration sanity check: a ratio
101
+ // outside them means the report does not describe the bytes the plugin watched.
102
+ const BYTES_PER_TOKEN_MIN = 1;
103
+ const BYTES_PER_TOKEN_MAX = 16;
104
+ function estimateTokens(bytes, bytesPerToken) {
105
+ return Math.ceil(bytes / bytesPerToken);
103
106
  }
104
107
  function formatTps(value) {
105
108
  if (value < 10) return value.toFixed(2);
@@ -120,6 +123,10 @@ export class TpsTracker {
120
123
  // Insertion order is kept equal to run-start recency (see beginRun), which is
121
124
  // what makes eviction from the front drop the stalest session.
122
125
  runs = new Map();
126
+ // Calibrated bytes/token and the currently observed model step. Both share
127
+ // the bounded lifecycle of `runs`.
128
+ ratios = new Map();
129
+ calibrationSteps = new Map();
123
130
  constructor(config = DEFAULT_CONFIG) {
124
131
  this.config = config;
125
132
  }
@@ -128,10 +135,13 @@ export class TpsTracker {
128
135
  if (!st) {
129
136
  st = {
130
137
  phase: "ended",
131
- samples: [],
132
138
  bytes: 0,
133
139
  activeMs: 0,
140
+ activeAssistantMessageID: null,
134
141
  lastSampleAt: null,
142
+ activeTools: new Set(),
143
+ toolPauseStartedAt: null,
144
+ toolPausedMs: 0,
135
145
  frozen: null
136
146
  };
137
147
  this.runs.set(sessionID, st);
@@ -140,11 +150,15 @@ export class TpsTracker {
140
150
  }
141
151
  beginRun(sessionID) {
142
152
  const st = this.state(sessionID);
153
+ this.calibrationSteps.delete(sessionID);
143
154
  st.phase = "running";
144
- st.samples = [];
145
155
  st.bytes = 0;
146
156
  st.activeMs = 0;
157
+ st.activeAssistantMessageID = null;
147
158
  st.lastSampleAt = null;
159
+ st.activeTools.clear();
160
+ st.toolPauseStartedAt = null;
161
+ st.toolPausedMs = 0;
148
162
  st.frozen = null;
149
163
  // Re-insert so this session becomes the newest in iteration order. Every
150
164
  // entry is created through here, so the cap is checked on the one path that
@@ -158,10 +172,18 @@ export class TpsTracker {
158
172
  for (const [sessionID, st] of this.runs) {
159
173
  if (this.runs.size <= MAX_TRACKED_RUNS) return;
160
174
  if (st.phase === "running") continue;
161
- this.runs.delete(sessionID);
175
+ this.dropSession(sessionID);
162
176
  }
163
177
  }
164
- push(sessionID, delta, now) {
178
+ advanceTiming(st, now) {
179
+ if (st.lastSampleAt === null) return;
180
+ const currentPause = st.toolPauseStartedAt === null ? 0 : Math.max(0, now - st.toolPauseStartedAt);
181
+ st.activeMs += Math.max(0, now - st.lastSampleAt - st.toolPausedMs - currentPause);
182
+ st.lastSampleAt = now;
183
+ st.toolPausedMs = 0;
184
+ if (st.toolPauseStartedAt !== null) st.toolPauseStartedAt = now;
185
+ }
186
+ push(sessionID, delta, now, assistantMessageID) {
165
187
  if (!delta) return;
166
188
  const st = this.state(sessionID);
167
189
  if (st.phase !== "running") this.beginRun(sessionID); // execution.started missed
@@ -169,72 +191,124 @@ export class TpsTracker {
169
191
  // Bytes accumulate; tokens are derived from the run total, so providers
170
192
  // that emit 1-3 byte deltas are not rounded up on every single one.
171
193
  const bytes = Buffer.byteLength(delta, "utf8");
172
- st.samples.push({
173
- bytes,
174
- timestamp: now
175
- });
176
194
  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;
195
+ const calibration = this.calibrationSteps.get(sessionID);
196
+ if (calibration && calibration.assistantMessageID === assistantMessageID) calibration.bytes += bytes;
197
+ const stepID = assistantMessageID ?? "";
198
+ if (st.activeAssistantMessageID !== stepID) {
199
+ st.activeAssistantMessageID = stepID;
200
+ st.lastSampleAt = null;
201
+ st.activeTools.clear();
202
+ st.toolPauseStartedAt = null;
203
+ st.toolPausedMs = 0;
204
+ }
205
+ if (st.lastSampleAt === null) {
206
+ st.lastSampleAt = now;
207
+ if (st.activeTools.size > 0) st.toolPauseStartedAt = now;
208
+ } else {
209
+ this.advanceTiming(st, now);
210
+ }
211
+ }
212
+
213
+ /** Start observing one model step for a possible one-shot calibration. */
214
+ beginStep(sessionID, assistantMessageID) {
215
+ const st = this.runs.get(sessionID);
216
+ if (!st || st.phase !== "running") this.beginRun(sessionID);
217
+ const running = this.runs.get(sessionID);
218
+ if (running) {
219
+ running.activeAssistantMessageID = assistantMessageID;
220
+ running.lastSampleAt = null;
221
+ running.activeTools.clear();
222
+ running.toolPauseStartedAt = null;
223
+ running.toolPausedMs = 0;
224
+ }
225
+ if (this.ratios.has(sessionID)) return;
226
+ this.calibrationSteps.set(sessionID, {
227
+ assistantMessageID,
228
+ bytes: 0
229
+ });
230
+ }
231
+
232
+ /** Pair a completed step's usage with deltas from that assistant message. */
233
+ finishStep(sessionID, assistantMessageID, generatedTokens, now) {
234
+ const st = this.runs.get(sessionID);
235
+ if (st?.activeAssistantMessageID === assistantMessageID) {
236
+ this.advanceTiming(st, now);
237
+ st.activeAssistantMessageID = null;
238
+ st.lastSampleAt = null;
239
+ st.activeTools.clear();
240
+ st.toolPauseStartedAt = null;
241
+ st.toolPausedMs = 0;
242
+ }
243
+ const calibration = this.calibrationSteps.get(sessionID);
244
+ if (!calibration || calibration.assistantMessageID !== assistantMessageID) return;
245
+ this.calibrationSteps.delete(sessionID);
246
+ if (this.ratios.has(sessionID)) return;
247
+ if (generatedTokens === undefined || !Number.isFinite(generatedTokens) || generatedTokens < CALIBRATION_MIN_TOKENS) return;
248
+ const bytes = calibration.bytes;
249
+ if (bytes < CALIBRATION_MIN_BYTES) return;
250
+ const ratio = bytes / generatedTokens;
251
+ if (ratio < BYTES_PER_TOKEN_MIN || ratio > BYTES_PER_TOKEN_MAX) {
252
+ mark(`calibration rejected sid=${sessionID} bytes=${bytes} tokens=${generatedTokens} ratio=${ratio.toFixed(2)}`);
253
+ return;
254
+ }
255
+ this.ratios.set(sessionID, ratio);
256
+ mark(`calibrated sid=${sessionID} bytes=${bytes} tokens=${generatedTokens} ratio=${ratio.toFixed(2)}`);
257
+ }
258
+ beginTool(sessionID, assistantMessageID, toolID, now) {
259
+ const st = this.runs.get(sessionID);
260
+ if (!st || st.activeAssistantMessageID !== assistantMessageID || st.activeTools.has(toolID)) return;
261
+ if (st.activeTools.size === 0 && st.lastSampleAt !== null) st.toolPauseStartedAt = now;
262
+ st.activeTools.add(toolID);
263
+ }
264
+ finishTool(sessionID, assistantMessageID, toolID, now) {
265
+ const st = this.runs.get(sessionID);
266
+ if (st?.activeAssistantMessageID !== assistantMessageID || !st.activeTools.delete(toolID) || st.activeTools.size > 0) return;
267
+ if (st.toolPauseStartedAt !== null) st.toolPausedMs += Math.max(0, now - st.toolPauseStartedAt);
268
+ st.toolPauseStartedAt = null;
269
+ }
270
+
271
+ /** The session's calibrated ratio, or the configured fallback. */
272
+ ratioFor(sessionID) {
273
+ return this.ratios.get(sessionID) ?? this.config.bytesPerToken;
181
274
  }
182
275
  finish(sessionID, now) {
183
276
  const st = this.runs.get(sessionID);
184
277
  if (!st || st.phase === "ended") return;
278
+ this.calibrationSteps.delete(sessionID);
185
279
  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);
280
+ this.advanceTiming(st, now);
281
+ st.lastSampleAt = null;
282
+ st.activeAssistantMessageID = null;
283
+ st.activeTools.clear();
284
+ st.toolPauseStartedAt = null;
285
+ st.toolPausedMs = 0;
286
+ const tokens = estimateTokens(st.bytes, this.ratioFor(sessionID));
191
287
  if (tokens <= 0) {
192
- this.runs.delete(sessionID);
288
+ this.evictStale();
193
289
  return;
194
290
  }
195
- const seconds = Math.max(st.activeMs, this.config.singleSampleMinMs) / 1000;
291
+ const seconds = Math.max(st.activeMs, 1) / 1000;
196
292
  st.frozen = {
197
293
  tps: tokens / seconds,
198
294
  tokens
199
295
  };
200
- st.samples = [];
201
296
  mark(`finish sid=${sessionID} tokens=${tokens} activeMs=${st.activeMs} tps=${st.frozen.tps.toFixed(1)}`);
297
+ this.evictStale();
202
298
  }
203
- evict(sessionID) {
299
+ dropSession(sessionID) {
204
300
  this.runs.delete(sessionID);
301
+ this.ratios.delete(sessionID);
302
+ this.calibrationSteps.delete(sessionID);
205
303
  }
206
- hasRunning() {
207
- for (const st of this.runs.values()) if (st.phase === "running") return true;
208
- return false;
304
+ evict(sessionID) {
305
+ this.dropSession(sessionID);
209
306
  }
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);
307
+ hasRunning() {
308
+ for (const st of this.runs.values()) {
309
+ if (st.phase === "running" && st.activeAssistantMessageID !== null && st.lastSampleAt !== null) return true;
236
310
  }
237
- return tokens / durationMs * 1000;
311
+ return false;
238
312
  }
239
313
  value(sessionID, now) {
240
314
  const st = this.runs.get(sessionID);
@@ -245,34 +319,17 @@ export class TpsTracker {
245
319
  frozen: true
246
320
  };
247
321
  if (st.phase !== "running") return null;
248
- const tokens = estimateTokens(st.bytes);
322
+ const tokens = estimateTokens(st.bytes, this.ratioFor(sessionID));
249
323
  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;
324
+ const currentPause = st.toolPauseStartedAt === null ? 0 : Math.max(0, now - st.toolPauseStartedAt);
325
+ const activeTail = st.lastSampleAt === null ? 0 : Math.max(0, now - st.lastSampleAt - st.toolPausedMs - currentPause);
326
+ const seconds = Math.max(st.activeMs + activeTail, 1) / 1000;
261
327
  return {
262
328
  tps: tokens / seconds,
263
329
  tokens,
264
330
  frozen: false
265
331
  };
266
332
  }
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
333
  }
277
334
 
278
335
  // ---------------------------------------------------------------------------
@@ -308,17 +365,10 @@ function clampNumber(value, fallback, min, max) {
308
365
  return Math.min(Math.max(value, min), max);
309
366
  }
310
367
  export function resolveOptions(raw) {
311
- const singleSampleMinMs = clampNumber(raw.singleSampleMinMs, DEFAULT_OPTIONS.singleSampleMinMs, 50, 5_000);
312
368
  return {
313
369
  display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
314
370
  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),
371
+ bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
322
372
  debug: raw.debug === true
323
373
  };
324
374
  }
@@ -370,11 +420,9 @@ const definition = {
370
420
  stopTimer();
371
421
  return;
372
422
  }
373
- const now = Date.now();
374
- tracker.prune(now);
375
423
  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.
424
+ // While a model step is running, elapsed generation time changes even
425
+ // without new deltas, so republish every tick to expose provider stalls.
378
426
  if (dirty || running) {
379
427
  dirty = false;
380
428
  setVersion(v => v + 1);
@@ -394,7 +442,7 @@ const definition = {
394
442
  };
395
443
  const onDelta = e => {
396
444
  if (!isActive()) return;
397
- tracker.push(e.data.sessionID, e.data.delta, Date.now());
445
+ tracker.push(e.data.sessionID, e.data.delta, Date.now(), e.data.assistantMessageID);
398
446
  touch();
399
447
  };
400
448
  const onFinish = e => {
@@ -402,6 +450,24 @@ const definition = {
402
450
  tracker.finish(e.data.sessionID, Date.now());
403
451
  touch();
404
452
  };
453
+ const onStepStarted = e => {
454
+ if (!isActive()) return;
455
+ tracker.beginStep(e.data.sessionID, e.data.assistantMessageID);
456
+ };
457
+ const onStepFinished = e => {
458
+ if (!isActive()) return;
459
+ const tokens = e.data.tokens;
460
+ tracker.finishStep(e.data.sessionID, e.data.assistantMessageID, tokens === undefined ? undefined : tokens.output + tokens.reasoning, Date.now());
461
+ touch();
462
+ };
463
+ const onToolStarted = e => {
464
+ if (!isActive()) return;
465
+ tracker.beginTool(e.data.sessionID, e.data.assistantMessageID, e.data.id, Date.now());
466
+ };
467
+ const onToolFinished = e => {
468
+ if (!isActive()) return;
469
+ tracker.finishTool(e.data.sessionID, e.data.assistantMessageID, e.data.id, Date.now());
470
+ };
405
471
  const unsubs = [ctx.data.on("session.execution.started", e => {
406
472
  if (!isActive()) return;
407
473
  tracker.beginRun(e.data.sessionID);
@@ -409,7 +475,7 @@ const definition = {
409
475
  }), ctx.data.on("session.text.delta", onDelta), ctx.data.on("session.reasoning.delta", onDelta),
410
476
  // Tool arguments are model output too: without this the indicator blanks
411
477
  // 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 => {
478
+ ctx.data.on("session.tool.input.delta", onDelta), ctx.data.on("session.step.started", onStepStarted), ctx.data.on("session.step.ended", onStepFinished), ctx.data.on("session.step.failed", onStepFinished), ctx.data.on("session.tool.called", onToolStarted), ctx.data.on("session.tool.success", onToolFinished), ctx.data.on("session.tool.failed", onToolFinished), 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
479
  if (!isActive()) return;
414
480
  tracker.evict(e.data.sessionID);
415
481
  touch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-tps",
3
- "version": "0.1.1",
3
+ "version": "0.2.0-rc.1",
4
4
  "description": "Live token-throughput indicator for the OpenCode 2 TUI prompt composer",
5
5
  "license": "MIT",
6
6
  "author": "P-Theo",
@@ -28,7 +28,7 @@
28
28
  "dist"
29
29
  ],
30
30
  "peerDependencies": {
31
- "@opentui/solid": ">=0.5.3",
31
+ "@opentui/solid": ">=0.5.4",
32
32
  "solid-js": "^1.9.0"
33
33
  },
34
34
  "peerDependenciesMeta": {
@@ -47,11 +47,11 @@
47
47
  "devDependencies": {
48
48
  "@babel/core": "^7.29.7",
49
49
  "@babel/preset-typescript": "^7.29.7",
50
- "@opencode-ai/cli": "0.0.0-beta-17595",
51
- "@opencode-ai/plugin": "0.0.0-beta-17595",
52
- "@opencode-ai/theme": "0.0.0-beta-17595",
53
- "@opentui/core": "^0.5.3",
54
- "@opentui/solid": "^0.5.3",
50
+ "@opencode-ai/cli": "0.0.0-beta-17639",
51
+ "@opencode-ai/plugin": "0.0.0-beta-17639",
52
+ "@opencode-ai/theme": "0.0.0-beta-17639",
53
+ "@opentui/core": "^0.5.4",
54
+ "@opentui/solid": "^0.5.4",
55
55
  "@oxlint/plugins": "^1.78.0",
56
56
  "@types/bun": "^1.3.14",
57
57
  "@types/node": "^24.0.0",