@glaicer/supercode-token-usage-panel 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,29 +9,29 @@ An OpenCode plugin that adds a collapsible `Token Usage` section to the TUI sess
9
9
 
10
10
  Totals fold in the whole session family: the parent session plus all subagent descendants, from OpenCode's own `session.tokens` / `session.cost` aggregates. When descendants contribute, their usage sums up with parent agent usage.
11
11
 
12
+ Requires OpenCode v2 (`>=2.0.0`). On OpenCode v1 stay on `@glaicer/supercode-token-usage-panel@0.1.4`.
13
+
12
14
  ## Install
13
15
 
14
- Install with the OpenCode CLI — it detects the TUI target and registers the plugin in `tui.json` for you:
16
+ Install with the OpenCode CLI — it installs the package and registers the plugin in the global CLI configuration (`~/.config/opencode/cli.json`):
15
17
 
16
18
  ```bash
17
- opencode plugin @glaicer/supercode-token-usage-panel
19
+ opencode plugin add @glaicer/supercode-token-usage-panel
18
20
  ```
19
21
 
20
- - `--global` installs into the global config (`~/.config/opencode`); default is local (`.opencode` in the current project).
21
- - `--force` replaces an already-installed version.
22
- - Restart OpenCode after installing.
22
+ Restart OpenCode after installing.
23
+
24
+ > [!IMPORTANT]
25
+ > **The first OpenCode load after installing this plugin may be slow.** That's OpenCode downloading the plugin's packages and managed tools into its cache — it happens once. Every subsequent start is fast.
23
26
 
24
- Manual install also works: add the package to the `plugin` array in `tui.json` (global `~/.config/opencode/tui.json` or local `<project>/.opencode/tui.json`):
27
+ Manual install also works: add the package to the `plugins` array in `~/.config/opencode/cli.json`:
25
28
 
26
29
  ```jsonc
27
30
  {
28
- "plugin": ["@glaicer/supercode-token-usage-panel"]
31
+ "plugins": ["@glaicer/supercode-token-usage-panel"]
29
32
  }
30
33
  ```
31
34
 
32
- > [!IMPORTANT]
33
- > **The first OpenCode load after installing this plugin may be slow.** That's OpenCode downloading the plugin's packages and managed tools into its cache — it happens once. Every subsequent start is fast.
34
-
35
35
  ## Development
36
36
 
