@rejacky/opencode-insights 0.1.10 → 0.1.12

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.md CHANGED
@@ -92,11 +92,14 @@ The `uninstall` command removes plugin config entries and local Insights data; i
92
92
  ## What You Get
93
93
 
94
94
  - Configurable live metrics in the OpenCode session prompt zone.
95
+ - A collapsible session-wide `Token Usage` sidebar showing total tokens, response count, input/output/reasoning usage, cache read/write usage, and aggregate cache rate. It loads completed responses already present in the session and continues updating live.
95
96
  - Subagent status (running, done, failed, elapsed time, and token/context usage) in the sidebar.
96
97
  - Local capture of OpenCode hook/event data without redaction.
97
98
  - A local web viewer for reconstructed sessions, user turns, hidden request context, system/messages transforms, and assistant thinking/response sequences.
98
99
  - Native OpenCode footer components (project directory and version) remain visible — the plugin does not override `sidebar_footer` or `home_prompt_right` slots.
99
100
 
101
+ The right sidebar contains two independent plugin sections: `Token Usage` and `Subagents`. Click either section header to collapse or expand it. Token usage is aggregated across the full session; prompt-right `used` and `cache` values continue to represent the latest completed assistant response.
102
+
100
103
  ## TUI Metrics Configuration
101
104
 
102
105
  On TUI startup, Insights creates a configuration file beside its database:
@@ -24,6 +24,15 @@ type AssistantResponseUsage = {
24
24
  cacheWriteTokens?: number | undefined;
25
25
  finish?: string | undefined;
26
26
  };
27
+ type SessionTokenUsage = {
28
+ inputTokens: number;
29
+ outputTokens: number;
30
+ reasoningTokens: number;
31
+ cacheReadTokens: number;
32
+ cacheWriteTokens: number;
33
+ totalTokens: number;
34
+ responseCount: number;
35
+ };
27
36
  type PromptRightMetric = "tps" | "avg" | "ttft" | "used" | "cache" | "input" | "output" | "reasoning";
28
37
  declare const DEFAULT_PROMPT_RIGHT_METRICS: PromptRightMetric[];
29
38
  type MetricsState = {
@@ -31,6 +40,11 @@ type MetricsState = {
31
40
  messageTimingByID: Record<string, MessageTiming>;
32
41
  sessionAverageByID: Record<string, SessionAverage>;
33
42
  latestResponseUsageBySession: Record<string, AssistantResponseUsage>;
43
+ responseUsageByMessageID: Record<string, {
44
+ sessionID: string;
45
+ usage: AssistantResponseUsage;
46
+ }>;
47
+ sessionTokenUsageByID: Record<string, SessionTokenUsage>;
34
48
  };
35
49
  declare function createMetricsState(): MetricsState;
36
50
  declare function estimateStreamTokens(delta: string): number;
@@ -63,6 +77,8 @@ declare function renderPromptRightMetricsText(state: MetricsState, sessionID: st
63
77
  idle?: boolean;
64
78
  metrics?: PromptRightMetric[];
65
79
  }): string;
80
+ declare function getSessionTokenUsage(state: MetricsState, sessionID: string): SessionTokenUsage | undefined;
81
+ declare function renderSessionTokenUsage(state: MetricsState, sessionID: string): string;
66
82
 
67
83
  type CaptureKind = "chat.message" | "chat.params" | "chat.headers" | "experimental.chat.messages.transform" | "experimental.chat.system.transform" | "event" | "tool.execute.before" | "tool.execute.after";
