opencode-cache-hit 0.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.
Files changed (47) hide show
  1. package/AGENTS.md +67 -0
  2. package/CONTRIBUTING.md +73 -0
  3. package/LICENSE +21 -0
  4. package/README.md +216 -0
  5. package/README.zh-CN.md +186 -0
  6. package/cache-hit.config.example.json +22 -0
  7. package/docs/README.md +8 -0
  8. package/docs/assets/cache-hit-panel.png +0 -0
  9. package/docs/en/design.md +228 -0
  10. package/docs/en/timeline.md +253 -0
  11. package/docs/zh-CN/design.md +229 -0
  12. package/docs/zh-CN/timeline.md +301 -0
  13. package/index.tsx +1 -0
  14. package/package.json +71 -0
  15. package/scripts/README.md +39 -0
  16. package/scripts/plot-hit-rate.ts +222 -0
  17. package/src/agents-view.tsx +55 -0
  18. package/src/cache-hit-rows.tsx +68 -0
  19. package/src/child-session-sync.ts +93 -0
  20. package/src/format-cache-ui.ts +21 -0
  21. package/src/format-cost.ts +90 -0
  22. package/src/format-tokens.ts +5 -0
  23. package/src/i18n.ts +82 -0
  24. package/src/load-config.ts +29 -0
  25. package/src/main-session-view.tsx +76 -0
  26. package/src/message-timing.ts +35 -0
  27. package/src/plugin-config.ts +116 -0
  28. package/src/plugin.tsx +33 -0
  29. package/src/session-list.ts +11 -0
  30. package/src/sidebar-host.tsx +121 -0
  31. package/src/stats.ts +141 -0
  32. package/src/timeline/collector.ts +156 -0
  33. package/src/timeline/records.ts +73 -0
  34. package/src/timeline/rotation.ts +47 -0
  35. package/src/timeline/types.ts +22 -0
  36. package/src/timeline/writer.ts +134 -0
  37. package/src/tui-panel/README.md +78 -0
  38. package/src/tui-panel/README.zh-CN.md +76 -0
  39. package/src/tui-panel/components.tsx +163 -0
  40. package/src/tui-panel/index.ts +41 -0
  41. package/src/tui-panel/layout.ts +107 -0
  42. package/src/tui-panel/palette.ts +93 -0
  43. package/src/tui-panel/use-panel-layout.ts +69 -0
  44. package/src/types.ts +71 -0
  45. package/src/use-cache-hit-metrics.ts +103 -0
  46. package/src/version.ts +1 -0
  47. package/src/widget.tsx +117 -0
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Plot cache hit % from timeline JSONL (Bun, no npm deps).
4
+ *
5
+ * bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl
6
+ * bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl --root ses_xxx -o hit.svg
7
+ * bun scripts/plot-hit-rate.ts logs/timeline-2026-05-31.jsonl --by-root -o hit.svg
8
+ */
9
+
10
+ type Row = {
11
+ rootSessionId?: string
12
+ scope?: string
13
+ created?: number
14
+ completedAt?: number
15
+ hitPercent?: number | null
16
+ skippedForHit?: boolean
17
+ }
18
+
19
+ type Point = { t: number; y: number }
20
+
21
+ const COLORS = ["#3fb950", "#58a6ff", "#d29922", "#f85149", "#a371f7", "#79c0ff"]
22
+
23
+ function parseArgs(argv: string[]) {
24
+ const positional: string[] = []
25
+ let root: string | undefined
26
+ let output: string | undefined
27
+ let byRoot = false
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i]
30
+ if (a === "--root") root = argv[++i]
31
+ else if (a === "--by-root") byRoot = true
32
+ else if (a === "-o" || a === "--output") output = argv[++i]
33
+ else if (!a.startsWith("-")) positional.push(a)
34
+ }
35
+ if (!positional[0]) {
36
+ console.error(
37
+ "usage: bun scripts/plot-hit-rate.ts <file.jsonl> [--root ID | --by-root] [-o out.svg]",
38
+ )
39
+ process.exit(1)
40
+ }
41
+ return { file: positional[0], root, byRoot, output }
42
+ }
43
+
44
+ async function loadRecords(path: string, root?: string): Promise<Row[]> {
45
+ const text = await Bun.file(path).text()
46
+ const rows: Row[] = []
47
+ for (const line of text.split("\n")) {
48
+ const s = line.trim()
49
+ if (!s) continue
50
+ const rec = JSON.parse(s) as Row
51
+ if (root && rec.rootSessionId !== root) continue
52
+ if (rec.skippedForHit) continue
53
+ if (rec.hitPercent == null) continue
54
+ rows.push(rec)
55
+ }
56
+ return rows
57
+ }
58
+
59
+ function timeOf(r: Row): number {
60
+ return r.completedAt ?? r.created ?? 0
61
+ }
62
+
63
+ function groupByRoot(rows: Row[]): Map<string, Row[]> {
64
+ const map = new Map<string, Row[]>()
65
+ for (const r of rows) {
66
+ const id = r.rootSessionId ?? "(unknown)"
67
+ const list = map.get(id) ?? []
68
+ list.push(r)
69
+ map.set(id, list)
70
+ }
71
+ for (const list of map.values()) {
72
+ list.sort((a, b) => timeOf(a) - timeOf(b))
73
+ }
74
+ return new Map([...map.entries()].sort((a, b) => a[0].localeCompare(b[0])))
75
+ }
76
+
77
+ function shortId(id: string, max = 20): string {
78
+ return id.length <= max ? id : `…${id.slice(-max)}`
79
+ }
80
+
81
+ function asciiChart(values: number[], width = 48, height = 8): string {
82
+ const grid: string[][] = Array.from({ length: height }, () => Array(width).fill(" "))
83
+ const row = (v: number) =>
84
+ Math.min(height - 1, Math.max(0, Math.round((v / 100) * (height - 1))))
85
+ for (let i = 0; i < values.length; i++) {
86
+ const x = Math.round((i / Math.max(1, values.length - 1)) * (width - 1))
87
+ grid[height - 1 - row(values[i])][x] = "●"
88
+ }
89
+ const yLabels = ["100", " 50", " 0"]
90
+ return grid.map((line, i) => `${yLabels[i] ?? " "} │${line.join("")}`).join("\n")
91
+ }
92
+
93
+ function esc(s: string): string {
94
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;")
95
+ }
96
+
97
+ function svgChartSingle(values: number[], title: string, w = 640, h = 240): string {
98
+ const pad = { l: 48, r: 16, t: 24, b: 32 }
99
+ const innerW = w - pad.l - pad.r
100
+ const innerH = h - pad.t - pad.b
101
+ const pts = values.map((v, i) => {
102
+ const x = pad.l + (i / Math.max(1, values.length - 1)) * innerW
103
+ const y = pad.t + innerH - (v / 100) * innerH
104
+ return { x, y, v }
105
+ })
106
+ const poly = pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
107
+ return `<?xml version="1.0" encoding="UTF-8"?>
108
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
109
+ <rect width="100%" height="100%" fill="#0d1117"/>
110
+ <text x="${pad.l}" y="16" fill="#e6edf3" font-family="system-ui,sans-serif" font-size="12">${esc(title)}</text>
111
+ <line x1="${pad.l}" y1="${pad.t + innerH}" x2="${pad.l + innerW}" y2="${pad.t + innerH}" stroke="#30363d"/>
112
+ <line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${pad.t + innerH}" stroke="#30363d"/>
113
+ <text x="8" y="${pad.t + 4}" fill="#8b949e" font-size="10">100%</text>
114
+ <text x="8" y="${pad.t + innerH}" fill="#8b949e" font-size="10">0%</text>
115
+ <polyline fill="none" stroke="${COLORS[0]}" stroke-width="2" points="${poly}"/>
116
+ ${pts.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${COLORS[0]}"/>`).join("\n ")}
117
+ </svg>`
118
+ }
119
+
120
+ function svgChartByRoot(
121
+ series: { id: string; points: Point[] }[],
122
+ title: string,
123
+ w = 720,
124
+ h = 280,
125
+ ): string {
126
+ const pad = { l: 48, r: 16, t: 28, b: 56 }
127
+ const innerW = w - pad.l - pad.r
128
+ const innerH = h - pad.t - pad.b
129
+ let tMin = Infinity
130
+ let tMax = -Infinity
131
+ for (const s of series) {
132
+ for (const p of s.points) {
133
+ if (p.t < tMin) tMin = p.t
134
+ if (p.t > tMax) tMax = p.t
135
+ }
136
+ }
137
+ const span = Math.max(1, tMax - tMin)
138
+ const toXY = (p: Point) => ({
139
+ x: pad.l + ((p.t - tMin) / span) * innerW,
140
+ y: pad.t + innerH - (p.y / 100) * innerH,
141
+ })
142
+
143
+ const bodies: string[] = []
144
+ const legend: string[] = []
145
+ series.forEach((s, i) => {
146
+ const color = COLORS[i % COLORS.length]
147
+ const xy = s.points.map(toXY)
148
+ const poly = xy.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
149
+ bodies.push(
150
+ `<polyline fill="none" stroke="${color}" stroke-width="2" points="${poly}"/>`,
151
+ )
152
+ for (const p of xy) {
153
+ bodies.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2.5" fill="${color}"/>`)
154
+ }
155
+ const ly = pad.t + innerH + 18 + i * 14
156
+ legend.push(
157
+ `<line x1="${pad.l}" y1="${ly - 4}" x2="${pad.l + 20}" y2="${ly - 4}" stroke="${color}" stroke-width="2"/>`,
158
+ `<text x="${pad.l + 26}" y="${ly}" fill="#e6edf3" font-size="11" font-family="ui-monospace,monospace">${esc(shortId(s.id))} (${s.points.length})</text>`,
159
+ )
160
+ })
161
+
162
+ return `<?xml version="1.0" encoding="UTF-8"?>
163
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
164
+ <rect width="100%" height="100%" fill="#0d1117"/>
165
+ <text x="${pad.l}" y="18" fill="#e6edf3" font-family="system-ui,sans-serif" font-size="12">${esc(title)}</text>
166
+ <line x1="${pad.l}" y1="${pad.t + innerH}" x2="${pad.l + innerW}" y2="${pad.t + innerH}" stroke="#30363d"/>
167
+ <line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${pad.t + innerH}" stroke="#30363d"/>
168
+ <text x="8" y="${pad.t + 4}" fill="#8b949e" font-size="10">100%</text>
169
+ <text x="8" y="${pad.t + innerH}" fill="#8b949e" font-size="10">0%</text>
170
+ ${bodies.join("\n ")}
171
+ ${legend.join("\n ")}
172
+ </svg>`
173
+ }
174
+
175
+ const { file, root, byRoot, output } = parseArgs(process.argv.slice(2))
176
+ const allRows = await loadRecords(file, root)
177
+
178
+ if (allRows.length === 0) {
179
+ console.error("no plottable rows (check --root or hitPercent)")
180
+ process.exit(1)
181
+ }
182
+
183
+ const useByRoot = byRoot && !root
184
+
185
+ if (useByRoot) {
186
+ const groups = groupByRoot(allRows)
187
+ console.log(`${groups.size} root session(s), ${allRows.length} calls total`)
188
+ for (const [id, list] of groups) {
189
+ const hits = list.map((r) => r.hitPercent as number)
190
+ const avg = hits.reduce((a, b) => a + b, 0) / hits.length
191
+ console.log(` ${shortId(id, 28)}: ${list.length} calls, avg hit ${avg.toFixed(1)}%`)
192
+ }
193
+ if (output) {
194
+ const series = [...groups.entries()].map(([id, list]) => ({
195
+ id,
196
+ points: list.map((r) => ({ t: timeOf(r), y: r.hitPercent as number })),
197
+ }))
198
+ await Bun.write(output, svgChartByRoot(series, `${file} · by rootSessionId`))
199
+ console.log("")
200
+ console.log(`wrote ${output} (${groups.size} series)`)
201
+ } else {
202
+ console.log("")
203
+ console.log("tip: add -o /tmp/hit.svg (time-aligned, one color per rootSessionId)")
204
+ }
205
+ } else {
206
+ const sorted = [...allRows].sort((a, b) => timeOf(a) - timeOf(b))
207
+ const hits = sorted.map((r) => r.hitPercent as number)
208
+ const avg = hits.reduce((a, b) => a + b, 0) / hits.length
209
+ const label = root ? ` (${shortId(root)})` : " (all roots merged)"
210
+ console.log(`${sorted.length} calls, avg hit ${avg.toFixed(1)}%${label}`)
211
+ console.log("")
212
+ console.log(asciiChart(hits))
213
+ if (output) {
214
+ const title = root ? `${file} · ${shortId(root)}` : `${file} · merged`
215
+ await Bun.write(output, svgChartSingle(hits, title))
216
+ console.log("")
217
+ console.log(`wrote ${output}`)
218
+ } else {
219
+ console.log("")
220
+ console.log("tip: -o /tmp/hit.svg | --by-root for multi-session chart")
221
+ }
222
+ }
@@ -0,0 +1,55 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { For, Show } from "solid-js"
3
+ import { TokenDetailRows } from "./cache-hit-rows.tsx"
4
+ import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
5
+ import { aggregateSubAgents } from "./stats.ts"
6
+ import { formatTokenCount } from "./format-tokens.ts"
7
+ import { TuiMetricRow, truncateVisual, type PanelLayout } from "./tui-panel/index.ts"
8
+ import type { SubAgentSummary } from "./types.ts"
9
+
10
+ function agentRowLabel(id: string, gauge: number): string {
11
+ const tail = id.length > 10 ? id.slice(-8) : id
12
+ const raw = id.length > 10 ? "\u2026" + tail : tail
13
+ return truncateVisual(raw, Math.max(6, gauge - 14))
14
+ }
15
+
16
+ function subHasActivity(sub: SubAgentSummary): boolean {
17
+ return sub.cost > 0 || sub.cacheRead > 0 || sub.cacheWrite > 0 || sub.input > 0
18
+ }
19
+
20
+ export function AgentsView(props: {
21
+ m: CacheHitMetrics
22
+ layout: PanelLayout
23
+ formatCost: (n: number) => string
24
+ }) {
25
+ const { m, layout } = props
26
+ const total = () => aggregateSubAgents(m.subs())
27
+
28
+ return (
29
+ <>
30
+ <TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={total()} />
31
+ <Show when={total().cost > 0}>
32
+ <TuiMetricRow
33
+ pal={m.pal()}
34
+ layout={layout}
35
+ label={m.t().cost}
36
+ value={props.formatCost(total().cost)}
37
+ fg={m.pal().success}
38
+ />
39
+ </Show>
40
+ <For each={m.subs()}>
41
+ {(sub) => (
42
+ <Show when={subHasActivity(sub)}>
43
+ <TuiMetricRow
44
+ pal={m.pal()}
45
+ layout={layout}
46
+ label={" " + agentRowLabel(sub.id, layout.gauge())}
47
+ value={sub.cost > 0 ? props.formatCost(sub.cost) : formatTokenCount(sub.input)}
48
+ unit={sub.cost > 0 ? "" : m.t().tok}
49
+ />
50
+ </Show>
51
+ )}
52
+ </For>
53
+ </>
54
+ )
55
+ }
@@ -0,0 +1,68 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Show, type JSX } from "solid-js"
3
+ import type { UiStrings } from "./i18n.ts"
4
+ import { formatTokenCount } from "./format-tokens.ts"
5
+ import { TuiMetricRow } from "./tui-panel/index.ts"
6
+ import type { PanelLayout, PanelPalette } from "./tui-panel/index.ts"
7
+ import type { SessionSnapshot } from "./types.ts"
8
+
9
+ export type TokenSnap = Pick<
10
+ SessionSnapshot,
11
+ "cacheRead" | "cacheWrite" | "input" | "output" | "reasoning"
12
+ >
13
+
14
+ export function TokenDetailRows(props: {
15
+ pal: PanelPalette
16
+ layout: PanelLayout
17
+ t: UiStrings
18
+ snap: TokenSnap
19
+ children?: JSX.Element
20
+ }) {
21
+ const tok = (n: number) => formatTokenCount(n)
22
+ return (
23
+ <>
24
+ <Show when={props.snap.cacheRead > 0}>
25
+ <TuiMetricRow
26
+ pal={props.pal}
27
+ layout={props.layout}
28
+ label={props.t.read}
29
+ value={tok(props.snap.cacheRead)}
30
+ unit={props.t.tok}
31
+ />
32
+ </Show>
33
+ <Show when={props.snap.cacheWrite > 0}>
34
+ <TuiMetricRow
35
+ pal={props.pal}
36
+ layout={props.layout}
37
+ label={props.t.write}
38
+ value={tok(props.snap.cacheWrite)}
39
+ unit={props.t.tok}
40
+ />
41
+ </Show>
42
+ <TuiMetricRow
43
+ pal={props.pal}
44
+ layout={props.layout}
45
+ label={props.t.miss}
46
+ value={tok(props.snap.input)}
47
+ unit={props.t.tok}
48
+ />
49
+ <TuiMetricRow
50
+ pal={props.pal}
51
+ layout={props.layout}
52
+ label={props.t.out}
53
+ value={tok(props.snap.output)}
54
+ unit={props.t.tok}
55
+ />
56
+ <Show when={props.snap.reasoning > 0}>
57
+ <TuiMetricRow
58
+ pal={props.pal}
59
+ layout={props.layout}
60
+ label={props.t.reasoning}
61
+ value={tok(props.snap.reasoning)}
62
+ unit={props.t.tok}
63
+ />
64
+ </Show>
65
+ {props.children}
66
+ </>
67
+ )
68
+ }
@@ -0,0 +1,93 @@
1
+ import { childSessionIdsForParent, parseSessionListResponse } from "./session-list.ts"
2
+ import type { OpenCodeTuiApi } from "./types.ts"
3
+
4
+ /** Debounce for session.list after foreign-session message.updated (streaming fires often). */
5
+ export const CHILD_LIST_DEBOUNCE_MS = 200
6
+
7
+ /** Pass `api.client.session`, never `api.client`. */
8
+ export type ChildSessionListClient = OpenCodeTuiApi["client"]["session"]
9
+
10
+ /**
11
+ * Keeps child session ids in sync with session.list for a single parent session.
12
+ * - Parent change: invalidate in-flight work, clear ids, list immediately.
13
+ * - Foreign message.updated: debounced list (source of truth; no append-only).
14
+ */
15
+ export function createChildSessionSync(opts: {
16
+ client: ChildSessionListClient
17
+ getDirectory: () => string
18
+ getParentId: () => string
19
+ setChildIds: (ids: string[]) => void
20
+ onSynced?: () => void
21
+ debounceMs?: number
22
+ }) {
23
+ let listGen = 0
24
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined
25
+
26
+ const clearDebounce = () => {
27
+ if (debounceTimer !== undefined) clearTimeout(debounceTimer)
28
+ debounceTimer = undefined
29
+ }
30
+
31
+ const loadChildren = () => {
32
+ clearDebounce()
33
+ const parentId = opts.getParentId()
34
+ if (!parentId) {
35
+ opts.setChildIds([])
36
+ return
37
+ }
38
+ const gen = listGen
39
+ const directory = opts.getDirectory()
40
+ opts.client
41
+ .list({ query: { directory } })
42
+ .then(
43
+ (all) => {
44
+ if (gen !== listGen || opts.getParentId() !== parentId) return
45
+ opts.setChildIds(childSessionIdsForParent(parseSessionListResponse(all), parentId))
46
+ opts.onSynced?.()
47
+ },
48
+ () => {
49
+ if (gen !== listGen || opts.getParentId() !== parentId) return
50
+ opts.setChildIds([])
51
+ },
52
+ )
53
+ }
54
+
55
+ const scheduleLoad = () => {
56
+ clearDebounce()
57
+ if (!opts.getParentId()) return
58
+ const ms = opts.debounceMs ?? CHILD_LIST_DEBOUNCE_MS
59
+ debounceTimer = setTimeout(() => {
60
+ debounceTimer = undefined
61
+ loadChildren()
62
+ }, ms)
63
+ }
64
+
65
+ /** Call when the sidebar parent session id changes. */
66
+ const resetForParentChange = () => {
67
+ listGen++
68
+ clearDebounce()
69
+ opts.setChildIds([])
70
+ }
71
+
72
+ /** message.updated on a session other than the current parent. */
73
+ const onForeignSessionActivity = (sessionId: string | undefined) => {
74
+ const parentId = opts.getParentId()
75
+ if (!parentId || !sessionId || sessionId === parentId) return
76
+ scheduleLoad()
77
+ }
78
+
79
+ const dispose = () => {
80
+ listGen++
81
+ clearDebounce()
82
+ }
83
+
84
+ return {
85
+ loadChildren,
86
+ scheduleLoad,
87
+ resetForParentChange,
88
+ onForeignSessionActivity,
89
+ dispose,
90
+ /** Test hook: current generation token. */
91
+ _generation: () => listGen,
92
+ }
93
+ }
@@ -0,0 +1,21 @@
1
+ /** deltaPercent: change in hit % points. Visual-cache trendLabel (0 → "-"). */
2
+ export function formatTrendLabel(deltaPercent: number): string {
3
+ const t = deltaPercent
4
+ return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
5
+ }
6
+
7
+ /** One decimal place, e.g. 98.8% */
8
+ export function formatPercentOneDecimal(percent0to100: number): string {
9
+ return (Math.floor(percent0to100 * 10) / 10).toFixed(1) + "%"
10
+ }
11
+
12
+ export function formatRatioAsPercent(ratio0to1: number): string {
13
+ return formatPercentOneDecimal(ratio0to1 * 100)
14
+ }
15
+
16
+ /** Block chars only — wrap with `[` `]` in UI. */
17
+ export function formatHitBar(ratio: number, width = 16): string {
18
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)))
19
+ const empty = Math.max(0, width - filled)
20
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty)
21
+ }
@@ -0,0 +1,90 @@
1
+ export type CurrencyCode = "USD" | "CNY" | "EUR" | "GBP" | "JPY"
2
+
3
+ /** How to render msg.cost in the sidebar. */
4
+ export type CostDisplayConfig = {
5
+ /** Display currency (symbol / decimals). */
6
+ currency: CurrencyCode
7
+ symbol?: string
8
+ decimals?: number
9
+ minDisplay?: number
10
+ /** Unit of msg.cost from OpenCode (typically USD per opencode.json). */
11
+ costUnit?: CurrencyCode
12
+ /** USD → display currency. Shorthand for convert.rate. */
13
+ rate?: number
14
+ convert?: { from: CurrencyCode; rate: number }
15
+ }
16
+
17
+ export const CURRENCY_PRESETS: Record<CurrencyCode, { symbol: string; decimals: number; minDisplay: number }> = {
18
+ USD: { symbol: "$", decimals: 4, minDisplay: 0.0001 },
19
+ CNY: { symbol: "¥", decimals: 3, minDisplay: 0.01 },
20
+ EUR: { symbol: "€", decimals: 3, minDisplay: 0.01 },
21
+ GBP: { symbol: "£", decimals: 3, minDisplay: 0.01 },
22
+ JPY: { symbol: "¥", decimals: 2, minDisplay: 1 },
23
+ }
24
+
25
+ /** OpenCode msg.cost is USD; show RMB by default. */
26
+ export const DEFAULT_COST_DISPLAY: CostDisplayConfig = {
27
+ currency: "CNY",
28
+ costUnit: "USD",
29
+ rate: 7.2,
30
+ }
31
+
32
+ function resolveExchangeRate(cfg: CostDisplayConfig): number {
33
+ if (cfg.convert?.rate && cfg.convert.rate > 0) return cfg.convert.rate
34
+ if (cfg.rate && cfg.rate > 0) return cfg.rate
35
+ const unit = cfg.costUnit ?? "USD"
36
+ if (unit === cfg.currency) return 1
37
+ return 1
38
+ }
39
+
40
+ export function normalizeCostDisplay(raw: unknown): CostDisplayConfig {
41
+ if (!raw || typeof raw !== "object") return { ...DEFAULT_COST_DISPLAY }
42
+ const o = raw as Record<string, unknown>
43
+ const currency =
44
+ typeof o.currency === "string" && o.currency in CURRENCY_PRESETS
45
+ ? (o.currency as CurrencyCode)
46
+ : DEFAULT_COST_DISPLAY.currency
47
+
48
+ const cfg: CostDisplayConfig = { currency }
49
+
50
+ if (typeof o.symbol === "string" && o.symbol.length > 0) cfg.symbol = o.symbol
51
+ if (typeof o.decimals === "number" && o.decimals >= 0) cfg.decimals = o.decimals
52
+ if (typeof o.minDisplay === "number" && o.minDisplay > 0) cfg.minDisplay = o.minDisplay
53
+
54
+ if (typeof o.costUnit === "string" && o.costUnit in CURRENCY_PRESETS) {
55
+ cfg.costUnit = o.costUnit as CurrencyCode
56
+ }
57
+
58
+ if (typeof o.rate === "number" && o.rate > 0) cfg.rate = o.rate
59
+
60
+ const c = o.convert
61
+ if (c && typeof c === "object") {
62
+ const co = c as Record<string, unknown>
63
+ if (typeof co.from === "string" && co.from in CURRENCY_PRESETS && typeof co.rate === "number" && co.rate > 0) {
64
+ cfg.convert = { from: co.from as CurrencyCode, rate: co.rate }
65
+ }
66
+ }
67
+
68
+ if (!cfg.costUnit && !cfg.convert) cfg.costUnit = DEFAULT_COST_DISPLAY.costUnit
69
+ if (!cfg.rate && !cfg.convert?.rate && cfg.costUnit !== cfg.currency) {
70
+ cfg.rate = DEFAULT_COST_DISPLAY.rate
71
+ }
72
+
73
+ return cfg
74
+ }
75
+
76
+ export function createCostFormatter(config: CostDisplayConfig): (amountUsd: number) => string {
77
+ const preset = CURRENCY_PRESETS[config.currency]
78
+ const symbol = config.symbol ?? preset.symbol
79
+ const decimals = config.decimals ?? preset.decimals
80
+ const minDisplay = config.minDisplay ?? preset.minDisplay
81
+ const unit = config.costUnit ?? config.convert?.from ?? "USD"
82
+ const rate = unit === config.currency ? 1 : resolveExchangeRate(config)
83
+
84
+ return (amount: number) => {
85
+ if (amount <= 0) return ""
86
+ const v = amount * rate
87
+ if (v < minDisplay) return `<${symbol}${minDisplay}`
88
+ return "~" + symbol + v.toFixed(decimals)
89
+ }
90
+ }
@@ -0,0 +1,5 @@
1
+ export function formatTokenCount(n: number): string {
2
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
3
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"
4
+ return String(Math.round(n))
5
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type Lang = "en" | "zh"
2
+
3
+ export type UiStrings = {
4
+ title: string
5
+ hit: string
6
+ totalHit: string
7
+ read: string
8
+ write: string
9
+ miss: string
10
+ out: string
11
+ reasoning: string
12
+ cost: string
13
+ withAgents: string
14
+ hitFolded: string
15
+ noData: string
16
+ secDetail: string
17
+ secModel: string
18
+ model: string
19
+ secAgents: string
20
+ /** Shown in Agents section header: totals are child sessions only, not the parent session. */
21
+ agentsScopeHint: string
22
+ tok: string
23
+ }
24
+
25
+ const EN: UiStrings = {
26
+ title: "Cache Hit",
27
+ hit: "Hit",
28
+ totalHit: "Total Hit:",
29
+ read: "Read:",
30
+ write: "Write:",
31
+ miss: "Miss:",
32
+ out: "Out:",
33
+ reasoning: "Reason:",
34
+ cost: "Cost:",
35
+ withAgents: "w/ Agents:",
36
+ hitFolded: "hit",
37
+ noData: "Waiting for cache data...",
38
+ secDetail: "Detail",
39
+ secModel: "Model",
40
+ model: "Model:",
41
+ secAgents: "Agents",
42
+ agentsScopeHint: " · sub-sessions",
43
+ tok: "tok",
44
+ }
45
+
46
+ const ZH: UiStrings = {
47
+ title: "缓存命中",
48
+ hit: "命中率",
49
+ totalHit: "总命中:",
50
+ read: "缓存读:",
51
+ write: "缓存写:",
52
+ miss: "未命中:",
53
+ out: "输出:",
54
+ reasoning: "推理:",
55
+ cost: "费用:",
56
+ withAgents: "含 Agents:",
57
+ hitFolded: "命中",
58
+ noData: "等待缓存数据...",
59
+ secDetail: "明细",
60
+ secModel: "模型",
61
+ model: "模型:",
62
+ secAgents: "子 Agent",
63
+ agentsScopeHint: " · 仅子会话",
64
+ tok: "tok",
65
+ }
66
+
67
+ export function resolveLang(raw: unknown): Lang {
68
+ if (raw === "zh" || raw === "cn" || raw === "zh-CN") return "zh"
69
+ if (raw === "en") return "en"
70
+ if (raw === "auto") {
71
+ try {
72
+ return Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase().startsWith("zh") ? "zh" : "en"
73
+ } catch {
74
+ return "en"
75
+ }
76
+ }
77
+ return "en"
78
+ }
79
+
80
+ export function getUiStrings(lang: Lang): UiStrings {
81
+ return lang === "zh" ? ZH : EN
82
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import {
5
+ type PluginConfig,
6
+ normalizePluginConfig,
7
+ DEFAULT_PLUGIN_CONFIG,
8
+ } from "./plugin-config.ts"
9
+
10
+ /** Parent of `src/` (plugin package root). Do not wrap in `dirname` — `..` already resolves there. */
11
+ export const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url))
12
+ export const CONFIG_PATH = join(PLUGIN_ROOT, "cache-hit.config.json")
13
+
14
+ function cloneDefault(): PluginConfig {
15
+ return {
16
+ cost: { ...DEFAULT_PLUGIN_CONFIG.cost },
17
+ display: { ...DEFAULT_PLUGIN_CONFIG.display },
18
+ timeline: { ...DEFAULT_PLUGIN_CONFIG.timeline },
19
+ }
20
+ }
21
+
22
+ export function loadPluginConfig(): PluginConfig {
23
+ if (!existsSync(CONFIG_PATH)) return cloneDefault()
24
+ try {
25
+ return normalizePluginConfig(JSON.parse(readFileSync(CONFIG_PATH, "utf8")))
26
+ } catch {
27
+ return cloneDefault()
28
+ }
29
+ }