@rejacky/opencode-insights 0.1.10 → 0.1.11
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 +3 -0
- package/dist/{capture-D7z7hS72.d.ts → capture-BIiGg2nW.d.ts} +17 -1
- package/dist/{chunk-TGCIHODI.js → chunk-3YLLHABZ.js} +52 -3
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +28 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/tui.js +160 -40
- package/package.json +2 -1
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,
|
|
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))
|
|
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 *
|
|
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
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
openDatabase,
|
|
4
4
|
resolveCapturePath
|
|
5
|
-
} from "./chunk-
|
|
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
|
|
1731
|
-
const
|
|
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
|
-
|
|
1768
|
-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
307
|
+
if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
|
|
200
308
|
});
|
|
201
309
|
const offSessionUpdated = api.event.on("session.updated", (evt) => {
|
|
202
|
-
if (applySubagentEvent(subagents, evt))
|
|
310
|
+
if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
|
|
203
311
|
});
|
|
204
312
|
const offSessionStatus = api.event.on("session.status", (evt) => {
|
|
205
|
-
if (applySubagentEvent(subagents, evt))
|
|
313
|
+
if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
|
|
206
314
|
});
|
|
207
315
|
const offSessionIdle = api.event.on("session.idle", (evt) => {
|
|
208
|
-
if (applySubagentEvent(subagents, evt))
|
|
316
|
+
if (applySubagentEvent(subagents, evt)) subagentListeners.notify();
|
|
209
317
|
});
|
|
210
318
|
const offSessionError = api.event.on("session.error", (evt) => {
|
|
211
|
-
if (applySubagentEvent(subagents, evt))
|
|
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__ */
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
4
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -86,6 +86,7 @@
|
|
|
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
|
}
|