68
84
  type CaptureRecord = {
@@ -129,4 +145,4 @@ declare class SqliteCaptureStore implements CaptureStore {
129
145
  declare function createCaptureStore(options?: InsightsOptions): CaptureStore;
130
146
  declare function resolveRetentionDays(value: unknown): number;
131
147
 
132
- export { type AssistantResponseUsage as A, renderResponseMetricsText as B, type CaptureRecord as C, DEFAULT_PROMPT_RIGHT_METRICS as D, resolveCapturePath as E, resolveInsightsConfigPath as F, resolveRetentionDays as G, type InsightsConfig as I, JsonlCaptureStore as J, type MessageTiming as M, type PromptRightMetric as P, type SessionAverage as S, type CaptureKind as a, type CaptureStore as b, type InsightsOptions as c, type MetricsState as d, SqliteCaptureStore as e, type SqliteDb as f, type StreamSample as g, createCaptureStore as h, createMetricsState as i, defaultDataDir as j, estimateStreamTokens as k, extractEventType as l, normalizeChatMessageCapture as m, normalizeChatHeadersCapture as n, normalizeChatParamsCapture as o, normalizeEventCapture as p, normalizeExperimentalChatMessagesTransformCapture as q, normalizeExperimentalChatSystemTransformCapture as r, normalizeToolCapture as s, openDatabase as t, readInsightsConfig as u, recordAssistantDelta as v, recordAssistantMessage as w, recordToolActivity as x, renderMetricsText as y, renderPromptRightMetricsText as z };
148
+ export { type AssistantResponseUsage as A, renderMetricsText as B, type CaptureRecord as C, DEFAULT_PROMPT_RIGHT_METRICS as D, renderPromptRightMetricsText as E, renderResponseMetricsText as F, renderSessionTokenUsage as G, resolveCapturePath as H, type InsightsConfig as I, JsonlCaptureStore as J, resolveInsightsConfigPath as K, resolveRetentionDays as L, type MessageTiming as M, type PromptRightMetric as P, type SessionAverage as S, type CaptureKind as a, type CaptureStore as b, type InsightsOptions as c, type MetricsState as d, type SessionTokenUsage as e, SqliteCaptureStore as f, type SqliteDb as g, type StreamSample as h, createCaptureStore as i, createMetricsState as j, defaultDataDir as k, estimateStreamTokens as l, extractEventType as m, getSessionTokenUsage as n, normalizeChatHeadersCapture as o, normalizeChatMessageCapture as p, normalizeChatParamsCapture as q, normalizeEventCapture as r, normalizeExperimentalChatMessagesTransformCapture as s, normalizeExperimentalChatSystemTransformCapture as t, normalizeToolCapture as u, openDatabase as v, readInsightsConfig as w, recordAssistantDelta as x, recordAssistantMessage as y, recordToolActivity as z };
@@ -8,7 +8,9 @@ function createMetricsState() {
8
8
  streamSamplesBySession: {},
9
9
  messageTimingByID: {},
10
10
  sessionAverageByID: {},
11
- latestResponseUsageBySession: {}
11
+ latestResponseUsageBySession: {},
12
+ responseUsageByMessageID: {},
13
+ sessionTokenUsageByID: {}
12
14
  };
13
15
  }
14
16
  function estimateStreamTokens(delta) {
@@ -35,7 +37,11 @@ function recordAssistantMessage(state, input) {
35
37
  cacheWriteTokens: input.cacheWriteTokens,
36
38
  finish: input.finish
37
39
  });
38
- if (hasTokenUsage(usage)) state.latestResponseUsageBySession[input.sessionID] = usage;
40
+ if (hasTokenUsage(usage)) {
41
+ state.latestResponseUsageBySession[input.sessionID] = usage;
42
+ state.responseUsageByMessageID[input.messageID] = { sessionID: input.sessionID, usage };
43
+ rebuildSessionTokenUsage(state, input.sessionID);
44
+ }
39
45
  const timing = state.messageTimingByID[input.messageID];
40
46
  if (timing?.sessionID === input.sessionID && typeof timing.firstResponseAt === "number") {
41
47
  const totalTokens = (input.outputTokens ?? 0) + (input.reasoningTokens ?? 0);
@@ -127,6 +133,25 @@ function renderPromptRightMetricsText(state, sessionID, options = {}) {
127
133
  };
128
134
  return (options.metrics?.length ? options.metrics : DEFAULT_PROMPT_RIGHT_METRICS).map((metric) => values[metric]).join(" | ");
129
135
  }
136
+ function getSessionTokenUsage(state, sessionID) {
137
+ return state.sessionTokenUsageByID[sessionID];
138
+ }
139
+ function renderSessionTokenUsage(state, sessionID) {
140
+ const usage = getSessionTokenUsage(state, sessionID);
141
+ if (!usage) return "";
142
+ const cachePromptTokens = usage.inputTokens + usage.cacheReadTokens;
143
+ const cacheRate = cachePromptTokens > 0 ? usage.cacheReadTokens / cachePromptTokens * 100 : void 0;
144
+ return [
145
+ "Token Usage",
146
+ `${formatTokenCount(usage.totalTokens)} total \xB7 ${usage.responseCount} responses`,
147
+ `${formatTokenCount(usage.inputTokens)} input`,
148
+ `${formatTokenCount(usage.outputTokens)} output`,
149
+ `${formatTokenCount(usage.reasoningTokens)} reasoning`,
150
+ `${formatTokenCount(usage.cacheReadTokens)} cache read`,
151
+ `${formatTokenCount(usage.cacheWriteTokens)} cache write`,
152
+ `${cacheRate === void 0 ? "-" : formatPercent(cacheRate)} cache rate`
153
+ ].join("\n");
154
+ }
130
155
  function pruneSamples(state, now = Date.now()) {
131
156
  for (const [sessionID, samples] of Object.entries(state.streamSamplesBySession)) {
132
157
  const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
@@ -134,6 +159,28 @@ function pruneSamples(state, now = Date.now()) {
134
159
  else delete state.streamSamplesBySession[sessionID];
135
160
  }
136
161
  }
162
+ function rebuildSessionTokenUsage(state, sessionID) {
163
+ const usage = {
164
+ inputTokens: 0,
165
+ outputTokens: 0,
166
+ reasoningTokens: 0,
167
+ cacheReadTokens: 0,
168
+ cacheWriteTokens: 0,
169
+ totalTokens: 0,
170
+ responseCount: 0
171
+ };
172
+ for (const response of Object.values(state.responseUsageByMessageID)) {
173
+ if (response.sessionID !== sessionID) continue;
174
+ usage.inputTokens += response.usage.inputTokens ?? 0;
175
+ usage.outputTokens += response.usage.outputTokens ?? 0;
176
+ usage.reasoningTokens += response.usage.reasoningTokens ?? 0;
177
+ usage.cacheReadTokens += response.usage.cacheReadTokens ?? 0;
178
+ usage.cacheWriteTokens += response.usage.cacheWriteTokens ?? 0;
179
+ usage.responseCount += 1;
180
+ }
181
+ usage.totalTokens = usage.inputTokens + usage.outputTokens + usage.reasoningTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
182
+ state.sessionTokenUsageByID[sessionID] = usage;
183
+ }
137
184
  function sessionAverage(state, sessionID) {
138
185
  const totals = state.sessionAverageByID[sessionID];
139
186
  if (!totals || totals.totalTokens <= 0 || totals.totalDurationMs <= 0) return void 0;
@@ -208,7 +255,7 @@ function formatTokenCount(value) {
208
255
  return String(Math.round(value));
209
256
  }
210
257
  function formatPercent(value) {
211
- return `${(Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/u, "")}%`;
258
+ return `${(Math.round(value * 100) / 100).toFixed(2)}%`;
212
259
  }
213
260
  function formatAbbreviatedTokenCount(value, divisor) {
214
261
  return (Math.round(value / divisor * 10) / 10).toFixed(1);
@@ -640,6 +687,8 @@ export {
640
687
  renderMetricsText,
641
688
  renderResponseMetricsText,
642
689
  renderPromptRightMetricsText,
690
+ getSessionTokenUsage,
691
+ renderSessionTokenUsage,
643
692
  defaultDataDir,
644
693
  resolveCapturePath,
645
694
  resolveInsightsConfigPath,
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as CaptureRecord } from './capture-D7z7hS72.js';
2
+ import { C as CaptureRecord } from './capture-BIiGg2nW.js';
3
3
 
4
4
  type HistoryMessage = {
5
5
  id: string;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  openDatabase,
4
4
  resolveCapturePath
5
- } from "./chunk-TGCIHODI.js";
5
+ } from "./chunk-3YLLHABZ.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { execFile as execFile2 } from "child_process";
@@ -12,6 +12,7 @@ import { dirname, join, resolve } from "path";
12
12
  import { homedir } from "os";
13
13
  import { pathToFileURL } from "url";
14
14
  import { promisify as promisify2 } from "util";
15
+ import { applyEdits, modify, parse } from "jsonc-parser";
15
16
 
16
17
  // src/inspect.ts
17
18
  import { readFile } from "fs/promises";
@@ -1727,8 +1728,10 @@ async function configureOpenCodeDebug(options) {
1727
1728
  if (!existsSync2(localServerEntry) || !existsSync2(localTuiEntry)) {
1728
1729
  throw new Error("Missing dist output. Run npm run build before opencode-insights debug.");
1729
1730
  }
1730
- const opencodeConfig = await readJsonConfig(opencodePath, { plugin: [] });
1731
- const tuiConfig = await readJsonConfig(tuiPath, { plugin: [] });
1731
+ const opencodeSource = await readJsonConfigSource(opencodePath);
1732
+ const tuiSource = await readJsonConfigSource(tuiPath);
1733
+ const opencodeConfig = await readJsonConfig(opencodePath, { plugin: [] }, opencodeSource);
1734
+ const tuiConfig = await readJsonConfig(tuiPath, { plugin: [] }, tuiSource);
1732
1735
  setSinglePluginSpec(opencodeConfig, SERVER_PLUGIN_SPEC, [localServerEntry, debugServerOptions(options)], localServerEntry);
1733
1736
  setSinglePluginSpec(tuiConfig, TUI_PLUGIN_SPEC, localTuiEntry);
1734
1737
  removePlugin(tuiConfig, SUBPATH_TUI_PLUGIN_SPEC);
@@ -1745,8 +1748,8 @@ async function configureOpenCodeDebug(options) {
1745
1748
  return lines.join("\n");
1746
1749
  }
1747
1750
  await mkdir(configDir, { recursive: true });
1748
- await writeJsonConfig(opencodePath, opencodeConfig);
1749
- await writeJsonConfig(tuiPath, tuiConfig);
1751
+ await writeJsonConfig(opencodePath, opencodeConfig, opencodeSource);
1752
+ await writeJsonConfig(tuiPath, tuiConfig, tuiSource);
1750
1753
  lines.push("Debug configuration written. Restart OpenCode to load the local build.");
1751
1754
  return lines.join("\n");
1752
1755
  }
@@ -1763,18 +1766,23 @@ function resolveOpenCodeConfigPath(configDir) {
1763
1766
  if (existsSync2(jsonPath)) return jsonPath;
1764
1767
  return jsonPath;
1765
1768
  }
1766
- async function readJsonConfig(path, fallback) {
1767
- if (!existsSync2(path)) return { ...fallback };
1768
- const content = await readFile2(path, "utf8");
1769
+ async function readJsonConfig(path, fallback, source) {
1770
+ const content = source ?? (existsSync2(path) ? await readFile2(path, "utf8") : void 0);
1771
+ if (content === void 0) return { ...fallback };
1769
1772
  const trimmed = content.trim();
1770
1773
  if (!trimmed) return { ...fallback };
1771
1774
  try {
1772
- const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(trimmed));
1775
+ const parseErrors = [];
1776
+ const parsed = parse(trimmed, parseErrors, { allowTrailingComma: true });
1777
+ if (parseErrors.length > 0) throw new Error("invalid JSONC syntax");
1773
1778
  return isJsonObject(parsed) ? parsed : { ...fallback };
1774
1779
  } catch (error) {
1775
1780
  throw new Error(`Could not parse ${path}: ${error instanceof Error ? error.message : String(error)}`);
1776
1781
  }
1777
1782
  }
1783
+ async function readJsonConfigSource(path) {
1784
+ return existsSync2(path) ? readFile2(path, "utf8") : void 0;
1785
+ }
1778
1786
  function stripJsonCommentsAndTrailingCommas(input) {
1779
1787
  let output = "";
1780
1788
  let inString = false;
@@ -1888,11 +1896,12 @@ async function uninstallOpenCode(options) {
1888
1896
  }
1889
1897
  async function removePluginFromConfig(path, plugin, options) {
1890
1898
  if (!existsSync2(path)) return "config not found";
1891
- const config = await readJsonConfig(path, { plugin: [] });
1899
+ const source = await readJsonConfigSource(path);
1900
+ const config = await readJsonConfig(path, { plugin: [] }, source);
1892
1901
  const changed = removePlugin(config, plugin);
1893
1902
  if (!changed) return `not present (${plugin})`;
1894
1903
  if (options.dryRun) return `would remove (${plugin})`;
1895
- await writeJsonConfig(path, config);
1904
+ await writeJsonConfig(path, config, source);
1896
1905
  return `removed (${plugin})`;
1897
1906
  }
1898
1907
  async function removeDataFiles(paths, options) {
@@ -1904,8 +1913,15 @@ async function removeDataFiles(paths, options) {
1904
1913
  }
1905
1914
  return existing;
1906
1915
  }
1907
- async function writeJsonConfig(path, config) {
1916
+ async function writeJsonConfig(path, config, source) {
1908
1917
  await mkdir(dirname(path), { recursive: true });
1918
+ if (source !== void 0) {
1919
+ const edits = modify(source, ["plugin"], config.plugin, {
1920
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }
1921
+ });
1922
+ await writeFile(path, applyEdits(source, edits), "utf8");
1923
+ return;
1924
+ }
1909
1925
  await writeFile(path, `${JSON.stringify(config, null, 2)}
1910
1926
  `, "utf8");
1911
1927
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
2
  import { TuiPlugin } from '@opencode-ai/plugin/tui';
3
- export { A as AssistantResponseUsage, a as CaptureKind, C as CaptureRecord, b as CaptureStore, D as DEFAULT_PROMPT_RIGHT_METRICS, I as InsightsConfig, c as InsightsOptions, J as JsonlCaptureStore, M as MessageTiming, d as MetricsState, P as PromptRightMetric, S as SessionAverage, e as SqliteCaptureStore, f as SqliteDb, g as StreamSample, h as createCaptureStore, i as createMetricsState, j as defaultDataDir, k as estimateStreamTokens, l as extractEventType, n as normalizeChatHeadersCapture, m as normalizeChatMessageCapture, o as normalizeChatParamsCapture, p as normalizeEventCapture, q as normalizeExperimentalChatMessagesTransformCapture, r as normalizeExperimentalChatSystemTransformCapture, s as normalizeToolCapture, t as openDatabase, u as readInsightsConfig, v as recordAssistantDelta, w as recordAssistantMessage, x as recordToolActivity, y as renderMetricsText, z as renderPromptRightMetricsText, B as renderResponseMetricsText, E as resolveCapturePath, F as resolveInsightsConfigPath, G as resolveRetentionDays } from './capture-D7z7hS72.js';
3
+ export { A as AssistantResponseUsage, a as CaptureKind, C as CaptureRecord, b as CaptureStore, D as DEFAULT_PROMPT_RIGHT_METRICS, I as InsightsConfig, c as InsightsOptions, J as JsonlCaptureStore, M as MessageTiming, d as MetricsState, P as PromptRightMetric, S as SessionAverage, e as SessionTokenUsage, f as SqliteCaptureStore, g as SqliteDb, h as StreamSample, i as createCaptureStore, j as createMetricsState, k as defaultDataDir, l as estimateStreamTokens, m as extractEventType, n as getSessionTokenUsage, o as normalizeChatHeadersCapture, p as normalizeChatMessageCapture, q as normalizeChatParamsCapture, r as normalizeEventCapture, s as normalizeExperimentalChatMessagesTransformCapture, t as normalizeExperimentalChatSystemTransformCapture, u as normalizeToolCapture, v as openDatabase, w as readInsightsConfig, x as recordAssistantDelta, y as recordAssistantMessage, z as recordToolActivity, B as renderMetricsText, E as renderPromptRightMetricsText, F as renderResponseMetricsText, G as renderSessionTokenUsage, H as resolveCapturePath, K as resolveInsightsConfigPath, L as resolveRetentionDays } from './capture-BIiGg2nW.js';
4
4
 
5
5
  type SubagentStatus = "running" | "done" | "error";
6
6
  type SubagentInfo = {
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  defaultDataDir,
19
19
  estimateStreamTokens,
20
20
  extractEventType,
21
+ getSessionTokenUsage,
21
22
  normalizeChatHeadersCapture,
22
23
  normalizeChatMessageCapture,
23
24
  normalizeChatParamsCapture,
@@ -33,10 +34,11 @@ import {
33
34
  renderMetricsText,
34
35
  renderPromptRightMetricsText,
35
36
  renderResponseMetricsText,
37
+ renderSessionTokenUsage,
36
38
  resolveCapturePath,
37
39
  resolveInsightsConfigPath,
38
40
  resolveRetentionDays
39
- } from "./chunk-TGCIHODI.js";
41
+ } from "./chunk-3YLLHABZ.js";
40
42
 
41
43
  // src/cli-shim.ts
42
44
  import { existsSync } from "fs";
@@ -150,6 +152,7 @@ export {
150
152
  defaultDataDir,
151
153
  estimateStreamTokens,
152
154
  extractEventType,
155
+ getSessionTokenUsage,
153
156
  getSubagentItems,
154
157
  getSubagentSidebarModel,
155
158
  getSubagentSidebarRowAtLine,
@@ -170,6 +173,7 @@ export {
170
173
  renderMetricsText,
171
174
  renderPromptRightMetricsText,
172
175
  renderResponseMetricsText,
176
+ renderSessionTokenUsage,
173
177
  renderSubagentFooter,
174
178
  renderSubagentSidebar,
175
179
  renderSubagentStatus,
package/dist/tui.js CHANGED
@@ -10,24 +10,50 @@ import {
10
10
  recordAssistantDelta,
11
11
  recordAssistantMessage,
12
12
  recordToolActivity,
13
- renderPromptRightMetricsText
14
- } from "./chunk-TGCIHODI.js";
13
+ renderPromptRightMetricsText,
14
+ renderSessionTokenUsage
15
+ } from "./chunk-3YLLHABZ.js";
15
16
 
16
17
  // src/tui.tsx
17
18
  import { createTextAttributes, StyledText } from "@opentui/core";
18
19
  import { createSignal, onCleanup } from "solid-js";
19
- import { jsx } from "@opentui/solid/jsx-runtime";
20
+
21
+ // src/listeners.ts
22
+ function createListenerRegistry() {
23
+ const listeners = /* @__PURE__ */ new Set();
24
+ return {
25
+ notify() {
26
+ for (const listener of listeners) listener();
27
+ },
28
+ subscribe(listener) {
29
+ listeners.add(listener);
30
+ return () => listeners.delete(listener);
31
+ }
32
+ };
33
+ }
34
+
35
+ // src/render-state.ts
36
+ function hasRenderStateChanged(previous, next) {
37
+ return !previous || previous.content !== next.content || previous.visible !== next.visible || previous.height !== next.height;
38
+ }
39
+
40
+ // src/tui.tsx
41
+ import { Fragment, jsx, jsxs } from "@opentui/solid/jsx-runtime";
20
42
  function isSessionID(value) {
21
43
  return typeof value === "string" && value.startsWith("ses");
22
44
  }
23
45
  function PromptRightMetrics(props) {
24
46
  let text;
47
+ let previous;
25
48
  const sync = () => {
26
49
  if (!text) return;
27
50
  const content = props.text();
28
- text.content = content;
29
- text.visible = content.length > 0;
30
- text.height = content.length > 0 ? 1 : 0;
51
+ const next = { content, visible: content.length > 0, height: content.length > 0 ? 1 : 0 };
52
+ if (!hasRenderStateChanged(previous, next)) return;
53
+ previous = next;
54
+ text.content = next.content;
55
+ text.visible = next.visible;
56
+ text.height = next.height;
31
57
  props.api.renderer.requestRender();
32
58
  };
33
59
  const unsubscribe = props.subscribe(sync);
@@ -52,15 +78,57 @@ function PromptRightMetrics(props) {
52
78
  }
53
79
  );
54
80
  }
81
+ function TokenUsageSidebar(props) {
82
+ let text;
83
+ let previous;
84
+ const [collapsed, setCollapsed] = createSignal(false);
85
+ const titleAttributes = createTextAttributes({ bold: true });
86
+ const toggleTokenUsage = (event) => {
87
+ if (!text || event.y !== text.y) return;
88
+ setCollapsed((prev) => !prev);
89
+ sync();
90
+ };
91
+ const sync = () => {
92
+ if (!text) return;
93
+ const content = renderSessionTokenUsage(props.state, props.sessionID);
94
+ const next = {
95
+ content: `${collapsed()}|${content}`,
96
+ visible: content.length > 0,
97
+ height: content.length > 0 ? "auto" : 0
98
+ };
99
+ if (!hasRenderStateChanged(previous, next)) return;
100
+ previous = next;
101
+ text.visible = next.visible;
102
+ text.height = next.height;
103
+ text.content = content ? renderTokenUsageSidebar(content, props.api, titleAttributes, collapsed()) : "";
104
+ props.api.renderer.requestRender();
105
+ };
106
+ const unsubscribe = props.subscribe(sync);
107
+ onCleanup(unsubscribe);
108
+ props.hydrate();
109
+ return /* @__PURE__ */ jsx(
110
+ "text",
111
+ {
112
+ ref: (ref) => {
113
+ text = ref;
114
+ sync();
115
+ },
116
+ onMouseDown: toggleTokenUsage,
117
+ fg: props.api.theme.current.textMuted,
118
+ children: ""
119
+ }
120
+ );
121
+ }
55
122
  function SubagentSidebar(props) {
56
123
  let text;
57
124
  const [collapsed, setCollapsed] = createSignal(false);
58
125
  const [hoveredRowID, setHoveredRowID] = createSignal();
59
126
  const titleAttributes = createTextAttributes({ bold: true });
127
+ let previous;
60
128
  const toggle = (event) => {
61
129
  if (!text || event.y !== text.y) return;
62
130
  setCollapsed((prev) => !prev);
63
- props.api.renderer.requestRender();
131
+ sync();
64
132
  };
65
133
  const openSubagent = (event) => {
66
134
  if (!text || collapsed()) return;
@@ -87,9 +155,17 @@ function SubagentSidebar(props) {
87
155
  const sync = () => {
88
156
  if (!text) return;
89
157
  const model = getSubagentSidebarModel(props.state, props.sessionID);
90
- text.visible = !!model;
91
- text.height = model ? "auto" : 0;
92
- text.content = model ? renderSubagentStyledSidebar(props.state, props.sessionID, props.api, titleAttributes, collapsed(), hoveredRowID()) : "";
158
+ const content = model ? renderSubagentStyledSidebar(model, props.api, titleAttributes, collapsed(), hoveredRowID()) : "";
159
+ const next = {
160
+ content: model ? contentSignature(model, collapsed(), hoveredRowID()) : "",
161
+ visible: !!model,
162
+ height: model ? "auto" : 0
163
+ };
164
+ if (!hasRenderStateChanged(previous, next)) return;
165
+ previous = next;
166
+ text.visible = next.visible;
167
+ text.height = next.height;
168
+ text.content = model ? content : "";
93
169
  props.api.renderer.requestRender();
94
170
  };
95
171
  const unsubscribe = props.subscribe(sync);
@@ -114,9 +190,7 @@ function SubagentSidebar(props) {
114
190
  }
115
191
  );
116
192
  }
117
- function renderSubagentStyledSidebar(state, sessionID, api, titleAttributes, collapsed, hoveredRowID) {
118
- const model = getSubagentSidebarModel(state, sessionID);
119
- if (!model) return "";
193
+ function renderSubagentStyledSidebar(model, api, titleAttributes, collapsed, hoveredRowID) {
120
194
  const indicator = collapsed ? "\u25B6 " : "\u25BC ";
121
195
  const chunks = [
122
196
  textChunk(`${indicator}${model.title}
@@ -137,6 +211,18 @@ function renderSubagentStyledSidebar(state, sessionID, api, titleAttributes, col
137
211
  }
138
212
  return new StyledText(chunks);
139
213
  }
214
+ function renderTokenUsageSidebar(content, api, titleAttributes, collapsed) {
215
+ const [title, ...details] = content.split("\n");
216
+ const visibleDetails = collapsed ? details.slice(0, 1) : details;
217
+ return new StyledText([
218
+ textChunk(`${collapsed ? "\u25B6" : "\u25BC"} ${title}
219
+ `, api.theme.current.text, titleAttributes),
220
+ textChunk(visibleDetails.join("\n"), api.theme.current.textMuted)
221
+ ]);
222
+ }
223
+ function contentSignature(model, collapsed, hoveredRowID) {
224
+ return JSON.stringify({ collapsed, hoveredRowID, title: model.title, summary: model.summary, rows: model.rows });
225
+ }
140
226
  function textChunk(text, fg, attributes, bg) {
141
227
  return {
142
228
  __isChunk: true,
@@ -150,13 +236,35 @@ var tui = async (api, options) => {
150
236
  const config = await readInsightsConfig(options ?? {});
151
237
  const metrics = createMetricsState();
152
238
  const subagents = createSubagentState();
153
- const listeners = /* @__PURE__ */ new Set();
154
- const bump = () => {
155
- for (const listener of listeners) listener();
156
- };
157
- const subscribe = (listener) => {
158
- listeners.add(listener);
159
- return () => listeners.delete(listener);
239
+ const metricListeners = createListenerRegistry();
240
+ const subagentListeners = createListenerRegistry();
241
+ const hydratedSessions = /* @__PURE__ */ new Set();
242
+ const hydrateSessionMetrics = async (sessionID) => {
243
+ if (!isSessionID(sessionID) || hydratedSessions.has(sessionID)) return;
244
+ hydratedSessions.add(sessionID);
245
+ try {
246
+ const response = await api.client.session.messages({ sessionID });
247
+ const messages = response.data ?? [];
248
+ for (const message of messages) {
249
+ const info = message.info;
250
+ if (info.role !== "assistant" || typeof info.time.completed !== "number") continue;
251
+ const input = {
252
+ sessionID: info.sessionID,
253
+ messageID: info.id,
254
+ createdAt: info.time.created,
255
+ completedAt: info.time.completed,
256
+ inputTokens: info.tokens.input,
257
+ outputTokens: info.tokens.output,
258
+ reasoningTokens: info.tokens.reasoning,
259
+ cacheReadTokens: info.tokens.cache.read,
260
+ cacheWriteTokens: info.tokens.cache.write
261
+ };
262
+ recordAssistantMessage(metrics, typeof info.finish === "string" ? { ...input, finish: info.finish } : input);
263
+ }
264
+ metricListeners.notify();
265
+ } catch {
266
+ hydratedSessions.delete(sessionID);
267
+ }
160
268
  };
161
269
  const offDelta = api.event.on("message.part.delta", (evt) => {
162
270
  if (evt.properties.field !== "text") return;
@@ -166,11 +274,11 @@ var tui = async (api, options) => {
166
274
  delta: evt.properties.delta,
167
275
  at: Date.now()
168
276
  });
169
- bump();
277
+ metricListeners.notify();
170
278
  });
171
279
  const offMessage = api.event.on("message.updated", (evt) => {
172
280
  const info = evt.properties.info;
173
- if (applySubagentEvent(subagents, evt)) bump();
281
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
174
282
  if (info.role !== "assistant") return;
175
283
  const messageInput = {
176
284
  sessionID: info.sessionID ?? evt.properties.sessionID,
@@ -185,30 +293,30 @@ var tui = async (api, options) => {
185
293
  if (typeof info.time.completed === "number") messageInput.completedAt = info.time.completed;
186
294
  if (typeof info.finish === "string") messageInput.finish = info.finish;
187
295
  recordAssistantMessage(metrics, messageInput);
188
- bump();
296
+ metricListeners.notify();
189
297
  });
190
298
  const offPart = api.event.on("message.part.updated", (evt) => {
191
299
  const part = evt.properties.part;
192
300
  if (part.type === "tool") {
193
301
  recordToolActivity(metrics, part.sessionID ?? evt.properties.sessionID, part.messageID, Date.now());
194
302
  }
195
- applySubagentEvent(subagents, evt);
196
- bump();
303
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
304
+ metricListeners.notify();
197
305
  });
198
306
  const offSessionCreated = api.event.on("session.created", (evt) => {
199
- if (applySubagentEvent(subagents, evt)) bump();
307
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
200
308
  });
201
309
  const offSessionUpdated = api.event.on("session.updated", (evt) => {
202
- if (applySubagentEvent(subagents, evt)) bump();
310
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
203
311
  });
204
312
  const offSessionStatus = api.event.on("session.status", (evt) => {
205
- if (applySubagentEvent(subagents, evt)) bump();
313
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
206
314
  });
207
315
  const offSessionIdle = api.event.on("session.idle", (evt) => {
208
- if (applySubagentEvent(subagents, evt)) bump();
316
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
209
317
  });
210
318
  const offSessionError = api.event.on("session.error", (evt) => {
211
- if (applySubagentEvent(subagents, evt)) bump();
319
+ if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
212
320
  });
213
321
  const offSlots = api.slots.register({
214
322
  slots: {
@@ -217,7 +325,7 @@ var tui = async (api, options) => {
217
325
  {
218
326
  api,
219
327
  sessionID: props.session_id,
220
- subscribe,
328
+ subscribe: metricListeners.subscribe,
221
329
  text: () => {
222
330
  if (!isSessionID(props.session_id)) return "";
223
331
  const status = api.state.session.status(props.session_id);
@@ -228,15 +336,27 @@ var tui = async (api, options) => {
228
336
  }
229
337
  }
230
338
  ),
231
- sidebar_content: (_ctx, props) => /* @__PURE__ */ jsx(
232
- SubagentSidebar,
233
- {
234
- api,
235
- sessionID: props.session_id,
236
- state: subagents,
237
- subscribe
238
- }
239
- )
339
+ sidebar_content: (_ctx, props) => /* @__PURE__ */ jsxs(Fragment, { children: [
340
+ /* @__PURE__ */ jsx(
341
+ TokenUsageSidebar,
342
+ {
343
+ api,
344
+ sessionID: props.session_id,
345
+ state: metrics,
346
+ subscribe: metricListeners.subscribe,
347
+ hydrate: () => void hydrateSessionMetrics(props.session_id)
348
+ }
349
+ ),
350
+ /* @__PURE__ */ jsx(
351
+ SubagentSidebar,
352
+ {
353
+ api,
354
+ sessionID: props.session_id,
355
+ state: subagents,
356
+ subscribe: subagentListeners.subscribe
357
+ }
358
+ )
359
+ ] })
240
360
  }
241
361
  });
242
362
  api.lifecycle.onDispose(() => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@rejacky/opencode-insights",
4
- "version": "0.1.10",
4
+ "version": "0.1.12",
5
5
  "description": "OpenCode plugin for local request capture, TPS metrics, and subagent status visibility.",
6
6
  "type": "module",
7
7
  "author": "opencode-insights contributors",
@@ -68,24 +68,25 @@
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@opencode-ai/plugin": ">=1.15.0 <2",
71
- "@opentui/core": ">=0.4.0 <0.5",
72
- "@opentui/solid": ">=0.4.0 <0.5",
73
- "solid-js": "1.9.12"
71
+ "@opentui/core": ">=0.4.0 <0.6",
72
+ "@opentui/solid": ">=0.4.0 <0.6",
73
+ "solid-js": "^1.9.12"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@opencode-ai/plugin": "^1.17.13",
77
- "@opentui/core": "^0.4.3",
78
- "@opentui/solid": "^0.4.3",
77
+ "@opentui/core": "^0.5.1",
78
+ "@opentui/solid": "^0.5.1",
79
79
  "@types/better-sqlite3": "^7.6.13",
80
80
  "@types/node": "^24.12.2",
81
81
  "@types/sql.js": "^1.4.11",
82
- "solid-js": "1.9.12",
82
+ "solid-js": "^1.9.12",
83
83
  "tsup": "^8.5.1",
84
84
  "typescript": "^6.0.3",
85
85
  "vitest": "^4.1.10"
86
86
  },
87
87
  "dependencies": {
88
88
  "better-sqlite3": "^12.11.1",
89
+ "jsonc-parser": "^3.3.1",
89
90
  "sql.js": "^1.14.1"
90
91
  }
91
92
  }