@mtayfur/opencode-cache-view 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +95 -0
  2. package/dist/index.js +737 -0
  3. package/package.json +60 -0
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # OpenCode Cache View
2
+
3
+ `@mtayfur/opencode-cache-view` adds a compact sidebar to the OpenCode TUI for cache efficiency, estimated context usage, generation speed, and loaded skills.
4
+
5
+ ```text
6
+ Cache View
7
+ Hit [████████████████] 98.7% ↑0.8%
8
+ Session Hit 96.0%
9
+ Read 8.0M tok
10
+ Miss 326K tok
11
+ ▼ Speed ─────────────────────
12
+ Now —
13
+ TTFT ~1.4 s
14
+ Last ~37 tok/s
15
+ Avg ~42 tok/s
16
+ Trend ▂▅▄▇▆█
17
+ ▼ Estimated Tokens ──────────
18
+ Prompt 186K tok
19
+ Tool Call 59K tok
20
+ Tool Result 80K tok
21
+ Agent Reasoning 14K tok
22
+ Agent Output 21K tok
23
+ Total 326K tok
24
+ ▼ Loaded Skills (2) ─────────
25
+ playwright-mcp-ops 2.4K tok
26
+ customize-opencode 3.1K tok
27
+ ```
28
+
29
+ ## Metrics
30
+
31
+ - **Cache:** `Hit` is `cache.read / (input + cache.read + cache.write)` for the latest regular LLM step. `Session Hit` is the aggregate OpenCode session ratio, including compaction calls. `Miss` combines uncached input and cache writes.
32
+ - **Estimated Tokens:** Uses one character-based estimator for the visible system, user, tool, reasoning, and output content from the latest completed compaction summary, its preserved tail, and subsequent messages loaded in the TUI. Provider system prompts, tool schemas, and media tokens are not included.
33
+ - **Speed:** `TTFT` estimates the time from assistant creation to the first reasoning or text block. `Now`, `Last`, and `Avg` use only visible reasoning and text content and the active duration of those blocks, excluding tool execution time. `Avg` covers the latest eight completed responses.
34
+ - **Loaded Skills:** Shows active, non-compacted skill outputs and their estimated token counts. When a skill is loaded more than once, the latest load is shown.
35
+
36
+ Rows and the cache-hit bar adapt to the available sidebar width. The arrow next to the hit rate compares the latest two LLM steps. Sections can be expanded or collapsed with the mouse.
37
+
38
+ ## Requirements
39
+
40
+ - OpenCode `1.18.16` or a newer compatible 1.x release
41
+ - Bun `1.3.14` or newer for local development
42
+
43
+ ## Installation
44
+
45
+ Add the package to `~/.config/opencode/tui.json`:
46
+
47
+ ```json
48
+ {
49
+ "plugin": ["@mtayfur/opencode-cache-view"]
50
+ }
51
+ ```
52
+
53
+ OpenCode installs package plugins automatically. Restart the TUI after changing the configuration.
54
+
55
+ ### Local checkout
56
+
57
+ ```sh
58
+ bash ./install.sh
59
+ ```
60
+
61
+ The installer resolves dependencies, builds `dist/index.js`, and adds its local `file://` URL to the TUI plugin configuration. Remove the local entry with:
62
+
63
+ ```sh
64
+ bash ./install.sh --uninstall
65
+ ```
66
+
67
+ Restart OpenCode after either operation.
68
+
69
+ ## Architecture
70
+
71
+ ```text
72
+ src/
73
+ ├── index.tsx Plugin entrypoint
74
+ ├── cache-view.tsx Solid/OpenTUI view and event subscriptions
75
+ ├── format.ts Display formatting
76
+ ├── token-estimator.ts Cached character-based token estimation
77
+ └── metrics/
78
+ ├── cache.ts Cache usage and hit ratios
79
+ ├── context.ts Active context, token, and skill calculations
80
+ ├── helpers.ts Shared message and part helpers
81
+ ├── read.ts Metric orchestration
82
+ ├── speed.ts TTFT and generation speed
83
+ └── types.ts Metric data contracts
84
+ ```
85
+
86
+ Metric calculations are isolated from the TUI rendering layer. `read.ts` snapshots OpenCode state and coordinates the dedicated metric modules, while `cache-view.tsx` owns reactive updates and presentation.
87
+
88
+ ## Development
89
+
90
+ ```sh
91
+ bun install --frozen-lockfile
92
+ bun run typecheck
93
+ bun run build
94
+ npm pack --dry-run
95
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,737 @@
1
+ // @bun
2
+ // src/index.tsx
3
+ import { createComponent as _$createComponent2 } from "@opentui/solid";
4
+ import { memo as _$memo2 } from "@opentui/solid";
5
+
6
+ // src/cache-view.tsx
7
+ import { memo as _$memo } from "@opentui/solid";
8
+ import { createComponent as _$createComponent } from "@opentui/solid";
9
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
10
+ import { use as _$use } from "@opentui/solid";
11
+ import { setProp as _$setProp } from "@opentui/solid";
12
+ import { effect as _$effect } from "@opentui/solid";
13
+ import { insertNode as _$insertNode } from "@opentui/solid";
14
+ import { insert as _$insert } from "@opentui/solid";
15
+ import { createElement as _$createElement } from "@opentui/solid";
16
+ import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
17
+
18
+ // src/format.ts
19
+ function formatCompact(value) {
20
+ if (value >= 1e6)
21
+ return `${(value / 1e6).toFixed(1)}M`;
22
+ if (value >= 1e4)
23
+ return `${(value / 1000).toFixed(1)}K`;
24
+ return Math.round(value).toLocaleString("en-US");
25
+ }
26
+ function formatPercent(value) {
27
+ return value === undefined ? "\u2014" : `${value.toFixed(1)}%`;
28
+ }
29
+ function formatSpeed(value, estimated = false) {
30
+ if (value === undefined)
31
+ return "\u2014";
32
+ return `${estimated ? "~" : ""}${Math.round(value)} tok/s`;
33
+ }
34
+ function formatDuration(value, estimated = false) {
35
+ if (value === undefined)
36
+ return "\u2014";
37
+ const prefix = estimated ? "~" : "";
38
+ if (value < 1000)
39
+ return `${prefix}${Math.round(value)} ms`;
40
+ if (value < 1e4)
41
+ return `${prefix}${(value / 1000).toFixed(2)} s`;
42
+ return `${prefix}${(value / 1000).toFixed(1)} s`;
43
+ }
44
+ function formatHitTrend(value) {
45
+ if (value === undefined)
46
+ return "\u2014";
47
+ if (Math.abs(value) < 0.05)
48
+ return "-";
49
+ return `${value > 0 ? "\u2191" : "\u2193"}${Math.abs(value).toFixed(1)}%`;
50
+ }
51
+ function progressBar(percent, width) {
52
+ const filled = percent === undefined ? 0 : Math.round(Math.max(0, Math.min(100, percent)) / 100 * width);
53
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
54
+ }
55
+ function sparkline(values) {
56
+ if (values.length === 0)
57
+ return "\u2014";
58
+ if (values.length === 1)
59
+ return "\u2585";
60
+ const bars = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
61
+ const min = Math.min(...values);
62
+ const max = Math.max(...values);
63
+ if (min === max)
64
+ return "\u2585".repeat(values.length);
65
+ return values.map((value) => bars[Math.round((value - min) / (max - min) * (bars.length - 1))]).join("");
66
+ }
67
+ function row(label, value, width) {
68
+ return label + " ".repeat(Math.max(1, width - label.length - value.length)) + value;
69
+ }
70
+ function skillRow(skill, width) {
71
+ const value = `${formatCompact(skill.tokens)} tok`;
72
+ const maxName = Math.max(4, width - value.length - 1);
73
+ const name = skill.name.length > maxName ? `${skill.name.slice(0, maxName - 1)}\u2026` : skill.name;
74
+ return row(name, value, width);
75
+ }
76
+
77
+ // src/metrics/helpers.ts
78
+ function finite(value) {
79
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
80
+ }
81
+ function partsFor(parts, messageID) {
82
+ return parts.get(messageID) ?? [];
83
+ }
84
+ function assistantMessages(messages, includeSummaries) {
85
+ return messages.filter((message) => message.role === "assistant" && (includeSummaries || !message.summary));
86
+ }
87
+ function serializedInput(part) {
88
+ if (part.state.status === "pending" && part.state.raw)
89
+ return part.state.raw;
90
+ try {
91
+ return JSON.stringify(part.state.input);
92
+ } catch {
93
+ return "";
94
+ }
95
+ }
96
+
97
+ // src/metrics/cache.ts
98
+ function usageSamples(assistants, parts) {
99
+ const samples = [];
100
+ for (const message of assistants) {
101
+ const finishes = partsFor(parts, message.id).filter((part) => part.type === "step-finish");
102
+ if (finishes.length > 0) {
103
+ for (const finish of finishes)
104
+ samples.push({ tokens: finish.tokens });
105
+ continue;
106
+ }
107
+ const tokens = message.tokens;
108
+ const total = finite(tokens.input) + finite(tokens.output) + finite(tokens.reasoning) + finite(tokens.cache.read) + finite(tokens.cache.write);
109
+ if (message.time.completed && total > 0)
110
+ samples.push({ tokens });
111
+ }
112
+ return samples;
113
+ }
114
+ function sumUsage(samples) {
115
+ const result = {
116
+ input: 0,
117
+ output: 0,
118
+ reasoning: 0,
119
+ cache: { read: 0, write: 0 }
120
+ };
121
+ for (const sample of samples) {
122
+ result.input += finite(sample.tokens.input);
123
+ result.output += finite(sample.tokens.output);
124
+ result.reasoning += finite(sample.tokens.reasoning);
125
+ result.cache.read += finite(sample.tokens.cache.read);
126
+ result.cache.write += finite(sample.tokens.cache.write);
127
+ }
128
+ return result;
129
+ }
130
+ function promptTokens(tokens) {
131
+ return finite(tokens.input) + finite(tokens.cache.read) + finite(tokens.cache.write);
132
+ }
133
+ function cacheMetrics(sessionTokens, normalUsage, allUsage) {
134
+ const hitRates = [];
135
+ for (const sample of normalUsage) {
136
+ const tokens = sample.tokens;
137
+ const read2 = finite(tokens.cache?.read);
138
+ const prompt2 = promptTokens(tokens);
139
+ if (prompt2 > 0)
140
+ hitRates.push(read2 / prompt2 * 100);
141
+ }
142
+ const latestHit = hitRates.at(-1);
143
+ const previousHit = hitRates.at(-2);
144
+ const aggregate = sessionTokens ?? sumUsage(allUsage);
145
+ const input = finite(aggregate.input);
146
+ const read = finite(aggregate.cache.read);
147
+ const write = finite(aggregate.cache.write);
148
+ const prompt = input + read + write;
149
+ return {
150
+ latestHit,
151
+ trend: latestHit !== undefined && previousHit !== undefined ? latestHit - previousHit : undefined,
152
+ totalHit: prompt > 0 ? read / prompt * 100 : undefined,
153
+ read,
154
+ miss: input + write
155
+ };
156
+ }
157
+
158
+ // src/metrics/context.ts
159
+ function estimateSessionTokens(messages, parts, activeStart, session, config, estimator) {
160
+ const agentConfig = config;
161
+ const agentName = session?.agent ?? agentConfig.default_agent;
162
+ const agentPrompt = agentName ? agentConfig.agent?.[agentName]?.prompt ?? "" : "";
163
+ const result = {
164
+ system: estimator.count(`agent:${agentName ?? "default"}:prompt`, agentPrompt),
165
+ user: 0,
166
+ agent: 0,
167
+ toolCall: 0,
168
+ toolResult: 0,
169
+ output: 0,
170
+ reasoning: 0,
171
+ total: 0
172
+ };
173
+ for (let index = activeStart;index < messages.length; index++) {
174
+ const message = messages[index];
175
+ if (message.role === "user") {
176
+ result.system += estimator.count(`message:${message.id}:system`, message.system ?? "");
177
+ }
178
+ for (const part of partsFor(parts, message.id)) {
179
+ if (message.role === "user" && part.type === "text" && !part.ignored) {
180
+ result.user += estimator.count(`part:${part.id}:text`, part.text);
181
+ } else if (message.role === "user" && part.type === "compaction") {
182
+ result.user += estimator.count(`part:${part.id}:compaction`, "What did we do so far?");
183
+ } else if (message.role === "assistant" && part.type === "text") {
184
+ result.output += estimator.count(`part:${part.id}:text`, part.text);
185
+ } else if (message.role === "assistant" && part.type === "reasoning") {
186
+ result.reasoning += estimator.count(`part:${part.id}:text`, part.text);
187
+ } else if (message.role === "assistant" && part.type === "tool") {
188
+ const input = estimator.count(`part:${part.id}:input`, serializedInput(part));
189
+ if (part.tool === "task")
190
+ result.agent += input;
191
+ else
192
+ result.toolCall += input;
193
+ if (part.state.status === "completed") {
194
+ const output = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output;
195
+ result.toolResult += estimator.count(`part:${part.id}:output`, output);
196
+ } else if (part.state.status === "error") {
197
+ result.toolResult += estimator.count(`part:${part.id}:error`, part.state.error);
198
+ }
199
+ } else if (part.type === "subtask") {
200
+ result.agent += estimator.count(`part:${part.id}:subtask`, part.prompt || part.description);
201
+ } else if (part.type === "agent") {
202
+ result.agent += estimator.count(`part:${part.id}:agent`, part.source?.value ?? "");
203
+ }
204
+ }
205
+ }
206
+ result.total = result.system + result.user + result.agent + result.toolCall + result.toolResult + result.output + result.reasoning;
207
+ return result;
208
+ }
209
+ function activeContextStart(messages, parts) {
210
+ const messageIndex = new Map(messages.map((message, index) => [message.id, index]));
211
+ for (let index = messages.length - 1;index >= 0; index--) {
212
+ const summary = messages[index];
213
+ if (summary.role !== "assistant" || !summary.summary || !summary.finish || summary.error !== undefined) {
214
+ continue;
215
+ }
216
+ const compactionIndex = messageIndex.get(summary.parentID);
217
+ if (compactionIndex === undefined)
218
+ continue;
219
+ const compaction = partsFor(parts, summary.parentID).find((part) => part.type === "compaction");
220
+ if (!compaction)
221
+ continue;
222
+ const tailIndex = compaction.tail_start_id ? messageIndex.get(compaction.tail_start_id) : undefined;
223
+ return tailIndex !== undefined && tailIndex < compactionIndex ? tailIndex : compactionIndex;
224
+ }
225
+ return 0;
226
+ }
227
+ function skillName(part) {
228
+ const metadataName = "metadata" in part.state ? part.state.metadata?.name : undefined;
229
+ if (typeof metadataName === "string" && metadataName)
230
+ return metadataName;
231
+ const output = part.state.status === "completed" ? part.state.output : undefined;
232
+ const outputMatch = output?.match(/^#{1,2}\s*Skill:\s*(.+)$/m);
233
+ if (outputMatch?.[1])
234
+ return outputMatch[1].trim();
235
+ const input = part.state.input;
236
+ if (typeof input === "object" && input !== null && "name" in input) {
237
+ const inputName = input.name;
238
+ if (typeof inputName === "string" && inputName)
239
+ return inputName;
240
+ }
241
+ return;
242
+ }
243
+ function loadedSkills(messages, parts, activeStart, estimator) {
244
+ const skills = new Map;
245
+ for (let index = activeStart;index < messages.length; index++) {
246
+ const message = messages[index];
247
+ if (message.role !== "assistant")
248
+ continue;
249
+ for (const part of partsFor(parts, message.id)) {
250
+ if (part.type !== "tool" || part.tool !== "skill" || part.state.status !== "completed") {
251
+ continue;
252
+ }
253
+ if (part.state.time.compacted)
254
+ continue;
255
+ const name = skillName(part);
256
+ if (!name)
257
+ continue;
258
+ const tokens = estimator.count(`part:${part.id}:output`, part.state.output);
259
+ skills.set(name, { name, tokens });
260
+ }
261
+ }
262
+ return [...skills.values()];
263
+ }
264
+
265
+ // src/metrics/speed.ts
266
+ function firstTokenTime(parts, message) {
267
+ let first = Number.POSITIVE_INFINITY;
268
+ for (const part of partsFor(parts, message.id)) {
269
+ if (part.type !== "text" && part.type !== "reasoning")
270
+ continue;
271
+ const start = part.time?.start;
272
+ if (typeof start === "number" && start < first)
273
+ first = start;
274
+ }
275
+ return Number.isFinite(first) ? first : undefined;
276
+ }
277
+ function timeToFirstToken(parts, message) {
278
+ if (!message)
279
+ return;
280
+ const first = firstTokenTime(parts, message);
281
+ if (first === undefined || first < message.time.created)
282
+ return;
283
+ return first - message.time.created;
284
+ }
285
+ function generationSample(message, parts, estimator) {
286
+ let tokens = 0;
287
+ let duration = 0;
288
+ for (const part of partsFor(parts, message.id)) {
289
+ if (part.type !== "text" && part.type !== "reasoning")
290
+ continue;
291
+ const start = part.time?.start;
292
+ const end = part.time?.end;
293
+ if (typeof start !== "number" || typeof end !== "number" || end <= start)
294
+ continue;
295
+ tokens += estimator.count(`part:${part.id}:text`, part.text);
296
+ duration += end - start;
297
+ }
298
+ if (tokens === 0 || duration < 500)
299
+ return;
300
+ return { speed: tokens / duration * 1000, tokens, duration };
301
+ }
302
+ function streamingSpeed(message, parts, estimator, now) {
303
+ if (!message || message.time.completed)
304
+ return;
305
+ let tokens = 0;
306
+ let duration = 0;
307
+ for (const part of partsFor(parts, message.id)) {
308
+ if (part.type !== "text" && part.type !== "reasoning")
309
+ continue;
310
+ const start = part.time?.start;
311
+ if (part.time?.end !== undefined || typeof start !== "number" || now <= start)
312
+ continue;
313
+ tokens += estimator.count(`part:${part.id}:text`, part.text);
314
+ duration += now - start;
315
+ }
316
+ if (tokens === 0 || duration < 500)
317
+ return;
318
+ return tokens / duration * 1000;
319
+ }
320
+ function speedMetrics(assistants, parts, estimator, now) {
321
+ const samples = assistants.filter((message) => message.time.completed !== undefined).map((message) => generationSample(message, parts, estimator)).filter((sample) => sample !== undefined);
322
+ const recent = samples.slice(-8);
323
+ const totalTokens = recent.reduce((sum, sample) => sum + sample.tokens, 0);
324
+ const totalDuration = recent.reduce((sum, sample) => sum + sample.duration, 0);
325
+ return {
326
+ ttft: timeToFirstToken(parts, assistants.at(-1)),
327
+ now: streamingSpeed(assistants.at(-1), parts, estimator, now),
328
+ last: samples.at(-1)?.speed,
329
+ average: totalDuration > 0 ? totalTokens / totalDuration * 1000 : undefined,
330
+ trend: recent.map((sample) => sample.speed)
331
+ };
332
+ }
333
+
334
+ // src/metrics/read.ts
335
+ function emptyTokenEstimate() {
336
+ return {
337
+ system: 0,
338
+ user: 0,
339
+ agent: 0,
340
+ toolCall: 0,
341
+ toolResult: 0,
342
+ output: 0,
343
+ reasoning: 0,
344
+ total: 0
345
+ };
346
+ }
347
+ function readMetrics(api, sessionID, now, estimator) {
348
+ if (!sessionID) {
349
+ return {
350
+ cache: { read: 0, miss: 0 },
351
+ tokens: emptyTokenEstimate(),
352
+ skills: [],
353
+ speed: { trend: [] }
354
+ };
355
+ }
356
+ const messages = api.state.session.messages(sessionID);
357
+ const parts = new Map;
358
+ for (const message of messages)
359
+ parts.set(message.id, api.state.part(message.id));
360
+ const allAssistants = assistantMessages(messages, true);
361
+ const assistants = assistantMessages(messages, false);
362
+ const allUsage = usageSamples(allAssistants, parts);
363
+ const normalUsage = usageSamples(assistants, parts);
364
+ const activeStart = activeContextStart(messages, parts);
365
+ const session = api.state.session.get(sessionID);
366
+ return {
367
+ cache: cacheMetrics(session?.tokens, normalUsage, allUsage),
368
+ tokens: estimateSessionTokens(messages, parts, activeStart, session, api.state.config, estimator),
369
+ skills: loadedSkills(messages, parts, activeStart, estimator),
370
+ speed: speedMetrics(assistants, parts, estimator, now)
371
+ };
372
+ }
373
+
374
+ // src/token-estimator.ts
375
+ function estimateTokenUnits(text) {
376
+ let units = 0;
377
+ for (const char of text) {
378
+ const code = char.codePointAt(0) ?? 0;
379
+ const isWide = code >= 11904 && code <= 40959 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255;
380
+ units += isWide ? 4 : 1;
381
+ }
382
+ return units;
383
+ }
384
+
385
+ class TokenEstimator {
386
+ cache = new Map;
387
+ count(key, text) {
388
+ const cached = this.cache.get(key);
389
+ if (cached?.text === text)
390
+ return cached.tokens;
391
+ const units = estimateTokenUnits(text);
392
+ const tokens = Math.ceil(units / 4);
393
+ this.cache.set(key, { text, units, tokens });
394
+ return tokens;
395
+ }
396
+ append(key, delta) {
397
+ const cached = this.cache.get(key);
398
+ if (!cached)
399
+ return;
400
+ const units = cached.units + estimateTokenUnits(delta);
401
+ this.cache.set(key, {
402
+ text: cached.text + delta,
403
+ units,
404
+ tokens: Math.ceil(units / 4)
405
+ });
406
+ }
407
+ clear() {
408
+ this.cache.clear();
409
+ }
410
+ }
411
+
412
+ // src/cache-view.tsx
413
+ var DEFAULT_CONTENT_WIDTH = 29;
414
+ var MIN_CONTENT_WIDTH = 20;
415
+ function SectionHeader(props) {
416
+ const prefix = `${props.open ? "\u25BC" : "\u25B6"} ${props.title}`;
417
+ return (() => {
418
+ var _el$ = _$createElement("text"), _el$2 = _$createElement("span"), _el$3 = _$createElement("b"), _el$4 = _$createElement("span");
419
+ _$insertNode(_el$, _el$2);
420
+ _$insertNode(_el$, _el$4);
421
+ _$insertNode(_el$2, _el$3);
422
+ _$insert(_el$3, prefix);
423
+ _$insert(_el$4, () => "\u2500".repeat(Math.max(1, props.width - prefix.length)));
424
+ _$effect((_p$) => {
425
+ var _v$ = props.toggle, _v$2 = {
426
+ fg: props.theme.text
427
+ }, _v$3 = {
428
+ fg: props.theme.border
429
+ };
430
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "onMouseUp", _v$, _p$.e));
431
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$2, "style", _v$2, _p$.t));
432
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$4, "style", _v$3, _p$.a));
433
+ return _p$;
434
+ }, {
435
+ e: undefined,
436
+ t: undefined,
437
+ a: undefined
438
+ });
439
+ return _el$;
440
+ })();
441
+ }
442
+ function CacheView(props) {
443
+ const estimator = new TokenEstimator;
444
+ const [refresh, setRefresh] = createSignal(0);
445
+ const [tokensOpen, setTokensOpen] = createSignal(true);
446
+ const [skillsOpen, setSkillsOpen] = createSignal(true);
447
+ const [speedOpen, setSpeedOpen] = createSignal(true);
448
+ const [contentWidth, setContentWidth] = createSignal(DEFAULT_CONTENT_WIDTH);
449
+ let estimatorSession = "";
450
+ let boxElement;
451
+ const metrics = createMemo(() => {
452
+ refresh();
453
+ if (estimatorSession !== props.sessionID) {
454
+ estimator.clear();
455
+ estimatorSession = props.sessionID;
456
+ }
457
+ return readMetrics(props.api, props.sessionID, Date.now(), estimator);
458
+ });
459
+ const hitColor = createMemo(() => {
460
+ const hit = metrics().cache.latestHit;
461
+ if (hit === undefined)
462
+ return props.theme.textMuted;
463
+ if (hit >= 85)
464
+ return props.theme.success;
465
+ if (hit >= 60)
466
+ return props.theme.warning;
467
+ return props.theme.error;
468
+ });
469
+ const trendColor = createMemo(() => {
470
+ const trend = metrics().cache.trend;
471
+ if (trend === undefined || Math.abs(trend) < 0.05)
472
+ return props.theme.textMuted;
473
+ return trend > 0 ? props.theme.success : props.theme.error;
474
+ });
475
+ const hitBarWidth = createMemo(() => {
476
+ const percent = formatPercent(metrics().cache.latestHit);
477
+ const trend = formatHitTrend(metrics().cache.trend);
478
+ const fixedWidth = "Hit ".length + 2 + 1 + percent.length + 1 + trend.length;
479
+ return Math.max(3, contentWidth() - fixedWidth);
480
+ });
481
+ onMount(() => {
482
+ let refreshTimer;
483
+ const matches = (event) => event.properties.sessionID === props.sessionID;
484
+ const bump = () => setRefresh((value) => value + 1);
485
+ const scheduleRefresh = (event) => {
486
+ if (!matches(event) || refreshTimer !== undefined)
487
+ return;
488
+ refreshTimer = setTimeout(() => {
489
+ refreshTimer = undefined;
490
+ bump();
491
+ }, 100);
492
+ };
493
+ const message = props.api.event.on("message.updated", scheduleRefresh);
494
+ const messageRemoved = props.api.event.on("message.removed", (event) => {
495
+ if (!matches(event))
496
+ return;
497
+ estimator.clear();
498
+ scheduleRefresh(event);
499
+ });
500
+ const part = props.api.event.on("message.part.updated", scheduleRefresh);
501
+ const partRemoved = props.api.event.on("message.part.removed", (event) => {
502
+ if (!matches(event))
503
+ return;
504
+ estimator.clear();
505
+ scheduleRefresh(event);
506
+ });
507
+ const delta = props.api.event.on("message.part.delta", (event) => {
508
+ if (!matches(event))
509
+ return;
510
+ if (event.properties.field === "text") {
511
+ estimator.append(`part:${event.properties.partID}:text`, event.properties.delta);
512
+ }
513
+ scheduleRefresh(event);
514
+ });
515
+ const session = props.api.event.on("session.updated", scheduleRefresh);
516
+ const compacted = props.api.event.on("session.compacted", scheduleRefresh);
517
+ onCleanup(() => {
518
+ if (refreshTimer !== undefined)
519
+ clearTimeout(refreshTimer);
520
+ message();
521
+ messageRemoved();
522
+ part();
523
+ partRemoved();
524
+ delta();
525
+ session();
526
+ compacted();
527
+ });
528
+ });
529
+ return (() => {
530
+ var _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$7 = _$createElement("span"), _el$8 = _$createElement("b"), _el$0 = _$createElement("text"), _el$1 = _$createElement("span"), _el$11 = _$createElement("span"), _el$12 = _$createTextNode(`[`), _el$13 = _$createTextNode(`]`), _el$14 = _$createElement("span"), _el$15 = _$createTextNode(` `), _el$16 = _$createElement("span"), _el$17 = _$createTextNode(` `), _el$18 = _$createElement("text"), _el$19 = _$createElement("text"), _el$20 = _$createElement("text");
531
+ _$insertNode(_el$5, _el$6);
532
+ _$insertNode(_el$5, _el$0);
533
+ _$insertNode(_el$5, _el$18);
534
+ _$insertNode(_el$5, _el$19);
535
+ _$insertNode(_el$5, _el$20);
536
+ _$use((element) => {
537
+ boxElement = element;
538
+ }, _el$5);
539
+ _$setProp(_el$5, "flexDirection", "column");
540
+ _$setProp(_el$5, "onSizeChange", () => {
541
+ const width = boxElement?.width;
542
+ if (typeof width === "number" && width > 0) {
543
+ setContentWidth(Math.max(MIN_CONTENT_WIDTH, width));
544
+ }
545
+ });
546
+ _$insertNode(_el$6, _el$7);
547
+ _$insertNode(_el$7, _el$8);
548
+ _$insertNode(_el$8, _$createTextNode(`Cache View`));
549
+ _$insertNode(_el$0, _el$1);
550
+ _$insertNode(_el$0, _el$11);
551
+ _$insertNode(_el$0, _el$14);
552
+ _$insertNode(_el$0, _el$16);
553
+ _$insertNode(_el$1, _$createTextNode(`Hit `));
554
+ _$insertNode(_el$11, _el$12);
555
+ _$insertNode(_el$11, _el$13);
556
+ _$insert(_el$11, () => progressBar(metrics().cache.latestHit, hitBarWidth()), _el$13);
557
+ _$insertNode(_el$14, _el$15);
558
+ _$insert(_el$14, () => formatPercent(metrics().cache.latestHit), null);
559
+ _$insertNode(_el$16, _el$17);
560
+ _$insert(_el$16, () => formatHitTrend(metrics().cache.trend), null);
561
+ _$insert(_el$18, () => row("Session Hit", formatPercent(metrics().cache.totalHit), contentWidth()));
562
+ _$insert(_el$19, () => row("Read", `${formatCompact(metrics().cache.read)} tok`, contentWidth()));
563
+ _$insert(_el$20, () => row("Miss", `${formatCompact(metrics().cache.miss)} tok`, contentWidth()));
564
+ _$insert(_el$5, _$createComponent(SectionHeader, {
565
+ title: "Speed",
566
+ get open() {
567
+ return speedOpen();
568
+ },
569
+ get width() {
570
+ return contentWidth();
571
+ },
572
+ get theme() {
573
+ return props.theme;
574
+ },
575
+ toggle: () => setSpeedOpen((value) => !value)
576
+ }), null);
577
+ _$insert(_el$5, (() => {
578
+ var _c$ = _$memo(() => !!speedOpen());
579
+ return () => _c$() && [(() => {
580
+ var _el$21 = _$createElement("text");
581
+ _$insert(_el$21, () => row("Now", formatSpeed(metrics().speed.now, true), contentWidth()));
582
+ _$effect((_$p) => _$setProp(_el$21, "fg", props.theme.textMuted, _$p));
583
+ return _el$21;
584
+ })(), (() => {
585
+ var _el$22 = _$createElement("text");
586
+ _$insert(_el$22, () => row("TTFT", formatDuration(metrics().speed.ttft, true), contentWidth()));
587
+ _$effect((_$p) => _$setProp(_el$22, "fg", props.theme.textMuted, _$p));
588
+ return _el$22;
589
+ })(), (() => {
590
+ var _el$23 = _$createElement("text");
591
+ _$insert(_el$23, () => row("Last", formatSpeed(metrics().speed.last, true), contentWidth()));
592
+ _$effect((_$p) => _$setProp(_el$23, "fg", props.theme.textMuted, _$p));
593
+ return _el$23;
594
+ })(), (() => {
595
+ var _el$24 = _$createElement("text");
596
+ _$insert(_el$24, () => row("Avg", formatSpeed(metrics().speed.average, true), contentWidth()));
597
+ _$effect((_$p) => _$setProp(_el$24, "fg", props.theme.textMuted, _$p));
598
+ return _el$24;
599
+ })(), (() => {
600
+ var _el$25 = _$createElement("text");
601
+ _$insert(_el$25, () => row("Trend", sparkline(metrics().speed.trend), contentWidth()));
602
+ _$effect((_$p) => _$setProp(_el$25, "fg", props.theme.textMuted, _$p));
603
+ return _el$25;
604
+ })()];
605
+ })(), null);
606
+ _$insert(_el$5, _$createComponent(SectionHeader, {
607
+ title: "Estimated Tokens",
608
+ get open() {
609
+ return tokensOpen();
610
+ },
611
+ get width() {
612
+ return contentWidth();
613
+ },
614
+ get theme() {
615
+ return props.theme;
616
+ },
617
+ toggle: () => setTokensOpen((value) => !value)
618
+ }), null);
619
+ _$insert(_el$5, (() => {
620
+ var _c$2 = _$memo(() => !!tokensOpen());
621
+ return () => _c$2() && [(() => {
622
+ var _el$26 = _$createElement("text");
623
+ _$insert(_el$26, () => row("Prompt", `${formatCompact(metrics().tokens.system + metrics().tokens.user + metrics().tokens.agent)} tok`, contentWidth()));
624
+ _$effect((_$p) => _$setProp(_el$26, "fg", props.theme.textMuted, _$p));
625
+ return _el$26;
626
+ })(), (() => {
627
+ var _el$27 = _$createElement("text");
628
+ _$insert(_el$27, () => row("Tool Call", `${formatCompact(metrics().tokens.toolCall)} tok`, contentWidth()));
629
+ _$effect((_$p) => _$setProp(_el$27, "fg", props.theme.textMuted, _$p));
630
+ return _el$27;
631
+ })(), (() => {
632
+ var _el$28 = _$createElement("text");
633
+ _$insert(_el$28, () => row("Tool Result", `${formatCompact(metrics().tokens.toolResult)} tok`, contentWidth()));
634
+ _$effect((_$p) => _$setProp(_el$28, "fg", props.theme.textMuted, _$p));
635
+ return _el$28;
636
+ })(), (() => {
637
+ var _el$29 = _$createElement("text");
638
+ _$insert(_el$29, () => row("Agent Reasoning", `${formatCompact(metrics().tokens.reasoning)} tok`, contentWidth()));
639
+ _$effect((_$p) => _$setProp(_el$29, "fg", props.theme.textMuted, _$p));
640
+ return _el$29;
641
+ })(), (() => {
642
+ var _el$30 = _$createElement("text");
643
+ _$insert(_el$30, () => row("Agent Output", `${formatCompact(metrics().tokens.output)} tok`, contentWidth()));
644
+ _$effect((_$p) => _$setProp(_el$30, "fg", props.theme.textMuted, _$p));
645
+ return _el$30;
646
+ })(), (() => {
647
+ var _el$31 = _$createElement("text");
648
+ _$insert(_el$31, () => row("Total", `${formatCompact(metrics().tokens.total)} tok`, contentWidth()));
649
+ _$effect((_$p) => _$setProp(_el$31, "fg", props.theme.text, _$p));
650
+ return _el$31;
651
+ })()];
652
+ })(), null);
653
+ _$insert(_el$5, (() => {
654
+ var _c$3 = _$memo(() => metrics().skills.length > 0);
655
+ return () => _c$3() && [_$createComponent(SectionHeader, {
656
+ get title() {
657
+ return `Loaded Skills (${metrics().skills.length})`;
658
+ },
659
+ get open() {
660
+ return skillsOpen();
661
+ },
662
+ get width() {
663
+ return contentWidth();
664
+ },
665
+ get theme() {
666
+ return props.theme;
667
+ },
668
+ toggle: () => setSkillsOpen((value) => !value)
669
+ }), _$memo(() => _$memo(() => !!skillsOpen())() && metrics().skills.map((skill) => (() => {
670
+ var _el$32 = _$createElement("text");
671
+ _$insert(_el$32, () => skillRow(skill, contentWidth()));
672
+ _$effect((_$p) => _$setProp(_el$32, "fg", props.theme.textMuted, _$p));
673
+ return _el$32;
674
+ })()))];
675
+ })(), null);
676
+ _$effect((_p$) => {
677
+ var _v$4 = {
678
+ fg: props.theme.text
679
+ }, _v$5 = {
680
+ fg: props.theme.text
681
+ }, _v$6 = {
682
+ fg: hitColor()
683
+ }, _v$7 = {
684
+ fg: props.theme.text
685
+ }, _v$8 = {
686
+ fg: trendColor()
687
+ }, _v$9 = props.theme.textMuted, _v$0 = props.theme.textMuted, _v$1 = props.theme.textMuted;
688
+ _v$4 !== _p$.e && (_p$.e = _$setProp(_el$7, "style", _v$4, _p$.e));
689
+ _v$5 !== _p$.t && (_p$.t = _$setProp(_el$1, "style", _v$5, _p$.t));
690
+ _v$6 !== _p$.a && (_p$.a = _$setProp(_el$11, "style", _v$6, _p$.a));
691
+ _v$7 !== _p$.o && (_p$.o = _$setProp(_el$14, "style", _v$7, _p$.o));
692
+ _v$8 !== _p$.i && (_p$.i = _$setProp(_el$16, "style", _v$8, _p$.i));
693
+ _v$9 !== _p$.n && (_p$.n = _$setProp(_el$18, "fg", _v$9, _p$.n));
694
+ _v$0 !== _p$.s && (_p$.s = _$setProp(_el$19, "fg", _v$0, _p$.s));
695
+ _v$1 !== _p$.h && (_p$.h = _$setProp(_el$20, "fg", _v$1, _p$.h));
696
+ return _p$;
697
+ }, {
698
+ e: undefined,
699
+ t: undefined,
700
+ a: undefined,
701
+ o: undefined,
702
+ i: undefined,
703
+ n: undefined,
704
+ s: undefined,
705
+ h: undefined
706
+ });
707
+ return _el$5;
708
+ })();
709
+ }
710
+
711
+ // src/index.tsx
712
+ var tui = async (api) => {
713
+ api.slots.register({
714
+ order: 56,
715
+ slots: {
716
+ sidebar_content(ctx, props) {
717
+ return _$createComponent2(CacheView, {
718
+ api,
719
+ get sessionID() {
720
+ return props.session_id ?? "";
721
+ },
722
+ get theme() {
723
+ return ctx.theme.current;
724
+ }
725
+ });
726
+ }
727
+ }
728
+ });
729
+ };
730
+ var plugin = {
731
+ id: "opencode-cache-view",
732
+ tui
733
+ };
734
+ var src_default = plugin;
735
+ export {
736
+ src_default as default
737
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@mtayfur/opencode-cache-view",
4
+ "version": "0.0.1",
5
+ "description": "Minimal OpenCode TUI sidebar for cache hit, estimated tokens, and generation speed.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mtayfur/opencode-cache-view.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/mtayfur/opencode-cache-view/issues"
12
+ },
13
+ "homepage": "https://github.com/mtayfur/opencode-cache-view#readme",
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ "./tui": "./dist/index.js"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "bun run scripts/build.ts",
26
+ "setup": "bash ./install.sh",
27
+ "setup:uninstall": "bash ./install.sh --uninstall",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepack": "bun run build"
30
+ },
31
+ "dependencies": {
32
+ "solid-js": "^1.9.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@opencode-ai/plugin": ">=1.14.0",
36
+ "@opencode-ai/sdk": ">=1.14.0",
37
+ "@opentui/core": ">=0.2.0",
38
+ "@opentui/solid": ">=0.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@opencode-ai/plugin": "^1.18.16",
42
+ "@opencode-ai/sdk": "^1.18.16",
43
+ "@opentui/core": "^0.5.1",
44
+ "@opentui/solid": "^0.5.1",
45
+ "jsonc-parser": "^3.3.1",
46
+ "typescript": "^5.8.0"
47
+ },
48
+ "engines": {
49
+ "bun": ">=1.3.14",
50
+ "opencode": ">=1.18.16 <2"
51
+ },
52
+ "keywords": [
53
+ "opencode",
54
+ "plugin",
55
+ "cache",
56
+ "tokens",
57
+ "tui"
58
+ ],
59
+ "packageManager": "bun@1.3.14"
60
+ }