@glaicer/supercode-token-usage-panel 1.0.0 → 1.2.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
@@ -13,7 +13,7 @@ Requires OpenCode v2 (`>=2.0.0`). On OpenCode v1 stay on `@glaicer/supercode-tok
13
13
 
14
14
  ## Install
15
15
 
16
- Install with the OpenCode CLI — it installs the package and registers the plugin in the global CLI configuration (`~/.config/opencode/cli.json`):
16
+ Install with the OpenCode CLI — it installs the package and registers the plugin in the global server configuration (`~/.config/opencode/opencode.jsonc`):
17
17
 
18
18
  ```bash
19
19
  opencode plugin add @glaicer/supercode-token-usage-panel
@@ -24,7 +24,7 @@ Restart OpenCode after installing.
24
24
  > [!IMPORTANT]
25
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.
26
26
 
27
- Manual install also works: add the package to the `plugins` array in `~/.config/opencode/cli.json`:
27
+ Manual install also works: add the package to the `plugins` array in `~/.config/opencode/opencode.jsonc`:
28
28
 
29
29
  ```jsonc
30
30
  {
@@ -32,6 +32,22 @@ Manual install also works: add the package to the `plugins` array in `~/.config/
32
32
  }
33
33
  ```
34
34
 
35
+ ### Sidebar order
36
+
37
+ The panel claims `after: "sidebar.content"`, and so do other plugins that extend the sidebar, so their relative order is the order in which they were registered. Entries in `opencode.jsonc` are always ordered before entries in `cli.json`, so register the panel here to place it relative to another sidebar plugin — for example, between `context-progress-bar` and `session-recap`:
38
+
39
+ ```jsonc
40
+ {
41
+ "plugins": [
42
+ "@glaicer/supercode-context-progress-bar",
43
+ "@glaicer/supercode-token-usage-panel",
44
+ "@glaicer/supercode-session-recap"
45
+ ]
46
+ }
47
+ ```
48
+
49
+ The package ships a no-op server entry purely to make this possible: a `./tui`-only package can only be registered in `cli.json`, which always sorts last.
50
+
35
51
  ## Development
36
52
 
37
53
  ```bash
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Server entrypoint (required so the plugin loads at all).
3
+ *
4
+ * The panel itself is TUI-only; all behaviour lives in `src/usage-panel.tsx`.
5
+ * This no-op server half exists for two host requirements: the server resolves
6
+ * a package through its main/server entry and only then reports `features.tui`,
7
+ * and sidebar claims from different plugins share one slot, so their relative
8
+ * order is the order of the `plugins` array that loaded them. Registering in
9
+ * `opencode.jsonc` is therefore the only way to place this panel relative to
10
+ * another `sidebar.content` claim such as session-recap — a `cli.json`-only
11
+ * entry is always ordered last.
12
+ */
13
+ import { Plugin } from "@opencode/plugin";
14
+ export default Plugin.define({
15
+ id: "supercode.token-usage",
16
+ setup() {}
17
+ });
@@ -6,9 +6,9 @@
6
6
  * deltas. Exposes only ready-to-render rows plus a state flag.
7
7
  *
8
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).
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.
12
12
  */
13
13
 
14
14
  export const USAGE_SECTION_TITLE = "Token Usage";
