@glaicer/supercode-token-usage-panel 0.1.4 → 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);
178
- continue;
179
- }
180
- if (part.type === "tool") {
181
- stepHasTool = true;
191
+ for (const item of info.content) {
192
+ if (item.type === "text" || item.type === "reasoning") {
193
+ stepChars += codePoints(item.text);
182
194
  continue;
183
195
  }
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;
@@ -259,15 +267,25 @@ function aggregateContributions(contributions, incompleteBranches = new Set(), v
259
267
  incompleteBranches
260
268
  };
261
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
+ }
262
285
  async function fetchContribution(client, session) {
263
286
  let metrics = emptyMetrics();
264
287
  try {
265
- const messages = (await client.session.messages({
266
- sessionID: session.id
267
- }, {
268
- throwOnError: true
269
- })).data;
270
- metrics = completedMetrics(messages);
288
+ metrics = completedMetrics(await listAllMessages(client, session.id));
271
289
  } catch {
272
290
  // Totals stay usable when diagnostic history cannot be read.
273
291
  }
@@ -290,10 +308,8 @@ async function fetchBranch(client, root) {
290
308
  visited.add(session.id);
291
309
  contributions.set(session.id, await fetchContribution(client, session));
292
310
  try {
293
- const children = (await client.session.children({
294
- sessionID: session.id
295
- }, {
296
- throwOnError: true
311
+ const children = (await client.session.list({
312
+ parentID: session.id
297
313
  })).data;
298
314
  for (const child of children) {
299
315
  if (child && !visited.has(child.id)) queue.push(child);
@@ -322,25 +338,21 @@ function belongsToBranch(sessionID, rootID, contributions) {
322
338
  }
323
339
 
324
340
  /**
325
- * Totals cover the current session plus all of its descendants (subagents).
326
- * The walk counts each session once and keeps resolved aggregates when a
327
- * 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.
328
344
  */
329
345
  async function fetchFamily(client, sessionID) {
330
- let root = (await client.session.get({
346
+ let root = await client.session.get({
331
347
  sessionID
332
- }, {
333
- throwOnError: true
334
- })).data;
348
+ });
335
349
  if (!root) throw new Error("session unavailable");
336
350
  const ancestors = new Set([root.id]);
337
351
  while (root.parentID && !ancestors.has(root.parentID)) {
338
352
  ancestors.add(root.parentID);
339
- const parent = (await client.session.get({
353
+ const parent = await client.session.get({
340
354
  sessionID: root.parentID
341
- }, {
342
- throwOnError: true
343
- })).data;
355
+ });
344
356
  if (!parent) throw new Error("parent session unavailable");
345
357
  root = parent;
346
358
  }
@@ -359,9 +371,9 @@ async function fetchFamily(client, sessionID) {
359
371
  */
360
372
 
361
373
  /**
362
- * Usage Model over OpenCode's session aggregate. Totals cover the current
363
- * session plus all of its descendants (subagents); request sequencing keeps
364
- * 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.
365
377
  */
366
378
  export function createUsageModel(api, sessionId, solid) {
367
379
  const [remote, setRemote] = solid.createSignal();
@@ -375,10 +387,14 @@ export function createUsageModel(api, sessionId, solid) {
375
387
  const contributionRevisions = new Map();
376
388
  const pendingBranches = new Map();
377
389
  const failedMembers = new Set();
390
+ /** Parent links announced by events, for contributions created before any fetch lands. */
391
+ const parents = new Map();
378
392
  /** Deleted session ids; a full refresh must never resurrect their totals. */
379
393
  const tombstones = new Set();
394
+ /** Content items whose stream already ended; stale deltas must not restart them. */
395
+ const endedParts = new Set();
380
396
  let timer;
381
- let ttftTurns = new Set();
397
+ let ttftTurnShown = false;
382
398
  let members = new Set();
383
399
  const isAttachable = session => !!session.parentID && members.has(session.parentID) && !members.has(session.id);
384
400
  const captureFreshness = () => ({
@@ -396,6 +412,9 @@ export function createUsageModel(api, sessionId, solid) {
396
412
  const publishFamily = (sessionID, contributions, incompleteBranches) => {
397
413
  const aggregate = aggregateContributions(contributions, incompleteBranches, sessionID);
398
414
  members = aggregate.members;
415
+ for (const [id, contribution] of contributions) {
416
+ if (contribution.parentID) parents.set(id, contribution.parentID);
417
+ }
399
418
  return {
400
419
  sessionID,
401
420
  totals: aggregate.totals,
@@ -442,16 +461,37 @@ export function createUsageModel(api, sessionId, solid) {
442
461
  if (timer) clearInterval(timer);
443
462
  timer = undefined;
444
463
  };
445
- const completedTurns = sessionID => {
446
- 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 => {
447
474
  try {
448
- for (const message of api.state.session.messages(sessionID)) {
449
- if (message.role !== "assistant") continue;
450
- const hasFinishedStep = api.state.part(message.id).some(part => part.type === "step-finish");
451
- 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);
452
481
  }
453
- } catch {}
454
- 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
+ }
455
495
  };
456
496
  const refresh = sessionID => {
457
497
  const freshness = {
@@ -496,7 +536,7 @@ export function createUsageModel(api, sessionId, solid) {
496
536
  }
497
537
  if (!isAttachable(session)) continue;
498
538
  deferred.splice(i, 1);
499
- refreshBranch(session, true);
539
+ refreshBranch(session.id, true);
500
540
  progressed = true;
501
541
  }
502
542
  }
@@ -523,9 +563,20 @@ export function createUsageModel(api, sessionId, solid) {
523
563
  pendingBranches.clear();
524
564
  failedMembers.clear();
525
565
  tombstones.clear();
566
+ endedParts.clear();
526
567
  setRemote(undefined);
527
568
  solid.untrack(() => {
528
- 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
+ }
529
580
  refresh(sessionID);
530
581
  });
531
582
  });
@@ -547,11 +598,7 @@ export function createUsageModel(api, sessionId, solid) {
547
598
  memberRequests.set(memberID, memberRequest);
548
599
  void api.client.session.get({
549
600
  sessionID: memberID
550
- }, {
551
- throwOnError: true
552
- }).then(({
553
- data
554
- }) => {
601
+ }).then(data => {
555
602
  if (!data) throw new Error("session unavailable");
556
603
  return fetchContribution(api.client, data);
557
604
  }).then(contribution => {
@@ -563,20 +610,25 @@ export function createUsageModel(api, sessionId, solid) {
563
610
  failedMembers.add(memberID);
564
611
  });
565
612
  };
566
- const refreshBranch = (session, isNew) => {
567
- if (isNew) members = new Set([...members, session.id]);
613
+ const refreshBranch = (branchID, isNew) => {
614
+ if (isNew) members = new Set([...members, branchID]);
568
615
  const freshness = captureFreshness();
569
616
  const branchRequest = ++nextAsyncRequest;
570
- branchRequests.set(session.id, branchRequest);
571
- 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 => {
572
624
  setRemote(previous => {
573
- if (!isCurrent(previous, freshness) || branchRequests.get(session.id) !== branchRequest || !previous.contributions) {
625
+ if (!isCurrent(previous, freshness) || branchRequests.get(branchID) !== branchRequest || !previous.contributions) {
574
626
  return previous;
575
627
  }
576
628
  const contributions = new Map(previous.contributions);
577
629
  const changedIds = new Set();
578
630
  for (const id of previous.contributions.keys()) {
579
- 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)) {
580
632
  contributions.delete(id);
581
633
  changedIds.add(id);
582
634
  }
@@ -589,7 +641,7 @@ export function createUsageModel(api, sessionId, solid) {
589
641
  }
590
642
  const incompleteBranches = new Set(previous.incompleteBranches);
591
643
  for (const id of incompleteBranches) {
592
- if (belongsToBranch(id, session.id, previous.contributions)) {
644
+ if (belongsToBranch(id, branchID, previous.contributions)) {
593
645
  incompleteBranches.delete(id);
594
646
  }
595
647
  }
@@ -599,20 +651,12 @@ export function createUsageModel(api, sessionId, solid) {
599
651
  return publishFamily(freshness.sessionID, contributions, incompleteBranches);
600
652
  });
601
653
  }).catch(() => {
602
- if (isNew) members = new Set([...members].filter(id => id !== session.id));
654
+ if (isNew) members = new Set([...members].filter(id => id !== branchID));
603
655
  });
604
656
  };
605
657
  const retryIncompleteBranches = () => {
606
- for (const sessionID of remote()?.incompleteBranches ?? []) {
607
- void api.client.session.get({
608
- sessionID
609
- }, {
610
- throwOnError: true
611
- }).then(({
612
- data
613
- }) => {
614
- if (data) refreshBranch(data, false);
615
- }).catch(() => {});
658
+ for (const branchID of remote()?.incompleteBranches ?? []) {
659
+ refreshBranch(branchID, false);
616
660
  }
617
661
  for (const memberID of failedMembers) {
618
662
  if (!members.has(memberID)) {
@@ -623,6 +667,7 @@ export function createUsageModel(api, sessionId, solid) {
623
667
  }
624
668
  };
625
669
  const addBranch = session => {
670
+ if (session.parentID) parents.set(session.id, session.parentID);
626
671
  if (members.has(session.id)) return;
627
672
  if (!session.parentID) return;
628
673
  if (!remote()?.contributions) {
@@ -631,13 +676,18 @@ export function createUsageModel(api, sessionId, solid) {
631
676
  return;
632
677
  }
633
678
  if (!isAttachable(session)) return;
634
- refreshBranch(session, true);
679
+ refreshBranch(session.id, true);
635
680
  };
636
- const offSessionCreated = api.event.on("session.created", event => {
637
- 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
+ });
638
688
  });
639
- const offSessionDeleted = api.event.on("session.deleted", event => {
640
- const deletedID = event.properties.sessionID;
689
+ const offSessionDeleted = api.data.on("session.deleted", event => {
690
+ const deletedID = event.data.sessionID;
641
691
  const tracked = deletedID === sessionId() || members.has(deletedID) || pendingBranches.has(deletedID);
642
692
  const pruned = new Set([deletedID]);
643
693
  let expanded = true;
@@ -672,103 +722,109 @@ export function createUsageModel(api, sessionId, solid) {
672
722
  refresh(sessionId());
673
723
  }
674
724
  });
675
- const offPartUpdated = api.event.on("message.part.updated", event => {
676
- const part = event.properties.part;
677
- if (part.sessionID === sessionId() && (part.type === "text" || part.type === "reasoning") && part.time?.end !== undefined && liveSpeed()?.partID === part.id) {
678
- clearLiveSpeed();
679
- }
680
- if (event.properties.part.type === "step-finish") {
681
- if (event.properties.part.sessionID === sessionId() && liveTtft()?.messageID === event.properties.part.messageID) {
682
- clearLiveTtft();
683
- }
684
- if (event.properties.part.sessionID === sessionId()) {
685
- const message = api.state.session.messages(sessionId()).find(candidate => candidate.id === event.properties.part.messageID);
686
- if (message?.role === "assistant") ttftTurns.add(message.parentID);
687
- }
688
- refreshMember(event.properties.part.sessionID);
689
- retryIncompleteBranches();
690
- }
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();
691
753
  });
692
- const offPartRemoved = api.event.on("message.part.removed", event => {
693
- if (event.properties.sessionID === sessionId()) clearProvisional();
694
- 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();
695
769
  });
696
- const offMessageRemoved = api.event.on("message.removed", event => {
697
- if (event.properties.sessionID === sessionId()) clearProvisional();
698
- 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;
699
773
  });
700
- const offSessionUpdated = api.event.on("session.updated", event => {
701
- if (pendingBranches.has(event.properties.info.id)) {
702
- pendingBranches.set(event.properties.info.id, event.properties.info);
703
- return;
704
- }
705
- if (members.has(event.properties.info.id)) {
706
- refreshMember(event.properties.info.id);
707
- retryIncompleteBranches();
708
- return;
709
- }
710
- 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();
711
781
  });
712
- const offMessageUpdated = api.event.on("message.updated", event => {
713
- const message = event.properties.info;
714
- if (!members.has(event.properties.sessionID) || message.role !== "assistant") return;
715
- if (event.properties.sessionID !== sessionId()) {
716
- if (message.time.completed !== undefined) {
717
- refreshMember(event.properties.sessionID);
718
- retryIncompleteBranches();
719
- }
720
- return;
782
+ const onStepSettled = (sessionID, assistantMessageID) => {
783
+ if (sessionID === sessionId()) {
784
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
785
+ ttftTurnShown = true;
721
786
  }
722
- if (message.time.completed !== undefined) {
723
- ttftTurns.add(message.parentID);
724
- if (liveTtft()?.messageID === message.id) clearLiveTtft();
725
- refreshMember(event.properties.sessionID);
726
- return;
727
- }
728
- if (ttftTurns.has(message.parentID)) return;
729
- ttftTurns.add(message.parentID);
730
- const now = Date.now();
731
- setLiveTtft({
732
- messageID: message.id,
733
- createdAt: message.time.created,
734
- now
735
- });
736
- 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);
737
795
  });
738
- const offPartDelta = api.event.on("message.part.delta", event => {
796
+ const onStreamDelta = (kind, stream) => {
739
797
  const {
740
798
  sessionID,
741
- messageID,
742
- partID,
743
- field,
799
+ assistantMessageID,
800
+ ordinal,
744
801
  delta
745
- } = event.properties;
746
- if (sessionID !== sessionId() || field !== "text") return;
747
- let part;
802
+ } = stream;
803
+ if (sessionID !== sessionId()) return;
804
+ const partKey = `${kind}:${assistantMessageID}:${ordinal}`;
805
+ if (endedParts.has(partKey)) return;
748
806
  let message;
749
807
  try {
750
- part = api.state.part(messageID).find(candidate => candidate.id === partID);
751
- message = api.state.session.messages(sessionID).find(candidate => candidate.id === messageID);
808
+ message = api.data.session.message.list(sessionID).find(candidate => candidate.id === assistantMessageID);
752
809
  } catch {
753
810
  return;
754
811
  }
755
- if (part?.type !== "text" && part?.type !== "reasoning" || part.time?.end !== undefined || message?.role !== "assistant") {
756
- return;
757
- }
812
+ if (message?.type !== "assistant" || message.time.completed !== undefined) return;
813
+ const streaming = message;
758
814
  const now = Date.now();
759
815
  const chars = codePoints(delta);
760
816
  setLiveSpeed(current => {
761
- if (current?.partID === partID && current.messageID === messageID) {
817
+ if (current?.partKey === partKey) {
762
818
  return {
763
819
  ...current,
764
820
  chars: current.chars + chars
765
821
  };
766
822
  }
767
- const calibration = remote()?.metrics?.calibrations.get(modelKey(message));
823
+ const calibration = remote()?.metrics?.calibrations.get(modelKey(streaming));
768
824
  const charsPerToken = (400 + (calibration?.chars ?? 0)) / (100 + (calibration?.tokens ?? 0));
769
825
  return {
770
- messageID,
771
- partID,
826
+ messageID: assistantMessageID,
827
+ partKey,
772
828
  startedAt: now,
773
829
  now,
774
830
  chars,
@@ -777,32 +833,64 @@ export function createUsageModel(api, sessionId, solid) {
777
833
  hasTicked: false
778
834
  };
779
835
  });
780
- if (liveTtft()?.messageID === messageID) clearLiveTtft();
836
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
781
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());
782
865
  });
783
- const offServerConnected = api.event.on("server.connected", () => {
866
+ const offServerConnected = api.data.on("server.connected", () => {
784
867
  clearProvisional();
785
868
  refresh(sessionId());
786
869
  });
787
- const offSessionError = api.event.on("session.error", event => {
788
- 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();
789
872
  });
790
- const offSessionIdle = api.event.on("session.idle", event => {
791
- if (event.properties.sessionID === sessionId()) clearProvisional();
873
+ const offSessionIdle = api.data.on("session.idle", event => {
874
+ if (event.data.sessionID === sessionId()) clearProvisional();
792
875
  });
793
876
  solid.onCleanup(() => {
794
877
  request++;
795
878
  clearProvisional();
796
879
  offSessionCreated();
797
880
  offSessionDeleted();
798
- offPartUpdated();
799
- offPartRemoved();
800
- offMessageRemoved();
801
- offSessionUpdated();
802
- offMessageUpdated();
803
- offPartDelta();
881
+ offUsageUpdated();
882
+ offStepStarted();
883
+ offExecutionStarted();
884
+ offStepStreamed();
885
+ offStepEnded();
886
+ offStepFailed();
887
+ offTextDelta();
888
+ offReasoningDelta();
889
+ offTextEnded();
890
+ offReasoningEnded();
891
+ offContentUpdated();
804
892
  offServerConnected();
805
- offSessionError();
893
+ offExecutionFailed();
806
894
  offSessionIdle();
807
895
  });
808
896
  const snapshot = solid.createMemo(() => {
@@ -819,7 +907,7 @@ export function createUsageModel(api, sessionId, solid) {
819
907
  hasDescendants: false
820
908
  };
821
909
  }
822
- 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));
823
911
  const hasDescendants = loaded?.sessionID === sessionID ? loaded.hasDescendants : false;
824
912
  if (!totals) {
825
913
  const speed = liveSpeed();
@@ -22,6 +22,7 @@ import { createElement as _$createElement } from "@opentui/solid";
22
22
  */
23
23
  /** @jsxImportSource @opentui/solid */
24
24
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, untrack } from "solid-js";
25
+ import { Plugin } from "@opencode/plugin/tui";
25
26
  import { USAGE_SECTION_TITLE, USAGE_SECTION_TITLE_WITH_SUBAGENTS, USAGE_STATUS_TEXT, createUsageModel } from "./usage-model.js";
26
27
 
27
28
  /**
@@ -38,9 +39,9 @@ const solid = {
38
39
  untrack
39
40
  };
40
41
  function Section(props) {
41
- const theme = () => props.api.theme.current;
42
+ const theme = () => props.context.theme;
42
43
  const [collapsed, setCollapsed] = createSignal(false);
43
- const model = createUsageModel(props.api, () => props.session_id, solid);
44
+ const model = createUsageModel(props.context, () => props.sessionID, solid);
44
45
  return (() => {
45
46
  var _el$ = _$createElement("box"),
46
47
  _el$2 = _$createElement("box"),
@@ -68,7 +69,7 @@ function Section(props) {
68
69
  get children() {
69
70
  var _el$6 = _$createElement("text");
70
71
  _$insert(_el$6, () => USAGE_STATUS_TEXT[model.status()]);
71
- _$effect(_$p => _$setProp(_el$6, "fg", theme().textMuted, _$p));
72
+ _$effect(_$p => _$setProp(_el$6, "fg", theme().text.muted, _$p));
72
73
  return _el$6;
73
74
  }
74
75
  }), _$createComponent(For, {
@@ -86,8 +87,8 @@ function Section(props) {
86
87
  _$insert(_el$8, () => row.label);
87
88
  _$insert(_el$9, () => row.value);
88
89
  _$effect(_p$ => {
89
- var _v$3 = theme().textMuted,
90
- _v$4 = theme().text;
90
+ var _v$3 = theme().text.muted,
91
+ _v$4 = theme().text.base;
91
92
  _v$3 !== _p$.e && (_p$.e = _$setProp(_el$8, "fg", _v$3, _p$.e));
92
93
  _v$4 !== _p$.t && (_p$.t = _$setProp(_el$9, "fg", _v$4, _p$.t));
93
94
  return _p$;
@@ -101,8 +102,8 @@ function Section(props) {
101
102
  }
102
103
  }), null);
103
104
  _$effect(_p$ => {
104
- var _v$ = theme().text,
105
- _v$2 = theme().text;
105
+ var _v$ = theme().text.base,
106
+ _v$2 = theme().text.base;
106
107
  _v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "fg", _v$, _p$.e));
107
108
  _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
108
109
  return _p$;
@@ -113,25 +114,21 @@ function Section(props) {
113
114
  return _el$;
114
115
  })();
115
116
  }
116
- const tui = async api => {
117
- // Order 150: internal sidebar sections sit at 100/200/300/400/500, so this
118
- // lands right after the first block without moving any existing section.
119
- api.slots.register({
120
- order: 150,
121
- slots: {
122
- sidebar_content(_ctx, props) {
123
- return _$createComponent(Section, {
124
- api: api,
125
- get session_id() {
126
- return props.session_id;
127
- }
128
- });
129
- }
130
- }
131
- });
132
- };
133
- const plugin = {
117
+ export default Plugin.define({
134
118
  id: "supercode.token-usage",
135
- tui
136
- };
137
- 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.4",
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
  }