opencode2-tps 0.2.0-rc.1 → 0.2.1-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 +7 -9
  2. package/dist/tui.js +179 -165
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,13 +2,9 @@
2
2
 
3
3
  Live token-throughput indicator for the OpenCode 2 TUI prompt composer.
4
4
 
5
- While a session streams, the top right of the composer shows the estimated tokens and the throughput of the current run:
5
+ While a session streams, the top right of the composer shows estimated observable tokens and throughput: `~1712 tok · ~51.5 t/s`
6
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.
7
+ When OpenCode reports terminal usage, the token count becomes exact while TPS remains approximate, for example `1715 tok · ~51.5 t/s`. The result freezes until the next run starts. Nothing is shown on the home screen, or before the first observable output.
12
8
 
13
9
  <p align="center">
14
10
  <img src="docs/screenshots/opencode2_tps.png" width="750" alt="Composer showing live token-throughput indicator" />
@@ -16,7 +12,7 @@ When the run ends, the number freezes at the run average and stays there until t
16
12
 
17
13
  ## Install
18
14
 
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.
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-17639`. 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.
20
16
 
21
17
  Add the package to `~/.config/opencode/cli.json`:
22
18
 
@@ -69,9 +65,11 @@ The defaults are usable as they are. For the full option list, the ranges and mo
69
65
 
70
66
  ## How it works
71
67
 
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.
68
+ 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.
69
+
70
+ 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 the final model-content boundary, which excludes later local tool execution and time between model calls.
73
71
 
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.
72
+ TPS is always approximate (`~`) because OpenCode does not expose token-level timestamps. Proprietary reasoning may be encrypted or represented only by a short summary, and some providers buffer tool arguments until completion. During those opaque intervals the live rate holds or becomes unavailable instead of continuously falling. Opaque provider state is never counted by byte length.
75
73
 
76
74
  For more detail, see [Architecture](docs/development.md#architecture).
77
75
 
package/dist/tui.js CHANGED
@@ -78,7 +78,8 @@ export function isEnvEnabled(value) {
78
78
  function mark(line) {
79
79
  if (!debugState.enabled) return;
80
80
  try {
81
- appendFileSync(debugState.file, `${new Date().toISOString()} ${line}\n`);
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`);
82
83
  } catch {
83
84
  // debug only; never break the host
84
85
  }
@@ -92,15 +93,11 @@ export const DEFAULT_CONFIG = {
92
93
  };
93
94
  // The frozen final average stays visible until the next prompt starts a new run.
94
95
 
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
96
  const BYTES_PER_TOKEN_MIN = 1;
103
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;
104
101
  function estimateTokens(bytes, bytesPerToken) {
105
102
  return Math.ceil(bytes / bytesPerToken);
106
103
  }