@@ -180,7 +180,7 @@ function addTotals(target, source) {
180
180
  function finishedStep(message) {
181
181
  return message.tokens !== undefined || message.time.completed !== undefined;
182
182
  }
183
- function completedMetrics(messages) {
183
+ function completedMetrics(messages, firstOutputs) {
184
184
  const result = emptyMetrics();
185
185
  for (const info of messages) {
186
186
  if (info.type !== "assistant") continue;
@@ -198,24 +198,16 @@ function completedMetrics(messages) {
198
198
  if (info.tokens && !stepHasTool) {
199
199
  addCalibration(result.calibrations, modelKey(info), stepChars, info.tokens.output + info.tokens.reasoning);
200
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
201
  const streamed = info.time.streamed;
205
202
  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;
209
- if (!positive(ttft)) 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;
210
207
  result.ttftMs += ttft;
211
208
  result.ttftCount++;
212
209
  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);
216
- return positive(duration) ? sum + duration : sum;
217
- }, 0);
218
- const decode = info.time.completed - info.time.created - ttft - tools;
210
+ const decode = streamed - first;
219
211
  if (!positive(generated) || !positive(decode)) continue;
220
212
  result.generated += generated;
221
213
  result.decodeMs += decode;
@@ -282,10 +274,10 @@ async function listAllMessages(client, sessionID) {
282
274
  } while (cursor);
283
275
  return collected.sort((a, b) => (a.time.created ?? 0) - (b.time.created ?? 0));
284
276
  }
285
- async function fetchContribution(client, session) {
277
+ async function fetchContribution(client, session, firstOutputs) {
286
278
  let metrics = emptyMetrics();
287
279
  try {
288
- metrics = completedMetrics(await listAllMessages(client, session.id));
280
+ metrics = completedMetrics(await listAllMessages(client, session.id), firstOutputs);
289
281
  } catch {
290
282
  // Totals stay usable when diagnostic history cannot be read.
291
283
  }
@@ -297,7 +289,7 @@ async function fetchContribution(client, session) {
297
289
  } : {})
298
290
  };
299
291
  }
300
- async function fetchBranch(client, root) {
292
+ async function fetchBranch(client, root, firstOutputs) {
301
293
  const contributions = new Map();
302
294
  const incompleteBranches = new Set();
303
295
  const queue = [root];
@@ -306,7 +298,7 @@ async function fetchBranch(client, root) {
306
298
  const session = queue.shift();
307
299
  if (visited.has(session.id)) continue;
308
300
  visited.add(session.id);
309
- contributions.set(session.id, await fetchContribution(client, session));
301
+ contributions.set(session.id, await fetchContribution(client, session, firstOutputs));
310
302
  try {
311
303
  const children = (await client.session.list({
312
304
  parentID: session.id
@@ -342,7 +334,7 @@ function belongsToBranch(sessionID, rootID, contributions) {
342
334
  * all descendants (subagents). The walk counts each session once and keeps
343
335
  * resolved aggregates when a branch fails to resolve.
344
336
  */
345
- async function fetchFamily(client, sessionID) {
337
+ async function fetchFamily(client, sessionID, firstOutputs) {
346
338
  let root = await client.session.get({
347
339
  sessionID
348
340
  });
@@ -356,7 +348,7 @@ async function fetchFamily(client, sessionID) {
356
348
  if (!parent) throw new Error("parent session unavailable");
357
349
  root = parent;
358
350
  }
359
- return fetchBranch(client, root);
351
+ return fetchBranch(client, root, firstOutputs);
360
352
  }
361
353
 
362
354
  /**
@@ -393,6 +385,7 @@ export function createUsageModel(api, sessionId, solid) {
393
385
  const tombstones = new Set();
394
386
  /** Content items whose stream already ended; stale deltas must not restart them. */
395
387
  const endedParts = new Set();
388
+ const firstOutputs = new Map();
396
389
  let timer;
397
390
  let ttftTurnShown = false;
398
391
  let members = new Set();
@@ -499,7 +492,7 @@ export function createUsageModel(api, sessionId, solid) {
499
492
  sessionID,
500
493
  startRevision: appliedRevision
501
494
  };
502
- void fetchFamily(api.client, sessionID).then(family => {
495
+ void fetchFamily(api.client, sessionID, firstOutputs).then(family => {
503
496
  if (!isFresh(freshness)) return;
504
497
  setRemote(previous => {
505
498
  const contributions = new Map(family.contributions);
@@ -564,6 +557,7 @@ export function createUsageModel(api, sessionId, solid) {
564
557
  failedMembers.clear();
565
558
  tombstones.clear();
566
559
  endedParts.clear();
560
+ firstOutputs.clear();
567
561
  setRemote(undefined);
568
562
  solid.untrack(() => {
569
563
  const turn = scanTurnState(sessionID);
@@ -600,7 +594,7 @@ export function createUsageModel(api, sessionId, solid) {
600
594
  sessionID: memberID
601
595
  }).then(data => {
602
596
  if (!data) throw new Error("session unavailable");
603
- return fetchContribution(api.client, data);
597
+ return fetchContribution(api.client, data, firstOutputs);
604
598
  }).then(contribution => {
605
599
  if (memberRequests.get(memberID) !== memberRequest) return;
606
600
  mergeContributions(new Map([[memberID, contribution]]), freshness);
@@ -619,7 +613,7 @@ export function createUsageModel(api, sessionId, solid) {
619
613
  sessionID: branchID
620
614
  }).then(root => {
621
615
  if (!root) throw new Error("session unavailable");
622
- return fetchBranch(api.client, root);
616
+ return fetchBranch(api.client, root, firstOutputs);
623
617
  }).then(branch => {
624
618
  setRemote(previous => {
625
619
  if (!isCurrent(previous, freshness) || branchRequests.get(branchID) !== branchRequest || !previous.contributions) {
@@ -757,6 +751,7 @@ export function createUsageModel(api, sessionId, solid) {
757
751
  assistantMessageID,
758
752
  started
759
753
  } = event.data;
754
+ if (members.has(sessionID)) firstOutputs.delete(assistantMessageID);
760
755
  if (sessionID !== sessionId()) return;
761
756
  if (ttftTurnShown) return;
762
757
  ttftTurnShown = true;
@@ -779,6 +774,18 @@ export function createUsageModel(api, sessionId, solid) {
779
774
  if (sessionID !== sessionId()) return;
780
775
  if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
781
776
  });
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);
782
789
  const onStepSettled = (sessionID, assistantMessageID) => {
783
790
  if (sessionID === sessionId()) {
784
791
  if (liveTtft()?.messageID === assistantMessageID) clearLiveTtft();
@@ -882,6 +889,9 @@ export function createUsageModel(api, sessionId, solid) {
882
889
  offStepStarted();
883
890
  offExecutionStarted();
884
891
  offStepStreamed();
892
+ offTextStarted();
893
+ offReasoningStarted();
894
+ offToolInputStarted();
885
895
  offStepEnded();
886
896
  offStepFailed();
887
897
  offTextDelta();
package/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Root server entry for local-directory loading.
3
+ *
4
+ * When this package is referenced by directory path in `opencode.json(c)`
5
+ * `plugins`, the host loads `<dir>/index.{ts,js}` directly and does not consult
6
+ * `package.json` `exports`. The npm route keeps using `exports["."]` →
7
+ * `./dist/index.js`. Both entries define the same plugin; the host loads
8
+ * exactly one per package.
9
+ */
10
+ export { default } from "./dist/index.js";
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": "1.0.0",
4
+ "version": "1.2.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",
@@ -21,10 +21,14 @@
21
21
  "generation-speed"
22
22
  ],
23
23
  "exports": {
24
+ ".": "./dist/index.js",
25
+ "./server": "./dist/index.js",
24
26
  "./tui": "./dist/usage-panel.js"
25
27
  },
26
28
  "files": [
27
- "dist"
29
+ "dist",
30
+ "index.ts",
31
+ "tui.js"
28
32
  ],
29
33
  "publishConfig": {
30
34
  "access": "public"
package/tui.js ADDED
@@ -0,0 +1,3 @@
1
+ // Local-install entry: a directory plugin target resolves "<dir>/tui.*"
2
+ // instead of package.json exports["./tui"]; npm installs use the export.
3
+ export { default } from "./dist/usage-panel.js";