opencode-usage-total 0.1.0 → 0.1.2
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/{usage-total.ts → dist/chunk-XNCJ32GU.js} +19 -18
- package/dist/index.js +287 -0
- package/dist/usage-total.js +6 -0
- package/package.json +11 -8
- package/index.ts +0 -2
- package/usage-total-tui.tsx +0 -316
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export const UsageTotalPlugin: Plugin = async ({ client }) => {
|
|
1
|
+
// usage-total.ts
|
|
2
|
+
var UsageTotalPlugin = async ({ client }) => {
|
|
4
3
|
await client.app.log({
|
|
5
4
|
body: {
|
|
6
5
|
service: "usage-total",
|
|
7
6
|
level: "info",
|
|
8
|
-
message: "usage-total plugin loaded"
|
|
9
|
-
}
|
|
10
|
-
})
|
|
11
|
-
|
|
7
|
+
message: "usage-total plugin loaded"
|
|
8
|
+
}
|
|
9
|
+
});
|
|
12
10
|
return {
|
|
13
11
|
event: async ({ event }) => {
|
|
14
12
|
if (event.type === "session.created") {
|
|
@@ -16,21 +14,24 @@ export const UsageTotalPlugin: Plugin = async ({ client }) => {
|
|
|
16
14
|
body: {
|
|
17
15
|
service: "usage-total",
|
|
18
16
|
level: "info",
|
|
19
|
-
message: `session.created [${event.properties.info.id}]
|
|
20
|
-
}
|
|
21
|
-
})
|
|
17
|
+
message: `session.created [${event.properties.info.id}]`
|
|
18
|
+
}
|
|
19
|
+
});
|
|
22
20
|
}
|
|
23
|
-
|
|
24
21
|
if (event.type === "message.updated") {
|
|
25
|
-
const msg = event.properties.info
|
|
22
|
+
const msg = event.properties.info;
|
|
26
23
|
void client.app.log({
|
|
27
24
|
body: {
|
|
28
25
|
service: "usage-total",
|
|
29
26
|
level: "info",
|
|
30
|
-
message: `message.updated [${msg.id}] role=${msg.role}
|
|
31
|
-
}
|
|
32
|
-
})
|
|
27
|
+
message: `message.updated [${msg.id}] role=${msg.role}`
|
|
28
|
+
}
|
|
29
|
+
});
|
|
33
30
|
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
UsageTotalPlugin
|
|
37
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UsageTotalPlugin
|
|
3
|
+
} from "./chunk-XNCJ32GU.js";
|
|
4
|
+
|
|
5
|
+
// usage-total-tui.tsx
|
|
6
|
+
import { createRoot, createSignal } from "solid-js";
|
|
7
|
+
|
|
8
|
+
// package.json
|
|
9
|
+
var version = "0.1.2";
|
|
10
|
+
|
|
11
|
+
// usage-total-tui.tsx
|
|
12
|
+
import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
|
|
13
|
+
var DEFAULT_AGENT = "primary";
|
|
14
|
+
var UNKNOWN_ID = "?";
|
|
15
|
+
function resolveRouteSessionID(api) {
|
|
16
|
+
const current = api.route.current;
|
|
17
|
+
if (current.name === "session") {
|
|
18
|
+
const sid = current.params?.sessionID;
|
|
19
|
+
return typeof sid === "string" ? sid : void 0;
|
|
20
|
+
}
|
|
21
|
+
return void 0;
|
|
22
|
+
}
|
|
23
|
+
function modelTokens(m) {
|
|
24
|
+
return m.tokensInput + m.tokensOutput + m.tokensReasoning;
|
|
25
|
+
}
|
|
26
|
+
function safeNum(value) {
|
|
27
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
28
|
+
return Number.isFinite(n) ? n : 0;
|
|
29
|
+
}
|
|
30
|
+
function roundCost(n) {
|
|
31
|
+
return safeNum(Number(n.toFixed(6)));
|
|
32
|
+
}
|
|
33
|
+
function fmtTokens(n) {
|
|
34
|
+
if (!Number.isFinite(n) || n === 0) return "0";
|
|
35
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
36
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
|
37
|
+
return String(Math.round(n));
|
|
38
|
+
}
|
|
39
|
+
function fmtCost(n) {
|
|
40
|
+
if (!Number.isFinite(n) || n === 0) return "";
|
|
41
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
42
|
+
return `$${n.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
var initialized = false;
|
|
45
|
+
var tui = async (api) => {
|
|
46
|
+
if (initialized) {
|
|
47
|
+
api.ui?.toast?.({
|
|
48
|
+
message: "usage-total TUI already loaded; skipping re-init",
|
|
49
|
+
variant: "warning"
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
initialized = true;
|
|
54
|
+
api.ui?.toast?.({ message: "usage-total TUI loaded", variant: "info" });
|
|
55
|
+
let unsub;
|
|
56
|
+
let keymapDispose;
|
|
57
|
+
let solidDispose;
|
|
58
|
+
let flushPending;
|
|
59
|
+
const cleanup = () => {
|
|
60
|
+
try {
|
|
61
|
+
flushPending?.();
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
unsub?.();
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
keymapDispose?.();
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
solidDispose?.();
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
initialized = false;
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
if (api.lifecycle?.onDispose) {
|
|
80
|
+
api.lifecycle.onDispose(cleanup);
|
|
81
|
+
}
|
|
82
|
+
createRoot((dispose) => {
|
|
83
|
+
solidDispose = dispose;
|
|
84
|
+
const [modelState, setModelState] = createSignal({});
|
|
85
|
+
const EXPANDED_KV_KEY = "usage-total.sidebar.expanded";
|
|
86
|
+
const [expanded, setExpanded] = createSignal(
|
|
87
|
+
api.kv?.get?.(EXPANDED_KV_KEY, true) !== false
|
|
88
|
+
);
|
|
89
|
+
const TOGGLE_CMD = "usage-total.toggle-section";
|
|
90
|
+
keymapDispose = api.keymap?.registerLayer ? api.keymap.registerLayer({
|
|
91
|
+
commands: [
|
|
92
|
+
{
|
|
93
|
+
name: TOGGLE_CMD,
|
|
94
|
+
title: "Usage: Toggle models section",
|
|
95
|
+
description: "Collapse or expand the models list in the sidebar",
|
|
96
|
+
run: () => {
|
|
97
|
+
const next = !expanded();
|
|
98
|
+
setExpanded(next);
|
|
99
|
+
api.kv?.set?.(EXPANDED_KV_KEY, next);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
bindings: [{ key: "alt+m", cmd: TOGGLE_CMD }]
|
|
104
|
+
}) : void 0;
|
|
105
|
+
const loadedSessions = /* @__PURE__ */ new Set();
|
|
106
|
+
function kvKey(sessionID) {
|
|
107
|
+
return `usage-total:models:${sessionID}`;
|
|
108
|
+
}
|
|
109
|
+
const SAVE_DEBOUNCE_MS = 500;
|
|
110
|
+
const dirtySessions = /* @__PURE__ */ new Set();
|
|
111
|
+
let saveTimer;
|
|
112
|
+
function flushDirtySessions() {
|
|
113
|
+
if (saveTimer) {
|
|
114
|
+
clearTimeout(saveTimer);
|
|
115
|
+
saveTimer = void 0;
|
|
116
|
+
}
|
|
117
|
+
for (const sid of dirtySessions) {
|
|
118
|
+
const models = modelState()[sid];
|
|
119
|
+
if (models && models.length > 0) {
|
|
120
|
+
api.kv?.set?.(kvKey(sid), models);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
dirtySessions.clear();
|
|
124
|
+
}
|
|
125
|
+
flushPending = flushDirtySessions;
|
|
126
|
+
function scheduleSave(sessionID) {
|
|
127
|
+
dirtySessions.add(sessionID);
|
|
128
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
129
|
+
saveTimer = setTimeout(flushDirtySessions, SAVE_DEBOUNCE_MS);
|
|
130
|
+
}
|
|
131
|
+
function loadSession(sessionID) {
|
|
132
|
+
if (loadedSessions.has(sessionID)) return;
|
|
133
|
+
loadedSessions.add(sessionID);
|
|
134
|
+
const saved = api.kv?.get?.(kvKey(sessionID));
|
|
135
|
+
if (saved && saved.length > 0) {
|
|
136
|
+
setModelState((current) => ({ ...current, [sessionID]: saved }));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function upsertModel(sessionID, entry, cost, tokens) {
|
|
140
|
+
const dedupeKey = `${entry.provider}/${entry.model}/${entry.agent}`;
|
|
141
|
+
const current = modelState();
|
|
142
|
+
const sessionModels = [...current[sessionID] ?? []];
|
|
143
|
+
const existingIdx = sessionModels.findIndex(
|
|
144
|
+
(m) => `${m.provider}/${m.model}/${m.agent}` === dedupeKey
|
|
145
|
+
);
|
|
146
|
+
const safeCost = safeNum(cost);
|
|
147
|
+
const safeInput = safeNum(tokens.input);
|
|
148
|
+
const safeOutput = safeNum(tokens.output);
|
|
149
|
+
const safeReasoning = safeNum(tokens.reasoning);
|
|
150
|
+
if (existingIdx >= 0) {
|
|
151
|
+
const existing = sessionModels[existingIdx];
|
|
152
|
+
sessionModels[existingIdx] = {
|
|
153
|
+
...existing,
|
|
154
|
+
cost: roundCost(existing.cost + safeCost),
|
|
155
|
+
tokensInput: safeNum(existing.tokensInput + safeInput),
|
|
156
|
+
tokensOutput: safeNum(existing.tokensOutput + safeOutput),
|
|
157
|
+
tokensReasoning: safeNum(existing.tokensReasoning + safeReasoning),
|
|
158
|
+
messageCount: existing.messageCount + 1
|
|
159
|
+
};
|
|
160
|
+
} else {
|
|
161
|
+
sessionModels.push({
|
|
162
|
+
...entry,
|
|
163
|
+
cost: safeCost,
|
|
164
|
+
tokensInput: safeInput,
|
|
165
|
+
tokensOutput: safeOutput,
|
|
166
|
+
tokensReasoning: safeReasoning,
|
|
167
|
+
messageCount: 1
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
setModelState({
|
|
171
|
+
...current,
|
|
172
|
+
[sessionID]: sessionModels
|
|
173
|
+
});
|
|
174
|
+
scheduleSave(sessionID);
|
|
175
|
+
return existingIdx < 0;
|
|
176
|
+
}
|
|
177
|
+
function trackModel(eventSessionID, entry, cost, tokens) {
|
|
178
|
+
const isNew = upsertModel(eventSessionID, entry, cost, tokens);
|
|
179
|
+
if (isNew) {
|
|
180
|
+
api.ui?.toast?.({
|
|
181
|
+
message: `${entry.agent}: ${entry.model}`,
|
|
182
|
+
variant: "success"
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const session = api.state?.session?.get?.(eventSessionID);
|
|
186
|
+
if (session?.parentID && session.parentID !== eventSessionID) {
|
|
187
|
+
upsertModel(session.parentID, entry, cost, tokens);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
unsub = api.event?.on?.("message.updated", (event) => {
|
|
191
|
+
const info = event?.properties?.info;
|
|
192
|
+
if (!info) return;
|
|
193
|
+
const eventSessionID = info.sessionID;
|
|
194
|
+
if (!eventSessionID) return;
|
|
195
|
+
let provider;
|
|
196
|
+
let model;
|
|
197
|
+
let agent;
|
|
198
|
+
let cost = 0;
|
|
199
|
+
let tokens = {};
|
|
200
|
+
if (info.role === "user") {
|
|
201
|
+
const mdl = info.model;
|
|
202
|
+
provider = mdl?.providerID ?? UNKNOWN_ID;
|
|
203
|
+
model = mdl?.modelID ?? UNKNOWN_ID;
|
|
204
|
+
agent = info.agent ?? DEFAULT_AGENT;
|
|
205
|
+
} else if (info.role === "assistant") {
|
|
206
|
+
provider = info.providerID ?? UNKNOWN_ID;
|
|
207
|
+
model = info.modelID ?? UNKNOWN_ID;
|
|
208
|
+
agent = info.agent ?? info.mode ?? DEFAULT_AGENT;
|
|
209
|
+
cost = safeNum(info.cost);
|
|
210
|
+
tokens = {
|
|
211
|
+
input: safeNum(info.tokens?.input),
|
|
212
|
+
output: safeNum(info.tokens?.output),
|
|
213
|
+
reasoning: safeNum(info.tokens?.reasoning)
|
|
214
|
+
};
|
|
215
|
+
} else {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
trackModel(eventSessionID, { provider, model, agent }, cost, tokens);
|
|
219
|
+
});
|
|
220
|
+
api.slots?.register?.({
|
|
221
|
+
order: 200,
|
|
222
|
+
slots: {
|
|
223
|
+
sidebar_content(ctx, props) {
|
|
224
|
+
const sessionID = props.session_id ?? resolveRouteSessionID(api) ?? "";
|
|
225
|
+
if (sessionID) loadSession(sessionID);
|
|
226
|
+
const models = sessionID ? modelState()[sessionID] ?? [] : [];
|
|
227
|
+
if (!sessionID || models.length === 0) {
|
|
228
|
+
return /* @__PURE__ */ jsxs("box", { flexDirection: "column", children: [
|
|
229
|
+
/* @__PURE__ */ jsx("text", { fg: ctx.theme.current.text, children: "\u{1F9E0} Models" }),
|
|
230
|
+
/* @__PURE__ */ jsx("text", { fg: ctx.theme.current.textMuted, children: sessionID ? "waiting for messages..." : "open a session to track models" })
|
|
231
|
+
] });
|
|
232
|
+
}
|
|
233
|
+
const totalCost = models.reduce((sum, m) => sum + m.cost, 0);
|
|
234
|
+
const totalTokens = models.reduce(
|
|
235
|
+
(sum, m) => sum + modelTokens(m),
|
|
236
|
+
0
|
|
237
|
+
);
|
|
238
|
+
return /* @__PURE__ */ jsxs("box", { flexDirection: "column", children: [
|
|
239
|
+
/* @__PURE__ */ jsxs(
|
|
240
|
+
"box",
|
|
241
|
+
{
|
|
242
|
+
flexDirection: "row",
|
|
243
|
+
justifyContent: "space-between",
|
|
244
|
+
children: [
|
|
245
|
+
/* @__PURE__ */ jsxs("box", { flexDirection: "row", children: [
|
|
246
|
+
/* @__PURE__ */ jsxs("text", { fg: ctx.theme.current.text, children: [
|
|
247
|
+
expanded() ? "\u25BC" : "\u25B6",
|
|
248
|
+
" \u{1F9E0} Models"
|
|
249
|
+
] }),
|
|
250
|
+
/* @__PURE__ */ jsxs("text", { fg: ctx.theme.current.textMuted, children: [
|
|
251
|
+
" ",
|
|
252
|
+
version
|
|
253
|
+
] })
|
|
254
|
+
] }),
|
|
255
|
+
/* @__PURE__ */ jsx("text", { fg: ctx.theme.current.text, children: totalCost > 0 ? `${fmtCost(totalCost)} \xB7 ${fmtTokens(totalTokens)}` : fmtTokens(totalTokens) })
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
),
|
|
259
|
+
expanded() && models.map((m) => /* @__PURE__ */ jsxs("box", { flexDirection: "column", children: [
|
|
260
|
+
/* @__PURE__ */ jsxs("text", { fg: ctx.theme.current.text, children: [
|
|
261
|
+
m.agent,
|
|
262
|
+
":"
|
|
263
|
+
] }),
|
|
264
|
+
/* @__PURE__ */ jsxs("box", { flexDirection: "row", justifyContent: "space-between", children: [
|
|
265
|
+
/* @__PURE__ */ jsx("text", { fg: ctx.theme.current.text, children: " " + m.model }),
|
|
266
|
+
/* @__PURE__ */ jsx("text", { fg: ctx.theme.current.textMuted, children: m.cost > 0 ? `${fmtCost(m.cost)}${modelTokens(m) > 0 ? ` \xB7 ${fmtTokens(modelTokens(m))}` : ""}` : modelTokens(m) > 0 ? fmtTokens(modelTokens(m)) : "-" })
|
|
267
|
+
] })
|
|
268
|
+
] }))
|
|
269
|
+
] });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
} catch (err) {
|
|
275
|
+
cleanup();
|
|
276
|
+
api.ui?.toast?.({
|
|
277
|
+
message: "usage-total TUI failed to initialize",
|
|
278
|
+
variant: "error"
|
|
279
|
+
});
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
var usage_total_tui_default = { id: "usage-total", tui };
|
|
284
|
+
export {
|
|
285
|
+
UsageTotalPlugin,
|
|
286
|
+
usage_total_tui_default as default
|
|
287
|
+
};
|
package/package.json
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-total",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Track model usage, tokens, and costs per agent in the OpenCode TUI sidebar",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "index.
|
|
6
|
+
"main": "dist/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./index.
|
|
9
|
-
"./runtime": "./usage-total.
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./runtime": "./dist/usage-total.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"usage-total.ts",
|
|
14
|
-
"usage-total-tui.tsx",
|
|
12
|
+
"dist",
|
|
15
13
|
"image.png",
|
|
16
14
|
"README.md",
|
|
17
15
|
"README.en.md"
|
|
@@ -30,11 +28,16 @@
|
|
|
30
28
|
"type": "git",
|
|
31
29
|
"url": "git+https://github.com/AlonsoSG0/opencode-usage-total.git"
|
|
32
30
|
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsup",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
34
|
+
},
|
|
33
35
|
"devDependencies": {
|
|
34
36
|
"@opencode-ai/plugin": "^1.17.8",
|
|
35
37
|
"@opentui/core": "^0.4.1",
|
|
36
38
|
"@opentui/keymap": "^0.4.1",
|
|
37
39
|
"@opentui/solid": "^0.4.1",
|
|
38
|
-
"solid-js": "^1.9.12"
|
|
40
|
+
"solid-js": "^1.9.12",
|
|
41
|
+
"tsup": "^8.5.1"
|
|
39
42
|
}
|
|
40
43
|
}
|
package/index.ts
DELETED
package/usage-total-tui.tsx
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
/** @jsxImportSource @opentui/solid */
|
|
3
|
-
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
|
4
|
-
import { createRoot, createSignal } from "solid-js"
|
|
5
|
-
|
|
6
|
-
// ---- Constants ----
|
|
7
|
-
const DEFAULT_AGENT = "primary"
|
|
8
|
-
const UNKNOWN_ID = "?"
|
|
9
|
-
|
|
10
|
-
interface ModelEntry {
|
|
11
|
-
provider: string
|
|
12
|
-
model: string
|
|
13
|
-
agent: string
|
|
14
|
-
cost: number
|
|
15
|
-
tokensInput: number
|
|
16
|
-
tokensOutput: number
|
|
17
|
-
tokensReasoning: number
|
|
18
|
-
messageCount: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
type ModelEntryKey = Omit<
|
|
22
|
-
ModelEntry,
|
|
23
|
-
"cost" | "tokensInput" | "tokensOutput" | "tokensReasoning" | "messageCount"
|
|
24
|
-
>
|
|
25
|
-
|
|
26
|
-
// ---- Helpers ----
|
|
27
|
-
function resolveRouteSessionID(api: any): string | undefined {
|
|
28
|
-
return (
|
|
29
|
-
api.route.current.name === "session" &&
|
|
30
|
-
typeof api.route.current.params?.sessionID === "string"
|
|
31
|
-
? api.route.current.params.sessionID
|
|
32
|
-
: undefined
|
|
33
|
-
)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function modelTokens(m: ModelEntry): number {
|
|
37
|
-
return m.tokensInput + m.tokensOutput + m.tokensReasoning
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function safeNum(value: unknown): number {
|
|
41
|
-
const n = typeof value === "number" ? value : Number(value)
|
|
42
|
-
return Number.isFinite(n) ? n : 0
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function fmtTokens(n: number): string {
|
|
46
|
-
if (!Number.isFinite(n) || n === 0) return "0"
|
|
47
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
48
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
|
|
49
|
-
return String(Math.round(n))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function fmtCost(n: number): string {
|
|
53
|
-
if (!Number.isFinite(n) || n === 0) return ""
|
|
54
|
-
if (n < 0.01) return `$${n.toFixed(4)}`
|
|
55
|
-
return `$${n.toFixed(2)}`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ---- Plugin ----
|
|
59
|
-
const tui: TuiPlugin = async (api) => {
|
|
60
|
-
api.ui.toast({ message: "usage-total TUI loaded", variant: "info" })
|
|
61
|
-
|
|
62
|
-
createRoot((dispose) => {
|
|
63
|
-
const [modelState, setModelState] = createSignal<
|
|
64
|
-
Record<string, ModelEntry[]>
|
|
65
|
-
>({})
|
|
66
|
-
|
|
67
|
-
// Collapse/expand toggle persisted via KV
|
|
68
|
-
const EXPANDED_KV_KEY = "usage-total.sidebar.expanded"
|
|
69
|
-
const [expanded, setExpanded] = createSignal(
|
|
70
|
-
api.kv.get(EXPANDED_KV_KEY, true) !== false,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
// Register keyboard shortcut to toggle section
|
|
74
|
-
const TOGGLE_CMD = "usage-total.toggle-section"
|
|
75
|
-
const keymapDispose = api.keymap?.registerLayer
|
|
76
|
-
? api.keymap.registerLayer({
|
|
77
|
-
commands: [
|
|
78
|
-
{
|
|
79
|
-
name: TOGGLE_CMD,
|
|
80
|
-
title: "Usage: Toggle models section",
|
|
81
|
-
description: "Collapse or expand the models list in the sidebar",
|
|
82
|
-
run: () => {
|
|
83
|
-
const next = !expanded()
|
|
84
|
-
setExpanded(next)
|
|
85
|
-
api.kv.set(EXPANDED_KV_KEY, next)
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
bindings: [{ key: "alt+m", cmd: TOGGLE_CMD }],
|
|
90
|
-
})
|
|
91
|
-
: undefined
|
|
92
|
-
|
|
93
|
-
// KV persistence – loadedSessions grows with visited sessions,
|
|
94
|
-
// bounded by total session count (not a true leak).
|
|
95
|
-
const loadedSessions = new Set<string>()
|
|
96
|
-
|
|
97
|
-
function kvKey(sessionID: string) {
|
|
98
|
-
return `usage-total:models:${sessionID}`
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function saveSession(sessionID: string) {
|
|
102
|
-
const models = modelState()[sessionID]
|
|
103
|
-
if (models && models.length > 0) {
|
|
104
|
-
api.kv.set(kvKey(sessionID), models)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function loadSession(sessionID: string) {
|
|
109
|
-
if (loadedSessions.has(sessionID)) return
|
|
110
|
-
loadedSessions.add(sessionID)
|
|
111
|
-
const saved = api.kv.get<ModelEntry[]>(kvKey(sessionID))
|
|
112
|
-
if (saved && saved.length > 0) {
|
|
113
|
-
setModelState((current) => ({ ...current, [sessionID]: saved }))
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function upsertModel(
|
|
118
|
-
sessionID: string,
|
|
119
|
-
entry: ModelEntryKey,
|
|
120
|
-
cost: number,
|
|
121
|
-
tokens: { input?: number; output?: number; reasoning?: number },
|
|
122
|
-
) {
|
|
123
|
-
const dedupeKey = `${entry.provider}/${entry.model}/${entry.agent}`
|
|
124
|
-
const current = modelState()
|
|
125
|
-
const sessionModels = [...(current[sessionID] ?? [])]
|
|
126
|
-
const existingIdx = sessionModels.findIndex(
|
|
127
|
-
(m) => `${m.provider}/${m.model}/${m.agent}` === dedupeKey,
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
// Guard against NaN/Infinity that would corrupt accumulators and KV
|
|
131
|
-
const safeCost = safeNum(cost)
|
|
132
|
-
const safeInput = safeNum(tokens.input)
|
|
133
|
-
const safeOutput = safeNum(tokens.output)
|
|
134
|
-
const safeReasoning = safeNum(tokens.reasoning)
|
|
135
|
-
|
|
136
|
-
if (existingIdx >= 0) {
|
|
137
|
-
const existing = sessionModels[existingIdx]
|
|
138
|
-
sessionModels[existingIdx] = {
|
|
139
|
-
...existing,
|
|
140
|
-
cost: safeNum(existing.cost + safeCost),
|
|
141
|
-
tokensInput: safeNum(existing.tokensInput + safeInput),
|
|
142
|
-
tokensOutput: safeNum(existing.tokensOutput + safeOutput),
|
|
143
|
-
tokensReasoning: safeNum(existing.tokensReasoning + safeReasoning),
|
|
144
|
-
messageCount: existing.messageCount + 1,
|
|
145
|
-
}
|
|
146
|
-
} else {
|
|
147
|
-
sessionModels.push({
|
|
148
|
-
...entry,
|
|
149
|
-
cost: safeCost,
|
|
150
|
-
tokensInput: safeInput,
|
|
151
|
-
tokensOutput: safeOutput,
|
|
152
|
-
tokensReasoning: safeReasoning,
|
|
153
|
-
messageCount: 1,
|
|
154
|
-
})
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
setModelState({
|
|
158
|
-
...current,
|
|
159
|
-
[sessionID]: sessionModels,
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
saveSession(sessionID)
|
|
163
|
-
|
|
164
|
-
return existingIdx < 0
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function trackModel(
|
|
168
|
-
eventSessionID: string,
|
|
169
|
-
entry: ModelEntryKey,
|
|
170
|
-
cost: number,
|
|
171
|
-
tokens: { input?: number; output?: number; reasoning?: number },
|
|
172
|
-
) {
|
|
173
|
-
const isNew = upsertModel(eventSessionID, entry, cost, tokens)
|
|
174
|
-
if (isNew) {
|
|
175
|
-
api.ui.toast({
|
|
176
|
-
message: `${entry.agent}: ${entry.model}`,
|
|
177
|
-
variant: "success",
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Attribute sub-agent models to the parent session so they
|
|
182
|
-
// appear in the main session sidebar.
|
|
183
|
-
const session = api.state.session.get(eventSessionID)
|
|
184
|
-
if (session?.parentID && session.parentID !== eventSessionID) {
|
|
185
|
-
upsertModel(session.parentID, entry, cost, tokens)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const unsub = api.event.on("message.updated", (event) => {
|
|
190
|
-
const info = event?.properties?.info
|
|
191
|
-
if (!info) return
|
|
192
|
-
|
|
193
|
-
const eventSessionID = info.sessionID
|
|
194
|
-
if (!eventSessionID) return
|
|
195
|
-
|
|
196
|
-
let provider: string
|
|
197
|
-
let model: string
|
|
198
|
-
let agent: string
|
|
199
|
-
let cost = 0
|
|
200
|
-
let tokens: { input?: number; output?: number; reasoning?: number } = {}
|
|
201
|
-
|
|
202
|
-
if (info.role === "user") {
|
|
203
|
-
const mdl = info.model
|
|
204
|
-
provider = mdl?.providerID ?? UNKNOWN_ID
|
|
205
|
-
model = mdl?.modelID ?? UNKNOWN_ID
|
|
206
|
-
agent = info.agent ?? DEFAULT_AGENT
|
|
207
|
-
} else if (info.role === "assistant") {
|
|
208
|
-
provider = info.providerID ?? UNKNOWN_ID
|
|
209
|
-
model = info.modelID ?? UNKNOWN_ID
|
|
210
|
-
agent = info.agent ?? info.mode ?? DEFAULT_AGENT
|
|
211
|
-
cost = safeNum(info.cost)
|
|
212
|
-
tokens = {
|
|
213
|
-
input: safeNum(info.tokens?.input),
|
|
214
|
-
output: safeNum(info.tokens?.output),
|
|
215
|
-
reasoning: safeNum(info.tokens?.reasoning),
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
trackModel(eventSessionID, { provider, model, agent }, cost, tokens)
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
api.slots.register({
|
|
225
|
-
id: "usage-total",
|
|
226
|
-
order: 200,
|
|
227
|
-
slots: {
|
|
228
|
-
sidebar_content(ctx) {
|
|
229
|
-
const sessionID =
|
|
230
|
-
ctx.session_id ?? resolveRouteSessionID(api) ?? ""
|
|
231
|
-
if (sessionID) loadSession(sessionID)
|
|
232
|
-
const models = sessionID ? (modelState()[sessionID] ?? []) : []
|
|
233
|
-
|
|
234
|
-
if (!sessionID || models.length === 0) {
|
|
235
|
-
return (
|
|
236
|
-
<box
|
|
237
|
-
flexDirection="column"
|
|
238
|
-
padding={{ left: 1, right: 1, top: 1 }}
|
|
239
|
-
>
|
|
240
|
-
<text fg={ctx.theme.current.text} bold>
|
|
241
|
-
🧠 Models
|
|
242
|
-
</text>
|
|
243
|
-
<text fg={ctx.theme.current.textMuted}>
|
|
244
|
-
{sessionID
|
|
245
|
-
? "waiting for messages..."
|
|
246
|
-
: "open a session to track models"}
|
|
247
|
-
</text>
|
|
248
|
-
</box>
|
|
249
|
-
)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const totalCost = models.reduce((sum, m) => sum + m.cost, 0)
|
|
253
|
-
const totalTokens = models.reduce(
|
|
254
|
-
(sum, m) => sum + modelTokens(m),
|
|
255
|
-
0,
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
return (
|
|
259
|
-
<box
|
|
260
|
-
flexDirection="column"
|
|
261
|
-
padding={{ left: 1, right: 1, top: 1 }}
|
|
262
|
-
>
|
|
263
|
-
<box
|
|
264
|
-
flexDirection="row"
|
|
265
|
-
justifyContent="space-between"
|
|
266
|
-
>
|
|
267
|
-
<box flexDirection="row">
|
|
268
|
-
<text
|
|
269
|
-
fg={ctx.theme.current.text}
|
|
270
|
-
bold
|
|
271
|
-
>
|
|
272
|
-
{expanded() ? "▼" : "▶"} 🧠 Models
|
|
273
|
-
</text>
|
|
274
|
-
<text fg={ctx.theme.current.textMuted}> 0.1.0</text>
|
|
275
|
-
</box>
|
|
276
|
-
<text fg={ctx.theme.current.text}>
|
|
277
|
-
{totalCost > 0
|
|
278
|
-
? `${fmtCost(totalCost)} · ${fmtTokens(totalTokens)}`
|
|
279
|
-
: fmtTokens(totalTokens)}
|
|
280
|
-
</text>
|
|
281
|
-
</box>
|
|
282
|
-
{expanded() &&
|
|
283
|
-
models.map((m, i) => (
|
|
284
|
-
<box key={i} flexDirection="column">
|
|
285
|
-
<text fg={ctx.theme.current.text}>
|
|
286
|
-
{m.agent}:
|
|
287
|
-
</text>
|
|
288
|
-
<box flexDirection="row" justifyContent="space-between">
|
|
289
|
-
<text fg={ctx.theme.current.text}>
|
|
290
|
-
{" " + m.model}
|
|
291
|
-
</text>
|
|
292
|
-
<text fg={ctx.theme.current.textMuted}>
|
|
293
|
-
{m.cost > 0
|
|
294
|
-
? `${fmtCost(m.cost)}${modelTokens(m) > 0 ? ` · ${fmtTokens(modelTokens(m))}` : ""}`
|
|
295
|
-
: modelTokens(m) > 0
|
|
296
|
-
? fmtTokens(modelTokens(m))
|
|
297
|
-
: "-"}
|
|
298
|
-
</text>
|
|
299
|
-
</box>
|
|
300
|
-
</box>
|
|
301
|
-
))}
|
|
302
|
-
</box>
|
|
303
|
-
)
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
api.lifecycle.onDispose(() => {
|
|
309
|
-
unsub()
|
|
310
|
-
keymapDispose?.()
|
|
311
|
-
dispose()
|
|
312
|
-
})
|
|
313
|
-
})
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export default { id: "usage-total", tui }
|