@glaicer/supercode-token-usage-panel 0.1.4 → 1.0.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.
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). `time.streamed`
10
+ * marks the end of the provider stream; first output comes from content-start
11
+ * events or the timestamp of the first reasoning/tool content item.
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,41 @@ function addTotals(target, source) {
158
173
  target.cacheWrite += source.cacheWrite;
159
174
  target.cost += source.cost;
160
175
  }
161
- function completedMetrics(messages) {
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
+ }
183
+ function completedMetrics(messages, firstOutputs) {
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 (!positive(ttft)) continue;
198
+ if (info.tokens && !stepHasTool) {
199
+ addCalibration(result.calibrations, modelKey(info), stepChars, info.tokens.output + info.tokens.reasoning);
200
+ }
201
+ const streamed = info.time.streamed;
202
+ if (!positive(streamed ?? 0)) continue;
203
+ const head = info.content[0];
204
+ const first = firstOutputs.get(info.id) ?? (head && head.type !== "text" ? head.time?.created : undefined);
205
+ if (first === undefined || first < info.time.created || first > streamed) continue;
206
+ const ttft = first - info.time.created;
199
207
  result.ttftMs += ttft;
200
208
  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;
208
- return positive(duration) ? sum + duration : sum;
209
- }, 0);
210
- const decode = message.time.completed - message.time.created - ttft - tools;
209
+ const generated = info.tokens ? info.tokens.output + info.tokens.reasoning : 0;
210
+ const decode = streamed - first;
211
211
  if (!positive(generated) || !positive(decode)) continue;
212
212
  result.generated += generated;
213
213
  result.decodeMs += decode;
@@ -259,15 +259,25 @@ function aggregateContributions(contributions, incompleteBranches = new Set(), v
259
259
  incompleteBranches
260
260
  };
261
261
  }
262
- async function fetchContribution(client, session) {
262
+ async function listAllMessages(client, sessionID) {
263
+ const collected = [];
264
+ let cursor;
265
+ do {
266
+ const page = await client.message.list({
267
+ sessionID,
268
+ ...(cursor ? {
269
+ cursor
270
+ } : {})
271
+ });
272
+ collected.push(...page.data);
273
+ cursor = page.cursor?.next ?? undefined;
274
+ } while (cursor);
275
+ return collected.sort((a, b) => (a.time.created ?? 0) - (b.time.created ?? 0));
276
+ }
277
+ async function fetchContribution(client, session, firstOutputs) {
263
278
  let metrics = emptyMetrics();
264
279
  try {
265
- const messages = (await client.session.messages({
266
- sessionID: session.id
267
- }, {
268
- throwOnError: true
269
- })).data;
270
- metrics = completedMetrics(messages);
280
+ metrics = completedMetrics(await listAllMessages(client, session.id), firstOutputs);
271
281
  } catch {
272
282
  // Totals stay usable when diagnostic history cannot be read.
273
283
  }
@@ -279,7 +289,7 @@ async function fetchContribution(client, session) {
279
289
  } : {})
280
290
  };
281
291
  }
