opencode-cache-hit 0.1.0 → 0.2.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 (41) hide show
  1. package/AGENTS.md +4 -2
  2. package/CONTRIBUTING.md +24 -8
  3. package/README.md +55 -22
  4. package/README.zh-CN.md +155 -92
  5. package/cache-hit.config.example.json +7 -2
  6. package/docs/assets/cache-hit-panel.v3.png +0 -0
  7. package/docs/en/design.md +30 -8
  8. package/docs/en/timeline-duplicate-writes.md +125 -0
  9. package/docs/en/timeline.md +26 -21
  10. package/docs/zh-CN/design.md +31 -9
  11. package/docs/zh-CN/timeline.md +28 -24
  12. package/package.json +1 -2
  13. package/scripts/README.md +64 -1
  14. package/scripts/plot-hit-rate.ts +4 -3
  15. package/scripts/timeline-dashboard.ts +728 -0
  16. package/src/agents-view.tsx +24 -10
  17. package/src/cache-ttl-view.tsx +128 -0
  18. package/src/format-cost.ts +83 -1
  19. package/src/format-model.ts +227 -0
  20. package/src/i18n.ts +18 -3
  21. package/src/load-config.ts +24 -5
  22. package/src/main-session-view.tsx +43 -3
  23. package/src/plugin-config.ts +59 -1
  24. package/src/plugin.tsx +4 -1
  25. package/src/pricing.ts +57 -0
  26. package/src/sidebar-host.tsx +13 -7
  27. package/src/stats.ts +6 -15
  28. package/src/timeline/collector.ts +40 -87
  29. package/src/timeline/records.ts +18 -29
  30. package/src/timeline/types.ts +3 -3
  31. package/src/timeline/writer.ts +5 -4
  32. package/src/tui-panel/README.md +2 -2
  33. package/src/tui-panel/README.zh-CN.md +2 -2
  34. package/src/tui-panel/components.tsx +31 -4
  35. package/src/tui-panel/index.ts +6 -1
  36. package/src/tui-panel/palette.ts +5 -0
  37. package/src/types.ts +16 -0
  38. package/src/use-cache-hit-metrics.ts +8 -9
  39. package/src/version.ts +4 -1
  40. package/src/widget.tsx +11 -3
  41. package/docs/assets/cache-hit-panel.png +0 -0
@@ -1,11 +1,11 @@
1
1
  import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
2
+ import { homedir } from "node:os"
2
3
  import { dirname, join } from "node:path"
3
4
  import type { TimelineConfig } from "../plugin-config.ts"
4
- import { PLUGIN_ROOT } from "../load-config.ts"
5
5
  import type { LlmCallRecord } from "./types.ts"
6
6
  import { rotateFileBySize, trimFileToMaxLines } from "./rotation.ts"
7
7
 
8
- export const DEFAULT_TIMELINE_DIR = join(PLUGIN_ROOT, "logs")
8
+ export const DEFAULT_TIMELINE_DIR = join(homedir(), ".local", "share", "opencode", "logs", "cache-hit")
9
9
  export const TIMELINE_FILE_PREFIX = "timeline"
10
10
 
11
11
  export type TimelineWriteOptions = Pick<
@@ -14,8 +14,9 @@ export type TimelineWriteOptions = Pick<
14
14
  >
15
15
 
16
16
  export function resolveTimelineDir(config: TimelineConfig): string {
17
- const raw = config.dir?.trim()
18
- return raw.length > 0 ? raw : DEFAULT_TIMELINE_DIR
17
+ const raw = (config.dir ?? "").trim()
18
+ if (!raw) return DEFAULT_TIMELINE_DIR
19
+ return raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw
19
20
  }
20
21
 
21
22
  /** Local calendar day `YYYY-MM-DD` for daily log files. */
@@ -36,7 +36,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
36
36
  open={open()}
37
37
  onToggle={() => setOpen((o) => !o)}
38
38
  title="My Panel"
39
- version="0.1.0"
39
+ version="0.2.0"
40
40
  />
41
41
  <Show when={open()}>
42
42
  <Show
@@ -69,7 +69,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
69
69
  | `TuiPanel` | Outer border + padding |
70
70
  | `TuiPanelTitle` | Foldable title; optional `collapsed` summary |
