opencode-usage-total 0.1.1 → 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.
Files changed (2) hide show
  1. package/dist/index.js +238 -191
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,12 +4,21 @@ import {
4
4
 
5
5
  // usage-total-tui.tsx
6
6
  import { createRoot, createSignal } from "solid-js";
7
+
8
+ // package.json
9
+ var version = "0.1.2";
10
+
11
+ // usage-total-tui.tsx
7
12
  import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
8
13
  var DEFAULT_AGENT = "primary";
9
14
  var UNKNOWN_ID = "?";
10
15
  function resolveRouteSessionID(api) {
11
- var _a;
12
- return api.route.current.name === "session" && typeof ((_a = api.route.current.params) == null ? void 0 : _a.sessionID) === "string" ? api.route.current.params.sessionID : void 0;
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;
13
22
  }
14
23
  function modelTokens(m) {
15
24
  return m.tokensInput + m.tokensOutput + m.tokensReasoning;
@@ -18,6 +27,9 @@ function safeNum(value) {
18
27
  const n = typeof value === "number" ? value : Number(value);
19
28
  return Number.isFinite(n) ? n : 0;
20
29
  }
30
+ function roundCost(n) {
31
+ return safeNum(Number(n.toFixed(6)));
32
+ }
21
33
  function fmtTokens(n) {
22
34
  if (!Number.isFinite(n) || n === 0) return "0";
23
35
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
@@ -29,209 +41,244 @@ function fmtCost(n) {
29
41
  if (n < 0.01) return `$${n.toFixed(4)}`;
30
42
  return `$${n.toFixed(2)}`;
31
43
  }
44
+ var initialized = false;
32
45
  var tui = async (api) => {
33
- api.ui.toast({ message: "usage-total TUI loaded", variant: "info" });
34
- createRoot((dispose) => {
35
- var _a;
36
- const [modelState, setModelState] = createSignal({});
37
- const EXPANDED_KV_KEY = "usage-total.sidebar.expanded";
38
- const [expanded, setExpanded] = createSignal(
39
- api.kv.get(EXPANDED_KV_KEY, true) !== false
40
- );
41
- const TOGGLE_CMD = "usage-total.toggle-section";
42
- const keymapDispose = ((_a = api.keymap) == null ? void 0 : _a.registerLayer) ? api.keymap.registerLayer({
43
- commands: [
44
- {
45
- name: TOGGLE_CMD,
46
- title: "Usage: Toggle models section",
47
- description: "Collapse or expand the models list in the sidebar",
48
- run: () => {
49
- const next = !expanded();
50
- setExpanded(next);
51
- api.kv.set(EXPANDED_KV_KEY, next);
52
- }
53
- }
54
- ],
55
- bindings: [{ key: "alt+m", cmd: TOGGLE_CMD }]
56
- }) : void 0;
57
- const loadedSessions = /* @__PURE__ */ new Set();
58
- function kvKey(sessionID) {
59
- return `usage-total:models:${sessionID}`;
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 {
60
63
  }
61
- function saveSession(sessionID) {
62
- const models = modelState()[sessionID];
63
- if (models && models.length > 0) {
64
- api.kv.set(kvKey(sessionID), models);
65
- }
64
+ try {
65
+ unsub?.();
66
+ } catch {
66
67
  }
67
- function loadSession(sessionID) {
68
- if (loadedSessions.has(sessionID)) return;
69
- loadedSessions.add(sessionID);
70
- const saved = api.kv.get(kvKey(sessionID));
71
- if (saved && saved.length > 0) {
72
- setModelState((current) => ({ ...current, [sessionID]: saved }));
73
- }
68
+ try {
69
+ keymapDispose?.();
70
+ } catch {
71
+ }
72
+ try {
73
+ solidDispose?.();
74
+ } catch {
74
75
  }
75
- function upsertModel(sessionID, entry, cost, tokens) {
76
- const dedupeKey = `${entry.provider}/${entry.model}/${entry.agent}`;
77
- const current = modelState();
78
- const sessionModels = [...current[sessionID] ?? []];
79
- const existingIdx = sessionModels.findIndex(
80
- (m) => `${m.provider}/${m.model}/${m.agent}` === dedupeKey
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
81
88
  );
82
- const safeCost = safeNum(cost);
83
- const safeInput = safeNum(tokens.input);
84
- const safeOutput = safeNum(tokens.output);
85
- const safeReasoning = safeNum(tokens.reasoning);
86
- if (existingIdx >= 0) {
87
- const existing = sessionModels[existingIdx];
88
- sessionModels[existingIdx] = {
89
- ...existing,
90
- cost: safeNum(existing.cost + safeCost),
91
- tokensInput: safeNum(existing.tokensInput + safeInput),
92
- tokensOutput: safeNum(existing.tokensOutput + safeOutput),
93
- tokensReasoning: safeNum(existing.tokensReasoning + safeReasoning),
94
- messageCount: existing.messageCount + 1
95
- };
96
- } else {
97
- sessionModels.push({
98
- ...entry,
99
- cost: safeCost,
100
- tokensInput: safeInput,
101
- tokensOutput: safeOutput,
102
- tokensReasoning: safeReasoning,
103
- messageCount: 1
104
- });
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}`;
105
108
  }
106
- setModelState({
107
- ...current,
108
- [sessionID]: sessionModels
109
- });
110
- saveSession(sessionID);
111
- return existingIdx < 0;
112
- }
113
- function trackModel(eventSessionID, entry, cost, tokens) {
114
- const isNew = upsertModel(eventSessionID, entry, cost, tokens);
115
- if (isNew) {
116
- api.ui.toast({
117
- message: `${entry.agent}: ${entry.model}`,
118
- variant: "success"
119
- });
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();
120
124
  }
121
- const session = api.state.session.get(eventSessionID);
122
- if ((session == null ? void 0 : session.parentID) && session.parentID !== eventSessionID) {
123
- upsertModel(session.parentID, entry, cost, tokens);
125
+ flushPending = flushDirtySessions;
126
+ function scheduleSave(sessionID) {
127
+ dirtySessions.add(sessionID);
128
+ if (saveTimer) clearTimeout(saveTimer);
129
+ saveTimer = setTimeout(flushDirtySessions, SAVE_DEBOUNCE_MS);
124
130
  }
125
- }
126
- const unsub = api.event.on("message.updated", (event) => {
127
- var _a2, _b, _c, _d;
128
- const info = (_a2 = event == null ? void 0 : event.properties) == null ? void 0 : _a2.info;
129
- if (!info) return;
130
- const eventSessionID = info.sessionID;
131
- if (!eventSessionID) return;
132
- let provider;
133
- let model;
134
- let agent;
135
- let cost = 0;
136
- let tokens = {};
137
- if (info.role === "user") {
138
- const mdl = info.model;
139
- provider = (mdl == null ? void 0 : mdl.providerID) ?? UNKNOWN_ID;
140
- model = (mdl == null ? void 0 : mdl.modelID) ?? UNKNOWN_ID;
141
- agent = info.agent ?? DEFAULT_AGENT;
142
- } else if (info.role === "assistant") {
143
- provider = info.providerID ?? UNKNOWN_ID;
144
- model = info.modelID ?? UNKNOWN_ID;
145
- agent = info.agent ?? info.mode ?? DEFAULT_AGENT;
146
- cost = safeNum(info.cost);
147
- tokens = {
148
- input: safeNum((_b = info.tokens) == null ? void 0 : _b.input),
149
- output: safeNum((_c = info.tokens) == null ? void 0 : _c.output),
150
- reasoning: safeNum((_d = info.tokens) == null ? void 0 : _d.reasoning)
151
- };
152
- } else {
153
- return;
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
+ }
154
138
  }
155
- trackModel(eventSessionID, { provider, model, agent }, cost, tokens);
156
- });
157
- api.slots.register({
158
- id: "usage-total",
159
- order: 200,
160
- slots: {
161
- sidebar_content(ctx) {
162
- const sessionID = ctx.session_id ?? resolveRouteSessionID(api) ?? "";
163
- if (sessionID) loadSession(sessionID);
164
- const models = sessionID ? modelState()[sessionID] ?? [] : [];
165
- if (!sessionID || models.length === 0) {
166
- return /* @__PURE__ */ jsxs(
167
- "box",
168
- {
169
- flexDirection: "column",
170
- padding: { left: 1, right: 1, top: 1 },
171
- children: [
172
- /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.text, bold: true, children: "\u{1F9E0} Models" }),
173
- /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.textMuted, children: sessionID ? "waiting for messages..." : "open a session to track models" })
174
- ]
175
- }
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
176
237
  );
177
- }
178
- const totalCost = models.reduce((sum, m) => sum + m.cost, 0);
179
- const totalTokens = models.reduce(
180
- (sum, m) => sum + modelTokens(m),
181
- 0
182
- );
183
- return /* @__PURE__ */ jsxs(
184
- "box",
185
- {
186
- flexDirection: "column",
187
- padding: { left: 1, right: 1, top: 1 },
188
- children: [
189
- /* @__PURE__ */ jsxs(
190
- "box",
191
- {
192
- flexDirection: "row",
193
- justifyContent: "space-between",
194
- children: [
195
- /* @__PURE__ */ jsxs("box", { flexDirection: "row", children: [
196
- /* @__PURE__ */ jsxs(
197
- "text",
198
- {
199
- fg: ctx.theme.current.text,
200
- bold: true,
201
- children: [
202
- expanded() ? "\u25BC" : "\u25B6",
203
- " \u{1F9E0} Models"
204
- ]
205
- }
206
- ),
207
- /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.textMuted, children: " 0.1.0" })
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"
208
249
  ] }),
209
- /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.text, children: totalCost > 0 ? `${fmtCost(totalCost)} \xB7 ${fmtTokens(totalTokens)}` : fmtTokens(totalTokens) })
210
- ]
211
- }
212
- ),
213
- expanded() && models.map((m, i) => /* @__PURE__ */ jsxs("box", { flexDirection: "column", children: [
214
- /* @__PURE__ */ jsxs("text", { fg: ctx.theme.current.text, children: [
215
- m.agent,
216
- ":"
217
- ] }),
218
- /* @__PURE__ */ jsxs("box", { flexDirection: "row", justifyContent: "space-between", children: [
219
- /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.text, children: " " + m.model }),
220
- /* @__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)) : "-" })
221
- ] })
222
- ] }, i))
223
- ]
224
- }
225
- );
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
+ }
226
271
  }
227
- }
272
+ });
228
273
  });
229
- api.lifecycle.onDispose(() => {
230
- unsub();
231
- keymapDispose == null ? void 0 : keymapDispose();
232
- dispose();
274
+ } catch (err) {
275
+ cleanup();
276
+ api.ui?.toast?.({
277
+ message: "usage-total TUI failed to initialize",
278
+ variant: "error"
233
279
  });
234
- });
280
+ throw err;
281
+ }
235
282
  };
236
283
  var usage_total_tui_default = { id: "usage-total", tui };
237
284
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-total",
3
- "version": "0.1.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
6
  "main": "dist/index.js",