@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 +18 -2
- package/dist/index.js +17 -0
- package/dist/usage-model.js +36 -26
- package/index.ts +10 -0
- package/package.json +6 -2
- package/tui.js +3 -0
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
|
|
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/
|
|
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
|
+
});
|
package/dist/usage-model.js
CHANGED
|
@@ -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)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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.
|
|
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