71
71
  | `TuiSection` | `▼` section header + separator fill |
72
- | `TuiMetricRow` | Label left, value (+ unit) right |
72
+ | `TuiMetricRow` | Label left, value (+ unit) right; optional `labelFg` / `valueFg` for split colors |
73
73
  | `TuiHitRow` | Hit bar + % + trend |
74
74
  | `computeHitBarWidth` | Dynamic bar width |
75
75
 
@@ -36,7 +36,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
36
36
  open={open()}
37
37
  onToggle={() => setOpen((o) => !o)}
38
38
  title="My Panel"
39
- version="0.1.0"
39
+ version="0.2.0"
40
40
  />
41
41
  <Show when={open()}>
42
42
  <Show
@@ -69,7 +69,7 @@ export function MySidebar(props: { theme: Record<string, unknown> }) {
69
69
  | `TuiPanel` | 外框 + padding |
70
70
  | `TuiPanelTitle` | 可折叠标题;可选 `collapsed` 摘要 |
71
71
  | `TuiSection` | `▼` 区块标题 + 分隔线填充 |
72
- | `TuiMetricRow` | 左标签右数值(可选 unit |
72
+ | `TuiMetricRow` | 左标签右数值(可选 unit);可选 `labelFg` / `valueFg` 分段上色 |
73
73
  | `TuiHitRow` | Hit 条 + % + 趋势 |
74
74
  | `computeHitBarWidth` | 动态进度条宽度 |
75
75
 
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { Show, type JSX } from "solid-js"
3
- import { padBeforeTitleSummary, sepAfterPrefix, visualWidth } from "./layout.ts"
3
+ import { padBeforeTitleSummary, sepAfterPrefix, UNIT_GAP, visualWidth } from "./layout.ts"
4
4
  import type { PanelLayout } from "./use-panel-layout.ts"
5
5
  import type { PanelPalette } from "./palette.ts"
6
6
 
@@ -126,18 +126,45 @@ export function TuiSection(props: {
126
126
  )
127
127
  }
128
128
 
129
+ function metricRowGap(label: string, value: string, unit: string, gauge: number): number {
130
+ const used =
131
+ visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
132
+ return Math.max(1, gauge - used)
133
+ }
134
+
129
135
  export function TuiMetricRow(props: {
130
136
  pal: PanelPalette
131
137
  layout: PanelLayout
132
138
  label: string
133
139
  value: string
134
140
  unit?: string
141
+ /** Whole line (label + value + unit). Ignored when `labelFg` / `valueFg` set. */
135
142
  fg?: string
143
+ /** Label-only color (e.g. sub-agent model); value stays `valueFg` or muted. */
144
+ labelFg?: string
145
+ valueFg?: string
136
146
  }) {
137
- const fg = props.fg ?? props.pal.muted
147
+ const unit = props.unit ?? ""
148
+ const unitSuffix = unit ? " " + unit : ""
149
+ const split = props.labelFg !== undefined || props.valueFg !== undefined
150
+ if (split) {
151
+ const gap = metricRowGap(props.label, props.value, unit, props.layout.gauge())
152
+ const labelColor = props.labelFg ?? props.fg ?? props.pal.muted
153
+ const valueColor = props.valueFg ?? props.fg ?? props.pal.muted
154
+ return (
155
+ <text>
156
+ <span style={{ fg: labelColor }}>{props.label}</span>
157
+ {" ".repeat(gap)}
158
+ <span style={{ fg: valueColor }}>
159
+ {props.value}
160
+ {unitSuffix}
161
+ </span>
162
+ </text>
163
+ )
164
+ }
138
165
  return (
139
- <text fg={fg}>
140
- {props.layout.row(props.label, props.value, props.unit ?? "")}
166
+ <text fg={props.fg ?? props.pal.muted}>
167
+ {props.layout.row(props.label, props.value, unit)}
141
168
  </text>
142
169
  )
143
170
  }
@@ -19,7 +19,12 @@ export {
19
19
  padBeforeTitleSummary,
20
20
  } from "./layout.ts"
21
21
 
22
- export { buildPanelPalette, themeColorToHex, type PanelPalette } from "./palette.ts"
22
+ export {
23
+ buildPanelPalette,
24
+ themeColorToHex,
25
+ toneBrandHex,
26
+ type PanelPalette,
27
+ } from "./palette.ts"
23
28
 
24
29
  export {
25
30
  createPanelLayout,
@@ -79,6 +79,11 @@ export function themeColorToHex(raw: unknown, fallback: string): string {
79
79
  return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
80
80
  }
81
81
 
82
+ /** Tone a vendor brand hex for dark TUI panels (same max saturation as theme colors). */
83
+ export function toneBrandHex(hex: string, fallback: string): string {
84
+ return desaturateTo(hex, MAX_SAT, fallback)
85
+ }
86
+
82
87
  export function buildPanelPalette(theme: Record<string, unknown>): PanelPalette {
83
88
  const sat = (k: string, fb: string) => desaturateTo(theme[k], MAX_SAT, fb)
84
89
  return {
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type SessionSnapshot = {
2
2
  model: string
3
+ providerID: string
3
4
  input: number
4
5
  output: number
5
6
  reasoning: number
@@ -10,6 +11,8 @@ export type SessionSnapshot = {
10
11
 
11
12
  export type SubAgentSummary = {
12
13
  id: string
14
+ model: string
15
+ providerID: string
13
16
  cost: number
14
17
  input: number
15
18
  output: number
@@ -23,6 +26,7 @@ export type AssistantMessage = {
23
26
  id?: string
24
27
  messageID?: string
25
28
  modelID?: string
29
+ providerID?: string
26
30
  cost?: number
27
31
  /** OpenCode SDK: true = summary/compaction message, not a full LLM pricing turn */
28
32
  summary?: boolean
@@ -38,9 +42,21 @@ export type AssistantMessage = {
38
42
  }
39
43
  }
40
44
 
45
+ export type ModelCost = {
46
+ input: number
47
+ output: number
48
+ cache: { read: number; write: number }
49
+ }
50
+
51
+ export type ProviderInfo = {
52
+ id: string
53
+ models: { [key: string]: { cost: ModelCost } }
54
+ }
55
+
41
56
  export type OpenCodeTuiApi = {
42
57
  state: {
43
58
  path: { directory: string }
59
+ provider: ReadonlyArray<ProviderInfo>
44
60
  session: {
45
61
  messages: (id: string) => unknown[] | undefined
46
62
  get: (id: string) => { parentID?: string } | undefined
@@ -10,14 +10,14 @@ import {
10
10
  import { computeHitBarWidth, visualWidth } from "./tui-panel/layout.ts"
11
11
  import { buildPanelPalette, type PanelPalette } from "./tui-panel/palette.ts"
12
12
  import type { PanelLayout } from "./tui-panel/use-panel-layout.ts"
13
- import type { AssistantMessage, SessionSnapshot, SubAgentSummary } from "./types.ts"
13
+ import type { AssistantMessage, ProviderInfo, SessionSnapshot, SubAgentSummary } from "./types.ts"
14
14
  import {
15
15
  cacheHitRatio,
16
- combinedCacheHitRatio,
17
16
  computePerCallHitTrend,
18
17
  mainSessionHasStats,
19
18
  shortModelName,
20
19
  } from "./stats.ts"
20
+ import { computePricing, type PricingInfo } from "./pricing.ts"
21
21
 
22
22
  function activeLang(display: DisplayConfig) {
23
23
  return display.lang === "auto" ? resolveLang("auto") : display.lang
@@ -35,6 +35,7 @@ export function useCacheHitMetrics(props: {
35
35
  messages: Accessor<AssistantMessage[]>
36
36
  main: Accessor<SessionSnapshot>
37
37
  subAgents: Accessor<SubAgentSummary[]>
38
+ providers: Accessor<ReadonlyArray<ProviderInfo>>
38
39
  layout: PanelLayout
39
40
  }) {
40
41
  const pal = createMemo(() => buildPanelPalette(props.theme()))
@@ -44,12 +45,10 @@ export function useCacheHitMetrics(props: {
44
45
  const main = createMemo(() => props.main())
45
46
  const perCall = createMemo(() => computePerCallHitTrend(props.messages()))
46
47
  const sessionRatio = createMemo(() => cacheHitRatio(main().cacheRead, main().input))
47
- const combinedRatio = createMemo(() => combinedCacheHitRatio(main(), subs()))
48
48
 
49
- const showCombinedHit = createMemo(() => {
50
- if (subs().length === 0) return false
51
- return Math.abs(combinedRatio() - sessionRatio()) >= 0.0005
52
- })
49
+ const pricing = createMemo<PricingInfo>(() =>
50
+ computePricing(props.providers(), main().providerID, main().model, main().cacheRead),
51
+ )
53
52
 
54
53
  const mainHasStats = createMemo(() => mainSessionHasStats(main()))
55
54
  const hasData = createMemo(() => mainHasStats() || subs().length > 0)
@@ -85,9 +84,9 @@ export function useCacheHitMetrics(props: {
85
84
  main,
86
85
  mainHasStats,
87
86
  perCall,
87
+ pricing,
88
88
  sessionPct: createMemo(() => formatRatioAsPercent(sessionRatio())),
89
- combinedPct: createMemo(() => formatRatioAsPercent(combinedRatio())),
90
- showCombinedHit,
89
+
91
90
  hasData,
92
91
  trendLabel,
93
92
  bar,
package/src/version.ts CHANGED
@@ -1 +1,4 @@
1
- export const PLUGIN_VERSION = "0.1.0"
1
+ import packageJson from "../package.json" with { type: "json" }
2
+
3
+ /** Sidebar label; kept in sync with package.json "version". */
4
+ export const PLUGIN_VERSION = packageJson.version
package/src/widget.tsx CHANGED
@@ -1,10 +1,11 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { createMemo, createSignal, Show, type Accessor } from "solid-js"
3
- import type { DisplayConfig } from "./plugin-config.ts"
4
- import type { AssistantMessage, SessionSnapshot, SubAgentSummary } from "./types.ts"
3
+ import type { DisplayConfig, CacheTTLConfig } from "./plugin-config.ts"
4
+ import type { AssistantMessage, ProviderInfo, SessionSnapshot, SubAgentSummary } from "./types.ts"
5
5
  import { PLUGIN_VERSION } from "./version.ts"
6
6
  import { AgentsView } from "./agents-view.tsx"
7
7
  import { MainSessionView } from "./main-session-view.tsx"
8
+
8
9
  import { useCacheHitMetrics } from "./use-cache-hit-metrics.ts"
9
10
  import {
10
11
  createPanelLayout,
@@ -22,10 +23,13 @@ export function CacheHitSidebar(props: {
22
23
  sessionId: Accessor<string>
23
24
  theme: Record<string, unknown>
24
25
  display: DisplayConfig
26
+ cacheTTL: CacheTTLConfig
25
27
  messages: Accessor<AssistantMessage[]>
26
28
  main: Accessor<SessionSnapshot>
27
29
  subAgents: Accessor<SubAgentSummary[]>
30
+ providers: Accessor<ReadonlyArray<ProviderInfo>>
28
31
  formatCost: (amount: number) => string
32
+ formatRate: (perMillion: number) => string
29
33
  }) {
30
34
  const [panelOpen, setPanelOpen] = createSignal(true)
31
35
  const detail = createSectionFold(true)
@@ -41,6 +45,7 @@ export function CacheHitSidebar(props: {
41
45
  messages: props.messages,
42
46
  main: props.main,
43
47
  subAgents: props.subAgents,
48
+ providers: props.providers,
44
49
  layout,
45
50
  })
46
51
 
@@ -96,6 +101,9 @@ export function CacheHitSidebar(props: {
96
101
  detail={detail}
97
102
  model={model}
98
103
  formatCost={props.formatCost}
104
+ formatRate={props.formatRate}
105
+ cacheTTL={props.cacheTTL}
106
+ messages={props.messages}
99
107
  />
100
108
  <Show when={m.subs().length > 0}>
101
109
  <TuiSection
@@ -106,7 +114,7 @@ export function CacheHitSidebar(props: {
106
114
  suffix={agentsSuffix()}
107
115
  onToggle={agents.toggle}
108
116
  >
109
- <AgentsView m={m} layout={layout} formatCost={props.formatCost} />
117
+ <AgentsView m={m} layout={layout} providers={props.providers()} formatCost={props.formatCost} />
110
118
  </TuiSection>
111
119
  </Show>
112
120
  </Show>
Binary file