37
37
  ```bash
@@ -4,6 +4,11 @@
4
4
  * Reads OpenCode's authoritative session aggregate and family message history,
5
5
  * folds completed steps/speed/TTFT, and estimates the current visible stream from
6
6
  * deltas. Exposes only ready-to-render rows plus a state flag.
7
+ *
8
+ * OpenCode v2 shape: one assistant message = one model step, so per-step tokens
9
+ * live on `message.tokens` (there are no `step-finish` parts) and TTFT is
10
+ * `message.time.streamed - message.time.created` (`time.streamed` is set by
11
+ * `session.step.streamed`, the first streamed token).
7
12
  */
8
13
 
9
14
  export const USAGE_SECTION_TITLE = "Token Usage";
@@ -18,6 +23,13 @@ export const USAGE_STATUS_TEXT = {
18
23
 
19
24
  /** Placeholder for missing values: never render NaN/Infinity as a number. */
20
25
  export const USAGE_DASH = "–";
26
+
27
+ /**
28
+ * The slice of the TUI plugin context the model reads. The panel passes the
29
+ * real `Plugin.Context` (typecheck at the call site keeps this slice honest);
30
+ * tests fake exactly this surface.
31
+ */
32
+
21
33
  function emptyMetrics() {
22
34
  return {
23
35
  generated: 0,
@@ -34,17 +46,20 @@ function allZero(totals) {
34
46
  function safe(value) {
35
47
  return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0;
36
48
  }
37
- function totalsFromSession(session) {
38
- if (!session?.tokens) return undefined;
49
+ function totalsFromUsage(tokens, cost) {
39
50
  return {
40
- input: safe(session.tokens.input),
41
- output: safe(session.tokens.output),
42
- reasoning: safe(session.tokens.reasoning),
43
- cacheRead: safe(session.tokens.cache.read),
44
- cacheWrite: safe(session.tokens.cache.write),
45
- cost: safe(session.cost)
51
+ input: safe(tokens?.input),
52
+ output: safe(tokens?.output),
53
+ reasoning: safe(tokens?.reasoning),
54
+ cacheRead: safe(tokens?.cache.read),
55
+ cacheWrite: safe(tokens?.cache.write),
56
+ cost: safe(cost)
46
57
  };
47
58
  }
59
+ function totalsFromSession(session) {
60
+ if (!session?.tokens) return undefined;
61
+ return totalsFromUsage(session.tokens, session.cost);
62
+ }
48
63
  function groupDigits(value) {
49
64
  return String(Math.trunc(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
50
65
  }
@@ -115,7 +130,7 @@ function buildUsageRows(totals, metrics) {
115
130
  }];
116
131
  }
117
132
  function formatLiveSpeed(live) {
118
- if (!live.hasTicked) return `${USAGE_DASH}`;
133
+ if (!live.hasTicked) return USAGE_DASH;
119
134
  const elapsed = (live.now - live.startedAt) / 1_000;
120
135
  const value = live.displayedChars / live.charsPerToken / elapsed;
121
136
  const rounded = Math.round(value);
@@ -138,7 +153,7 @@ function codePoints(value) {
138
153
  return Array.from(value).length;
139
154
  }
140
155
  function modelKey(message) {
141
- return JSON.stringify([message.providerID, message.modelID]);
156
+ return JSON.stringify([message.model.providerID, message.model.id]);
142
157
  }
143
158
  function addCalibration(calibrations, key, chars, tokens) {
144
159
  if (!positive(chars) || !positive(tokens)) return;
@@ -158,56 +173,49 @@ function addTotals(target, source) {
158
173
  target.cacheWrite += source.cacheWrite;
159
174
  target.cost += source.cost;
160
175
  }
176
+
177
+ /** Minimal identity of a session announced by an event; totals arrive via fetch. */
178
+
179
+ /** A step is finished once its tokens or completion time is recorded. */
180
+ function finishedStep(message) {
181
+ return message.tokens !== undefined || message.time.completed !== undefined;
182
+ }
161
183
  function completedMetrics(messages) {
162
184
  const result = emptyMetrics();
163
- for (const {
164
- info,
165
- parts
166
- } of messages) {
167
- if (info.role !== "assistant") continue;
168
- for (const part of parts) {
169
- if (part.type === "step-finish") result.steps++;
170
- }
171
- const message = info;
172
- if (!positive(message.time.completed ?? 0)) continue;
185
+ for (const info of messages) {
186
+ if (info.type !== "assistant") continue;
187
+ if (finishedStep(info)) result.steps++;
188
+ if (!positive(info.time.completed ?? 0)) continue;
173
189
  let stepChars = 0;
174
190
  let stepHasTool = false;
175
- for (const part of parts) {
176
- if (part.type === "text" || part.type === "reasoning") {
177
- if (positive(part.time?.end ?? 0)) stepChars += codePoints(part.text);
191
+ for (const item of info.content) {
192
+ if (item.type === "text" || item.type === "reasoning") {
193
+ stepChars += codePoints(item.text);
178
194
  continue;
179
195
  }
180
- if (part.type === "tool") {
181
- stepHasTool = true;
182
- continue;
183
- }
184
- if (part.type !== "step-finish") continue;
185
- const tokens = part.tokens.output + part.tokens.reasoning;
186
- if (!stepHasTool) addCalibration(result.calibrations, modelKey(message), stepChars, tokens);
187
- stepChars = 0;
188
- stepHasTool = false;
196
+ if (item.type === "tool") stepHasTool = true;
189
197
  }
190
- const visibleStarts = parts.flatMap(part => {
191
- if (part.type !== "text" && part.type !== "reasoning") return [];
192
- if (!part.time || !positive(part.time.start) || !positive(part.time.end ?? 0)) return [];
193
- return [part.time.start];
194
- });
195
- if (visibleStarts.length === 0) continue;
196
- const firstVisibleAt = Math.min(...visibleStarts);
197
- const ttft = firstVisibleAt - message.time.created;
198
+ if (info.tokens && !stepHasTool) {
199
+ addCalibration(result.calibrations, modelKey(info), stepChars, info.tokens.output + info.tokens.reasoning);
200
+ }
201
+
202
+ // Without streamed timing there is no first-token sample and no decode
203
+ // baseline, so the step contributes steps/calibration only.
204
+ const streamed = info.time.streamed;
205
+ if (!positive(streamed ?? 0)) continue;
206
+ const visible = info.content.some(item => item.type === "text" || item.type === "reasoning");
207
+ if (!visible) continue;
208
+ const ttft = streamed - info.time.created;
198
209
  if (!positive(ttft)) continue;
199
210
  result.ttftMs += ttft;
200
211
  result.ttftCount++;
201
- const generated = parts.reduce((sum, part) => {
202
- if (part.type !== "step-finish") return sum;
203
- return sum + part.tokens.output + part.tokens.reasoning;
204
- }, 0);
205
- const tools = parts.reduce((sum, part) => {
206
- if (part.type !== "tool" || part.state.status !== "completed") return sum;
207
- const duration = part.state.time.end - part.state.time.start;
212
+ const generated = info.tokens ? info.tokens.output + info.tokens.reasoning : 0;
213
+ const tools = info.content.reduce((sum, item) => {
214
+ if (item.type !== "tool" || item.state.status !== "completed") return sum;
215
+ const duration = (item.time.completed ?? 0) - (item.time.ran ?? item.time.created);
208
216
  return positive(duration) ? sum + duration : sum;
209
217
  }, 0);
210
- const decode = message.time.completed - message.time.created - ttft - tools;
218
+ const decode = info.time.completed - info.time.created - ttft - tools;
211
219
  if (!positive(generated) || !positive(decode)) continue;
212
220
  result.generated += generated;
213
221
  result.decodeMs += decode;
@@ -224,7 +232,21 @@ function addMetrics(target, source) {
224
232
  addCalibration(target.calibrations, key, sourceCalibration.chars, sourceCalibration.tokens);
225
233
  }
226
234
  }
227
- function aggregateContributions(contributions, incompleteBranches = new Set()) {
235
+
236
+ /**
237
+ * Whether the viewed session launched subagents: at least one other member of
238
+ * the family descends from it. Siblings and ancestors do not count, so the
239
+ * section title stays "Token Usage" unless the current session itself has
240
+ * descendants.
241
+ */
242
+ function hasCurrentDescendant(viewedID, contributions) {
243
+ for (const id of contributions.keys()) {
244
+ if (id === viewedID) continue;
245
+ if (belongsToBranch(id, viewedID, contributions)) return true;
246
+ }
247
+ return false;
248
+ }
249
+ function aggregateContributions(contributions, incompleteBranches = new Set(), viewedID) {
228
250
  let totals;
229
251
  const metrics = emptyMetrics();
230
252
  for (const contribution of contributions.values()) {
@@ -239,21 +261,31 @@ function aggregateContributions(contributions, incompleteBranches = new Set()) {
239
261
  return {
240
262
  totals,
241
263
  metrics,
242
- hasDescendants: members.size > 1,
264
+ hasDescendants: viewedID ? hasCurrentDescendant(viewedID, contributions) : members.size > 1,
243
265
  members,
244
266
  contributions,
245
267
  incompleteBranches
246
268
  };
247
269
  }
270
+ async function listAllMessages(client, sessionID) {
271
+ const collected = [];
272
+ let cursor;
273
+ do {
274
+ const page = await client.message.list({
275
+ sessionID,
276
+ ...(cursor ? {
277
+ cursor
278
+ } : {})
279
+ });
280
+ collected.push(...page.data);
281
+ cursor = page.cursor?.next ?? undefined;
282
+ } while (cursor);
283
+ return collected.sort((a, b) => (a.time.created ?? 0) - (b.time.created ?? 0));
284
+ }
248
285
  async function fetchContribution(client, session) {
249
286
  let metrics = emptyMetrics();
250
287
  try {
251
- const messages = (await client.session.messages({
252
- sessionID: session.id
253
- }, {
254
- throwOnError: true
255
- })).data;
256
- metrics = completedMetrics(messages);
288
+ metrics = completedMetrics(await listAllMessages(client, session.id));
257
289
  } catch {
258
290
  // Totals stay usable when diagnostic history cannot be read.
259
291
  }
@@ -276,10 +308,8 @@ async function fetchBranch(client, root) {
276
308
  visited.add(session.id);
277
309
  contributions.set(session.id, await fetchContribution(client, session));
278
310
  try {
279
- const children = (await client.session.children({
280
- sessionID: session.id
281
- }, {
282
- throwOnError: true
311
+ const children = (await client.session.list({
312
+ parentID: session.id
283
313
  })).data;
284
314
  for (const child of children) {
285
315
  if (child && !visited.has(child.id)) queue.push(child);
@@ -288,7 +318,7 @@ async function fetchBranch(client, root) {
288
318
  incompleteBranches.add(session.id);
289
319
  }
290
320
  }
291
- return aggregateContributions(contributions, incompleteBranches);
321
+ return aggregateContributions(contributions, incompleteBranches, root.id);
292
322
  }
293
323
  function walkParents(sessionID, contributions, matches) {
294
324
  const visited = new Set();
@@ -308,25 +338,21 @@ function belongsToBranch(sessionID, rootID, contributions) {
308
338
  }
309
339
 
310
340
  /**
311
- * Totals cover the current session plus all of its descendants (subagents).
312
- * The walk counts each session once and keeps resolved aggregates when a
313
- * branch fails to resolve.
341
+ * Totals cover the whole family resolved from the root: the root session plus
342
+ * all descendants (subagents). The walk counts each session once and keeps
343
+ * resolved aggregates when a branch fails to resolve.
314
344
  */
315
345
  async function fetchFamily(client, sessionID) {
316
- let root = (await client.session.get({
346
+ let root = await client.session.get({
317
347
  sessionID
318
- }, {
319
- throwOnError: true
320
- })).data;
348
+ });
321
349
  if (!root) throw new Error("session unavailable");
322
350
  const ancestors = new Set([root.id]);
323
351
  while (root.parentID && !ancestors.has(root.parentID)) {
324
352
  ancestors.add(root.parentID);
325
- const parent = (await client.session.get({
353
+ const parent = await client.session.get({
326
354
  sessionID: root.parentID
327
- }, {
328
- throwOnError: true
329
- })).data;
355
+ });
330
356
  if (!parent) throw new Error("parent session unavailable");
331
357
  root = parent;
332
358
  }
@@ -345,9 +371,9 @@ async function fetchFamily(client, sessionID) {
345
371
  */
346
372
 
347
373
  /**
348
- * Usage Model over OpenCode's session aggregate. Totals cover the current
349
- * session plus all of its descendants (subagents); request sequencing keeps
350
- * slower responses from overwriting newer ones.
374
+ * Usage Model over OpenCode's session aggregate. Totals cover the whole family
375
+ * resolved from the root (the session plus all subagent descendants); request
376
+ * sequencing keeps slower responses from overwriting newer ones.
351
377
  */
352
378
  export function createUsageModel(api, sessionId, solid) {
353
379
  const [remote, setRemote] = solid.createSignal();
@@ -361,10 +387,14 @@ export function createUsageModel(api, sessionId, solid) {
361
387
  const contributionRevisions = new Map();
362
388
  const pendingBranches = new Map();
363
389
  const failedMembers = new Set();
390
+ /** Parent links announced by events, for contributions created before any fetch lands. */
391
+ const parents = new Map();
364
392
  /** Deleted session ids; a full refresh must never resurrect their totals. */
365
393
  const tombstones = new Set();
394
+ /** Content items whose stream already ended; stale deltas must not restart them. */
395
+ const endedParts = new Set();
366
396
  let timer;
367
- let ttftTurns = new Set();
397
+ let ttftTurnShown = false;
368
398
  let members = new Set();
369
399
  const isAttachable = session => !!session.parentID && members.has(session.parentID) && !members.has(session.id);
370
400
  const captureFreshness = () => ({
@@ -380,8 +410,11 @@ export function createUsageModel(api, sessionId, solid) {
380
410
  for (const id of ids) contributionRevisions.set(id, revision);
381
411
  };
382
412
  const publishFamily = (sessionID, contributions, incompleteBranches) => {
383
- const aggregate = aggregateContributions(contributions, incompleteBranches);
413
+ const aggregate = aggregateContributions(contributions, incompleteBranches, sessionID);
384
414
  members = aggregate.members;
415
+ for (const [id, contribution] of contributions) {
416
+ if (contribution.parentID) parents.set(id, contribution.parentID);
417
+ }
385
418
  return {
386
419
  sessionID,
387
420
  totals: aggregate.totals,
@@ -428,16 +461,37 @@ export function createUsageModel(api, sessionId, solid) {
428
461
  if (timer) clearInterval(timer);
429
462
  timer = undefined;
430
463
  };
431
- const completedTurns = sessionID => {
432
- const turns = new Set();
464
+
465
+ /**
466
+ * Live TTFT belongs to the first assistant step of a turn only; later steps
467
+ * follow tool calls and would report tool latency as "time to first token".
468
+ * A turn is the run of assistant messages after the last non-assistant
469
+ * message; it is already spoken for when any of its steps finished. When the
470
+ * panel mounts mid-first-step the seeded measurement keeps the elapsed time
471
+ * visible from the step's start.
472
+ */
473
+ const scanTurnState = sessionID => {
433
474
  try {
434
- for (const message of api.state.session.messages(sessionID)) {
435
- if (message.role !== "assistant") continue;
436
- const hasFinishedStep = api.state.part(message.id).some(part => part.type === "step-finish");
437
- if (message.time.completed !== undefined || hasFinishedStep) turns.add(message.parentID);
475
+ const messages = api.data.session.message.list(sessionID);
476
+ const run = [];
477
+ for (let index = messages.length - 1; index >= 0; index--) {
478
+ const message = messages[index];
479
+ if (!message || message.type !== "assistant") break;
480
+ run.unshift(message);
438
481
  }
439
- } catch {}
440
- return turns;
482
+ const shown = run.some(finishedStep);
483
+ const seed = !shown ? run[0] : undefined;
484
+ return {
485
+ shown,
486
+ ...(seed ? {
487
+ seed
488
+ } : {})
489
+ };
490
+ } catch {
491
+ return {
492
+ shown: false
493
+ };
494
+ }
441
495
  };
442
496
  const refresh = sessionID => {
443
497
  const freshness = {
@@ -482,7 +536,7 @@ export function createUsageModel(api, sessionId, solid) {
482
536
  }
483
537
  if (!isAttachable(session)) continue;
484
538
  deferred.splice(i, 1);
485
- refreshBranch(session, true);
539
+ refreshBranch(session.id, true);
486
540
  progressed = true;
487
541
  }
488
542
  }
@@ -509,9 +563,20 @@ export function createUsageModel(api, sessionId, solid) {
509
563
  pendingBranches.clear();
510
564
  failedMembers.clear();
511
565
  tombstones.clear();
566
+ endedParts.clear();
512
567
  setRemote(undefined);
513
568
  solid.untrack(() => {
514
- ttftTurns = completedTurns(sessionID);
569
+ const turn = scanTurnState(sessionID);
570
+ ttftTurnShown = turn.shown;
571
+ if (turn.seed) {
572
+ ttftTurnShown = true;
573
+ setLiveTtft({
574
+ messageID: turn.seed.id,
575
+ createdAt: turn.seed.time.created,
576
+ now: Date.now()
577
+ });
578
+ ensureTimer();
579
+ }
515
580
  refresh(sessionID);
516
581
  });
517
582
  });
@@ -533,11 +598,7 @@ export function createUsageModel(api, sessionId, solid) {
533
598
  memberRequests.set(memberID, memberRequest);
534
599
  void api.client.session.get({
535
600
  sessionID: memberID
536
- }, {
537
- throwOnError: true
538
- }).then(({
539
- data
540
- }) => {
601
+ }).then(data => {
541
602
  if (!data) throw new Error("session unavailable");
542
603
  return fetchContribution(api.client, data);
543
604
  }).then(contribution => {
@@ -549,20 +610,25 @@ export function createUsageModel(api, sessionId, solid) {
549
610
  failedMembers.add(memberID);
550
611
  });
551
612
  };
552
- const refreshBranch = (session, isNew) => {
553
- if (isNew) members = new Set([...members, session.id]);
613
+ const refreshBranch = (branchID, isNew) => {
614
+ if (isNew) members = new Set([...members, branchID]);
554
615
  const freshness = captureFreshness();
555
616
  const branchRequest = ++nextAsyncRequest;
556
- branchRequests.set(session.id, branchRequest);
557
- void fetchBranch(api.client, session).then(branch => {
617
+ branchRequests.set(branchID, branchRequest);
618
+ void api.client.session.get({
619
+ sessionID: branchID
620
+ }).then(root => {
621
+ if (!root) throw new Error("session unavailable");
622
+ return fetchBranch(api.client, root);
623
+ }).then(branch => {
558
624
  setRemote(previous => {
559
- if (!isCurrent(previous, freshness) || branchRequests.get(session.id) !== branchRequest || !previous.contributions) {
625
+ if (!isCurrent(previous, freshness) || branchRequests.get(branchID) !== branchRequest || !previous.contributions) {
560
626
  return previous;
561
627
  }
562
628
  const contributions = new Map(previous.contributions);
563
629
  const changedIds = new Set();
564
630
  for (const id of previous.contributions.keys()) {
565
- if (belongsToBranch(id, session.id, previous.contributions) && !branch.contributions.has(id) && !belongsToIncompleteBranch(id, branch.incompleteBranches, previous.contributions) && !hasNewerIncremental(id, freshness)) {
631
+ if (belongsToBranch(id, branchID, previous.contributions) && !branch.contributions.has(id) && !belongsToIncompleteBranch(id, branch.incompleteBranches, previous.contributions) && !hasNewerIncremental(id, freshness)) {
566
632
  contributions.delete(id);
567
633
  changedIds.add(id);
568
634
  }
@@ -575,7 +641,7 @@ export function createUsageModel(api, sessionId, solid) {
575
641
  }
576
642
  const incompleteBranches = new Set(previous.incompleteBranches);
577
643
  for (const id of incompleteBranches) {
578
- if (belongsToBranch(id, session.id, previous.contributions)) {
644
+ if (belongsToBranch(id, branchID, previous.contributions)) {
579
645
  incompleteBranches.delete(id);
580
646
  }
581
647
  }
@@ -585,20 +651,12 @@ export function createUsageModel(api, sessionId, solid) {
585
651
  return publishFamily(freshness.sessionID, contributions, incompleteBranches);
586
652
  });
587
653
  }).catch(() => {
588
- if (isNew) members = new Set([...members].filter(id => id !== session.id));
654
+ if (isNew) members = new Set([...members].filter(id => id !== branchID));
589
655
  });
590
656
  };
591
657
  const retryIncompleteBranches = () => {
592
- for (const sessionID of remote()?.incompleteBranches ?? []) {
593
- void api.client.session.get({
594
- sessionID
595
- }, {
596
- throwOnError: true
597
- }).then(({
598
- data
599
- }) => {
600
- if (data) refreshBranch(data, false);
601
- }).catch(() => {});
658
+ for (const branchID of remote()?.incompleteBranches ?? []) {
659
+ refreshBranch(branchID, false);
602
660
  }
603
661
  for (const memberID of failedMembers) {
604
662
  if (!members.has(memberID)) {
@@ -609,6 +667,7 @@ export function createUsageModel(api, sessionId, solid) {
609
667
  }
610
668
  };
611
669
  const addBranch = session => {
670
+ if (session.parentID) parents.set(session.id, session.parentID);
612
671
  if (members.has(session.id)) return;
613
672
  if (!session.parentID) return;
614
673
  if (!remote()?.contributions) {
@@ -617,13 +676,18 @@ export function createUsageModel(api, sessionId, solid) {
617
676
  return;
618
677
  }
619
678
  if (!isAttachable(session)) return;
620
- refreshBranch(session, true);
679
+ refreshBranch(session.id, true);
621
680
  };
622
- const offSessionCreated = api.event.on("session.created", event => {
623
- addBranch(event.properties.info);
681
+ const offSessionCreated = api.data.on("session.created", event => {
682
+ addBranch({
683
+ id: event.data.sessionID,
684
+ ...(event.data.parentID ? {
685
+ parentID: event.data.parentID
686
+ } : {})
687
+ });
624
688
  });
625
- const offSessionDeleted = api.event.on("session.deleted", event => {
626
- const deletedID = event.properties.sessionID;
689
+ const offSessionDeleted = api.data.on("session.deleted", event => {
690
+ const deletedID = event.data.sessionID;
627
691
  const tracked = deletedID === sessionId() || members.has(deletedID) || pendingBranches.has(deletedID);
628
692
  const pruned = new Set([deletedID]);
629
693
  let expanded = true;
@@ -658,103 +722,109 @@ export function createUsageModel(api, sessionId, solid) {
658
722
  refresh(sessionId());
659
723
  }
660
724
  });
661
- const offPartUpdated = api.event.on("message.part.updated", event => {
662
- const part = event.properties.part;
663
- if (part.sessionID === sessionId() && (part.type === "text" || part.type === "reasoning") && part.time?.end !== undefined && liveSpeed()?.partID === part.id) {
664
- clearLiveSpeed();
665
- }
666
- if (event.properties.part.type === "step-finish") {
667
- if (event.properties.part.sessionID === sessionId() && liveTtft()?.messageID === event.properties.part.messageID) {
668
- clearLiveTtft();
669
- }
670
- if (event.properties.part.sessionID === sessionId()) {
671
- const message = api.state.session.messages(sessionId()).find(candidate => candidate.id === event.properties.part.messageID);
672
- if (message?.role === "assistant") ttftTurns.add(message.parentID);
673
- }
674
- refreshMember(event.properties.part.sessionID);
675
- retryIncompleteBranches();
676
- }
725
+ const offUsageUpdated = api.data.on("session.usage.updated", event => {
726
+ const {
727
+ sessionID,
728
+ cost,
729
+ tokens
730
+ } = event.data;
731
+ if (pendingBranches.has(sessionID)) return;
732
+ if (!members.has(sessionID)) return;
733
+ const freshness = captureFreshness();
734
+ setRemote(previous => {
735
+ if (!isCurrent(previous, freshness) || !previous.contributions) return previous;
736
+ const prior = previous.contributions.get(sessionID);
737
+ const parentID = prior?.parentID ?? parents.get(sessionID);
738
+ const contributions = new Map(previous.contributions);
739
+ contributions.set(sessionID, {
740
+ ...(prior ?? {
741
+ metrics: emptyMetrics()
742
+ }),
743
+ totals: totalsFromUsage(tokens, cost),
744
+ ...(parentID ? {
745
+ parentID
746
+ } : {})
747
+ });
748
+ markRevised([sessionID]);
749
+ failedMembers.delete(sessionID);
750
+ return publishFamily(freshness.sessionID, contributions, previous.incompleteBranches);
751
+ });
752
+ retryIncompleteBranches();
677
753
  });
678
- const offPartRemoved = api.event.on("message.part.removed", event => {
679
- if (event.properties.sessionID === sessionId()) clearProvisional();
680
- if (members.has(event.properties.sessionID)) refresh(sessionId());
754
+ const offStepStarted = api.data.on("session.step.started", event => {
755
+ const {
756
+ sessionID,
757
+ assistantMessageID,
758
+ started
759
+ } = event.data;
760
+ if (sessionID !== sessionId()) return;
761
+ if (ttftTurnShown) return;
762
+ ttftTurnShown = true;
763
+ setLiveTtft({
764
+ messageID: assistantMessageID,
765
+ createdAt: started,
766
+ now: Date.now()
767
+ });
768
+ ensureTimer();
681
769
  });
682
- const offMessageRemoved = api.event.on("message.removed", event => {
683
- if (event.properties.sessionID === sessionId()) clearProvisional();
684
- if (members.has(event.properties.sessionID)) refresh(sessionId());
770
+ const offExecutionStarted = api.data.on("session.execution.started", event => {
771
+ if (event.data.sessionID !== sessionId()) return;
772
+ ttftTurnShown = false;
685
773
  });
686
- const offSessionUpdated = api.event.on("session.updated", event => {
687
- if (pendingBranches.has(event.properties.info.id)) {
688
- pendingBranches.set(event.properties.info.id, event.properties.info);
689
- return;
690
- }
691
- if (members.has(event.properties.info.id)) {
692
- refreshMember(event.properties.info.id);
693
- retryIncompleteBranches();
694
- return;
695
- }
696
- addBranch(event.properties.info);
774
+ const offStepStreamed = api.data.on("session.step.streamed", event => {
775
+ const {
776
+ sessionID,
777
+ assistantMessageID
778
+ } = event.data;
779
+ if (sessionID !== sessionId()) return;
780
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
697
781
  });
698
- const offMessageUpdated = api.event.on("message.updated", event => {
699
- const message = event.properties.info;
700
- if (!members.has(event.properties.sessionID) || message.role !== "assistant") return;
701
- if (event.properties.sessionID !== sessionId()) {
702
- if (message.time.completed !== undefined) {
703
- refreshMember(event.properties.sessionID);
704
- retryIncompleteBranches();
705
- }
706
- return;
782
+ const onStepSettled = (sessionID, assistantMessageID) => {
783
+ if (sessionID === sessionId()) {
784
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
785
+ ttftTurnShown = true;
707
786
  }
708
- if (message.time.completed !== undefined) {
709
- ttftTurns.add(message.parentID);
710
- if (liveTtft()?.messageID === message.id) clearLiveTtft();
711
- refreshMember(event.properties.sessionID);
712
- return;
713
- }
714
- if (ttftTurns.has(message.parentID)) return;
715
- ttftTurns.add(message.parentID);
716
- const now = Date.now();
717
- setLiveTtft({
718
- messageID: message.id,
719
- createdAt: message.time.created,
720
- now
721
- });
722
- ensureTimer();
787
+ refreshMember(sessionID);
788
+ retryIncompleteBranches();
789
+ };
790
+ const offStepEnded = api.data.on("session.step.ended", event => {
791
+ onStepSettled(event.data.sessionID, event.data.assistantMessageID);
792
+ });
793
+ const offStepFailed = api.data.on("session.step.failed", event => {
794
+ onStepSettled(event.data.sessionID, event.data.assistantMessageID);
723
795
  });
724
- const offPartDelta = api.event.on("message.part.delta", event => {
796
+ const onStreamDelta = (kind, stream) => {
725
797
  const {
726
798
  sessionID,
727
- messageID,
728
- partID,
729
- field,
799
+ assistantMessageID,
800
+ ordinal,
730
801
  delta
731
- } = event.properties;
732
- if (sessionID !== sessionId() || field !== "text") return;
733
- let part;
802
+ } = stream;
803
+ if (sessionID !== sessionId()) return;
804
+ const partKey = `${kind}:${assistantMessageID}:${ordinal}`;
805
+ if (endedParts.has(partKey)) return;
734
806
  let message;
735
807
  try {
736
- part = api.state.part(messageID).find(candidate => candidate.id === partID);
737
- message = api.state.session.messages(sessionID).find(candidate => candidate.id === messageID);
808
+ message = api.data.session.message.list(sessionID).find(candidate => candidate.id === assistantMessageID);
738
809
  } catch {
739
810
  return;
740
811
  }
741
- if (part?.type !== "text" && part?.type !== "reasoning" || part.time?.end !== undefined || message?.role !== "assistant") {
742
- return;
743
- }
812
+ if (message?.type !== "assistant" || message.time.completed !== undefined) return;
813
+ const streaming = message;
744
814
  const now = Date.now();
745
815
  const chars = codePoints(delta);
746
816
  setLiveSpeed(current => {
747
- if (current?.partID === partID && current.messageID === messageID) {
817
+ if (current?.partKey === partKey) {
748
818
  return {
749
819
  ...current,
750
820
  chars: current.chars + chars
751
821
  };
752
822
  }
753
- const calibration = remote()?.metrics?.calibrations.get(modelKey(message));
823
+ const calibration = remote()?.metrics?.calibrations.get(modelKey(streaming));
754
824
  const charsPerToken = (400 + (calibration?.chars ?? 0)) / (100 + (calibration?.tokens ?? 0));
755
825
  return {
756
- messageID,
757
- partID,
826
+ messageID: assistantMessageID,
827
+ partKey,
758
828
  startedAt: now,
759
829
  now,
760
830
  chars,
@@ -763,32 +833,64 @@ export function createUsageModel(api, sessionId, solid) {
763
833
  hasTicked: false
764
834
  };
765
835
  });
766
- if (liveTtft()?.messageID === messageID) clearLiveTtft();
836
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
767
837
  ensureTimer();
838
+ };
839
+ const offTextDelta = api.data.on("session.text.delta", event => {
840
+ onStreamDelta("text", event.data);
841
+ });
842
+ const offReasoningDelta = api.data.on("session.reasoning.delta", event => {
843
+ onStreamDelta("reasoning", event.data);
844
+ });
845
+ const onStreamEnded = (kind, end) => {
846
+ const {
847
+ sessionID,
848
+ assistantMessageID,
849
+ ordinal
850
+ } = end;
851
+ if (sessionID !== sessionId()) return;
852
+ const partKey = `${kind}:${assistantMessageID}:${ordinal}`;
853
+ endedParts.add(partKey);
854
+ if (liveSpeed()?.partKey === partKey) clearLiveSpeed();
855
+ };
856
+ const offTextEnded = api.data.on("session.text.ended", event => {
857
+ onStreamEnded("text", event.data);
858
+ });
859
+ const offReasoningEnded = api.data.on("session.reasoning.ended", event => {
860
+ onStreamEnded("reasoning", event.data);
861
+ });
862
+ const offContentUpdated = api.data.on("session.revert.committed", event => {
863
+ if (event.data.sessionID === sessionId()) clearProvisional();
864
+ if (members.has(event.data.sessionID)) refresh(sessionId());
768
865
  });
769
- const offServerConnected = api.event.on("server.connected", () => {
866
+ const offServerConnected = api.data.on("server.connected", () => {
770
867
  clearProvisional();
771
868
  refresh(sessionId());
772
869
  });
773
- const offSessionError = api.event.on("session.error", event => {
774
- if (!event.properties.sessionID || event.properties.sessionID === sessionId()) clearProvisional();
870
+ const offExecutionFailed = api.data.on("session.execution.failed", event => {
871
+ if (!event.data.sessionID || event.data.sessionID === sessionId()) clearProvisional();
775
872
  });
776
- const offSessionIdle = api.event.on("session.idle", event => {
777
- if (event.properties.sessionID === sessionId()) clearProvisional();
873
+ const offSessionIdle = api.data.on("session.idle", event => {
874
+ if (event.data.sessionID === sessionId()) clearProvisional();
778
875
  });
779
876
  solid.onCleanup(() => {
780
877
  request++;
781
878
  clearProvisional();
782
879
  offSessionCreated();
783
880
  offSessionDeleted();
784
- offPartUpdated();
785
- offPartRemoved();
786
- offMessageRemoved();
787
- offSessionUpdated();
788
- offMessageUpdated();
789
- offPartDelta();
881
+ offUsageUpdated();
882
+ offStepStarted();
883
+ offExecutionStarted();
884
+ offStepStreamed();
885
+ offStepEnded();
886
+ offStepFailed();
887
+ offTextDelta();
888
+ offReasoningDelta();
889
+ offTextEnded();
890
+ offReasoningEnded();
891
+ offContentUpdated();
790
892
  offServerConnected();
791
- offSessionError();
893
+ offExecutionFailed();
792
894
  offSessionIdle();
793
895
  });
794
896
  const snapshot = solid.createMemo(() => {
@@ -805,7 +907,7 @@ export function createUsageModel(api, sessionId, solid) {
805
907
  hasDescendants: false
806
908
  };
807
909
  }
808
- const totals = loaded?.sessionID === sessionID && loaded.totals ? loaded.totals : totalsFromSession(api.state.session.get(sessionID));
910
+ const totals = loaded?.sessionID === sessionID && loaded.totals ? loaded.totals : totalsFromSession(api.data.session.get(sessionID));
809
911
  const hasDescendants = loaded?.sessionID === sessionID ? loaded.hasDescendants : false;
810
912
  if (!totals) {
811
913
  const speed = liveSpeed();
@@ -10,8 +10,9 @@ import { createElement as _$createElement } from "@opentui/solid";
10
10
  * Usage of the current session family: token totals, cache rate, completed
11
11
  * step count, generation speed/TTFT, and provisional diagnostics for the
12
12
  * visible stream.
13
- * Family totals include all subagent descendants; when they contribute the
14
- * section title becomes "Token Usage (including subagents)".
13
+ * Family totals include all subagent descendants; when the current session
14
+ * itself has descendant sessions the section title becomes
15
+ * "Token Usage (including subagents)", otherwise it stays "Token Usage".
15
16
  *
16
17
  * This file is only the View plus slot registration: it computes no numbers
17
18
  * and formats nothing — all logic lives in ./usage-model.ts (the tested seam).
@@ -21,6 +22,7 @@ import { createElement as _$createElement } from "@opentui/solid";
21
22
  */
22
23
  /** @jsxImportSource @opentui/solid */
23
24
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, untrack } from "solid-js";
25
+ import { Plugin } from "@opencode/plugin/tui";
24
26
  import { USAGE_SECTION_TITLE, USAGE_SECTION_TITLE_WITH_SUBAGENTS, USAGE_STATUS_TEXT, createUsageModel } from "./usage-model.js";
25
27
 
26
28
  /**
@@ -37,9 +39,9 @@ const solid = {
37
39
  untrack
38
40
  };
39
41
  function Section(props) {
40
- const theme = () => props.api.theme.current;
42
+ const theme = () => props.context.theme;
41
43
  const [collapsed, setCollapsed] = createSignal(false);
42
- const model = createUsageModel(props.api, () => props.session_id, solid);
44
+ const model = createUsageModel(props.context, () => props.sessionID, solid);
43
45
  return (() => {
44
46
  var _el$ = _$createElement("box"),
45
47
  _el$2 = _$createElement("box"),
@@ -67,7 +69,7 @@ function Section(props) {
67
69
  get children() {
68
70
  var _el$6 = _$createElement("text");
69
71
  _$insert(_el$6, () => USAGE_STATUS_TEXT[model.status()]);
70
- _$effect(_$p => _$setProp(_el$6, "fg", theme().textMuted, _$p));
72
+ _$effect(_$p => _$setProp(_el$6, "fg", theme().text.muted, _$p));
71
73
  return _el$6;
72
74
  }
73
75
  }), _$createComponent(For, {
@@ -85,8 +87,8 @@ function Section(props) {
85
87
  _$insert(_el$8, () => row.label);
86
88
  _$insert(_el$9, () => row.value);
87
89
  _$effect(_p$ => {
88
- var _v$3 = theme().textMuted,
89
- _v$4 = theme().text;
90
+ var _v$3 = theme().text.muted,
91
+ _v$4 = theme().text.base;
90
92
  _v$3 !== _p$.e && (_p$.e = _$setProp(_el$8, "fg", _v$3, _p$.e));
91
93
  _v$4 !== _p$.t && (_p$.t = _$setProp(_el$9, "fg", _v$4, _p$.t));
92
94
  return _p$;
@@ -100,8 +102,8 @@ function Section(props) {
100
102
  }
101
103
  }), null);
102
104
  _$effect(_p$ => {
103
- var _v$ = theme().text,
104
- _v$2 = theme().text;
105
+ var _v$ = theme().text.base,
106
+ _v$2 = theme().text.base;
105
107
  _v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "fg", _v$, _p$.e));
106
108
  _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
107
109
  return _p$;
@@ -112,25 +114,21 @@ function Section(props) {
112
114
  return _el$;
113
115
  })();
114
116
  }
115
- const tui = async api => {
116
- // Order 150: internal sidebar sections sit at 100/200/300/400/500, so this
117
- // lands right after the first block without moving any existing section.
118
- api.slots.register({
119
- order: 150,
120
- slots: {
121
- sidebar_content(_ctx, props) {
122
- return _$createComponent(Section, {
123
- api: api,
124
- get session_id() {
125
- return props.session_id;
126
- }
127
- });
128
- }
129
- }
130
- });
131
- };
132
- const plugin = {
117
+ export default Plugin.define({
133
118
  id: "supercode.token-usage",
134
- tui
135
- };
136
- export default plugin;
119
+ setup(context) {
120
+ // `after`, not `append`: a replace takeover of this path (e.g.
121
+ // context-progress-bar's hideMcp) suppresses every append/prepend claim
122
+ // on it, while before/after claims render as siblings around the
123
+ // boundary. Content still lands below the built-in sidebar sections.
124
+ context.ui.slot({
125
+ after: "sidebar.content",
126
+ render: props => _$createComponent(Section, {
127
+ context: context,
128
+ get sessionID() {
129
+ return props.sessionID;
130
+ }
131
+ })
132
+ });
133
+ }
134
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@glaicer/supercode-token-usage-panel",
4
- "version": "0.1.3",
4
+ "version": "1.0.0",
5
5
  "description": "OpenCode TUI sidebar section: cumulative token usage and cost of the current session family.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -37,21 +37,26 @@
37
37
  "typecheck": "tsc --noEmit"
38
38
  },
39
39
  "dependencies": {
40
- "@opencode-ai/plugin": ">=1.18.21",
41
- "@opentui/core": ">=0.4.5",
42
- "@opentui/solid": ">=0.4.5",
43
- "solid-js": "1.9.12"
40
+ "@opencode/plugin": ">=2.0.16"
41
+ },
42
+ "peerDependencies": {
43
+ "@opentui/core": ">=0.5.10",
44
+ "@opentui/solid": ">=0.5.10",
45
+ "solid-js": ">=1.9.0"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@babel/core": "7.28.0",
47
49
  "@babel/preset-typescript": "7.27.1",
48
- "@opencode-ai/sdk": "1.18.21",
50
+ "@opencode/client": "2.0.16",
51
+ "@opentui/core": "0.5.12",
52
+ "@opentui/solid": "0.5.12",
49
53
  "@types/node": "24.13.3",
50
54
  "babel-preset-solid": "1.9.12",
55
+ "solid-js": "1.9.12",
51
56
  "typescript": "5.8.2"
52
57
  },
53
58
  "engines": {
54
59
  "node": ">=24",
55
- "opencode": ">=1.18.21"
60
+ "opencode": ">=2.0.0"
56
61
  }
57
62
  }