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 +40 -0
- package/README.md +40 -0
- package/image.png +0 -0
- package/index.ts +2 -0
- package/package.json +40 -0
- package/usage-total-tui.tsx +316 -0
- package/usage-total.ts +36 -0
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
37
|
+
|
|
38
|
+
## Licencia
|
|
39
|
+
|
|
40
|
+
MIT
|
package/image.png
ADDED
|
Binary file
|
package/index.ts
ADDED
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
|
+
}
|