opencode-usage-stats 0.3.0 → 0.3.1

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/dist/tui.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC5E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,MAAM,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC/D;AAmCD,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,aAAa,CAAC,OAAO,CAAC,GAC/B,mBAAmB,CA0CrB;AAwCD,QAAA,MAAM,MAAM,EAAE,eAAe,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAqD3C,CAAA;eAEc,MAAM"}
1
+ {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC5E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,MAAM,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC/D;AAmCD,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,aAAa,CAAC,OAAO,CAAC,GAC/B,mBAAmB,CA0CrB;AAgED,QAAA,MAAM,MAAM,EAAE,eAAe,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAmG3C,CAAA;eAEc,MAAM"}
package/dist/tui.js CHANGED
@@ -58,7 +58,7 @@ function aggregateCurrentSessionMessages(messages) {
58
58
  stats.models = Object.entries(modelMap).sort(([, a], [, b]) => b.messages - a.messages).map(([key, usage]) => ({ key, ...usage }));
59
59
  return stats;
60
60
  }
61
- function renderStatsPanel(stats, theme) {
61
+ function renderStatsPanel(stats, theme, error) {
62
62
  const totalTokens = stats.inputTokens + stats.outputTokens + stats.reasoningTokens + stats.cacheRead + stats.cacheWrite;
63
63
  const lines = [
64
64
  { label: "Messages", value: stats.messages.toLocaleString() },
@@ -66,7 +66,22 @@ function renderStatsPanel(stats, theme) {
66
66
  { label: "Tokens", value: formatNumber(totalTokens) }
67
67
  ];
68
68
  if (stats.models.length > 0) {
69
- lines.push({ label: "Model", value: stats.models[0].key.split("/").pop() ?? stats.models[0].key });
69
+ lines.push({
70
+ label: "Model",
71
+ value: stats.models[0].key.split("/").pop() ?? stats.models[0].key
72
+ });
73
+ }
74
+ const children = [text({ fg: theme.text, bold: true }, ["Usage"])];
75
+ for (const line of lines) {
76
+ children.push(
77
+ box({ width: "100%", flexDirection: "row" }, [
78
+ text({ fg: theme.textMuted }, [`${line.label}:`]),
79
+ text({ fg: line.accent ? theme.accent : theme.text }, [line.value])
80
+ ])
81
+ );
82
+ }
83
+ if (error) {
84
+ children.push(text({ fg: theme.accent, wrap: "truncate" }, [error]));
70
85
  }
71
86
  return box(
72
87
  {
@@ -77,20 +92,24 @@ function renderStatsPanel(stats, theme) {
77
92
  paddingLeft: 1,
78
93
  paddingRight: 1
79
94
  },
80
- [
81
- text({ fg: theme.text, bold: true }, ["Usage"]),
82
- ...lines.map(
83
- (line) => box({ width: "100%", flexDirection: "row", justifyContent: "space-between" }, [
84
- text({ fg: theme.textMuted }, [`${line.label}:`]),
85
- text({ fg: line.accent ? theme.accent : theme.text }, [line.value])
86
- ])
87
- )
88
- ]
95
+ children
89
96
  );
90
97
  }
98
+ function log(api, level, message, extra) {
99
+ try {
100
+ void api.client.app.log({
101
+ service: "opencode-usage-stats:tui",
102
+ level,
103
+ message,
104
+ extra
105
+ });
106
+ } catch {
107
+ }
108
+ }
91
109
  var plugin = {
92
110
  id: "opencode-usage-stats:tui",
93
111
  tui: async (api) => {
112
+ log(api, "info", "TUI plugin initializing");
94
113
  const [getStats, setStats] = createSignal({
95
114
  messages: 0,
96
115
  cost: 0,
@@ -101,37 +120,81 @@ var plugin = {
101
120
  cacheWrite: 0,
102
121
  models: []
103
122
  });
123
+ const [getError, setError] = createSignal(void 0);
104
124
  const [getSessionID, setSessionID] = createSignal("");
105
125
  const refresh = () => {
126
+ if (!api.state.ready) {
127
+ setError("state not ready");
128
+ return;
129
+ }
106
130
  const sessionID = getSessionID();
107
131
  if (!sessionID) return;
108
132
  try {
109
133
  const messages = api.state.session.messages(sessionID);
110
134
  const stats = aggregateCurrentSessionMessages(messages);
111
135
  setStats(stats);
136
+ setError(void 0);
137
+ log(api, "debug", "Refreshed usage stats", {
138
+ messages: stats.messages,
139
+ cost: stats.cost,
140
+ sessionID
141
+ });
112
142
  } catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ setError(message);
145
+ log(api, "error", "Failed to refresh usage stats", { error: message, sessionID });
113
146
  }
114
147
  };
115
- api.event.on("message.updated", refresh);
116
- api.event.on("message.part.updated", refresh);
117
- api.event.on("session.status", refresh);
118
- api.slots.register({
119
- order: 100,
120
- slots: {
121
- sidebar_content(_ctx, data) {
122
- if (data.session_id !== getSessionID()) {
123
- setSessionID(data.session_id);
124
- refresh();
148
+ const unsubscribers = [];
149
+ try {
150
+ unsubscribers.push(api.event.on("message.updated", refresh));
151
+ unsubscribers.push(api.event.on("message.part.updated", refresh));
152
+ unsubscribers.push(api.event.on("session.status", refresh));
153
+ log(api, "info", "Subscribed to usage events");
154
+ } catch (error) {
155
+ log(api, "error", "Failed to subscribe to events", {
156
+ error: error instanceof Error ? error.message : String(error)
157
+ });
158
+ }
159
+ try {
160
+ api.slots.register({
161
+ order: 100,
162
+ slots: {
163
+ sidebar_content(_ctx, data) {
164
+ try {
165
+ if (data.session_id !== getSessionID()) {
166
+ setSessionID(data.session_id);
167
+ log(api, "debug", "Session changed", { session_id: data.session_id });
168
+ refresh();
169
+ }
170
+ const theme = api.theme.current;
171
+ const stats = getStats();
172
+ const error = getError();
173
+ return renderStatsPanel(stats, {
174
+ text: theme.text,
175
+ textMuted: theme.textMuted,
176
+ accent: theme.accent
177
+ }, error);
178
+ } catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ log(api, "error", "Failed to render sidebar_content", { error: message });
181
+ return text({ fg: api.theme.current.error }, ["usage stats error"]);
182
+ }
125
183
  }
126
- const theme = api.theme.current;
127
- const stats = getStats();
128
- return renderStatsPanel(stats, {
129
- text: theme.text,
130
- textMuted: theme.textMuted,
131
- accent: theme.accent
132
- });
133
184
  }
185
+ });
186
+ log(api, "info", "Registered sidebar_content slot");
187
+ api.renderer.requestRender();
188
+ } catch (error) {
189
+ log(api, "error", "Failed to register sidebar_content slot", {
190
+ error: error instanceof Error ? error.message : String(error)
191
+ });
192
+ }
193
+ api.lifecycle.onDispose(() => {
194
+ for (const unsubscribe of unsubscribers) {
195
+ unsubscribe();
134
196
  }
197
+ log(api, "info", "TUI plugin disposed");
135
198
  });
136
199
  }
137
200
  };
package/dist/tui.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/tui.ts"],
4
- "sourcesContent": ["import type { TuiPluginApi, TuiPluginModule } from \"@opencode-ai/plugin/tui\"\nimport type { Message } from \"@opencode-ai/sdk/v2\"\nimport type { JSX } from \"@opentui/solid\"\nimport { createElement, insert, setProp } from \"@opentui/solid\"\nimport { createSignal } from \"solid-js\"\n\nexport interface CurrentSessionStats {\n messages: number\n cost: number\n inputTokens: number\n outputTokens: number\n reasoningTokens: number\n cacheRead: number\n cacheWrite: number\n models: Array<{ key: string; messages: number; cost: number }>\n}\n\ntype Child = JSX.Element | string | number | null | undefined | false\n\nfunction element(\n tag: string,\n props: Record<string, unknown>,\n children: Child[] = [],\n): JSX.Element {\n const node = createElement(tag)\n for (const [key, value] of Object.entries(props)) {\n if (value !== undefined) setProp(node, key, value)\n }\n for (const child of children) {\n if (child !== null && child !== undefined && child !== false) {\n insert(node, child)\n }\n }\n return node as JSX.Element\n}\n\nfunction box(props: Record<string, unknown>, children: Child[] = []): JSX.Element {\n return element(\"box\", props, children)\n}\n\nfunction text(props: Record<string, unknown>, children: Child[] = []): JSX.Element {\n return element(\"text\", props, children)\n}\n\nfunction formatNumber(num: number): string {\n if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`\n if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`\n return num.toLocaleString()\n}\n\nexport function aggregateCurrentSessionMessages(\n messages: ReadonlyArray<Message>,\n): CurrentSessionStats {\n const stats: CurrentSessionStats = {\n messages: 0,\n cost: 0,\n inputTokens: 0,\n outputTokens: 0,\n reasoningTokens: 0,\n cacheRead: 0,\n cacheWrite: 0,\n models: [],\n }\n\n const modelMap: Record<string, { messages: number; cost: number }> = {}\n\n for (const message of messages) {\n if (message.role !== \"assistant\") continue\n\n stats.messages += 1\n stats.cost += message.cost ?? 0\n\n const tokens = message.tokens\n if (tokens) {\n stats.inputTokens += tokens.input ?? 0\n stats.outputTokens += tokens.output ?? 0\n stats.reasoningTokens += tokens.reasoning ?? 0\n stats.cacheRead += tokens.cache?.read ?? 0\n stats.cacheWrite += tokens.cache?.write ?? 0\n }\n\n const key = `${message.providerID}/${message.modelID}`\n if (!modelMap[key]) {\n modelMap[key] = { messages: 0, cost: 0 }\n }\n modelMap[key].messages += 1\n modelMap[key].cost += message.cost ?? 0\n }\n\n stats.models = Object.entries(modelMap)\n .sort(([, a], [, b]) => b.messages - a.messages)\n .map(([key, usage]) => ({ key, ...usage }))\n\n return stats\n}\n\nfunction renderStatsPanel(\n stats: CurrentSessionStats,\n theme: { text: unknown; textMuted: unknown; accent: unknown },\n): JSX.Element {\n const totalTokens =\n stats.inputTokens + stats.outputTokens + stats.reasoningTokens + stats.cacheRead + stats.cacheWrite\n\n const lines: Array<{ label: string; value: string; accent?: boolean }> = [\n { label: \"Messages\", value: stats.messages.toLocaleString() },\n { label: \"Cost\", value: `$${stats.cost.toFixed(4)}` },\n { label: \"Tokens\", value: formatNumber(totalTokens) },\n ]\n\n if (stats.models.length > 0) {\n lines.push({ label: \"Model\", value: stats.models[0].key.split(\"/\").pop() ?? stats.models[0].key })\n }\n\n return box(\n {\n width: \"100%\",\n flexDirection: \"column\",\n paddingTop: 1,\n paddingBottom: 1,\n paddingLeft: 1,\n paddingRight: 1,\n },\n [\n text({ fg: theme.text, bold: true }, [\"Usage\"]),\n ...lines.map((line) =>\n box({ width: \"100%\", flexDirection: \"row\", justifyContent: \"space-between\" }, [\n text({ fg: theme.textMuted }, [`${line.label}:`]),\n text({ fg: line.accent ? theme.accent : theme.text }, [line.value]),\n ]),\n ),\n ],\n )\n}\n\nconst plugin: TuiPluginModule & { id: string } = {\n id: \"opencode-usage-stats:tui\",\n tui: async (api) => {\n const [getStats, setStats] = createSignal<CurrentSessionStats>({\n messages: 0,\n cost: 0,\n inputTokens: 0,\n outputTokens: 0,\n reasoningTokens: 0,\n cacheRead: 0,\n cacheWrite: 0,\n models: [],\n })\n\n const [getSessionID, setSessionID] = createSignal<string>(\"\")\n\n const refresh = (): void => {\n const sessionID = getSessionID()\n if (!sessionID) return\n try {\n const messages = api.state.session.messages(sessionID)\n const stats = aggregateCurrentSessionMessages(messages)\n setStats(stats)\n } catch (error) {\n // Ignore refresh errors; the session may not be ready yet.\n }\n }\n\n api.event.on(\"message.updated\", refresh)\n api.event.on(\"message.part.updated\", refresh)\n api.event.on(\"session.status\", refresh)\n\n api.slots.register({\n order: 100,\n slots: {\n sidebar_content(_ctx, data: { session_id: string }) {\n if (data.session_id !== getSessionID()) {\n setSessionID(data.session_id)\n refresh()\n }\n\n const theme = api.theme.current\n const stats = getStats()\n\n return renderStatsPanel(stats, {\n text: theme.text,\n textMuted: theme.textMuted,\n accent: theme.accent,\n })\n },\n },\n })\n },\n}\n\nexport default plugin\n"],
5
- "mappings": ";AAGA,SAAS,eAAe,QAAQ,eAAe;AAC/C,SAAS,oBAAoB;AAe7B,SAAS,QACP,KACA,OACA,WAAoB,CAAC,GACR;AACb,QAAM,OAAO,cAAc,GAAG;AAC9B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,OAAW,SAAQ,MAAM,KAAK,KAAK;AAAA,EACnD;AACA,aAAW,SAAS,UAAU;AAC5B,QAAI,UAAU,QAAQ,UAAU,UAAa,UAAU,OAAO;AAC5D,aAAO,MAAM,KAAK;AAAA,IACpB;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,IAAI,OAAgC,WAAoB,CAAC,GAAgB;AAChF,SAAO,QAAQ,OAAO,OAAO,QAAQ;AACvC;AAEA,SAAS,KAAK,OAAgC,WAAoB,CAAC,GAAgB;AACjF,SAAO,QAAQ,QAAQ,OAAO,QAAQ;AACxC;AAEA,SAAS,aAAa,KAAqB;AACzC,MAAI,OAAO,IAAW,QAAO,IAAI,MAAM,KAAW,QAAQ,CAAC,CAAC;AAC5D,MAAI,OAAO,IAAO,QAAO,IAAI,MAAM,KAAO,QAAQ,CAAC,CAAC;AACpD,SAAO,IAAI,eAAe;AAC5B;AAEO,SAAS,gCACd,UACqB;AACrB,QAAM,QAA6B;AAAA,IACjC,UAAU;AAAA,IACV,MAAM;AAAA,IACN,aAAa;AAAA,IACb,cAAc;AAAA,IACd,iBAAiB;AAAA,IACjB,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,QAAQ,CAAC;AAAA,EACX;AAEA,QAAM,WAA+D,CAAC;AAEtE,aAAW,WAAW,UAAU;AAC9B,QAAI,QAAQ,SAAS,YAAa;AAElC,UAAM,YAAY;AAClB,UAAM,QAAQ,QAAQ,QAAQ;AAE9B,UAAM,SAAS,QAAQ;AACvB,QAAI,QAAQ;AACV,YAAM,eAAe,OAAO,SAAS;AACrC,YAAM,gBAAgB,OAAO,UAAU;AACvC,YAAM,mBAAmB,OAAO,aAAa;AAC7C,YAAM,aAAa,OAAO,OAAO,QAAQ;AACzC,YAAM,cAAc,OAAO,OAAO,SAAS;AAAA,IAC7C;AAEA,UAAM,MAAM,GAAG,QAAQ,UAAU,IAAI,QAAQ,OAAO;AACpD,QAAI,CAAC,SAAS,GAAG,GAAG;AAClB,eAAS,GAAG,IAAI,EAAE,UAAU,GAAG,MAAM,EAAE;AAAA,IACzC;AACA,aAAS,GAAG,EAAE,YAAY;AAC1B,aAAS,GAAG,EAAE,QAAQ,QAAQ,QAAQ;AAAA,EACxC;AAEA,QAAM,SAAS,OAAO,QAAQ,QAAQ,EACnC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,EAC9C,IAAI,CAAC,CAAC,KAAK,KAAK,OAAO,EAAE,KAAK,GAAG,MAAM,EAAE;AAE5C,SAAO;AACT;AAEA,SAAS,iBACP,OACA,OACa;AACb,QAAM,cACJ,MAAM,cAAc,MAAM,eAAe,MAAM,kBAAkB,MAAM,YAAY,MAAM;AAE3F,QAAM,QAAmE;AAAA,IACvE,EAAE,OAAO,YAAY,OAAO,MAAM,SAAS,eAAe,EAAE;AAAA,IAC5D,EAAE,OAAO,QAAQ,OAAO,IAAI,MAAM,KAAK,QAAQ,CAAC,CAAC,GAAG;AAAA,IACpD,EAAE,OAAO,UAAU,OAAO,aAAa,WAAW,EAAE;AAAA,EACtD;AAEA,MAAI,MAAM,OAAO,SAAS,GAAG;AAC3B,UAAM,KAAK,EAAE,OAAO,SAAS,OAAO,MAAM,OAAO,CAAC,EAAE,IAAI,MAAM,GAAG,EAAE,IAAI,KAAK,MAAM,OAAO,CAAC,EAAE,IAAI,CAAC;AAAA,EACnG;AAEA,SAAO;AAAA,IACL;AAAA,MACE,OAAO;AAAA,MACP,eAAe;AAAA,MACf,YAAY;AAAA,MACZ,eAAe;AAAA,MACf,aAAa;AAAA,MACb,cAAc;AAAA,IAChB;AAAA,IACA;AAAA,MACE,KAAK,EAAE,IAAI,MAAM,MAAM,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC;AAAA,MAC9C,GAAG,MAAM;AAAA,QAAI,CAAC,SACZ,IAAI,EAAE,OAAO,QAAQ,eAAe,OAAO,gBAAgB,gBAAgB,GAAG;AAAA,UAC5E,KAAK,EAAE,IAAI,MAAM,UAAU,GAAG,CAAC,GAAG,KAAK,KAAK,GAAG,CAAC;AAAA,UAChD,KAAK,EAAE,IAAI,KAAK,SAAS,MAAM,SAAS,MAAM,KAAK,GAAG,CAAC,KAAK,KAAK,CAAC;AAAA,QACpE,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAM,SAA2C;AAAA,EAC/C,IAAI;AAAA,EACJ,KAAK,OAAO,QAAQ;AAClB,UAAM,CAAC,UAAU,QAAQ,IAAI,aAAkC;AAAA,MAC7D,UAAU;AAAA,MACV,MAAM;AAAA,MACN,aAAa;AAAA,MACb,cAAc;AAAA,MACd,iBAAiB;AAAA,MACjB,WAAW;AAAA,MACX,YAAY;AAAA,MACZ,QAAQ,CAAC;AAAA,IACX,CAAC;AAED,UAAM,CAAC,cAAc,YAAY,IAAI,aAAqB,EAAE;AAE5D,UAAM,UAAU,MAAY;AAC1B,YAAM,YAAY,aAAa;AAC/B,UAAI,CAAC,UAAW;AAChB,UAAI;AACF,cAAM,WAAW,IAAI,MAAM,QAAQ,SAAS,SAAS;AACrD,cAAM,QAAQ,gCAAgC,QAAQ;AACtD,iBAAS,KAAK;AAAA,MAChB,SAAS,OAAO;AAAA,MAEhB;AAAA,IACF;AAEA,QAAI,MAAM,GAAG,mBAAmB,OAAO;AACvC,QAAI,MAAM,GAAG,wBAAwB,OAAO;AAC5C,QAAI,MAAM,GAAG,kBAAkB,OAAO;AAEtC,QAAI,MAAM,SAAS;AAAA,MACjB,OAAO;AAAA,MACP,OAAO;AAAA,QACL,gBAAgB,MAAM,MAA8B;AAClD,cAAI,KAAK,eAAe,aAAa,GAAG;AACtC,yBAAa,KAAK,UAAU;AAC5B,oBAAQ;AAAA,UACV;AAEA,gBAAM,QAAQ,IAAI,MAAM;AACxB,gBAAM,QAAQ,SAAS;AAEvB,iBAAO,iBAAiB,OAAO;AAAA,YAC7B,MAAM,MAAM;AAAA,YACZ,WAAW,MAAM;AAAA,YACjB,QAAQ,MAAM;AAAA,UAChB,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAEA,IAAO,cAAQ;",
4
+ "sourcesContent": ["import type { TuiPluginApi, TuiPluginModule } from \"@opencode-ai/plugin/tui\"\nimport type { Message } from \"@opencode-ai/sdk/v2\"\nimport type { JSX } from \"@opentui/solid\"\nimport { createElement, insert, setProp } from \"@opentui/solid\"\nimport { createSignal } from \"solid-js\"\n\nexport interface CurrentSessionStats {\n messages: number\n cost: number\n inputTokens: number\n outputTokens: number\n reasoningTokens: number\n cacheRead: number\n cacheWrite: number\n models: Array<{ key: string; messages: number; cost: number }>\n}\n\ntype Child = JSX.Element | string | number | null | undefined | false\n\nfunction element(\n tag: string,\n props: Record<string, unknown>,\n children: Child[] = [],\n): JSX.Element {\n const node = createElement(tag)\n for (const [key, value] of Object.entries(props)) {\n if (value !== undefined) setProp(node, key, value)\n }\n for (const child of children) {\n if (child !== null && child !== undefined && child !== false) {\n insert(node, child)\n }\n }\n return node as JSX.Element\n}\n\nfunction box(props: Record<string, unknown>, children: Child[] = []): JSX.Element {\n return element(\"box\", props, children)\n}\n\nfunction text(props: Record<string, unknown>, children: Child[] = []): JSX.Element {\n return element(\"text\", props, children)\n}\n\nfunction formatNumber(num: number): string {\n if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`\n if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`\n return num.toLocaleString()\n}\n\nexport function aggregateCurrentSessionMessages(\n messages: ReadonlyArray<Message>,\n): CurrentSessionStats {\n const stats: CurrentSessionStats = {\n messages: 0,\n cost: 0,\n inputTokens: 0,\n outputTokens: 0,\n reasoningTokens: 0,\n cacheRead: 0,\n cacheWrite: 0,\n models: [],\n }\n\n const modelMap: Record<string, { messages: number; cost: number }> = {}\n\n for (const message of messages) {\n if (message.role !== \"assistant\") continue\n\n stats.messages += 1\n stats.cost += message.cost ?? 0\n\n const tokens = message.tokens\n if (tokens) {\n stats.inputTokens += tokens.input ?? 0\n stats.outputTokens += tokens.output ?? 0\n stats.reasoningTokens += tokens.reasoning ?? 0\n stats.cacheRead += tokens.cache?.read ?? 0\n stats.cacheWrite += tokens.cache?.write ?? 0\n }\n\n const key = `${message.providerID}/${message.modelID}`\n if (!modelMap[key]) {\n modelMap[key] = { messages: 0, cost: 0 }\n }\n modelMap[key].messages += 1\n modelMap[key].cost += message.cost ?? 0\n }\n\n stats.models = Object.entries(modelMap)\n .sort(([, a], [, b]) => b.messages - a.messages)\n .map(([key, usage]) => ({ key, ...usage }))\n\n return stats\n}\n\nfunction renderStatsPanel(\n stats: CurrentSessionStats,\n theme: { text: unknown; textMuted: unknown; accent: unknown },\n error?: string,\n): JSX.Element {\n const totalTokens =\n stats.inputTokens + stats.outputTokens + stats.reasoningTokens + stats.cacheRead + stats.cacheWrite\n\n const lines: Array<{ label: string; value: string; accent?: boolean }> = [\n { label: \"Messages\", value: stats.messages.toLocaleString() },\n { label: \"Cost\", value: `$${stats.cost.toFixed(4)}` },\n { label: \"Tokens\", value: formatNumber(totalTokens) },\n ]\n\n if (stats.models.length > 0) {\n lines.push({\n label: \"Model\",\n value: stats.models[0].key.split(\"/\").pop() ?? stats.models[0].key,\n })\n }\n\n const children: Child[] = [text({ fg: theme.text, bold: true }, [\"Usage\"])]\n\n for (const line of lines) {\n children.push(\n box({ width: \"100%\", flexDirection: \"row\" }, [\n text({ fg: theme.textMuted }, [`${line.label}:`]),\n text({ fg: line.accent ? theme.accent : theme.text }, [line.value]),\n ]),\n )\n }\n\n if (error) {\n children.push(text({ fg: theme.accent, wrap: \"truncate\" }, [error]))\n }\n\n return box(\n {\n width: \"100%\",\n flexDirection: \"column\",\n paddingTop: 1,\n paddingBottom: 1,\n paddingLeft: 1,\n paddingRight: 1,\n },\n children,\n )\n}\n\nfunction log(api: TuiPluginApi, level: \"debug\" | \"info\" | \"warn\" | \"error\", message: string, extra?: Record<string, unknown>) {\n try {\n void api.client.app.log({\n service: \"opencode-usage-stats:tui\",\n level,\n message,\n extra,\n })\n } catch {\n // Logging is best-effort; ignore failures.\n }\n}\n\nconst plugin: TuiPluginModule & { id: string } = {\n id: \"opencode-usage-stats:tui\",\n tui: async (api) => {\n log(api, \"info\", \"TUI plugin initializing\")\n\n const [getStats, setStats] = createSignal<CurrentSessionStats>({\n messages: 0,\n cost: 0,\n inputTokens: 0,\n outputTokens: 0,\n reasoningTokens: 0,\n cacheRead: 0,\n cacheWrite: 0,\n models: [],\n })\n\n const [getError, setError] = createSignal<string | undefined>(undefined)\n const [getSessionID, setSessionID] = createSignal<string>(\"\")\n\n const refresh = (): void => {\n if (!api.state.ready) {\n setError(\"state not ready\")\n return\n }\n const sessionID = getSessionID()\n if (!sessionID) return\n try {\n const messages = api.state.session.messages(sessionID)\n const stats = aggregateCurrentSessionMessages(messages)\n setStats(stats)\n setError(undefined)\n log(api, \"debug\", \"Refreshed usage stats\", {\n messages: stats.messages,\n cost: stats.cost,\n sessionID,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n setError(message)\n log(api, \"error\", \"Failed to refresh usage stats\", { error: message, sessionID })\n }\n }\n\n const unsubscribers: Array<() => void> = []\n try {\n unsubscribers.push(api.event.on(\"message.updated\", refresh))\n unsubscribers.push(api.event.on(\"message.part.updated\", refresh))\n unsubscribers.push(api.event.on(\"session.status\", refresh))\n log(api, \"info\", \"Subscribed to usage events\")\n } catch (error) {\n log(api, \"error\", \"Failed to subscribe to events\", {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n\n try {\n api.slots.register({\n order: 100,\n slots: {\n sidebar_content(_ctx, data: { session_id: string }) {\n try {\n if (data.session_id !== getSessionID()) {\n setSessionID(data.session_id)\n log(api, \"debug\", \"Session changed\", { session_id: data.session_id })\n refresh()\n }\n\n const theme = api.theme.current\n const stats = getStats()\n const error = getError()\n\n return renderStatsPanel(stats, {\n text: theme.text,\n textMuted: theme.textMuted,\n accent: theme.accent,\n }, error)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n log(api, \"error\", \"Failed to render sidebar_content\", { error: message })\n return text({ fg: api.theme.current.error }, [\"usage stats error\"])\n }\n },\n },\n })\n log(api, \"info\", \"Registered sidebar_content slot\")\n api.renderer.requestRender()\n } catch (error) {\n log(api, \"error\", \"Failed to register sidebar_content slot\", {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n\n api.lifecycle.onDispose(() => {\n for (const unsubscribe of unsubscribers) {\n unsubscribe()\n }\n log(api, \"info\", \"TUI plugin disposed\")\n })\n },\n}\n\nexport default plugin\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-stats",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "An OpenCode plugin that surfaces local usage statistics and reserves a cloud usage API integration.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",