@@ -123,10 +120,6 @@ export class TpsTracker {
123
120
  // Insertion order is kept equal to run-start recency (see beginRun), which is
124
121
  // what makes eviction from the front drop the stalest session.
125
122
  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();
130
123
  constructor(config = DEFAULT_CONFIG) {
131
124
  this.config = config;
132
125
  }
@@ -135,13 +128,12 @@ export class TpsTracker {
135
128
  if (!st) {
136
129
  st = {
137
130
  phase: "ended",
138
- bytes: 0,
139
- activeMs: 0,
140
- activeAssistantMessageID: null,
141
- lastSampleAt: null,
142
- activeTools: new Set(),
143
- toolPauseStartedAt: null,
144
- toolPausedMs: 0,
131
+ settledTokens: 0,
132
+ settledDurationMs: 0,
133
+ tokensEstimated: false,
134
+ partial: false,
135
+ activeStep: null,
136
+ settledSteps: new Set(),
145
137
  frozen: null
146
138
  };
147
139
  this.runs.set(sessionID, st);
@@ -150,15 +142,13 @@ export class TpsTracker {
150
142
  }
151
143
  beginRun(sessionID) {
152
144
  const st = this.state(sessionID);
153
- this.calibrationSteps.delete(sessionID);
154
145
  st.phase = "running";
155
- st.bytes = 0;
156
- st.activeMs = 0;
157
- st.activeAssistantMessageID = null;
158
- st.lastSampleAt = null;
159
- st.activeTools.clear();
160
- st.toolPauseStartedAt = null;
161
- st.toolPausedMs = 0;
146
+ st.settledTokens = 0;
147
+ st.settledDurationMs = 0;
148
+ st.tokensEstimated = false;
149
+ st.partial = false;
150
+ st.activeStep = null;
151
+ st.settledSteps.clear();
162
152
  st.frozen = null;
163
153
  // Re-insert so this session becomes the newest in iteration order. Every
164
154
  // entry is created through here, so the cap is checked on the one path that
@@ -175,159 +165,167 @@ export class TpsTracker {
175
165
  this.dropSession(sessionID);
176
166
  }
177
167
  }
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;
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
+ lastBoundaryAt: null,
180
+ observableBytes: 0,
181
+ blocks: new Map(),
182
+ samples: []
183
+ };
184
+ running.activeStep = step;
185
+ running.frozen = null;
186
+ return step;
185
187
  }
186
- push(sessionID, delta, now, assistantMessageID) {
187
- if (!delta) return;
188
+ beginStep(sessionID, assistantMessageID, now = Date.now()) {
188
189
  const st = this.state(sessionID);
189
- if (st.phase !== "running") this.beginRun(sessionID); // execution.started missed
190
- st.frozen = null;
191
- // Bytes accumulate; tokens are derived from the run total, so providers
192
- // that emit 1-3 byte deltas are not rounded up on every single one.
193
- const bytes = Buffer.byteLength(delta, "utf8");
194
- st.bytes += bytes;
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);
190
+ if (st.phase !== "running") {
191
+ if (st.settledSteps.has(assistantMessageID)) return;
192
+ this.beginRun(sessionID);
210
193
  }
194
+ if (st.activeStep?.assistantMessageID === assistantMessageID) return;
195
+ this.ensureStep(sessionID, assistantMessageID, now, true);
211
196
  }
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;
197
+ beginBlock(sessionID, assistantMessageID, blockID, now) {
198
+ const step = this.ensureStep(sessionID, assistantMessageID, now);
199
+ if (!step) return;
200
+ if (!step.blocks.has(blockID)) step.blocks.set(blockID, {
201
+ streamedBytes: 0,
202
+ finalBytes: null
203
+ });
204
+ }
205
+ push(sessionID, delta, now, assistantMessageID = "implicit", blockID = "implicit") {
206
+ if (!delta) return;
207
+ const step = this.ensureStep(sessionID, assistantMessageID, now);
208
+ if (!step) return;
209
+ let block = step.blocks.get(blockID);
210
+ if (!block) {
211
+ block = {
212
+ streamedBytes: 0,
213
+ finalBytes: null
214
+ };
215
+ step.blocks.set(blockID, block);
224
216
  }
225
- if (this.ratios.has(sessionID)) return;
226
- this.calibrationSteps.set(sessionID, {
227
- assistantMessageID,
228
- bytes: 0
217
+ if (block.finalBytes !== null) return;
218
+ const bytes = Buffer.byteLength(delta, "utf8");
219
+ block.streamedBytes += bytes;
220
+ step.observableBytes += bytes;
221
+ step.samples.push({
222
+ bytes,
223
+ timestamp: now
229
224
  });
225
+ const oldest = now - LIVE_WINDOW_MS;
226
+ while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift();
230
227
  }
231
-
232
- /** Pair a completed step's usage with deltas from that assistant message. */
233
- finishStep(sessionID, assistantMessageID, generatedTokens, now) {
228
+ finishBlock(sessionID, assistantMessageID, blockID, text, now) {
234
229
  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;
230
+ const step = st?.activeStep;
231
+ if (!step || step.assistantMessageID !== assistantMessageID) return;
232
+ let block = step.blocks.get(blockID);
233
+ if (!block) {
234
+ block = {
235
+ streamedBytes: 0,
236
+ finalBytes: null
237
+ };
238
+ step.blocks.set(blockID, block);
254
239
  }
255
- this.ratios.set(sessionID, ratio);
256
- mark(`calibrated sid=${sessionID} bytes=${bytes} tokens=${generatedTokens} ratio=${ratio.toFixed(2)}`);
240
+ if (block.finalBytes !== null) return;
241
+ block.finalBytes = Buffer.byteLength(text, "utf8");
242
+ step.observableBytes += block.finalBytes - block.streamedBytes;
243
+ step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now);
257
244
  }
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);
245
+ settleActiveStep(st, generatedTokens) {
246
+ const step = st.activeStep;
247
+ if (!step) return;
248
+ const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0;
249
+ st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken);
250
+ if (!exact) {
251
+ st.tokensEstimated = true;
252
+ st.partial = true;
253
+ }
254
+ if (step.lastBoundaryAt !== null) st.settledDurationMs += Math.max(0, step.lastBoundaryAt - step.startedAt);
255
+ st.settledSteps.add(step.assistantMessageID);
256
+ st.activeStep = null;
263
257
  }
264
- finishTool(sessionID, assistantMessageID, toolID, now) {
258
+ finishStep(sessionID, assistantMessageID, generatedTokens, _now) {
265
259
  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;
260
+ if (st?.activeStep?.assistantMessageID !== assistantMessageID) return;
261
+ this.settleActiveStep(st, generatedTokens);
269
262
  }
270
-
271
- /** The session's calibrated ratio, or the configured fallback. */
272
- ratioFor(sessionID) {
273
- return this.ratios.get(sessionID) ?? this.config.bytesPerToken;
274
- }
275
- finish(sessionID, now) {
263
+ finish(sessionID, _now) {
276
264
  const st = this.runs.get(sessionID);
277
265
  if (!st || st.phase === "ended") return;
278
- this.calibrationSteps.delete(sessionID);
266
+ if (st.activeStep) this.settleActiveStep(st, undefined);
279
267
  st.phase = "ended";
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));
268
+ const tokens = st.settledTokens;
287
269
  if (tokens <= 0) {
288
270
  this.evictStale();
289
271
  return;
290
272
  }
291
- const seconds = Math.max(st.activeMs, 1) / 1000;
273
+ const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null;
292
274
  st.frozen = {
293
- tps: tokens / seconds,
294
- tokens
275
+ tps,
276
+ tokens,
277
+ tokensEstimated: st.tokensEstimated,
278
+ partial: st.partial
295
279
  };
296
- mark(`finish sid=${sessionID} tokens=${tokens} activeMs=${st.activeMs} tps=${st.frozen.tps.toFixed(1)}`);
280
+ mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`);
297
281
  this.evictStale();
298
282
  }
299
283
  dropSession(sessionID) {
300
284
  this.runs.delete(sessionID);
301
- this.ratios.delete(sessionID);
302
- this.calibrationSteps.delete(sessionID);
303
285
  }
304
286
  evict(sessionID) {
305
287
  this.dropSession(sessionID);
306
288
  }
307
- hasRunning() {
289
+ hasRunning(now = Date.now()) {
308
290
  for (const st of this.runs.values()) {
309
- if (st.phase === "running" && st.activeAssistantMessageID !== null && st.lastSampleAt !== null) return true;
291
+ const last = st.activeStep?.samples.at(-1);
292
+ if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true;
310
293
  }
311
294
  return false;
312
295
  }
296
+ liveTps(step, now) {
297
+ const last = step.samples.at(-1);
298
+ if (!last) return null;
299
+ const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS);
300
+ const oldest = effectiveNow - LIVE_WINDOW_MS;
301
+ const samples = step.samples.filter(sample => sample.timestamp >= oldest);
302
+ const first = samples[0];
303
+ if (!first) return null;
304
+ const bytes = samples.reduce((total, sample) => total + sample.bytes, 0);
305
+ const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS);
306
+ return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000);
307
+ }
313
308
  value(sessionID, now) {
314
309
  const st = this.runs.get(sessionID);
315
310
  if (!st) return null;
316
311
  if (st.frozen) return {
317
- tps: st.frozen.tps,
318
- tokens: st.frozen.tokens,
319
- frozen: true
312
+ ...st.frozen,
313
+ frozen: true,
314
+ tpsEstimated: true
320
315
  };
321
316
  if (st.phase !== "running") return null;
322
- const tokens = estimateTokens(st.bytes, this.ratioFor(sessionID));
317
+ const active = st.activeStep;
318
+ const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0;
319
+ const tokens = st.settledTokens + activeTokens;
323
320
  if (tokens <= 0) return null;
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;
321
+ const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null;
327
322
  return {
328
- tps: tokens / seconds,
323
+ tps: active ? this.liveTps(active, now) ?? settledTps : settledTps,
329
324
  tokens,
330
- frozen: false
325
+ frozen: false,
326
+ tokensEstimated: st.tokensEstimated || active !== null,
327
+ tpsEstimated: true,
328
+ partial: st.partial
331
329
  };
332
330
  }
333
331
  }
@@ -373,9 +371,11 @@ export function resolveOptions(raw) {
373
371
  };
374
372
  }
375
373
  export function formatLabel(value, display) {
376
- if (display === "tokens") return `${value.tokens} tok`;
377
- if (display === "tps") return `${formatTps(value.tps)} t/s`;
378
- return `${value.tokens} tok · ${formatTps(value.tps)} t/s`;
374
+ const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`;
375
+ const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`;
376
+ if (display === "tokens") return tokens;
377
+ if (display === "tps") return tps ?? "— t/s";
378
+ return tps === null ? tokens : `${tokens} · ${tps}`;
379
379
  }
380
380
 
381
381
  // ---------------------------------------------------------------------------
@@ -385,6 +385,10 @@ export function formatLabel(value, display) {
385
385
  // `data.listen` signature) rather than restated structurally: handlers are
386
386
  // contravariant, so hand-written shapes keep typechecking after a field rename.
387
387
 
388
+ function blockID(e) {
389
+ if (e.type === "session.tool.input.delta" || e.type === "session.tool.input.started" || e.type === "session.tool.input.ended") return `tool:${e.data.id}`;
390
+ return `${e.type.startsWith("session.text.") ? "text" : "reasoning"}:${e.data.ordinal}`;
391
+ }
388
392
  const definition = {
389
393
  id: "opencode2.tps",
390
394
  setup(ctx) {
@@ -406,6 +410,16 @@ const definition = {
406
410
  configureDebug(options.debug || isEnvEnabled(process.env["TPS_DEBUG"]));
407
411
  const tracker = new TpsTracker(options);
408
412
  const [version, setVersion] = createSignal(0);
413
+ const seenEventIDs = new Set();
414
+ const isNewEvent = e => {
415
+ if (seenEventIDs.has(e.id)) return false;
416
+ seenEventIDs.add(e.id);
417
+ if (seenEventIDs.size > 4_096) {
418
+ const oldest = seenEventIDs.values().next().value;
419
+ if (oldest !== undefined) seenEventIDs.delete(oldest);
420
+ }
421
+ return true;
422
+ };
409
423
  mark(`setup ok app=${ctx.app.version} gen=${mine} display=${options.display} refreshHz=${options.refreshHz}`);
410
424
 
411
425
  // Rendering is throttled: deltas arrive at 100-200/s, and every bump costs
@@ -420,9 +434,9 @@ const definition = {
420
434
  stopTimer();
421
435
  return;
422
436
  }
423
- const running = tracker.hasRunning();
424
- // While a model step is running, elapsed generation time changes even
425
- // without new deltas, so republish every tick to expose provider stalls.
437
+ const running = tracker.hasRunning(Date.now());
438
+ // The observable live rate decays only through a short stale tail. Opaque
439
+ // provider work after that is not charged to a numerator we cannot see.
426
440
  if (dirty || running) {
427
441
  dirty = false;
428
442
  setVersion(v => v + 1);
@@ -441,42 +455,42 @@ const definition = {
441
455
  timer.unref?.();
442
456
  };
443
457
  const onDelta = e => {
444
- if (!isActive()) return;
445
- tracker.push(e.data.sessionID, e.data.delta, Date.now(), e.data.assistantMessageID);
458
+ if (!isActive() || !isNewEvent(e)) return;
459
+ tracker.push(e.data.sessionID, e.data.delta, e.created, e.data.assistantMessageID, blockID(e));
460
+ touch();
461
+ };
462
+ const onBlockStarted = e => {
463
+ if (!isActive() || !isNewEvent(e)) return;
464
+ tracker.beginBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.created);
465
+ };
466
+ const onBlockEnded = e => {
467
+ if (!isActive() || !isNewEvent(e)) return;
468
+ tracker.finishBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.data.text, e.created);
446
469
  touch();
447
470
  };
448
471
  const onFinish = e => {
449
- if (!isActive()) return;
450
- tracker.finish(e.data.sessionID, Date.now());
472
+ if (!isActive() || !isNewEvent(e)) return;
473
+ tracker.finish(e.data.sessionID, e.created);
451
474
  touch();
452
475
  };
453
476
  const onStepStarted = e => {
454
- if (!isActive()) return;
455
- tracker.beginStep(e.data.sessionID, e.data.assistantMessageID);
477
+ if (!isActive() || !isNewEvent(e)) return;
478
+ tracker.beginStep(e.data.sessionID, e.data.assistantMessageID, e.created);
479
+ touch();
456
480
  };
457
481
  const onStepFinished = e => {
458
- if (!isActive()) return;
482
+ if (!isActive() || !isNewEvent(e)) return;
459
483
  const tokens = e.data.tokens;
460
- tracker.finishStep(e.data.sessionID, e.data.assistantMessageID, tokens === undefined ? undefined : tokens.output + tokens.reasoning, Date.now());
484
+ const generatedTokens = tokens !== undefined && Number.isFinite(tokens.output) && tokens.output >= 0 && Number.isFinite(tokens.reasoning) && tokens.reasoning >= 0 ? tokens.output + tokens.reasoning : undefined;
485
+ tracker.finishStep(e.data.sessionID, e.data.assistantMessageID, generatedTokens, e.created);
461
486
  touch();
462
487
  };
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
- };
471
488
  const unsubs = [ctx.data.on("session.execution.started", e => {
472
- if (!isActive()) return;
489
+ if (!isActive() || !isNewEvent(e)) return;
473
490
  tracker.beginRun(e.data.sessionID);
474
491
  touch();
475
- }), ctx.data.on("session.text.delta", onDelta), ctx.data.on("session.reasoning.delta", onDelta),
476
- // Tool arguments are model output too: without this the indicator blanks
477
- // partway through every large write/edit while generation is at full rate.
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 => {
479
- if (!isActive()) return;
492
+ }), ctx.data.on("session.text.delta", onDelta), ctx.data.on("session.reasoning.delta", onDelta), ctx.data.on("session.tool.input.delta", onDelta), ctx.data.on("session.text.started", onBlockStarted), ctx.data.on("session.reasoning.started", onBlockStarted), ctx.data.on("session.tool.input.started", onBlockStarted), ctx.data.on("session.text.ended", onBlockEnded), ctx.data.on("session.reasoning.ended", onBlockEnded), ctx.data.on("session.tool.input.ended", onBlockEnded), 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.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 => {
493
+ if (!isActive() || !isNewEvent(e)) return;
480
494
  tracker.evict(e.data.sessionID);
481
495
  touch();
482
496
  })];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-tps",
3
- "version": "0.2.0-rc.1",
3
+ "version": "0.2.1-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",