@mholtzscher/opencode-cache-metrics 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [1.1.0](https://github.com/mholtzscher/opencode-plugins/compare/cache-metrics-v1.0.0...cache-metrics-v1.1.0) (2026-10-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cache-metrics:** enable npm publishing ([#37](https://github.com/mholtzscher/opencode-plugins/issues/37)) ([3fcaa68](https://github.com/mholtzscher/opencode-plugins/commit/3fcaa68c19da7db3f8148c5801b21b560abad325))
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Holtzscher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # Cache metrics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shows input cache-hit rate and token totals in the OpenCode V2 terminal UI. Open cache history to inspect individual responses or export their metrics.
4
+
5
+ ## Quick start
6
+
7
+ Requires [OpenCode V2](https://opencode.ai/v2/docs/) and saved model responses with token usage. Export also requires a terminal that supports OSC52 clipboard writes. No additional credentials, skills, or plugin options are required.
8
+
9
+ Add this entry to the `plugins` array in your project or global `opencode.jsonc`, preserving existing settings:
10
+
11
+ ```jsonc
12
+ {
13
+ "$schema": "https://opencode.ai/config.json",
14
+ "plugins": ["@mholtzscher/opencode-cache-metrics"],
15
+ }
16
+ ```
17
+
18
+ OpenCode installs the plugin with its prebuilt TUI bundle. See [plugin configuration](https://opencode.ai/v2/docs/plugins) for updates and reload behavior.
19
+
20
+ For a Git install, use `"github:mholtzscher/opencode-plugins#main::path:cache-metrics"` instead. Load only one source of this plugin. See [releasing](../docs/RELEASING.md#first-cache-metrics-publish) for publication setup.
21
+
22
+ Open a session in the terminal UI. The **Cache** sidebar card shows its input cache-hit rate. Expand **Show additional metrics** for token totals. Click the rate or choose **Open cache history** from the command palette to inspect responses.
23
+
24
+ ## Reading the metrics
25
+
26
+ Cache hit rate is `cache.read / (input + cache.read)`. Output tokens and cache writes are excluded. For example, 900 cached input tokens and 100 new input tokens give a 90% hit rate. Totals use token counts, not an average of response percentages.
27
+
28
+ **In** means cached input, **New** means uncached input, and **Out** means generated output. The sidebar covers only the selected session. It shows **No token usage yet** when saved messages have no measured input tokens.
29
+
30
+ ![Expanded Cache sidebar showing the input cache-hit rate and token totals](./docs/images/cache-sidebar.png)
31
+
32
+ ## History and export
33
+
34
+ History includes the root session and its subagents by default, even when opened from a subagent. Responses are grouped under their preceding user request, with separate totals for each turn. Parent and subagent turns stay separate. The trend shows recent responses, oldest first. `·` means no measured input.
35
+
36
+ ![Cache history in fullscreen, showing a subagent turn, per-response cache rates, tool activity, and keyboard controls](./docs/images/cache-history.png)
37
+
38
+ These keys work while the panel has focus:
39
+
40
+ | Key | Action |
41
+ | ------ | -------------------------------------------------------------- |
42
+ | `s` | Toggle root session and subagents versus selected session only |
43
+ | `t` | Toggle follow mode. Disable it to scroll older entries. |
44
+ | `r` | Refresh |
45
+ | `e` | Copy JSON through the terminal's OSC52 clipboard support |
46
+ | `f` | Toggle fullscreen |
47
+ | Escape | Close |
48
+
49
+ To compare history with the sidebar, press `s` to select **This session**. Press `t` to turn follow mode off before scrolling through older turns. Follow mode starts on and keeps new responses in view.
50
+
51
+ The panel refreshes after model steps and execution ends. It only includes completed assistant responses with saved token usage. The sidebar refreshes when you select a session and after successful execution, so it can lag behind history during a turn.
52
+
53
+ Press `e` to copy the entire loaded timeline for the selected scope, including entries outside the visible area. Export writes JSON to your clipboard without a separate approval prompt. Paste it into a file to save it. If copying fails, enable OSC52 clipboard support in your terminal or multiplexer and retry.
54
+
55
+ History and exports include session and model identifiers, token totals, request excerpts, tool names, compaction markers, and finish or retry status. An excerpt can contain the whole request if it is short. Tool arguments and output are excluded, but excerpts are not redacted. Review the JSON before sharing it. See [`exportCacheHistory`](./cache-history.ts) for the export format.
56
+
57
+ The plugin reads saved OpenCode messages without changing them or keeping separate history storage. It does not make model calls or change provider caching. Its controls run directly in the terminal UI, without an agent.
58
+
59
+ ### Possible cache loss
60
+
61
+ **Possible cache loss** marks a sharp drop in cached reads without a comparable drop in input size, for the same session, provider, and model. It skips the response immediately after compaction. Compare surrounding requests and tool activity to investigate; the marker does not prove provider cache invalidation. The detection rules live in [`buildCacheHistory`](./cache-history.ts).
62
+
63
+ ## Troubleshooting
64
+
65
+ - If history will not open, open a session first.
66
+ - If history is empty, wait for a response to finish. Responses without saved token usage do not appear; the plugin cannot reconstruct missing usage.
67
+ - If history reports a refresh error, press `r` to retry. The displayed data may be incomplete until refresh succeeds.
68
+ - If `f` does not leave fullscreen, widen the terminal. OpenCode keeps panels fullscreen in narrow terminals.
69
+ - If loading reports a duplicate plugin ID, follow [local plugins versus global installs](../README.md#local-plugins-versus-global-installs).
70
+
71
+ See [development](./docs/DEVELOPMENT.md) for local setup, implementation references, and bundle checks.
@@ -0,0 +1,225 @@
1
+ import type { SessionMessageInfo } from "@opencode/client";
2
+
3
+ /** One recorded assistant response in the cache history timeline. */
4
+ export interface CacheHistoryPoint {
5
+ afterCompaction?: string;
6
+ /** Tool calls in the preceding assistant response. */
7
+ afterTools: string[];
8
+ agent: string;
9
+ cumulativeRate?: number;
10
+ finish?: string;
11
+ id: string;
12
+ input: number;
13
+ modelID: string;
14
+ output: number;
15
+ /** Cached reads on the preceding comparable response, when a likely loss is detected. */
16
+ previousRead?: number;
17
+ /** Short, single-line user request preceding this response; never tool input or output. */
18
+ prompt?: string;
19
+ providerID: string;
20
+ rate?: number;
21
+ read: number;
22
+ retryAttempt?: number;
23
+ sessionID: string;
24
+ time: number;
25
+ /** Tool calls in this response, useful for identifying the following step. */
26
+ tools: string[];
27
+ /** User-message boundary in this session; responses before any user message share a fallback group. */
28
+ turnID: string;
29
+ write: number;
30
+ }
31
+
32
+ export interface CacheHistoryTurn {
33
+ id: string;
34
+ input: number;
35
+ output: number;
36
+ points: CacheHistoryPoint[];
37
+ prompt?: string;
38
+ rate?: number;
39
+ read: number;
40
+ sessionID: string;
41
+ time: number;
42
+ }
43
+
44
+ /** Build a chronological cache history from saved messages, deduplicated by session and message ID. */
45
+ export const buildCacheHistory = (
46
+ sessions: ReadonlyMap<string, readonly SessionMessageInfo[]>
47
+ ): CacheHistoryPoint[] => {
48
+ const points = new Map<string, CacheHistoryPoint>();
49
+ for (const [sessionID, messages] of sessions) {
50
+ let prompt: string | undefined;
51
+ let turnID = `${sessionID}:before-user`;
52
+ let userIndex = 0;
53
+ let afterTools: string[] = [];
54
+ let afterCompaction: string | undefined;
55
+ for (const message of messages) {
56
+ if (message.type === "user") {
57
+ userIndex += 1;
58
+ turnID = `${sessionID}:user:${message.id ?? userIndex}`;
59
+ prompt =
60
+ message.text.replaceAll(/\s+/gu, " ").trim().slice(0, 90) ||
61
+ undefined;
62
+ afterTools = [];
63
+ continue;
64
+ }
65
+ if (message.type === "compaction" && message.status === "completed") {
66
+ afterCompaction = message.reason;
67
+ continue;
68
+ }
69
+ if (
70
+ message.type !== "assistant" ||
71
+ !message.tokens ||
72
+ !message.time.completed
73
+ ) {
74
+ continue;
75
+ }
76
+ const { input, output, cache } = message.tokens;
77
+ const tools = message.content
78
+ .filter((part) => part.type === "tool")
79
+ .map((part) => part.name);
80
+ points.set(`${sessionID}:${message.id}`, {
81
+ afterCompaction,
82
+ afterTools,
83
+ agent: message.agent,
84
+ finish: message.finish,
85
+ id: message.id,
86
+ input,
87
+ modelID: message.model.id,
88
+ output,
89
+ prompt,
90
+ providerID: message.model.providerID,
91
+ rate:
92
+ input + cache.read > 0
93
+ ? cache.read / (input + cache.read)
94
+ : undefined,
95
+ read: cache.read,
96
+ retryAttempt: message.retry?.attempt,
97
+ sessionID,
98
+ time: message.time.completed,
99
+ tools,
100
+ turnID,
101
+ write: cache.write,
102
+ });
103
+ afterTools = tools;
104
+ afterCompaction = undefined;
105
+ }
106
+ }
107
+ const sorted = [...points.values()].toSorted(
108
+ (a, b) =>
109
+ a.time - b.time ||
110
+ a.sessionID.localeCompare(b.sessionID) ||
111
+ a.id.localeCompare(b.id)
112
+ );
113
+ let input = 0;
114
+ let read = 0;
115
+ const previous = new Map<string, CacheHistoryPoint>();
116
+ return sorted.map((point) => {
117
+ const key = `${point.sessionID}:${point.providerID}:${point.modelID}`;
118
+ const baseline = previous.get(key);
119
+ const previousRead =
120
+ baseline !== undefined &&
121
+ !point.afterCompaction &&
122
+ baseline.read >= 1000 &&
123
+ point.read <= baseline.read * 0.3 &&
124
+ point.input + point.read >= (baseline.input + baseline.read) * 0.8
125
+ ? baseline.read
126
+ : undefined;
127
+ // Always compare to the latest completed response, even if it has no usage.
128
+ previous.set(key, point);
129
+ input += point.input;
130
+ read += point.read;
131
+ return {
132
+ ...point,
133
+ cumulativeRate: input + read > 0 ? read / (input + read) : undefined,
134
+ previousRead,
135
+ };
136
+ });
137
+ };
138
+
139
+ /** Group measured assistant responses by the user message that preceded them in their own session. */
140
+ export const groupCacheHistoryTurns = (
141
+ points: readonly CacheHistoryPoint[]
142
+ ): CacheHistoryTurn[] => {
143
+ const turns = new Map<string, CacheHistoryTurn>();
144
+ for (const point of points) {
145
+ let turn = turns.get(point.turnID);
146
+ if (!turn) {
147
+ turn = {
148
+ id: point.turnID,
149
+ input: 0,
150
+ output: 0,
151
+ points: [],
152
+ prompt: point.prompt,
153
+ read: 0,
154
+ sessionID: point.sessionID,
155
+ time: point.time,
156
+ };
157
+ turns.set(point.turnID, turn);
158
+ }
159
+ turn.points.push(point);
160
+ turn.time = Math.min(turn.time, point.time);
161
+ turn.input += point.input;
162
+ turn.read += point.read;
163
+ turn.output += point.output;
164
+ }
165
+ return [...turns.values()]
166
+ .map((turn) => ({
167
+ ...turn,
168
+ rate:
169
+ turn.input + turn.read > 0
170
+ ? turn.read / (turn.input + turn.read)
171
+ : undefined,
172
+ }))
173
+ .toSorted((a, b) => a.time - b.time || a.id.localeCompare(b.id));
174
+ };
175
+
176
+ /** Compact chronological cache-hit trend for up to the latest 40 responses; dots have no measured input. */
177
+ export const formatCacheHistoryTrend = (
178
+ points: readonly CacheHistoryPoint[]
179
+ ): string => {
180
+ const bars = "▁▂▃▄▅▆▇█";
181
+ return points
182
+ .slice(-40)
183
+ .map((point) =>
184
+ point.rate === undefined
185
+ ? "·"
186
+ : bars[Math.min(bars.length - 1, Math.floor(point.rate * bars.length))]
187
+ )
188
+ .join("");
189
+ };
190
+
191
+ /** Serialize the currently displayed scope and its per-response token timeline. */
192
+ export const exportCacheHistory = (
193
+ sessionID: string,
194
+ scope: "session" | "family",
195
+ points: readonly CacheHistoryPoint[]
196
+ ): string => {
197
+ const totals = { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 };
198
+ for (const point of points) {
199
+ totals.input += point.input;
200
+ totals.output += point.output;
201
+ totals.cacheRead += point.read;
202
+ totals.cacheWrite += point.write;
203
+ }
204
+ return JSON.stringify(
205
+ {
206
+ exportedAt: new Date().toISOString(),
207
+ responseCount: points.length,
208
+ scope,
209
+ sessionID,
210
+ timeline: points.map((point) => ({
211
+ ...point,
212
+ time: new Date(point.time).toISOString(),
213
+ })),
214
+ totals: {
215
+ ...totals,
216
+ cacheHitRate:
217
+ totals.input + totals.cacheRead > 0
218
+ ? totals.cacheRead / (totals.input + totals.cacheRead)
219
+ : null,
220
+ },
221
+ },
222
+ null,
223
+ 2
224
+ );
225
+ };
package/cache-rate.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { SessionMessageInfo } from "@opencode/client";
2
+
3
+ /** Cache read rate across completed assistant messages in one session; input excludes cached reads. */
4
+ export const calculateSessionCacheRate = (
5
+ messages: readonly SessionMessageInfo[]
6
+ ) => {
7
+ let input = 0;
8
+ let read = 0;
9
+ let write = 0;
10
+ let output = 0;
11
+ let calls = 0;
12
+
13
+ for (const message of messages) {
14
+ if (message.type !== "assistant" || !message.tokens) {
15
+ continue;
16
+ }
17
+ input += message.tokens.input;
18
+ read += message.tokens.cache.read;
19
+ write += message.tokens.cache.write;
20
+ output += message.tokens.output;
21
+ calls += 1;
22
+ }
23
+
24
+ return {
25
+ calls,
26
+ input,
27
+ output,
28
+ rate: input + read > 0 ? read / (input + read) : undefined,
29
+ read,
30
+ write,
31
+ };
32
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,857 @@
1
+ // @bun
2
+ // tui.tsx
3
+ import { memo as _$memo2 } from "@opentui/solid";
4
+ import { createComponent as _$createComponent2 } from "@opentui/solid";
5
+ import { effect as _$effect2 } from "@opentui/solid";
6
+ import { createTextNode as _$createTextNode2 } from "@opentui/solid";
7
+ import { insertNode as _$insertNode2 } from "@opentui/solid";
8
+ import { insert as _$insert2 } from "@opentui/solid";
9
+ import { setProp as _$setProp2 } from "@opentui/solid";
10
+ import { createElement as _$createElement2 } from "@opentui/solid";
11
+ import { Plugin } from "@opencode/plugin/tui";
12
+ import { createEffect as createEffect2, createMemo as createMemo2, createSignal as createSignal2, on as on2, onCleanup as onCleanup2, Show as Show2 } from "solid-js";
13
+
14
+ // cache-history-panel.tsx
15
+ import { use as _$use } from "@opentui/solid";
16
+ import { createComponent as _$createComponent } from "@opentui/solid";
17
+ import { effect as _$effect } from "@opentui/solid";
18
+ import { insert as _$insert } from "@opentui/solid";
19
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
20
+ import { insertNode as _$insertNode } from "@opentui/solid";
21
+ import { memo as _$memo } from "@opentui/solid";
22
+ import { setProp as _$setProp } from "@opentui/solid";
23
+ import { createElement as _$createElement } from "@opentui/solid";
24
+ import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js";
25
+
26
+ // cache-history.ts
27
+ var buildCacheHistory = (sessions) => {
28
+ const points = new Map;
29
+ for (const [sessionID, messages] of sessions) {
30
+ let prompt;
31
+ let turnID = `${sessionID}:before-user`;
32
+ let userIndex = 0;
33
+ let afterTools = [];
34
+ let afterCompaction;
35
+ for (const message of messages) {
36
+ if (message.type === "user") {
37
+ userIndex += 1;
38
+ turnID = `${sessionID}:user:${message.id ?? userIndex}`;
39
+ prompt = message.text.replaceAll(/\s+/gu, " ").trim().slice(0, 90) || undefined;
40
+ afterTools = [];
41
+ continue;
42
+ }
43
+ if (message.type === "compaction" && message.status === "completed") {
44
+ afterCompaction = message.reason;
45
+ continue;
46
+ }
47
+ if (message.type !== "assistant" || !message.tokens || !message.time.completed) {
48
+ continue;
49
+ }
50
+ const { input, output, cache } = message.tokens;
51
+ const tools = message.content.filter((part) => part.type === "tool").map((part) => part.name);
52
+ points.set(`${sessionID}:${message.id}`, {
53
+ afterCompaction,
54
+ afterTools,
55
+ agent: message.agent,
56
+ finish: message.finish,
57
+ id: message.id,
58
+ input,
59
+ modelID: message.model.id,
60
+ output,
61
+ prompt,
62
+ providerID: message.model.providerID,
63
+ rate: input + cache.read > 0 ? cache.read / (input + cache.read) : undefined,
64
+ read: cache.read,
65
+ retryAttempt: message.retry?.attempt,
66
+ sessionID,
67
+ time: message.time.completed,
68
+ tools,
69
+ turnID,
70
+ write: cache.write
71
+ });
72
+ afterTools = tools;
73
+ afterCompaction = undefined;
74
+ }
75
+ }
76
+ const sorted = [...points.values()].toSorted((a, b) => a.time - b.time || a.sessionID.localeCompare(b.sessionID) || a.id.localeCompare(b.id));
77
+ let input = 0;
78
+ let read = 0;
79
+ const previous = new Map;
80
+ return sorted.map((point) => {
81
+ const key = `${point.sessionID}:${point.providerID}:${point.modelID}`;
82
+ const baseline = previous.get(key);
83
+ const previousRead = baseline !== undefined && !point.afterCompaction && baseline.read >= 1000 && point.read <= baseline.read * 0.3 && point.input + point.read >= (baseline.input + baseline.read) * 0.8 ? baseline.read : undefined;
84
+ previous.set(key, point);
85
+ input += point.input;
86
+ read += point.read;
87
+ return {
88
+ ...point,
89
+ cumulativeRate: input + read > 0 ? read / (input + read) : undefined,
90
+ previousRead
91
+ };
92
+ });
93
+ };
94
+ var groupCacheHistoryTurns = (points) => {
95
+ const turns = new Map;
96
+ for (const point of points) {
97
+ let turn = turns.get(point.turnID);
98
+ if (!turn) {
99
+ turn = {
100
+ id: point.turnID,
101
+ input: 0,
102
+ output: 0,
103
+ points: [],
104
+ prompt: point.prompt,
105
+ read: 0,
106
+ sessionID: point.sessionID,
107
+ time: point.time
108
+ };
109
+ turns.set(point.turnID, turn);
110
+ }
111
+ turn.points.push(point);
112
+ turn.time = Math.min(turn.time, point.time);
113
+ turn.input += point.input;
114
+ turn.read += point.read;
115
+ turn.output += point.output;
116
+ }
117
+ return [...turns.values()].map((turn) => ({
118
+ ...turn,
119
+ rate: turn.input + turn.read > 0 ? turn.read / (turn.input + turn.read) : undefined
120
+ })).toSorted((a, b) => a.time - b.time || a.id.localeCompare(b.id));
121
+ };
122
+ var formatCacheHistoryTrend = (points) => {
123
+ const bars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
124
+ return points.slice(-40).map((point) => point.rate === undefined ? "\xB7" : bars[Math.min(bars.length - 1, Math.floor(point.rate * bars.length))]).join("");
125
+ };
126
+ var exportCacheHistory = (sessionID, scope, points) => {
127
+ const totals = { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 };
128
+ for (const point of points) {
129
+ totals.input += point.input;
130
+ totals.output += point.output;
131
+ totals.cacheRead += point.read;
132
+ totals.cacheWrite += point.write;
133
+ }
134
+ return JSON.stringify({
135
+ exportedAt: new Date().toISOString(),
136
+ responseCount: points.length,
137
+ scope,
138
+ sessionID,
139
+ timeline: points.map((point) => ({
140
+ ...point,
141
+ time: new Date(point.time).toISOString()
142
+ })),
143
+ totals: {
144
+ ...totals,
145
+ cacheHitRate: totals.input + totals.cacheRead > 0 ? totals.cacheRead / (totals.input + totals.cacheRead) : null
146
+ }
147
+ }, null, 2);
148
+ };
149
+
150
+ // cache-history-panel.tsx
151
+ var percent = (rate) => rate === undefined ? "\u2014" : `${(rate * 100).toFixed(1)}%`;
152
+ var count = (tokens) => tokens.toLocaleString();
153
+ var CacheHistoryPanel = (props) => {
154
+ const {
155
+ context
156
+ } = props;
157
+ const [family, setFamily] = createSignal(true);
158
+ const [snapshots, setSnapshots] = createSignal(new Map);
159
+ const [loading, setLoading] = createSignal(false);
160
+ const [error, setError] = createSignal(false);
161
+ const [follow, setFollow] = createSignal(true);
162
+ const [streamingSessions, setStreamingSessions] = createSignal(new Set);
163
+ let scrollbox;
164
+ const setScrollbox = (element) => {
165
+ scrollbox = element;
166
+ };
167
+ let generation = 0;
168
+ const inScope = (sessionID) => sessionID === props.panel.sessionID || family() && context.data.session.root(sessionID) === context.data.session.root(props.panel.sessionID);
169
+ const updateStreamingSession = (sessionID, streaming) => {
170
+ setStreamingSessions((current) => {
171
+ const next = new Set(current);
172
+ if (streaming) {
173
+ next.add(sessionID);
174
+ } else {
175
+ next.delete(sessionID);
176
+ }
177
+ return next;
178
+ });
179
+ };
180
+ const streamingInScope = () => [...streamingSessions()].some(inScope);
181
+ const emptyHistoryMessage = () => {
182
+ if (loading()) {
183
+ return "Loading saved responses\u2026";
184
+ }
185
+ if (streamingInScope()) {
186
+ return "Waiting for token usage\u2026";
187
+ }
188
+ return "No completed responses with token usage yet.";
189
+ };
190
+ const sessionIDs = (sessionID, includeFamily) => {
191
+ if (!includeFamily) {
192
+ return [sessionID];
193
+ }
194
+ const root = context.data.session.root(sessionID);
195
+ return [...new Set([root, ...context.data.session.family(root)])];
196
+ };
197
+ const refresh = async () => {
198
+ generation += 1;
199
+ const current = generation;
200
+ const {
201
+ sessionID
202
+ } = props.panel;
203
+ const includeFamily = family();
204
+ setSnapshots(new Map(sessionIDs(sessionID, includeFamily).map((id) => [id, context.data.session.message.list(id) ?? []])));
205
+ setLoading(true);
206
+ setError(false);
207
+ try {
208
+ await context.data.session.sync(sessionID);
209
+ const entries = await Promise.all(sessionIDs(sessionID, includeFamily).map(async (id) => {
210
+ context.data.session.message.invalidate(id);
211
+ await context.data.session.message.sync(id);
212
+ return [id, context.data.session.message.list(id) ?? []];
213
+ }));
214
+ if (generation === current) {
215
+ setSnapshots(new Map(entries));
216
+ }
217
+ } catch {
218
+ if (generation === current) {
219
+ setError(true);
220
+ }
221
+ } finally {
222
+ if (generation === current) {
223
+ setLoading(false);
224
+ }
225
+ }
226
+ };
227
+ createEffect(on([() => props.panel.sessionID, family], ([sessionID]) => {
228
+ setStreamingSessions(new Set);
229
+ if (sessionID) {
230
+ refresh();
231
+ }
232
+ }));
233
+ onCleanup(context.data.on("session.step.started", (event) => {
234
+ if (inScope(event.data.sessionID)) {
235
+ updateStreamingSession(event.data.sessionID, true);
236
+ }
237
+ }));
238
+ for (const type of ["session.step.ended", "session.step.failed", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]) {
239
+ onCleanup(context.data.on(type, (event) => {
240
+ if (inScope(event.data.sessionID)) {
241
+ updateStreamingSession(event.data.sessionID, false);
242
+ refresh();
243
+ }
244
+ }));
245
+ }
246
+ onCleanup(context.data.on("session.created", async (event) => {
247
+ if (family() && event.data.parentID && context.data.session.root(event.data.parentID) === context.data.session.root(props.panel.sessionID)) {
248
+ const current = generation;
249
+ try {
250
+ await context.data.session.sync(event.data.sessionID);
251
+ if (generation === current) {
252
+ await refresh();
253
+ }
254
+ } catch {
255
+ if (generation === current) {
256
+ setError(true);
257
+ }
258
+ }
259
+ }
260
+ }));
261
+ onCleanup(() => {
262
+ generation += 1;
263
+ });
264
+ const history = createMemo(() => buildCacheHistory(snapshots()));
265
+ const turns = createMemo(() => groupCacheHistoryTurns(history()));
266
+ createEffect(() => {
267
+ turns();
268
+ if (!follow()) {
269
+ return;
270
+ }
271
+ const timer = setTimeout(() => {
272
+ if (follow()) {
273
+ scrollbox?.scrollTo(scrollbox.scrollHeight);
274
+ }
275
+ }, 0);
276
+ onCleanup(() => clearTimeout(timer));
277
+ });
278
+ const rateColor = (point) => {
279
+ if (point.rate === undefined || point.rate < 0.3) {
280
+ return context.theme.text.muted;
281
+ }
282
+ if (point.rate < 0.7) {
283
+ return context.theme.text.feedback?.warning?.base ?? context.theme.hue.yellow[500];
284
+ }
285
+ return context.theme.text.feedback?.success?.base ?? context.theme.hue.green[500];
286
+ };
287
+ context.keymap.layer(() => ({
288
+ commands: [{
289
+ bind: "s",
290
+ enabled: () => props.panel.focused,
291
+ id: "cache-metrics.history.scope",
292
+ run: () => setFamily(!family()),
293
+ title: "Toggle cache history session scope"
294
+ }, {
295
+ bind: "t",
296
+ enabled: () => props.panel.focused,
297
+ id: "cache-metrics.history.follow",
298
+ run: () => setFollow(!follow()),
299
+ title: "Toggle cache history follow mode"
300
+ }, {
301
+ bind: "r",
302
+ enabled: () => props.panel.focused,
303
+ id: "cache-metrics.history.refresh",
304
+ run: refresh,
305
+ title: "Refresh cache history"
306
+ }, {
307
+ bind: "e",
308
+ enabled: () => props.panel.focused,
309
+ id: "cache-metrics.history.export",
310
+ run: () => {
311
+ try {
312
+ const json = exportCacheHistory(props.panel.sessionID, family() ? "family" : "session", history());
313
+ if (!context.renderer.copyToClipboardOSC52(json)) {
314
+ throw new Error("Clipboard unavailable");
315
+ }
316
+ context.ui.toast.show({
317
+ message: "Cache history JSON copied to clipboard",
318
+ variant: "success"
319
+ });
320
+ } catch {
321
+ context.ui.toast.show({
322
+ message: "Could not copy cache history to clipboard",
323
+ variant: "error"
324
+ });
325
+ }
326
+ },
327
+ title: "Copy cache history JSON"
328
+ }, {
329
+ bind: "f",
330
+ enabled: () => props.panel.focused,
331
+ id: "cache-metrics.history.fullscreen",
332
+ run: props.panel.toggleFullscreen
333
+ }, {
334
+ bind: "escape",
335
+ enabled: () => props.panel.focused,
336
+ id: "cache-metrics.history.close",
337
+ run: props.panel.close
338
+ }],
339
+ mode: "global"
340
+ }));
341
+ return (() => {
342
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"), _el$4 = _$createTextNode(`Cache history \xB7 `), _el$5 = _$createElement("text"), _el$6 = _$createTextNode(` turns \xB7 `), _el$7 = _$createTextNode(` responses \xB7 cumulative `), _el$9 = _$createTextNode(` \xB7 Follow `), _el$13 = _$createElement("scrollbox"), _el$15 = _$createElement("text");
343
+ _$insertNode(_el$, _el$2);
344
+ _$insertNode(_el$, _el$5);
345
+ _$insertNode(_el$, _el$13);
346
+ _$insertNode(_el$, _el$15);
347
+ _$setProp(_el$, "flexDirection", "column");
348
+ _$setProp(_el$, "gap", 1);
349
+ _$setProp(_el$, "height", "100%");
350
+ _$setProp(_el$, "paddingLeft", 1);
351
+ _$setProp(_el$, "paddingRight", 1);
352
+ _$setProp(_el$, "width", "100%");
353
+ _$insertNode(_el$2, _el$3);
354
+ _$insertNode(_el$3, _el$4);
355
+ _$insert(_el$3, () => family() ? "Session + subagents" : "This session", null);
356
+ _$insertNode(_el$5, _el$6);
357
+ _$insertNode(_el$5, _el$7);
358
+ _$insertNode(_el$5, _el$9);
359
+ _$insert(_el$5, () => turns().length, _el$6);
360
+ _$insert(_el$5, () => history().length, _el$7);
361
+ _$insert(_el$5, () => percent(history().at(-1)?.cumulativeRate), _el$9);
362
+ _$insert(_el$5, () => follow() ? "on" : "off", null);
363
+ _$insert(_el$, _$createComponent(Show, {
364
+ get when() {
365
+ return history().length > 0;
366
+ },
367
+ get children() {
368
+ var _el$1 = _$createElement("text"), _el$10 = _$createTextNode(`Trend (oldest \u2192 newest): `);
369
+ _$insertNode(_el$1, _el$10);
370
+ _$insert(_el$1, () => formatCacheHistoryTrend(history()), null);
371
+ _$effect((_$p) => _$setProp(_el$1, "fg", context.theme.text.muted, _$p));
372
+ return _el$1;
373
+ }
374
+ }), _el$13);
375
+ _$insert(_el$, _$createComponent(Show, {
376
+ get when() {
377
+ return error();
378
+ },
379
+ get children() {
380
+ var _el$11 = _$createElement("text");
381
+ _$insertNode(_el$11, _$createTextNode(`Could not refresh history. Press r to retry.`));
382
+ _$effect((_$p) => _$setProp(_el$11, "fg", context.theme.text.feedback?.error?.base ?? context.theme.hue.red[500], _$p));
383
+ return _el$11;
384
+ }
385
+ }), _el$13);
386
+ _$use(setScrollbox, _el$13);
387
+ _$setProp(_el$13, "flexGrow", 1);
388
+ _$setProp(_el$13, "stickyStart", "bottom");
389
+ _$insert(_el$13, _$createComponent(For, {
390
+ get each() {
391
+ return turns();
392
+ },
393
+ children: (turn, index) => (() => {
394
+ var _el$17 = _$createElement("box"), _el$18 = _$createElement("box"), _el$19 = _$createElement("text"), _el$20 = _$createElement("b"), _el$21 = _$createTextNode(`Turn `), _el$22 = _$createTextNode(` \xB7 `), _el$24 = _$createTextNode(` \xB7 `), _el$26 = _$createTextNode(` `), _el$27 = _$createElement("text"), _el$28 = _$createTextNode(`Request: `), _el$29 = _$createElement("text"), _el$30 = _$createTextNode(`Turn cache hit `), _el$31 = _$createTextNode(` \xB7 In `), _el$32 = _$createTextNode(` cached / `), _el$34 = _$createTextNode(` new \xB7 Out `), _el$35 = _$createElement("box");
395
+ _$insertNode(_el$17, _el$18);
396
+ _$insertNode(_el$17, _el$35);
397
+ _$setProp(_el$17, "border", true);
398
+ _$setProp(_el$17, "borderStyle", "rounded");
399
+ _$setProp(_el$17, "flexDirection", "column");
400
+ _$setProp(_el$17, "width", "100%");
401
+ _$insertNode(_el$18, _el$19);
402
+ _$insertNode(_el$18, _el$27);
403
+ _$insertNode(_el$18, _el$29);
404
+ _$setProp(_el$18, "flexDirection", "column");
405
+ _$setProp(_el$18, "paddingLeft", 1);
406
+ _$setProp(_el$18, "paddingRight", 1);
407
+ _$setProp(_el$18, "width", "100%");
408
+ _$insertNode(_el$19, _el$20);
409
+ _$insertNode(_el$20, _el$21);
410
+ _$insertNode(_el$20, _el$22);
411
+ _$insertNode(_el$20, _el$24);
412
+ _$insertNode(_el$20, _el$26);
413
+ _$insert(_el$20, () => index() + 1, _el$22);
414
+ _$insert(_el$20, () => turn.sessionID === context.data.session.root(props.panel.sessionID) ? "Parent" : "Subagent", _el$24);
415
+ _$insert(_el$20, () => turn.points.length, _el$26);
416
+ _$insert(_el$20, () => turn.points.length === 1 ? "response" : "responses", null);
417
+ _$insertNode(_el$27, _el$28);
418
+ _$setProp(_el$27, "truncate", true);
419
+ _$setProp(_el$27, "wrapMode", "none");
420
+ _$insert(_el$27, () => turn.prompt ?? "(No saved user request)", null);
421
+ _$insertNode(_el$29, _el$30);
422
+ _$insertNode(_el$29, _el$31);
423
+ _$insertNode(_el$29, _el$32);
424
+ _$insertNode(_el$29, _el$34);
425
+ _$insert(_el$29, () => percent(turn.rate), _el$31);
426
+ _$insert(_el$29, () => count(turn.read), _el$32);
427
+ _$insert(_el$29, () => count(turn.input), _el$34);
428
+ _$insert(_el$29, () => count(turn.output), null);
429
+ _$setProp(_el$35, "border", ["top"]);
430
+ _$setProp(_el$35, "height", 1);
431
+ _$setProp(_el$35, "width", "100%");
432
+ _$insert(_el$17, _$createComponent(For, {
433
+ get each() {
434
+ return turn.points;
435
+ },
436
+ children: (point, responseIndex) => (() => {
437
+ var _el$36 = _$createElement("box"), _el$38 = _$createElement("box"), _el$39 = _$createElement("box"), _el$40 = _$createElement("text"), _el$41 = _$createElement("b"), _el$42 = _$createTextNode(`Response `), _el$43 = _$createTextNode(` \xB7 `), _el$51 = _$createElement("text"), _el$52 = _$createTextNode(` \xB7 `), _el$54 = _$createTextNode(`/`), _el$55 = _$createElement("text"), _el$56 = _$createTextNode(` \xB7 In `), _el$58 = _$createTextNode(` cached / `), _el$59 = _$createTextNode(` new \xB7 Out `), _el$68 = _$createElement("text"), _el$69 = _$createTextNode(`Finish: `), _el$70 = _$createElement("text"), _el$71 = _$createTextNode(`Cache writes `), _el$72 = _$createTextNode(` \xB7 cumulative `);
438
+ _$insertNode(_el$36, _el$38);
439
+ _$setProp(_el$36, "flexDirection", "column");
440
+ _$setProp(_el$36, "width", "100%");
441
+ _$insert(_el$36, _$createComponent(Show, {
442
+ get when() {
443
+ return responseIndex() > 0;
444
+ },
445
+ get children() {
446
+ var _el$37 = _$createElement("box");
447
+ _$setProp(_el$37, "border", ["top"]);
448
+ _$setProp(_el$37, "height", 1);
449
+ _$setProp(_el$37, "width", "100%");
450
+ _$effect((_$p) => _$setProp(_el$37, "borderColor", context.theme.border.base, _$p));
451
+ return _el$37;
452
+ }
453
+ }), _el$38);
454
+ _$insertNode(_el$38, _el$39);
455
+ _$insertNode(_el$38, _el$55);
456
+ _$insertNode(_el$38, _el$68);
457
+ _$insertNode(_el$38, _el$70);
458
+ _$setProp(_el$38, "flexDirection", "column");
459
+ _$setProp(_el$38, "paddingLeft", 1);
460
+ _$setProp(_el$38, "paddingRight", 1);
461
+ _$setProp(_el$38, "width", "100%");
462
+ _$insertNode(_el$39, _el$40);
463
+ _$insertNode(_el$39, _el$51);
464
+ _$setProp(_el$39, "flexDirection", "row");
465
+ _$setProp(_el$39, "gap", 1);
466
+ _$insertNode(_el$40, _el$41);
467
+ _$insertNode(_el$41, _el$42);
468
+ _$insertNode(_el$41, _el$43);
469
+ _$insert(_el$41, () => responseIndex() + 1, _el$43);
470
+ _$insert(_el$41, () => percent(point.rate), null);
471
+ _$insert(_el$39, _$createComponent(Show, {
472
+ get when() {
473
+ return point.previousRead !== undefined;
474
+ },
475
+ get children() {
476
+ var _el$45 = _$createElement("text"), _el$46 = _$createElement("b"), _el$47 = _$createTextNode(`\u26A0 Possible cache loss (`), _el$48 = _$createTextNode(` \u2192 `), _el$50 = _$createTextNode(` cached)`);
477
+ _$insertNode(_el$45, _el$46);
478
+ _$insertNode(_el$46, _el$47);
479
+ _$insertNode(_el$46, _el$48);
480
+ _$insertNode(_el$46, _el$50);
481
+ _$insert(_el$46, () => count(point.previousRead ?? 0), _el$48);
482
+ _$insert(_el$46, () => count(point.read), _el$50);
483
+ _$effect((_$p) => _$setProp(_el$45, "fg", context.theme.text.feedback?.warning?.base ?? context.theme.hue.yellow[500], _$p));
484
+ return _el$45;
485
+ }
486
+ }), _el$51);
487
+ _$insertNode(_el$51, _el$52);
488
+ _$insertNode(_el$51, _el$54);
489
+ _$setProp(_el$51, "truncate", true);
490
+ _$setProp(_el$51, "wrapMode", "none");
491
+ _$insert(_el$51, () => new Date(point.time).toLocaleTimeString(), _el$52);
492
+ _$insert(_el$51, () => point.providerID, _el$54);
493
+ _$insert(_el$51, () => point.modelID, null);
494
+ _$insertNode(_el$55, _el$56);
495
+ _$insertNode(_el$55, _el$58);
496
+ _$insertNode(_el$55, _el$59);
497
+ _$setProp(_el$55, "truncate", true);
498
+ _$setProp(_el$55, "wrapMode", "none");
499
+ _$insert(_el$55, (() => {
500
+ var _c$ = _$memo(() => point.sessionID === context.data.session.root(props.panel.sessionID));
501
+ return () => _c$() ? "Parent" : `Subagent (${point.agent})`;
502
+ })(), _el$56);
503
+ _$insert(_el$55, () => count(point.read), _el$58);
504
+ _$insert(_el$55, () => count(point.input), _el$59);
505
+ _$insert(_el$55, () => count(point.output), null);
506
+ _$insert(_el$38, _$createComponent(Show, {
507
+ get when() {
508
+ return point.afterTools.length > 0;
509
+ },
510
+ get children() {
511
+ var _el$61 = _$createElement("text"), _el$62 = _$createTextNode(`After tools: `);
512
+ _$insertNode(_el$61, _el$62);
513
+ _$setProp(_el$61, "truncate", true);
514
+ _$setProp(_el$61, "wrapMode", "none");
515
+ _$insert(_el$61, () => point.afterTools.join(", "), null);
516
+ _$effect((_$p) => _$setProp(_el$61, "fg", context.theme.text.muted, _$p));
517
+ return _el$61;
518
+ }
519
+ }), _el$68);
520
+ _$insert(_el$38, _$createComponent(Show, {
521
+ get when() {
522
+ return point.afterCompaction;
523
+ },
524
+ get children() {
525
+ var _el$63 = _$createElement("text"), _el$64 = _$createTextNode(`After `), _el$65 = _$createTextNode(` compaction`);
526
+ _$insertNode(_el$63, _el$64);
527
+ _$insertNode(_el$63, _el$65);
528
+ _$insert(_el$63, () => point.afterCompaction, _el$65);
529
+ _$effect((_$p) => _$setProp(_el$63, "fg", context.theme.text.muted, _$p));
530
+ return _el$63;
531
+ }
532
+ }), _el$68);
533
+ _$insert(_el$38, _$createComponent(Show, {
534
+ get when() {
535
+ return point.tools.length > 0;
536
+ },
537
+ get children() {
538
+ var _el$66 = _$createElement("text"), _el$67 = _$createTextNode(`Called tools: `);
539
+ _$insertNode(_el$66, _el$67);
540
+ _$setProp(_el$66, "truncate", true);
541
+ _$setProp(_el$66, "wrapMode", "none");
542
+ _$insert(_el$66, () => point.tools.join(", "), null);
543
+ _$effect((_$p) => _$setProp(_el$66, "fg", context.theme.text.muted, _$p));
544
+ return _el$66;
545
+ }
546
+ }), _el$68);
547
+ _$insertNode(_el$68, _el$69);
548
+ _$insert(_el$68, () => point.finish ?? "unknown", null);
549
+ _$insert(_el$68, (() => {
550
+ var _c$2 = _$memo(() => !!point.retryAttempt);
551
+ return () => _c$2() ? ` \xB7 retry ${point.retryAttempt}` : "";
552
+ })(), null);
553
+ _$insertNode(_el$70, _el$71);
554
+ _$insertNode(_el$70, _el$72);
555
+ _$insert(_el$70, () => count(point.write), _el$72);
556
+ _$insert(_el$70, () => percent(point.cumulativeRate), null);
557
+ _$effect((_p$) => {
558
+ var _v$0 = rateColor(point), _v$1 = context.theme.text.base, _v$10 = context.theme.text.muted, _v$11 = context.theme.text.muted, _v$12 = context.theme.text.muted;
559
+ _v$0 !== _p$.e && (_p$.e = _$setProp(_el$40, "fg", _v$0, _p$.e));
560
+ _v$1 !== _p$.t && (_p$.t = _$setProp(_el$51, "fg", _v$1, _p$.t));
561
+ _v$10 !== _p$.a && (_p$.a = _$setProp(_el$55, "fg", _v$10, _p$.a));
562
+ _v$11 !== _p$.o && (_p$.o = _$setProp(_el$68, "fg", _v$11, _p$.o));
563
+ _v$12 !== _p$.i && (_p$.i = _$setProp(_el$70, "fg", _v$12, _p$.i));
564
+ return _p$;
565
+ }, {
566
+ e: undefined,
567
+ t: undefined,
568
+ a: undefined,
569
+ o: undefined,
570
+ i: undefined
571
+ });
572
+ return _el$36;
573
+ })()
574
+ }), null);
575
+ _$effect((_p$) => {
576
+ var _v$5 = context.theme.hue.blue[500], _v$6 = context.theme.text.base, _v$7 = context.theme.text.base, _v$8 = context.theme.text.muted, _v$9 = context.theme.border.base;
577
+ _v$5 !== _p$.e && (_p$.e = _$setProp(_el$17, "borderColor", _v$5, _p$.e));
578
+ _v$6 !== _p$.t && (_p$.t = _$setProp(_el$19, "fg", _v$6, _p$.t));
579
+ _v$7 !== _p$.a && (_p$.a = _$setProp(_el$27, "fg", _v$7, _p$.a));
580
+ _v$8 !== _p$.o && (_p$.o = _$setProp(_el$29, "fg", _v$8, _p$.o));
581
+ _v$9 !== _p$.i && (_p$.i = _$setProp(_el$35, "borderColor", _v$9, _p$.i));
582
+ return _p$;
583
+ }, {
584
+ e: undefined,
585
+ t: undefined,
586
+ a: undefined,
587
+ o: undefined,
588
+ i: undefined
589
+ });
590
+ return _el$17;
591
+ })()
592
+ }), null);
593
+ _$insert(_el$13, _$createComponent(Show, {
594
+ get when() {
595
+ return history().length === 0;
596
+ },
597
+ get children() {
598
+ var _el$14 = _$createElement("text");
599
+ _$insert(_el$14, emptyHistoryMessage);
600
+ _$effect((_$p) => _$setProp(_el$14, "fg", context.theme.text.muted, _$p));
601
+ return _el$14;
602
+ }
603
+ }), null);
604
+ _$insertNode(_el$15, _$createTextNode(`s Scope \xB7 t Follow \xB7 r Refresh \xB7 e Export JSON \xB7 f Fullscreen \xB7 Esc Close`));
605
+ _$effect((_p$) => {
606
+ var _v$ = context.theme.text.base, _v$2 = context.theme.text.muted, _v$3 = follow(), _v$4 = context.theme.text.muted;
607
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
608
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "fg", _v$2, _p$.t));
609
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$13, "stickyScroll", _v$3, _p$.a));
610
+ _v$4 !== _p$.o && (_p$.o = _$setProp(_el$15, "fg", _v$4, _p$.o));
611
+ return _p$;
612
+ }, {
613
+ e: undefined,
614
+ t: undefined,
615
+ a: undefined,
616
+ o: undefined
617
+ });
618
+ return _el$;
619
+ })();
620
+ };
621
+
622
+ // cache-rate.ts
623
+ var calculateSessionCacheRate = (messages) => {
624
+ let input = 0;
625
+ let read = 0;
626
+ let write = 0;
627
+ let output = 0;
628
+ let calls = 0;
629
+ for (const message of messages) {
630
+ if (message.type !== "assistant" || !message.tokens) {
631
+ continue;
632
+ }
633
+ input += message.tokens.input;
634
+ read += message.tokens.cache.read;
635
+ write += message.tokens.cache.write;
636
+ output += message.tokens.output;
637
+ calls += 1;
638
+ }
639
+ return {
640
+ calls,
641
+ input,
642
+ output,
643
+ rate: input + read > 0 ? read / (input + read) : undefined,
644
+ read,
645
+ write
646
+ };
647
+ };
648
+
649
+ // tui.tsx
650
+ var tui_default = Plugin.define({
651
+ id: "cache-metrics.tui",
652
+ setup(context) {
653
+ const openCacheHistory = () => {
654
+ if (!context.ui.panel.open("cache-metrics.history")) {
655
+ context.ui.toast.show({
656
+ message: "Open a session to view cache history",
657
+ variant: "info"
658
+ });
659
+ }
660
+ };
661
+ const CacheMetrics = (props) => {
662
+ const [expanded, setExpanded] = createSignal2(false);
663
+ const toggleExpanded = () => setExpanded((value) => !value);
664
+ const [messages, setMessages] = createSignal2(context.data.session.message.list(props.sessionID) ?? []);
665
+ const refresh = async (sessionID) => {
666
+ context.data.session.message.invalidate(sessionID);
667
+ try {
668
+ await context.data.session.message.sync(sessionID);
669
+ if (props.sessionID === sessionID) {
670
+ setMessages(context.data.session.message.list(sessionID) ?? []);
671
+ }
672
+ } catch {}
673
+ };
674
+ createEffect2(on2(() => props.sessionID, (sessionID) => {
675
+ setMessages(context.data.session.message.list(sessionID) ?? []);
676
+ refresh(sessionID);
677
+ }));
678
+ const stop = context.data.on("session.execution.succeeded", (event) => {
679
+ if (event.data.sessionID === props.sessionID) {
680
+ refresh(props.sessionID);
681
+ }
682
+ });
683
+ onCleanup2(stop);
684
+ const totals = createMemo2(() => calculateSessionCacheRate(messages()));
685
+ const rateColor = () => {
686
+ const rate = totals().rate ?? 0;
687
+ if (rate >= 0.7) {
688
+ return context.theme.text.feedback?.success?.base ?? context.theme.hue.green[500];
689
+ }
690
+ if (rate >= 0.3) {
691
+ return context.theme.text.feedback?.warning?.base ?? context.theme.hue.yellow[500];
692
+ }
693
+ return context.theme.text.muted;
694
+ };
695
+ return (() => {
696
+ var _el$ = _$createElement2("box"), _el$2 = _$createElement2("box"), _el$6 = _$createElement2("box"), _el$7 = _$createElement2("text");
697
+ _$insertNode2(_el$, _el$2);
698
+ _$insertNode2(_el$, _el$6);
699
+ _$setProp2(_el$, "border", true);
700
+ _$setProp2(_el$, "borderStyle", "rounded");
701
+ _$setProp2(_el$, "flexDirection", "column");
702
+ _$setProp2(_el$, "paddingLeft", 1);
703
+ _$setProp2(_el$, "paddingRight", 1);
704
+ _$setProp2(_el$, "title", "Cache");
705
+ _$setProp2(_el$, "width", "100%");
706
+ _$setProp2(_el$2, "onMouseDown", openCacheHistory);
707
+ _$insert2(_el$2, _$createComponent2(Show2, {
708
+ get fallback() {
709
+ return (() => {
710
+ var _el$29 = _$createElement2("text");
711
+ _$insertNode2(_el$29, _$createTextNode2(`No token usage yet`));
712
+ _$effect2((_$p) => _$setProp2(_el$29, "fg", context.theme.text.muted, _$p));
713
+ return _el$29;
714
+ })();
715
+ },
716
+ get when() {
717
+ return totals().rate !== undefined;
718
+ },
719
+ get children() {
720
+ var _el$3 = _$createElement2("text"), _el$4 = _$createElement2("b"), _el$5 = _$createTextNode2(`% input cache hit`);
721
+ _$insertNode2(_el$3, _el$4);
722
+ _$insertNode2(_el$4, _el$5);
723
+ _$insert2(_el$4, () => ((totals().rate ?? 0) * 100).toFixed(1), _el$5);
724
+ _$effect2((_$p) => _$setProp2(_el$3, "fg", rateColor(), _$p));
725
+ return _el$3;
726
+ }
727
+ }));
728
+ _$insertNode2(_el$6, _el$7);
729
+ _$setProp2(_el$7, "onMouseDown", toggleExpanded);
730
+ _$insert2(_el$7, () => expanded() ? "\u25BC Hide additional metrics" : "\u25B6 Show additional metrics");
731
+ _$insert2(_el$, _$createComponent2(Show2, {
732
+ get when() {
733
+ return expanded();
734
+ },
735
+ get children() {
736
+ var _el$8 = _$createElement2("box"), _el$9 = _$createElement2("box"), _el$0 = _$createElement2("text"), _el$1 = _$createElement2("b"), _el$11 = _$createElement2("text"), _el$12 = _$createTextNode2(` cached`), _el$13 = _$createElement2("box"), _el$14 = _$createElement2("text"), _el$15 = _$createElement2("b"), _el$17 = _$createElement2("text"), _el$18 = _$createElement2("box"), _el$19 = _$createElement2("text"), _el$20 = _$createElement2("b"), _el$22 = _$createElement2("text"), _el$23 = _$createTextNode2(` generated`), _el$24 = _$createElement2("box"), _el$25 = _$createElement2("text"), _el$26 = _$createElement2("b"), _el$28 = _$createElement2("text");
737
+ _$insertNode2(_el$8, _el$9);
738
+ _$insertNode2(_el$8, _el$13);
739
+ _$insertNode2(_el$8, _el$18);
740
+ _$insertNode2(_el$8, _el$24);
741
+ _$setProp2(_el$8, "flexDirection", "column");
742
+ _$insertNode2(_el$9, _el$0);
743
+ _$insertNode2(_el$9, _el$11);
744
+ _$setProp2(_el$9, "flexDirection", "row");
745
+ _$setProp2(_el$9, "gap", 1);
746
+ _$insertNode2(_el$0, _el$1);
747
+ _$insertNode2(_el$1, _$createTextNode2(`In:`));
748
+ _$insertNode2(_el$11, _el$12);
749
+ _$insert2(_el$11, () => totals().read.toLocaleString(), _el$12);
750
+ _$insertNode2(_el$13, _el$14);
751
+ _$insertNode2(_el$13, _el$17);
752
+ _$setProp2(_el$13, "flexDirection", "row");
753
+ _$setProp2(_el$13, "gap", 1);
754
+ _$insertNode2(_el$14, _el$15);
755
+ _$insertNode2(_el$15, _$createTextNode2(`New:`));
756
+ _$insert2(_el$17, () => totals().input.toLocaleString());
757
+ _$insertNode2(_el$18, _el$19);
758
+ _$insertNode2(_el$18, _el$22);
759
+ _$setProp2(_el$18, "flexDirection", "row");
760
+ _$setProp2(_el$18, "gap", 1);
761
+ _$insertNode2(_el$19, _el$20);
762
+ _$insertNode2(_el$20, _$createTextNode2(`Out:`));
763
+ _$insertNode2(_el$22, _el$23);
764
+ _$insert2(_el$22, () => totals().output.toLocaleString(), _el$23);
765
+ _$insertNode2(_el$24, _el$25);
766
+ _$insertNode2(_el$24, _el$28);
767
+ _$setProp2(_el$24, "flexDirection", "row");
768
+ _$setProp2(_el$24, "gap", 1);
769
+ _$insertNode2(_el$25, _el$26);
770
+ _$insertNode2(_el$26, _$createTextNode2(`Cache writes:`));
771
+ _$insert2(_el$28, () => totals().write.toLocaleString());
772
+ _$effect2((_p$) => {
773
+ var _v$ = context.theme.text.base, _v$2 = context.theme.text.muted, _v$3 = context.theme.text.base, _v$4 = context.theme.text.muted, _v$5 = context.theme.text.base, _v$6 = context.theme.text.muted, _v$7 = context.theme.text.base, _v$8 = context.theme.text.muted;
774
+ _v$ !== _p$.e && (_p$.e = _$setProp2(_el$0, "fg", _v$, _p$.e));
775
+ _v$2 !== _p$.t && (_p$.t = _$setProp2(_el$11, "fg", _v$2, _p$.t));
776
+ _v$3 !== _p$.a && (_p$.a = _$setProp2(_el$14, "fg", _v$3, _p$.a));
777
+ _v$4 !== _p$.o && (_p$.o = _$setProp2(_el$17, "fg", _v$4, _p$.o));
778
+ _v$5 !== _p$.i && (_p$.i = _$setProp2(_el$19, "fg", _v$5, _p$.i));
779
+ _v$6 !== _p$.n && (_p$.n = _$setProp2(_el$22, "fg", _v$6, _p$.n));
780
+ _v$7 !== _p$.s && (_p$.s = _$setProp2(_el$25, "fg", _v$7, _p$.s));
781
+ _v$8 !== _p$.h && (_p$.h = _$setProp2(_el$28, "fg", _v$8, _p$.h));
782
+ return _p$;
783
+ }, {
784
+ e: undefined,
785
+ t: undefined,
786
+ a: undefined,
787
+ o: undefined,
788
+ i: undefined,
789
+ n: undefined,
790
+ s: undefined,
791
+ h: undefined
792
+ });
793
+ return _el$8;
794
+ }
795
+ }), null);
796
+ _$effect2((_p$) => {
797
+ var _v$9 = context.theme.border.base, _v$0 = context.theme.text.base, _v$1 = context.theme.text.muted;
798
+ _v$9 !== _p$.e && (_p$.e = _$setProp2(_el$, "borderColor", _v$9, _p$.e));
799
+ _v$0 !== _p$.t && (_p$.t = _$setProp2(_el$, "titleColor", _v$0, _p$.t));
800
+ _v$1 !== _p$.a && (_p$.a = _$setProp2(_el$7, "fg", _v$1, _p$.a));
801
+ return _p$;
802
+ }, {
803
+ e: undefined,
804
+ t: undefined,
805
+ a: undefined
806
+ });
807
+ return _el$;
808
+ })();
809
+ };
810
+ const removeSidebar = context.ui.slot({
811
+ append: "sidebar.content",
812
+ render: (props) => _$createComponent2(CacheMetrics, {
813
+ get sessionID() {
814
+ return props.sessionID;
815
+ }
816
+ })
817
+ });
818
+ const removePanel = context.ui.slot({
819
+ append: "session.panel",
820
+ render: (panel) => _$createComponent2(Show2, {
821
+ get when() {
822
+ return panel.name === "cache-metrics.history";
823
+ },
824
+ get children() {
825
+ return _$createComponent2(CacheHistoryPanel, {
826
+ context,
827
+ panel
828
+ });
829
+ }
830
+ })
831
+ });
832
+ const removeCommand = context.ui.slot({
833
+ append: "app",
834
+ render: () => {
835
+ context.keymap.layer(() => ({
836
+ commands: [{
837
+ group: "Cache",
838
+ id: "cache-metrics.history.open",
839
+ palette: true,
840
+ run: openCacheHistory,
841
+ title: "Open cache history"
842
+ }],
843
+ mode: "global"
844
+ }));
845
+ return null;
846
+ }
847
+ });
848
+ return () => {
849
+ removeCommand();
850
+ removePanel();
851
+ removeSidebar();
852
+ };
853
+ }
854
+ });
855
+ export {
856
+ tui_default as default
857
+ };
@@ -0,0 +1,39 @@
1
+ # Cache metrics development
2
+
3
+ [Back to README](../README.md)
4
+
5
+ ## Local setup and verification
6
+
7
+ Use the repository's [environment setup](../../README.md#environment-setup) for [Bun](https://bun.sh/docs) and lint tooling. Dependency versions and package exports live in [`package.json`](../package.json).
8
+
9
+ The root [`opencode.jsonc`](../../opencode.jsonc) already loads `./cache-metrics`. Before launching it, read [local plugins versus global installs](../../README.md#local-plugins-versus-global-installs), then run plain `opencode` from the repository root. For another project, add the plugin's absolute directory path to `plugins`.
10
+
11
+ From `cache-metrics/`:
12
+
13
+ ```sh
14
+ bun install
15
+ bun run build:tui
16
+ bun run typecheck
17
+ bun test
18
+ ```
19
+
20
+ Run `bun run check` from the repository root. After TUI changes, include the rebuilt `dist/tui.js` with the source changes. Local development and Git installs load that same bundle. Restart the terminal UI if it still shows the old code.
21
+
22
+ [`installed-tui.test.ts`](../installed-tui.test.ts) checks bundle freshness and runs the [installed rendering check](../scripts/check-installed-tui.ts) from a temporary `node_modules` directory. For changes to keyboard handling or export, also check the [README workflows](../README.md#history-and-export) in OpenCode with an OSC52-capable terminal. The rendering check does not exercise the real host's focus handling or clipboard.
23
+
24
+ ## Implementation references
25
+
26
+ - [`index.ts`](../index.ts) is the empty server entry. The plugin adds no server tools or hooks.
27
+ - [`tui.tsx`](../tui.tsx) owns the sidebar and history command. [`cache-history-panel.tsx`](../cache-history-panel.tsx) owns history loading, controls, and export.
28
+ - [`cache-rate.ts`](../cache-rate.ts) calculates sidebar totals. [`cache-history.ts`](../cache-history.ts) defines history grouping, cache-loss detection, and JSON export.
29
+ - [`cache-rate.test.ts`](../cache-rate.test.ts) and [`cache-history.test.ts`](../cache-history.test.ts) cover calculation and history behavior.
30
+
31
+ Use the upstream [OpenCode CLI plugin API](https://opencode.ai/v2/docs/build/plugins/cli), [OpenTUI](https://github.com/anomalyco/opentui), and [Solid](https://www.solidjs.com/) documentation for host and rendering APIs.
32
+
33
+ ## TUI bundle
34
+
35
+ The package's `./tui` export points to [`dist/tui.js`](../dist/tui.js), which the build compiles with Solid's universal JSX transform. OpenTUI skips that transform for raw TSX in `node_modules`. Exporting source can work locally but produce nonreactive installed UI.
36
+
37
+ [`build.ts`](../build.ts) keeps OpenCode, OpenTUI, and Solid imports external so the plugin shares the host runtimes. Rebuild after TUI edits or before creating a package archive. Tests reject a stale artifact.
38
+
39
+ Keep the script named `build:tui`. Do not add `build`, `prepack`, `prepare`, or install lifecycle hooks. These can trigger dependency installation or builds during npm's Git package preparation. Git installs must use the committed artifact without a build step.
Binary file
Binary file
package/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+
3
+ export default Plugin.define({
4
+ id: "cache-metrics",
5
+ setup() {
6
+ // This plugin only supplies the TUI extension.
7
+ },
8
+ });
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@mholtzscher/opencode-cache-metrics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "OpenCode V2 plugin for input cache-hit rates, token totals, response history, and JSON export in the TUI",
5
+ "keywords": [
6
+ "cache",
7
+ "opencode",
8
+ "opencode-plugin",
9
+ "tokens"
10
+ ],
11
+ "homepage": "https://github.com/mholtzscher/opencode-plugins/tree/main/cache-metrics#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/mholtzscher/opencode-plugins/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Michael Holtzscher",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/mholtzscher/opencode-plugins.git",
20
+ "directory": "cache-metrics"
21
+ },
22
+ "files": [
23
+ "index.ts",
24
+ "dist/tui.js",
25
+ "cache-rate.ts",
26
+ "cache-history.ts",
27
+ "docs/",
28
+ "CHANGELOG.md",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
32
+ "type": "module",
33
+ "main": "./index.ts",
34
+ "exports": {
35
+ ".": "./index.ts",
36
+ "./tui": "./dist/tui.js"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build:tui": "bun run build.ts",
43
+ "test": "bun test",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "dependencies": {
47
+ "@opencode/plugin": "2.0.18"
48
+ },
49
+ "devDependencies": {
50
+ "@opentui/solid": "0.5.12",
51
+ "@types/bun": "latest",
52
+ "@types/node": "latest",
53
+ "solid-js": "1.9.12",
54
+ "typescript": "latest"
55
+ },
56
+ "peerDependencies": {
57
+ "@opentui/solid": ">=0.5.10",
58
+ "solid-js": ">=1.9.0"
59
+ }
60
+ }