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.
@@ -1,14 +1,12 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
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
+ };
@@ -0,0 +1,6 @@
1
+ import {
2
+ UsageTotalPlugin
3
+ } from "./chunk-XNCJ32GU.js";
4
+ export {
5
+ UsageTotalPlugin
6
+ };
package/package.json CHANGED
@@ -1,17 +1,15 @@
1
1
  {
2
2
  "name": "opencode-usage-total",
3
- "version": "0.1.0",
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.ts",
6
+ "main": "dist/index.js",
7
7
  "exports": {
8
- ".": "./index.ts",
9
- "./runtime": "./usage-total.ts"
8
+ ".": "./dist/index.js",
9
+ "./runtime": "./dist/usage-total.js"
10
10
  },
11
11
  "files": [
12
- "index.ts",
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
@@ -1,2 +0,0 @@
1
- export { default } from "./usage-total-tui"
2
- export { UsageTotalPlugin } from "./usage-total"
@@ -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 }