opencode-models-usage-plugin 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/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "opencode-models-usage-plugin",
3
+ "version": "0.1.0",
4
+ "description": "Show per-model usage, live TPS, tokens, and cost in the OpenCode sidebar.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "exports": {
8
+ "./tui": {
9
+ "import": "./src/tui.tsx"
10
+ }
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "engines": {
16
+ "opencode": ">=1.4.3"
17
+ },
18
+ "scripts": {
19
+ "test": "tsx --test src/**/*.test.ts",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "dependencies": {
23
+ "@opencode-ai/plugin": "1.4.3",
24
+ "@opentui/solid": "0.1.97",
25
+ "solid-js": "1.9.11"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "25.6.0",
29
+ "tsx": "4.21.0",
30
+ "typescript": "6.0.2"
31
+ },
32
+ "keywords": [],
33
+ "author": ""
34
+ }
@@ -0,0 +1,326 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+
4
+ import { getSessionUsage, getTokenTotal, type SessionMessageLike } from "./session-usage"
5
+
6
+ test("empty session returns no models", () => {
7
+ assert.deepEqual(getSessionUsage([]), {
8
+ models: [],
9
+ })
10
+ })
11
+
12
+ test("assistant message contributes all token counters and cost", () => {
13
+ const usage = getSessionUsage([
14
+ {
15
+ role: "assistant",
16
+ providerID: "openai",
17
+ modelID: "gpt-4.1",
18
+ cost: 0.05,
19
+ tokens: {
20
+ input: 11,
21
+ output: 7,
22
+ reasoning: 5,
23
+ cache: {
24
+ read: 3,
25
+ write: 2,
26
+ },
27
+ },
28
+ },
29
+ ])
30
+
31
+ assert.deepEqual(usage, {
32
+ models: [
33
+ {
34
+ label: "openai/gpt-4.1",
35
+ totalTokens: 28,
36
+ cost: 0.05,
37
+ tps: 0,
38
+ isLiveTps: false,
39
+ },
40
+ ],
41
+ })
42
+ })
43
+
44
+ test("repeated model is listed once while tokens keep accumulating", () => {
45
+ const messages: SessionMessageLike[] = [
46
+ {
47
+ role: "assistant",
48
+ providerID: "openai",
49
+ modelID: "gpt-4.1",
50
+ cost: 0.01,
51
+ tokens: {
52
+ input: 10,
53
+ output: 5,
54
+ },
55
+ time: { created: 1000, completed: 3000 },
56
+ },
57
+ {
58
+ role: "assistant",
59
+ providerID: "openai",
60
+ modelID: "gpt-4.1",
61
+ cost: 0.02,
62
+ tokens: {
63
+ input: 4,
64
+ output: 6,
65
+ },
66
+ time: { created: 4000, completed: 5000 },
67
+ },
68
+ ]
69
+
70
+ assert.deepEqual(getSessionUsage(messages), {
71
+ models: [
72
+ {
73
+ label: "openai/gpt-4.1",
74
+ totalTokens: 25,
75
+ cost: 0.03,
76
+ tps: 0,
77
+ isLiveTps: false, // (5 + 6) tokens / (2 + 1) seconds = 11 / 3
78
+ },
79
+ ],
80
+ })
81
+ })
82
+
83
+ test("multiple models preserve first-seen order", () => {
84
+ const usage = getSessionUsage([
85
+ {
86
+ role: "assistant",
87
+ providerID: "anthropic",
88
+ modelID: "claude-sonnet-4",
89
+ tokens: {
90
+ input: 1,
91
+ output: 2,
92
+ },
93
+ },
94
+ {
95
+ role: "assistant",
96
+ providerID: "openai",
97
+ modelID: "gpt-4.1",
98
+ tokens: {
99
+ input: 3,
100
+ output: 4,
101
+ },
102
+ },
103
+ ])
104
+
105
+ assert.deepEqual(usage.models.map((m) => m.label), [
106
+ "anthropic/claude-sonnet-4",
107
+ "openai/gpt-4.1",
108
+ ])
109
+ assert.equal(usage.models[0].totalTokens, 3)
110
+ assert.equal(usage.models[1].totalTokens, 7)
111
+ })
112
+
113
+ test("non-assistant messages are ignored", () => {
114
+ const usage = getSessionUsage([
115
+ {
116
+ role: "user",
117
+ providerID: "openai",
118
+ modelID: "gpt-4.1",
119
+ cost: 10,
120
+ tokens: {
121
+ input: 999,
122
+ output: 999,
123
+ },
124
+ },
125
+ {
126
+ role: "assistant",
127
+ providerID: "openai",
128
+ modelID: "gpt-4.1",
129
+ cost: 0.1,
130
+ tokens: {
131
+ input: 3,
132
+ output: 2,
133
+ },
134
+ },
135
+ ])
136
+
137
+ assert.deepEqual(usage, {
138
+ models: [
139
+ {
140
+ label: "openai/gpt-4.1",
141
+ totalTokens: 5,
142
+ cost: 0.1,
143
+ tps: 0,
144
+ isLiveTps: false,
145
+ },
146
+ ],
147
+ })
148
+ })
149
+
150
+ test("provider catalog names are used when available", () => {
151
+ const providers = {
152
+ openai: {
153
+ id: "openai",
154
+ name: "OpenAI",
155
+ models: {
156
+ "gpt-4.1": {
157
+ id: "gpt-4.1",
158
+ name: "GPT-4.1",
159
+ },
160
+ },
161
+ },
162
+ }
163
+
164
+ const usage = getSessionUsage(
165
+ [
166
+ {
167
+ role: "assistant",
168
+ providerID: "openai",
169
+ modelID: "gpt-4.1",
170
+ tokens: {
171
+ input: 2,
172
+ output: 3,
173
+ },
174
+ },
175
+ ],
176
+ providers,
177
+ )
178
+
179
+ assert.deepEqual(usage, {
180
+ models: [
181
+ {
182
+ label: "OpenAI/GPT-4.1",
183
+ totalTokens: 5,
184
+ cost: 0,
185
+ tps: 0,
186
+ isLiveTps: false,
187
+ },
188
+ ],
189
+ })
190
+ })
191
+
192
+ test("provider catalog also works when providers are stored as an array", () => {
193
+ const providers = [
194
+ {
195
+ id: "anthropic",
196
+ name: "Anthropic",
197
+ models: {
198
+ "claude-sonnet-4": {
199
+ id: "claude-sonnet-4",
200
+ name: "Claude Sonnet 4",
201
+ },
202
+ },
203
+ },
204
+ ]
205
+
206
+ const usage = getSessionUsage(
207
+ [
208
+ {
209
+ role: "assistant",
210
+ providerID: "anthropic",
211
+ modelID: "claude-sonnet-4",
212
+ tokens: {
213
+ input: 4,
214
+ output: 6,
215
+ },
216
+ },
217
+ ],
218
+ providers,
219
+ )
220
+
221
+ assert.deepEqual(usage, {
222
+ models: [
223
+ {
224
+ label: "Anthropic/Claude Sonnet 4",
225
+ totalTokens: 10,
226
+ cost: 0,
227
+ tps: 0,
228
+ isLiveTps: false,
229
+ },
230
+ ],
231
+ })
232
+ })
233
+
234
+ test("token helper tolerates missing counters", () => {
235
+ assert.equal(
236
+ getTokenTotal({
237
+ input: 1,
238
+ cache: {
239
+ read: 2,
240
+ },
241
+ }),
242
+ 3,
243
+ )
244
+ })
245
+
246
+ test("tps eligibility rules are applied correctly", () => {
247
+ const usage = getSessionUsage([
248
+ // valid
249
+ {
250
+ role: "assistant",
251
+ providerID: "openai",
252
+ modelID: "gpt-4",
253
+ tokens: { output: 10 },
254
+ time: { created: 1000, completed: 2000 },
255
+ },
256
+ // ignored: summary = true
257
+ {
258
+ role: "assistant",
259
+ providerID: "openai",
260
+ modelID: "gpt-4",
261
+ summary: true,
262
+ tokens: { output: 100 },
263
+ time: { created: 1000, completed: 2000 },
264
+ },
265
+ // ignored: no completed time
266
+ {
267
+ role: "assistant",
268
+ providerID: "openai",
269
+ modelID: "gpt-4",
270
+ tokens: { output: 100 },
271
+ time: { created: 1000 },
272
+ },
273
+ // ignored: duration <= 0
274
+ {
275
+ role: "assistant",
276
+ providerID: "openai",
277
+ modelID: "gpt-4",
278
+ tokens: { output: 100 },
279
+ time: { created: 1000, completed: 1000 },
280
+ },
281
+ // ignored: generated tokens <= 0
282
+ {
283
+ role: "assistant",
284
+ providerID: "openai",
285
+ modelID: "gpt-4",
286
+ tokens: { input: 100, output: 0 },
287
+ time: { created: 1000, completed: 2000 },
288
+ },
289
+ // ignored: finish === "tool-calls"
290
+ {
291
+ role: "assistant",
292
+ providerID: "openai",
293
+ modelID: "gpt-4",
294
+ finish: "tool-calls",
295
+ tokens: { output: 100 },
296
+ time: { created: 1000, completed: 2000 },
297
+ },
298
+ // valid: output + reasoning
299
+ {
300
+ role: "assistant",
301
+ providerID: "openai",
302
+ modelID: "gpt-4",
303
+ tokens: { output: 5, reasoning: 5 },
304
+ time: { created: 2000, completed: 3000 },
305
+ },
306
+ ])
307
+
308
+ assert.deepEqual(usage, {
309
+ models: [
310
+ {
311
+ label: "openai/gpt-4",
312
+ totalTokens: 520, // 10 + 100 + 100 + 100 + 100(input) + 100 + 10 = 520 actually wait
313
+ // Tokens: 10 + 100 + 100 + 100 + 100 + 100 + 10 = 520
314
+ // Cost: 0
315
+ // TPS: 20 tokens / 2 seconds = 10
316
+ cost: 0,
317
+ tps: 0,
318
+ isLiveTps: false,
319
+ },
320
+ ],
321
+ })
322
+
323
+ // Update token expectation to match manual addition
324
+ const total = 10 + 100 + 100 + 100 + 100 + 100 + 10;
325
+ assert.equal(usage.models[0].totalTokens, total)
326
+ })
@@ -0,0 +1,185 @@
1
+ export interface TokenUsageLike {
2
+ input?: number | null
3
+ output?: number | null
4
+ reasoning?: number | null
5
+ cache?: {
6
+ read?: number | null
7
+ write?: number | null
8
+ } | null
9
+ }
10
+
11
+ export interface SessionMessageLike {
12
+ id?: string
13
+ role: string
14
+ modelID?: string | null
15
+ providerID?: string | null
16
+ tokens?: TokenUsageLike | null
17
+ cost?: number | null
18
+ summary?: unknown
19
+ time?: {
20
+ created?: number | null
21
+ completed?: number | null
22
+ } | null
23
+ finish?: string | null
24
+ }
25
+
26
+ export interface ModelUsage {
27
+ label: string
28
+ tps: number
29
+ totalTokens: number
30
+ cost: number
31
+ isLiveTps?: boolean
32
+ }
33
+
34
+ export interface SessionUsage {
35
+ models: ModelUsage[]
36
+ }
37
+
38
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
39
+ typeof value === "object" && value !== null
40
+
41
+ const toNumber = (value: unknown): number =>
42
+ typeof value === "number" && Number.isFinite(value) ? value : 0
43
+
44
+ const getStringProp = (value: unknown, key: string): string | undefined => {
45
+ if (!isRecord(value)) {
46
+ return undefined
47
+ }
48
+
49
+ const prop = value[key]
50
+ return typeof prop === "string" ? prop : undefined
51
+ }
52
+
53
+ const getObjectProp = (value: unknown, key: string): unknown => {
54
+ if (!isRecord(value)) {
55
+ return undefined
56
+ }
57
+
58
+ return value[key]
59
+ }
60
+
61
+ const findProvider = (providers: unknown, providerID: string): unknown => {
62
+ if (Array.isArray(providers)) {
63
+ return providers.find((provider) => getStringProp(provider, "id") === providerID)
64
+ }
65
+
66
+ return getObjectProp(providers, providerID)
67
+ }
68
+
69
+ const findModel = (models: unknown, modelID: string): unknown => {
70
+ if (Array.isArray(models)) {
71
+ return models.find((model) => getStringProp(model, "id") === modelID)
72
+ }
73
+
74
+ return getObjectProp(models, modelID)
75
+ }
76
+
77
+ export const resolveModelLabel = (
78
+ modelID: string,
79
+ providerID: string | undefined,
80
+ providers: unknown,
81
+ ): string => {
82
+ if (!providerID) {
83
+ return modelID
84
+ }
85
+
86
+ const provider = findProvider(providers, providerID)
87
+ const providerName = getStringProp(provider, "name") ?? providerID
88
+ const model = findModel(getObjectProp(provider, "models"), modelID)
89
+ const modelName = getStringProp(model, "name") ?? modelID
90
+
91
+ return `${providerName}/${modelName}`
92
+ }
93
+
94
+ export const getTokenTotal = (tokens?: TokenUsageLike | null): number =>
95
+ toNumber(tokens?.input) +
96
+ toNumber(tokens?.output) +
97
+ toNumber(tokens?.reasoning) +
98
+ toNumber(tokens?.cache?.read) +
99
+ toNumber(tokens?.cache?.write)
100
+
101
+ export interface LiveTpsState {
102
+ modelID: string
103
+ providerID: string | undefined
104
+ tpsTokens: number
105
+ tpsDurationMs: number
106
+ }
107
+
108
+ export const getSessionUsage = (
109
+ messages: ReadonlyArray<SessionMessageLike>,
110
+ providers?: unknown,
111
+ liveTps?: LiveTpsState | null,
112
+ ): SessionUsage => {
113
+ const modelStats = new Map<string, {
114
+ label: string
115
+ totalTokens: number
116
+ cost: number
117
+ }>()
118
+ const modelsUsed: string[] = []
119
+
120
+ for (const message of messages) {
121
+ if (message.role !== "assistant") {
122
+ continue
123
+ }
124
+
125
+ const modelID = typeof message.modelID === "string" ? message.modelID : undefined
126
+ if (!modelID) {
127
+ continue
128
+ }
129
+
130
+ const providerID =
131
+ typeof message.providerID === "string" ? message.providerID : undefined
132
+ const modelKey = providerID ? `${providerID}:${modelID}` : modelID
133
+
134
+ if (!modelStats.has(modelKey)) {
135
+ modelStats.set(modelKey, {
136
+ label: resolveModelLabel(modelID, providerID, providers),
137
+ totalTokens: 0,
138
+ cost: 0,
139
+ })
140
+ modelsUsed.push(modelKey)
141
+ }
142
+
143
+ const stats = modelStats.get(modelKey)!
144
+ stats.totalTokens += getTokenTotal(message.tokens)
145
+ stats.cost += toNumber(message.cost)
146
+ }
147
+
148
+ // Inject live TPS
149
+ let liveTpsKey: string | undefined
150
+ if (liveTps && liveTps.modelID) {
151
+ const providerID = liveTps.providerID
152
+ const modelID = liveTps.modelID
153
+ liveTpsKey = providerID ? `${providerID}:${modelID}` : modelID
154
+ if (!modelStats.has(liveTpsKey)) {
155
+ modelStats.set(liveTpsKey, {
156
+ label: resolveModelLabel(modelID, providerID, providers),
157
+ totalTokens: 0,
158
+ cost: 0,
159
+ })
160
+ modelsUsed.push(liveTpsKey)
161
+ }
162
+ }
163
+
164
+ const models = modelsUsed.map((key) => {
165
+ const stats = modelStats.get(key)!
166
+ let isLiveTps = false
167
+ let tps = 0
168
+
169
+ if (key === liveTpsKey && liveTps) {
170
+ isLiveTps = true
171
+ const durationSeconds = liveTps.tpsDurationMs / 1000
172
+ tps = durationSeconds > 0 ? liveTps.tpsTokens / durationSeconds : 0
173
+ }
174
+
175
+ return {
176
+ label: stats.label,
177
+ totalTokens: stats.totalTokens,
178
+ cost: stats.cost,
179
+ tps,
180
+ isLiveTps,
181
+ }
182
+ })
183
+
184
+ return { models }
185
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,230 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import { For, createMemo, createSignal, onMount, onCleanup } from "solid-js"
5
+
6
+ import { getSessionUsage, type SessionMessageLike, type LiveTpsState } from "./session-usage"
7
+
8
+ const pluginID = "local:session-model-usage"
9
+
10
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === "object" && value !== null
12
+
13
+ const mapMessage = (message: unknown): SessionMessageLike => {
14
+ if (!isRecord(message)) {
15
+ return { role: "unknown" }
16
+ }
17
+
18
+ return {
19
+ id: typeof message.id === "string" ? message.id : undefined,
20
+ role: typeof message.role === "string" ? message.role : "unknown",
21
+ modelID: typeof message.modelID === "string" ? message.modelID : undefined,
22
+ providerID: typeof message.providerID === "string" ? message.providerID : undefined,
23
+ tokens: isRecord(message.tokens) ? message.tokens : undefined,
24
+ cost: typeof message.cost === "number" ? message.cost : undefined,
25
+ summary: message.summary,
26
+ time: isRecord(message.time)
27
+ ? {
28
+ created:
29
+ typeof message.time.created === "number" ? message.time.created : undefined,
30
+ completed:
31
+ typeof message.time.completed === "number" ? message.time.completed : undefined,
32
+ }
33
+ : undefined,
34
+ finish: typeof message.finish === "string" ? message.finish : undefined,
35
+ }
36
+ }
37
+
38
+ const mapMessages = (messages: readonly unknown[]): SessionMessageLike[] => {
39
+ return messages.map((message) => mapMessage(message))
40
+ }
41
+
42
+ const useSessionMessages = (api: TuiPluginApi, rootSessionID: string) => {
43
+ const [extraMessages, setExtraMessages] = createSignal<SessionMessageLike[]>([])
44
+ const [trackedSessionIDs, setTrackedSessionIDs] = createSignal<ReadonlySet<string>>(
45
+ new Set([rootSessionID]),
46
+ )
47
+
48
+ const [liveInfo, setLiveInfo] = createSignal<{
49
+ messageID: string;
50
+ modelID: string;
51
+ providerID?: string;
52
+ startTime: number;
53
+ chars: number;
54
+ } | null>(null)
55
+
56
+ const fetchChildrenMessages = async (
57
+ sessionID: string,
58
+ visited = new Set<string>(),
59
+ ): Promise<{ messages: SessionMessageLike[]; sessionIDs: Set<string> }> => {
60
+ if (visited.has(sessionID)) {
61
+ return { messages: [], sessionIDs: visited }
62
+ }
63
+
64
+ visited.add(sessionID)
65
+ const res = await api.client.session.children({ sessionID })
66
+ if (res.error || !res.data) {
67
+ return { messages: [], sessionIDs: visited }
68
+ }
69
+
70
+ let msgs: SessionMessageLike[] = []
71
+ for (const child of res.data) {
72
+ const childID = child.id
73
+ visited.add(childID)
74
+ const mRes = await api.client.session.messages({ sessionID: childID })
75
+ if (!mRes.error && mRes.data) {
76
+ msgs.push(...mapMessages(mRes.data.map((message) => message.info)))
77
+ }
78
+ const nested = await fetchChildrenMessages(childID, visited)
79
+ msgs.push(...nested.messages)
80
+ }
81
+ return { messages: msgs, sessionIDs: visited }
82
+ }
83
+
84
+ const reloadExtraMessages = async () => {
85
+ const result = await fetchChildrenMessages(rootSessionID)
86
+ setExtraMessages(result.messages)
87
+ setTrackedSessionIDs(new Set(result.sessionIDs))
88
+ }
89
+
90
+ onMount(() => {
91
+ reloadExtraMessages()
92
+
93
+ const unsubs = [
94
+ api.event.on("message.part.delta", (ev) => {
95
+ const { sessionID, messageID, field, delta } = ev.properties
96
+ if (!sessionID || !messageID || typeof delta !== "string") return
97
+ if (field !== "text" && field !== "reasoning") return
98
+
99
+ setLiveInfo(prev => {
100
+ if (prev?.messageID === messageID) {
101
+ return { ...prev, chars: prev.chars + delta.length }
102
+ }
103
+
104
+ // Try to find the message
105
+ const rootMsgs = mapMessages(api.state.session.messages(rootSessionID))
106
+ const extra = extraMessages()
107
+ let found = rootMsgs.find(m => m.id === messageID)
108
+ if (!found) {
109
+ found = extra.find(m => m.id === messageID)
110
+ }
111
+
112
+ if (!found || found.role !== "assistant" || !found.modelID) {
113
+ return prev
114
+ }
115
+
116
+ const modelID = found.modelID
117
+ if (typeof modelID !== "string") {
118
+ return prev
119
+ }
120
+
121
+ return {
122
+ messageID,
123
+ modelID,
124
+ providerID: typeof found.providerID === "string" ? found.providerID : undefined,
125
+ startTime: Date.now(),
126
+ chars: delta.length
127
+ }
128
+ })
129
+ }),
130
+ api.event.on("message.updated", () => reloadExtraMessages()),
131
+ api.event.on("session.status", () => reloadExtraMessages()),
132
+ api.event.on("session.idle", (ev) => {
133
+ if (!trackedSessionIDs().has(ev.properties.sessionID)) {
134
+ return
135
+ }
136
+
137
+ setLiveInfo(null)
138
+ })
139
+ ]
140
+
141
+ onCleanup(() => {
142
+ unsubs.forEach(u => u())
143
+ })
144
+ })
145
+
146
+ // Derive liveTps from liveInfo
147
+ const [now, setNow] = createSignal(Date.now())
148
+
149
+ onMount(() => {
150
+ const timer = setInterval(() => setNow(Date.now()), 500)
151
+ onCleanup(() => clearInterval(timer))
152
+ })
153
+
154
+ const liveTps = createMemo<LiveTpsState | null>(() => {
155
+ const info = liveInfo()
156
+ if (!info) return null
157
+
158
+ const durationMs = now() - info.startTime
159
+ // Estimate 1 token = 4 chars approx during streaming
160
+ const tpsTokens = Math.ceil(info.chars / 4)
161
+
162
+ return {
163
+ modelID: info.modelID,
164
+ providerID: info.providerID,
165
+ tpsTokens,
166
+ tpsDurationMs: Math.max(durationMs, 1)
167
+ }
168
+ })
169
+
170
+ return { extraMessages, liveTps }
171
+ }
172
+
173
+ const SessionUsagePanel = (props: { api: TuiPluginApi; sessionID: string }) => {
174
+ const { extraMessages, liveTps } = useSessionMessages(props.api, props.sessionID)
175
+
176
+ const usage = createMemo(() => {
177
+ const rootMsgs = mapMessages(props.api.state.session.messages(props.sessionID))
178
+ const allMsgs = [...rootMsgs, ...extraMessages()]
179
+ return getSessionUsage(allMsgs, props.api.state.provider, liveTps())
180
+ })
181
+
182
+ return (
183
+ <box flexDirection="column">
184
+ <text>📊 Models Usages</text>
185
+ {usage().models.length === 0 ? (
186
+ <text>None yet</text>
187
+ ) : (
188
+ <For each={usage().models}>
189
+ {(model) => (
190
+ <box flexDirection="column">
191
+ <text>● {model.label}</text>
192
+ <box flexDirection="column" paddingLeft={2}>
193
+ <box flexDirection="row">
194
+ <text>■ TPS </text>
195
+ <text fg="gray">{model.isLiveTps && model.tps > 0 ? model.tps.toFixed(1) : "—"}</text>
196
+ </box>
197
+ <box flexDirection="row">
198
+ <text>■ Total Tokens </text>
199
+ <text fg="gray">{model.totalTokens.toLocaleString("en-US")}</text>
200
+ </box>
201
+ <box flexDirection="row">
202
+ <text>■ spent </text>
203
+ <text fg="gray">${model.cost.toFixed(4)}</text>
204
+ </box>
205
+ </box>
206
+ </box>
207
+ )}
208
+ </For>
209
+ )}
210
+ </box>
211
+ )
212
+ }
213
+
214
+ const tui: TuiPlugin = async (api) => {
215
+ api.slots.register({
216
+ order: 150,
217
+ slots: {
218
+ sidebar_content(_ctx, props) {
219
+ return <SessionUsagePanel api={api} sessionID={props.session_id} />
220
+ },
221
+ },
222
+ })
223
+ }
224
+
225
+ const plugin: TuiPluginModule & { id: string } = {
226
+ id: pluginID,
227
+ tui,
228
+ }
229
+
230
+ export default plugin