opencode-usage-total 0.1.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.en.md ADDED
@@ -0,0 +1,40 @@
1
+ # opencode-usage-total 🧠
2
+
3
+ Track model usage, tokens, and costs per agent in the OpenCode TUI sidebar.
4
+
5
+ ![version](https://img.shields.io/badge/version-0.1.0-muted)
6
+
7
+ ## Features
8
+
9
+ - Tracks every model used by the main agent and all sub-agents
10
+ - Shows the agent or sub-agent, the model, tokens, and cost
11
+ - Collapsible sidebar section with `Alt+M` toggle
12
+ - Cost and token accumulation across the entire session
13
+ - Sub-agent models attributed to the parent session
14
+ - Persisted via KV — survives restarts and session switches
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ opencode plugin install opencode-usage-total
20
+ ```
21
+
22
+ Or add it manually to `tui.json`:
23
+
24
+ ```json
25
+ {
26
+ "plugin": ["opencode-usage-total"]
27
+ }
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Open a session in OpenCode. The sidebar shows a collapsible **🧠 Models** section with every model used in the current session.
33
+
34
+ Press `Alt+M` to collapse or expand the model list.
35
+
36
+ ![sidebar](https://github.com/AlonsoSG0/opencode-usage-total/raw/main/image.png)
37
+
38
+ ## License
39
+
40
+ MIT
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # opencode-usage-total 🧠
2
+
3
+ Realiza el seguimiento de modelos, tokens y costos por agente en la barra lateral de OpenCode.
4
+
5
+ ![version](https://img.shields.io/badge/version-0.1.0-muted)
6
+
7
+ ## Características
8
+
9
+ - Rastrea cada modelo usado por el agente principal y todos los sub-agentes
10
+ - Muestra el agente o sub-agente, el modelo, los tokens y el costo
11
+ - Sección colapsable con `Alt+M`
12
+ - Acumula costo y tokens durante toda la sesión
13
+ - Los modelos de sub-agentes se atribuyen a la sesión padre
14
+ - Persistencia vía KV — sobrevive reinicios y cambios de sesión
15
+
16
+ ## Instalación
17
+
18
+ ```bash
19
+ opencode plugin install opencode-usage-total
20
+ ```
21
+
22
+ O agrégalo manualmente en `tui.json`:
23
+
24
+ ```json
25
+ {
26
+ "plugin": ["opencode-usage-total"]
27
+ }
28
+ ```
29
+
30
+ ## Uso
31
+
32
+ Abre una sesión en OpenCode. La barra lateral muestra una sección colapsable **🧠 Models** con cada modelo usado en la sesión actual.
33
+
34
+ Presiona `Alt+M` para colapsar o expandir la lista.
35
+
36
+ ![sidebar](https://github.com/AlonsoSG0/opencode-usage-total/raw/main/image.png)
37
+
38
+ ## Licencia
39
+
40
+ MIT
package/image.png ADDED
Binary file
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./usage-total-tui"
2
+ export { UsageTotalPlugin } from "./usage-total"
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "opencode-usage-total",
3
+ "version": "0.1.0",
4
+ "description": "Track model usage, tokens, and costs per agent in the OpenCode TUI sidebar",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "exports": {
8
+ ".": "./index.ts",
9
+ "./runtime": "./usage-total.ts"
10
+ },
11
+ "files": [
12
+ "index.ts",
13
+ "usage-total.ts",
14
+ "usage-total-tui.tsx",
15
+ "image.png",
16
+ "README.md",
17
+ "README.en.md"
18
+ ],
19
+ "keywords": [
20
+ "opencode",
21
+ "opencode-plugin",
22
+ "tui",
23
+ "usage",
24
+ "tokens",
25
+ "cost",
26
+ "models"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/AlonsoSG0/opencode-usage-total.git"
32
+ },
33
+ "devDependencies": {
34
+ "@opencode-ai/plugin": "^1.17.8",
35
+ "@opentui/core": "^0.4.1",
36
+ "@opentui/keymap": "^0.4.1",
37
+ "@opentui/solid": "^0.4.1",
38
+ "solid-js": "^1.9.12"
39
+ }
40
+ }
@@ -0,0 +1,316 @@
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 }
package/usage-total.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ export const UsageTotalPlugin: Plugin = async ({ client }) => {
4
+ await client.app.log({
5
+ body: {
6
+ service: "usage-total",
7
+ level: "info",
8
+ message: "usage-total plugin loaded",
9
+ },
10
+ })
11
+
12
+ return {
13
+ event: async ({ event }) => {
14
+ if (event.type === "session.created") {
15
+ void client.app.log({
16
+ body: {
17
+ service: "usage-total",
18
+ level: "info",
19
+ message: `session.created [${event.properties.info.id}]`,
20
+ },
21
+ })
22
+ }
23
+
24
+ if (event.type === "message.updated") {
25
+ const msg = event.properties.info
26
+ void client.app.log({
27
+ body: {
28
+ service: "usage-total",
29
+ level: "info",
30
+ message: `message.updated [${msg.id}] role=${msg.role}`,
31
+ },
32
+ })
33
+ }
34
+ },
35
+ }
36
+ }