@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 +10 -10
- package/dist/usage-model.js +286 -198
- package/dist/usage-panel.js +25 -28
- package/package.json +12 -7
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 `
|
|
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
|
-
"
|
|
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
|
package/dist/usage-model.js
CHANGED
|
@@ -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
|
|
38
|
-
if (!session?.tokens) return undefined;
|
|
49
|
+
function totalsFromUsage(tokens, cost) {
|
|
39
50
|
return {
|
|
40
|
-
input: safe(
|
|
41
|
-
output: safe(
|
|
42
|
-
reasoning: safe(
|
|
43
|
-
cacheRead: safe(
|
|
44
|
-
cacheWrite: safe(
|
|
45
|
-
cost: safe(
|
|
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
|
|
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.
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
176
|
-
if (
|
|
177
|
-
|
|
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 (
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
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 =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
294
|
-
|
|
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
|
|
326
|
-
* The walk counts each session once and keeps
|
|
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 =
|
|
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 =
|
|
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
|
|
363
|
-
* session plus all
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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 = (
|
|
567
|
-
if (isNew) members = new Set([...members,
|
|
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(
|
|
571
|
-
void
|
|
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(
|
|
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,
|
|
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,
|
|
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 !==
|
|
654
|
+
if (isNew) members = new Set([...members].filter(id => id !== branchID));
|
|
603
655
|
});
|
|
604
656
|
};
|
|
605
657
|
const retryIncompleteBranches = () => {
|
|
606
|
-
for (const
|
|
607
|
-
|
|
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.
|
|
637
|
-
addBranch(
|
|
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.
|
|
640
|
-
const deletedID = event.
|
|
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
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
697
|
-
if (event.
|
|
698
|
-
|
|
770
|
+
const offExecutionStarted = api.data.on("session.execution.started", event => {
|
|
771
|
+
if (event.data.sessionID !== sessionId()) return;
|
|
772
|
+
ttftTurnShown = false;
|
|
699
773
|
});
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
if (
|
|
706
|
-
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
796
|
+
const onStreamDelta = (kind, stream) => {
|
|
739
797
|
const {
|
|
740
798
|
sessionID,
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
field,
|
|
799
|
+
assistantMessageID,
|
|
800
|
+
ordinal,
|
|
744
801
|
delta
|
|
745
|
-
} =
|
|
746
|
-
if (sessionID !== sessionId()
|
|
747
|
-
|
|
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
|
-
|
|
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 (
|
|
756
|
-
|
|
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?.
|
|
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(
|
|
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
|
-
|
|
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 ===
|
|
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.
|
|
866
|
+
const offServerConnected = api.data.on("server.connected", () => {
|
|
784
867
|
clearProvisional();
|
|
785
868
|
refresh(sessionId());
|
|
786
869
|
});
|
|
787
|
-
const
|
|
788
|
-
if (!event.
|
|
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.
|
|
791
|
-
if (event.
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|
package/dist/usage-panel.js
CHANGED
|
@@ -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.
|
|
42
|
+
const theme = () => props.context.theme;
|
|
42
43
|
const [collapsed, setCollapsed] = createSignal(false);
|
|
43
|
-
const model = createUsageModel(props.
|
|
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().
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
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
|
|
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": ">=
|
|
60
|
+
"opencode": ">=2.0.0"
|
|
56
61
|
}
|
|
57
62
|
}
|