282
- async function fetchBranch(client, root) {
292
+ async function fetchBranch(client, root, firstOutputs) {
283
293
  const contributions = new Map();
284
294
  const incompleteBranches = new Set();
285
295
  const queue = [root];
@@ -288,12 +298,10 @@ async function fetchBranch(client, root) {
288
298
  const session = queue.shift();
289
299
  if (visited.has(session.id)) continue;
290
300
  visited.add(session.id);
291
- contributions.set(session.id, await fetchContribution(client, session));
301
+ contributions.set(session.id, await fetchContribution(client, session, firstOutputs));
292
302
  try {
293
- const children = (await client.session.children({
294
- sessionID: session.id
295
- }, {
296
- throwOnError: true
303
+ const children = (await client.session.list({
304
+ parentID: session.id
297
305
  })).data;
298
306
  for (const child of children) {
299
307
  if (child && !visited.has(child.id)) queue.push(child);
@@ -322,29 +330,25 @@ function belongsToBranch(sessionID, rootID, contributions) {
322
330
  }
323
331
 
324
332
  /**
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.
333
+ * Totals cover the whole family resolved from the root: the root session plus
334
+ * all descendants (subagents). The walk counts each session once and keeps
335
+ * resolved aggregates when a branch fails to resolve.
328
336
  */
329
- async function fetchFamily(client, sessionID) {
330
- let root = (await client.session.get({
337
+ async function fetchFamily(client, sessionID, firstOutputs) {
338
+ let root = await client.session.get({
331
339
  sessionID
332
- }, {
333
- throwOnError: true
334
- })).data;
340
+ });
335
341
  if (!root) throw new Error("session unavailable");
336
342
  const ancestors = new Set([root.id]);
337
343
  while (root.parentID && !ancestors.has(root.parentID)) {
338
344
  ancestors.add(root.parentID);
339
- const parent = (await client.session.get({
345
+ const parent = await client.session.get({
340
346
  sessionID: root.parentID
341
- }, {
342
- throwOnError: true
343
- })).data;
347
+ });
344
348
  if (!parent) throw new Error("parent session unavailable");
345
349
  root = parent;
346
350
  }
347
- return fetchBranch(client, root);
351
+ return fetchBranch(client, root, firstOutputs);
348
352
  }
349
353
 
350
354
  /**
@@ -359,9 +363,9 @@ async function fetchFamily(client, sessionID) {
359
363
  */
360
364
 
361
365
  /**
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.
366
+ * Usage Model over OpenCode's session aggregate. Totals cover the whole family
367
+ * resolved from the root (the session plus all subagent descendants); request
368
+ * sequencing keeps slower responses from overwriting newer ones.
365
369
  */
366
370
  export function createUsageModel(api, sessionId, solid) {
367
371
  const [remote, setRemote] = solid.createSignal();
@@ -375,10 +379,15 @@ export function createUsageModel(api, sessionId, solid) {
375
379
  const contributionRevisions = new Map();
376
380
  const pendingBranches = new Map();
377
381
  const failedMembers = new Set();
382
+ /** Parent links announced by events, for contributions created before any fetch lands. */
383
+ const parents = new Map();
378
384
  /** Deleted session ids; a full refresh must never resurrect their totals. */
379
385
  const tombstones = new Set();
386
+ /** Content items whose stream already ended; stale deltas must not restart them. */
387
+ const endedParts = new Set();
388
+ const firstOutputs = new Map();
380
389
  let timer;
381
- let ttftTurns = new Set();
390
+ let ttftTurnShown = false;
382
391
  let members = new Set();
383
392
  const isAttachable = session => !!session.parentID && members.has(session.parentID) && !members.has(session.id);
384
393
  const captureFreshness = () => ({
@@ -396,6 +405,9 @@ export function createUsageModel(api, sessionId, solid) {
396
405
  const publishFamily = (sessionID, contributions, incompleteBranches) => {
397
406
  const aggregate = aggregateContributions(contributions, incompleteBranches, sessionID);
398
407
  members = aggregate.members;
408
+ for (const [id, contribution] of contributions) {
409
+ if (contribution.parentID) parents.set(id, contribution.parentID);
410
+ }
399
411
  return {
400
412
  sessionID,
401
413
  totals: aggregate.totals,
@@ -442,16 +454,37 @@ export function createUsageModel(api, sessionId, solid) {
442
454
  if (timer) clearInterval(timer);
443
455
  timer = undefined;
444
456
  };
445
- const completedTurns = sessionID => {
446
- const turns = new Set();
457
+
458
+ /**
459
+ * Live TTFT belongs to the first assistant step of a turn only; later steps
460
+ * follow tool calls and would report tool latency as "time to first token".
461
+ * A turn is the run of assistant messages after the last non-assistant
462
+ * message; it is already spoken for when any of its steps finished. When the
463
+ * panel mounts mid-first-step the seeded measurement keeps the elapsed time
464
+ * visible from the step's start.
465
+ */
466
+ const scanTurnState = sessionID => {
447
467
  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);
468
+ const messages = api.data.session.message.list(sessionID);
469
+ const run = [];
470
+ for (let index = messages.length - 1; index >= 0; index--) {
471
+ const message = messages[index];
472
+ if (!message || message.type !== "assistant") break;
473
+ run.unshift(message);
452
474
  }
453
- } catch {}
454
- return turns;
475
+ const shown = run.some(finishedStep);
476
+ const seed = !shown ? run[0] : undefined;
477
+ return {
478
+ shown,
479
+ ...(seed ? {
480
+ seed
481
+ } : {})
482
+ };
483
+ } catch {
484
+ return {
485
+ shown: false
486
+ };
487
+ }
455
488
  };
456
489
  const refresh = sessionID => {
457
490
  const freshness = {
@@ -459,7 +492,7 @@ export function createUsageModel(api, sessionId, solid) {
459
492
  sessionID,
460
493
  startRevision: appliedRevision
461
494
  };
462
- void fetchFamily(api.client, sessionID).then(family => {
495
+ void fetchFamily(api.client, sessionID, firstOutputs).then(family => {
463
496
  if (!isFresh(freshness)) return;
464
497
  setRemote(previous => {
465
498
  const contributions = new Map(family.contributions);
@@ -496,7 +529,7 @@ export function createUsageModel(api, sessionId, solid) {
496
529
  }
497
530
  if (!isAttachable(session)) continue;
498
531
  deferred.splice(i, 1);
499
- refreshBranch(session, true);
532
+ refreshBranch(session.id, true);
500
533
  progressed = true;
501
534
  }
502
535
  }
@@ -523,9 +556,21 @@ export function createUsageModel(api, sessionId, solid) {
523
556
  pendingBranches.clear();
524
557
  failedMembers.clear();
525
558
  tombstones.clear();
559
+ endedParts.clear();
560
+ firstOutputs.clear();
526
561
  setRemote(undefined);
527
562
  solid.untrack(() => {
528
- ttftTurns = completedTurns(sessionID);
563
+ const turn = scanTurnState(sessionID);
564
+ ttftTurnShown = turn.shown;
565
+ if (turn.seed) {
566
+ ttftTurnShown = true;
567
+ setLiveTtft({
568
+ messageID: turn.seed.id,
569
+ createdAt: turn.seed.time.created,
570
+ now: Date.now()
571
+ });
572
+ ensureTimer();
573
+ }
529
574
  refresh(sessionID);
530
575
  });
531
576
  });
@@ -547,13 +592,9 @@ export function createUsageModel(api, sessionId, solid) {
547
592
  memberRequests.set(memberID, memberRequest);
548
593
  void api.client.session.get({
549
594
  sessionID: memberID
550
- }, {
551
- throwOnError: true
552
- }).then(({
553
- data
554
- }) => {
595
+ }).then(data => {
555
596
  if (!data) throw new Error("session unavailable");
556
- return fetchContribution(api.client, data);
597
+ return fetchContribution(api.client, data, firstOutputs);
557
598
  }).then(contribution => {
558
599
  if (memberRequests.get(memberID) !== memberRequest) return;
559
600
  mergeContributions(new Map([[memberID, contribution]]), freshness);
@@ -563,20 +604,25 @@ export function createUsageModel(api, sessionId, solid) {
563
604
  failedMembers.add(memberID);
564
605
  });
565
606
  };
566
- const refreshBranch = (session, isNew) => {
567
- if (isNew) members = new Set([...members, session.id]);
607
+ const refreshBranch = (branchID, isNew) => {
608
+ if (isNew) members = new Set([...members, branchID]);
568
609
  const freshness = captureFreshness();
569
610
  const branchRequest = ++nextAsyncRequest;
570
- branchRequests.set(session.id, branchRequest);
571
- void fetchBranch(api.client, session).then(branch => {
611
+ branchRequests.set(branchID, branchRequest);
612
+ void api.client.session.get({
613
+ sessionID: branchID
614
+ }).then(root => {
615
+ if (!root) throw new Error("session unavailable");
616
+ return fetchBranch(api.client, root, firstOutputs);
617
+ }).then(branch => {
572
618
  setRemote(previous => {
573
- if (!isCurrent(previous, freshness) || branchRequests.get(session.id) !== branchRequest || !previous.contributions) {
619
+ if (!isCurrent(previous, freshness) || branchRequests.get(branchID) !== branchRequest || !previous.contributions) {
574
620
  return previous;
575
621
  }
576
622
  const contributions = new Map(previous.contributions);
577
623
  const changedIds = new Set();
578
624
  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)) {
625
+ if (belongsToBranch(id, branchID, previous.contributions) && !branch.contributions.has(id) && !belongsToIncompleteBranch(id, branch.incompleteBranches, previous.contributions) && !hasNewerIncremental(id, freshness)) {
580
626
  contributions.delete(id);
581
627
  changedIds.add(id);
582
628
  }
@@ -589,7 +635,7 @@ export function createUsageModel(api, sessionId, solid) {
589
635
  }
590
636
  const incompleteBranches = new Set(previous.incompleteBranches);
591
637
  for (const id of incompleteBranches) {
592
- if (belongsToBranch(id, session.id, previous.contributions)) {
638
+ if (belongsToBranch(id, branchID, previous.contributions)) {
593
639
  incompleteBranches.delete(id);
594
640
  }
595
641
  }
@@ -599,20 +645,12 @@ export function createUsageModel(api, sessionId, solid) {
599
645
  return publishFamily(freshness.sessionID, contributions, incompleteBranches);
600
646
  });
601
647
  }).catch(() => {
602
- if (isNew) members = new Set([...members].filter(id => id !== session.id));
648
+ if (isNew) members = new Set([...members].filter(id => id !== branchID));
603
649
  });
604
650
  };
605
651
  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(() => {});
652
+ for (const branchID of remote()?.incompleteBranches ?? []) {
653
+ refreshBranch(branchID, false);
616
654
  }
617
655
  for (const memberID of failedMembers) {
618
656
  if (!members.has(memberID)) {
@@ -623,6 +661,7 @@ export function createUsageModel(api, sessionId, solid) {
623
661
  }
624
662
  };
625
663
  const addBranch = session => {
664
+ if (session.parentID) parents.set(session.id, session.parentID);
626
665
  if (members.has(session.id)) return;
627
666
  if (!session.parentID) return;
628
667
  if (!remote()?.contributions) {
@@ -631,13 +670,18 @@ export function createUsageModel(api, sessionId, solid) {
631
670
  return;
632
671
  }
633
672
  if (!isAttachable(session)) return;
634
- refreshBranch(session, true);
673
+ refreshBranch(session.id, true);
635
674
  };
636
- const offSessionCreated = api.event.on("session.created", event => {
637
- addBranch(event.properties.info);
675
+ const offSessionCreated = api.data.on("session.created", event => {
676
+ addBranch({
677
+ id: event.data.sessionID,
678
+ ...(event.data.parentID ? {
679
+ parentID: event.data.parentID
680
+ } : {})
681
+ });
638
682
  });
639
- const offSessionDeleted = api.event.on("session.deleted", event => {
640
- const deletedID = event.properties.sessionID;
683
+ const offSessionDeleted = api.data.on("session.deleted", event => {
684
+ const deletedID = event.data.sessionID;
641
685
  const tracked = deletedID === sessionId() || members.has(deletedID) || pendingBranches.has(deletedID);
642
686
  const pruned = new Set([deletedID]);
643
687
  let expanded = true;
@@ -672,103 +716,122 @@ export function createUsageModel(api, sessionId, solid) {
672
716
  refresh(sessionId());
673
717
  }
674
718
  });
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
- }
719
+ const offUsageUpdated = api.data.on("session.usage.updated", event => {
720
+ const {
721
+ sessionID,
722
+ cost,
723
+ tokens
724
+ } = event.data;
725
+ if (pendingBranches.has(sessionID)) return;
726
+ if (!members.has(sessionID)) return;
727
+ const freshness = captureFreshness();
728
+ setRemote(previous => {
729
+ if (!isCurrent(previous, freshness) || !previous.contributions) return previous;
730
+ const prior = previous.contributions.get(sessionID);
731
+ const parentID = prior?.parentID ?? parents.get(sessionID);
732
+ const contributions = new Map(previous.contributions);
733
+ contributions.set(sessionID, {
734
+ ...(prior ?? {
735
+ metrics: emptyMetrics()
736
+ }),
737
+ totals: totalsFromUsage(tokens, cost),
738
+ ...(parentID ? {
739
+ parentID
740
+ } : {})
741
+ });
742
+ markRevised([sessionID]);
743
+ failedMembers.delete(sessionID);
744
+ return publishFamily(freshness.sessionID, contributions, previous.incompleteBranches);
745
+ });
746
+ retryIncompleteBranches();
691
747
  });
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());
748
+ const offStepStarted = api.data.on("session.step.started", event => {
749
+ const {
750
+ sessionID,
751
+ assistantMessageID,
752
+ started
753
+ } = event.data;
754
+ if (members.has(sessionID)) firstOutputs.delete(assistantMessageID);
755
+ if (sessionID !== sessionId()) return;
756
+ if (ttftTurnShown) return;
757
+ ttftTurnShown = true;
758
+ setLiveTtft({
759
+ messageID: assistantMessageID,
760
+ createdAt: started,
761
+ now: Date.now()
762
+ });
763
+ ensureTimer();
695
764
  });
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());
765
+ const offExecutionStarted = api.data.on("session.execution.started", event => {
766
+ if (event.data.sessionID !== sessionId()) return;
767
+ ttftTurnShown = false;
699
768
  });
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);
769
+ const offStepStreamed = api.data.on("session.step.streamed", event => {
770
+ const {
771
+ sessionID,
772
+ assistantMessageID
773
+ } = event.data;
774
+ if (sessionID !== sessionId()) return;
775
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
711
776
  });
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;
721
- }
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;
777
+ const onOutputStarted = event => {
778
+ const {
779
+ sessionID,
780
+ assistantMessageID
781
+ } = event.data;
782
+ if (!members.has(sessionID)) return;
783
+ if (!firstOutputs.has(assistantMessageID)) firstOutputs.set(assistantMessageID, event.created);
784
+ if (sessionID === sessionId() && liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
785
+ };
786
+ const offTextStarted = api.data.on("session.text.started", onOutputStarted);
787
+ const offReasoningStarted = api.data.on("session.reasoning.started", onOutputStarted);
788
+ const offToolInputStarted = api.data.on("session.tool.input.started", onOutputStarted);
789
+ const onStepSettled = (sessionID, assistantMessageID) => {
790
+ if (sessionID === sessionId()) {
791
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
792
+ ttftTurnShown = true;
727
793
  }
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();
794
+ refreshMember(sessionID);
795
+ retryIncompleteBranches();
796
+ };
797
+ const offStepEnded = api.data.on("session.step.ended", event => {
798
+ onStepSettled(event.data.sessionID, event.data.assistantMessageID);
799
+ });
800
+ const offStepFailed = api.data.on("session.step.failed", event => {
801
+ onStepSettled(event.data.sessionID, event.data.assistantMessageID);
737
802
  });
738
- const offPartDelta = api.event.on("message.part.delta", event => {
803
+ const onStreamDelta = (kind, stream) => {
739
804
  const {
740
805
  sessionID,
741
- messageID,
742
- partID,
743
- field,
806
+ assistantMessageID,
807
+ ordinal,
744
808
  delta
745
- } = event.properties;
746
- if (sessionID !== sessionId() || field !== "text") return;
747
- let part;
809
+ } = stream;
810
+ if (sessionID !== sessionId()) return;
811
+ const partKey = `${kind}:${assistantMessageID}:${ordinal}`;
812
+ if (endedParts.has(partKey)) return;
748
813
  let message;
749
814
  try {
750
- part = api.state.part(messageID).find(candidate => candidate.id === partID);
751
- message = api.state.session.messages(sessionID).find(candidate => candidate.id === messageID);
815
+ message = api.data.session.message.list(sessionID).find(candidate => candidate.id === assistantMessageID);
752
816
  } catch {
753
817
  return;
754
818
  }
755
- if (part?.type !== "text" && part?.type !== "reasoning" || part.time?.end !== undefined || message?.role !== "assistant") {
756
- return;
757
- }
819
+ if (message?.type !== "assistant" || message.time.completed !== undefined) return;
820
+ const streaming = message;
758
821
  const now = Date.now();
759
822
  const chars = codePoints(delta);
760
823
  setLiveSpeed(current => {
761
- if (current?.partID === partID && current.messageID === messageID) {
824
+ if (current?.partKey === partKey) {
762
825
  return {
763
826
  ...current,
764
827
  chars: current.chars + chars
765
828
  };
766
829
  }
767
- const calibration = remote()?.metrics?.calibrations.get(modelKey(message));
830
+ const calibration = remote()?.metrics?.calibrations.get(modelKey(streaming));
768
831
  const charsPerToken = (400 + (calibration?.chars ?? 0)) / (100 + (calibration?.tokens ?? 0));
769
832
  return {
770
- messageID,
771
- partID,
833
+ messageID: assistantMessageID,
834
+ partKey,
772
835
  startedAt: now,
773
836
  now,
774
837
  chars,
@@ -777,32 +840,67 @@ export function createUsageModel(api, sessionId, solid) {
777
840
  hasTicked: false
778
841
  };
779
842
  });
780
- if (liveTtft()?.messageID === messageID) clearLiveTtft();
843
+ if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
781
844
  ensureTimer();
845
+ };
846
+ const offTextDelta = api.data.on("session.text.delta", event => {
847
+ onStreamDelta("text", event.data);
848
+ });
849
+ const offReasoningDelta = api.data.on("session.reasoning.delta", event => {
850
+ onStreamDelta("reasoning", event.data);
851
+ });
852
+ const onStreamEnded = (kind, end) => {
853
+ const {
854
+ sessionID,
855
+ assistantMessageID,
856
+ ordinal
857
+ } = end;
858
+ if (sessionID !== sessionId()) return;
859
+ const partKey = `${kind}:${assistantMessageID}:${ordinal}`;
860
+ endedParts.add(partKey);
861
+ if (liveSpeed()?.partKey === partKey) clearLiveSpeed();
862
+ };
863
+ const offTextEnded = api.data.on("session.text.ended", event => {
864
+ onStreamEnded("text", event.data);
865
+ });
866
+ const offReasoningEnded = api.data.on("session.reasoning.ended", event => {
867
+ onStreamEnded("reasoning", event.data);
868
+ });
869
+ const offContentUpdated = api.data.on("session.revert.committed", event => {
870
+ if (event.data.sessionID === sessionId()) clearProvisional();
871
+ if (members.has(event.data.sessionID)) refresh(sessionId());
782
872
  });
783
- const offServerConnected = api.event.on("server.connected", () => {
873
+ const offServerConnected = api.data.on("server.connected", () => {
784
874
  clearProvisional();
785
875
  refresh(sessionId());
786
876
  });
787
- const offSessionError = api.event.on("session.error", event => {
788
- if (!event.properties.sessionID || event.properties.sessionID === sessionId()) clearProvisional();
877
+ const offExecutionFailed = api.data.on("session.execution.failed", event => {
878
+ if (!event.data.sessionID || event.data.sessionID === sessionId()) clearProvisional();
789
879
  });
790
- const offSessionIdle = api.event.on("session.idle", event => {
791
- if (event.properties.sessionID === sessionId()) clearProvisional();
880
+ const offSessionIdle = api.data.on("session.idle", event => {
881
+ if (event.data.sessionID === sessionId()) clearProvisional();
792
882
  });
793
883
  solid.onCleanup(() => {
794
884
  request++;
795
885
  clearProvisional();
796
886
  offSessionCreated();
797
887
  offSessionDeleted();
798
- offPartUpdated();
799
- offPartRemoved();
800
- offMessageRemoved();
801
- offSessionUpdated();
802
- offMessageUpdated();
803
- offPartDelta();
888
+ offUsageUpdated();
889
+ offStepStarted();
890
+ offExecutionStarted();
891
+ offStepStreamed();
892
+ offTextStarted();
893
+ offReasoningStarted();
894
+ offToolInputStarted();
895
+ offStepEnded();
896
+ offStepFailed();
897
+ offTextDelta();
898
+ offReasoningDelta();
899
+ offTextEnded();
900
+ offReasoningEnded();
901
+ offContentUpdated();
804
902
  offServerConnected();
805
- offSessionError();
903
+ offExecutionFailed();
806
904
  offSessionIdle();
807
905
  });
808
906
  const snapshot = solid.createMemo(() => {
@@ -819,7 +917,7 @@ export function createUsageModel(api, sessionId, solid) {
819
917
  hasDescendants: false
820
918
  };
821
919
  }
822
- const totals = loaded?.sessionID === sessionID && loaded.totals ? loaded.totals : totalsFromSession(api.state.session.get(sessionID));
920
+ const totals = loaded?.sessionID === sessionID && loaded.totals ? loaded.totals : totalsFromSession(api.data.session.get(sessionID));
823
921
  const hasDescendants = loaded?.sessionID === sessionID ? loaded.hasDescendants : false;
824
922
  if (!totals) {
825
923
  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.1",
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
  }