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,73 @@
1
+ import { timingFromAssistantMessage } from "../message-timing.ts"
2
+ import { perMessageHitPercent } from "../stats.ts"
3
+ import type { AssistantMessage } from "../types.ts"
4
+ import type { LlmCallRecord } from "./types.ts"
5
+
6
+ export function messageKeyFor(msg: AssistantMessage, sessionId: string): string {
7
+ const id = msg.id ?? msg.messageID
8
+ if (typeof id === "string" && id.length > 0) return `${sessionId}:${id}`
9
+ const created = msg.time?.created ?? 0
10
+ return `${sessionId}:${created}:${msg.modelID ?? ""}`
11
+ }
12
+
13
+ export function sortKeyForRecord(r: LlmCallRecord): number {
14
+ return r.completedAt ?? r.created
15
+ }
16
+
17
+ export function assistantMessageToRecord(
18
+ msg: AssistantMessage,
19
+ sessionId: string,
20
+ rootSessionId: string,
21
+ scope: "main" | "child",
22
+ recordedAt: number,
23
+ ): LlmCallRecord | null {
24
+ if (msg.role !== "assistant") return null
25
+ const timing = timingFromAssistantMessage(msg)
26
+ if (!timing) return null
27
+ const t = msg.tokens ?? {}
28
+ const skippedForHit = msg.summary === true
29
+ return {
30
+ schema: 1,
31
+ recordedAt,
32
+ sessionId,
33
+ rootSessionId,
34
+ scope,
35
+ messageKey: messageKeyFor(msg, sessionId),
36
+ modelId: msg.modelID ?? "",
37
+ created: timing.created,
38
+ completedAt: timing.completedAt,
39
+ durationMs: timing.durationMs,
40
+ isComplete: timing.isComplete,
41
+ input: t.input ?? 0,
42
+ output: t.output ?? 0,
43
+ reasoning: t.reasoning ?? 0,
44
+ cacheRead: t.cache?.read ?? 0,
45
+ cacheWrite: t.cache?.write ?? 0,
46
+ cost: msg.cost ?? 0,
47
+ hitPercent: perMessageHitPercent(msg),
48
+ skippedForHit,
49
+ }
50
+ }
51
+
52
+ export function buildCallRecords(
53
+ sessionId: string,
54
+ rootSessionId: string,
55
+ scope: "main" | "child",
56
+ messages: readonly AssistantMessage[],
57
+ opts?: { logSummaryMessages?: boolean; recordedAt?: number },
58
+ ): LlmCallRecord[] {
59
+ const now = opts?.recordedAt ?? Date.now()
60
+ const logSummary = opts?.logSummaryMessages !== false
61
+ const out: LlmCallRecord[] = []
62
+ for (const msg of messages) {
63
+ if (!logSummary && msg.summary === true) continue
64
+ const rec = assistantMessageToRecord(msg, sessionId, rootSessionId, scope, now)
65
+ if (rec) out.push(rec)
66
+ }
67
+ return out
68
+ }
69
+
70
+ export function mergeAndSortRecords(chunks: readonly LlmCallRecord[][]): LlmCallRecord[] {
71
+ const all = chunks.flat()
72
+ return all.sort((a, b) => sortKeyForRecord(a) - sortKeyForRecord(b))
73
+ }
@@ -0,0 +1,47 @@
1
+ import { readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
2
+
3
+ /** Keep only the last `maxLines` non-empty lines (in-place rewrite). */
4
+ export async function trimFileToMaxLines(logPath: string, maxLines: number): Promise<void> {
5
+ if (maxLines <= 0) return
6
+ let text: string
7
+ try {
8
+ text = await readFile(logPath, "utf8")
9
+ } catch {
10
+ return
11
+ }
12
+ const lines = text.split("\n").filter((line) => line.length > 0)
13
+ if (lines.length <= maxLines) return
14
+ await writeFile(logPath, lines.slice(-maxLines).join("\n") + "\n", "utf8")
15
+ }
16
+
17
+ /**
18
+ * Size-based roll: `file` → `file.1` → `file.2` … keep at most `retainRotated` backups.
19
+ * Current active file is removed by rename; caller appends to a new empty `file`.
20
+ */
21
+ export async function rotateFileBySize(
22
+ logPath: string,
23
+ maxBytes: number,
24
+ retainRotated: number,
25
+ ): Promise<void> {
26
+ if (maxBytes <= 0) return
27
+ let size = 0
28
+ try {
29
+ size = (await stat(logPath)).size
30
+ } catch {
31
+ return
32
+ }
33
+ if (size < maxBytes) return
34
+
35
+ const retain = Math.max(0, Math.floor(retainRotated))
36
+ if (retain === 0) {
37
+ await unlink(logPath).catch(() => {})
38
+ return
39
+ }
40
+
41
+ const oldest = `${logPath}.${retain}`
42
+ await unlink(oldest).catch(() => {})
43
+ for (let i = retain - 1; i >= 1; i--) {
44
+ await rename(`${logPath}.${i}`, `${logPath}.${i + 1}`).catch(() => {})
45
+ }
46
+ await rename(logPath, `${logPath}.1`)
47
+ }
@@ -0,0 +1,22 @@
1
+ /** Single LLM call row (one JSONL line). */
2
+ export type LlmCallRecord = {
3
+ schema: 1
4
+ recordedAt: number
5
+ sessionId: string
6
+ rootSessionId: string
7
+ scope: "main" | "child"
8
+ messageKey: string
9
+ modelId: string
10
+ created: number
11
+ completedAt?: number
12
+ durationMs?: number
13
+ isComplete: boolean
14
+ input: number
15
+ output: number
16
+ reasoning: number
17
+ cacheRead: number
18
+ cacheWrite: number
19
+ cost: number
20
+ hitPercent: number | null
21
+ skippedForHit: boolean
22
+ }
@@ -0,0 +1,134 @@
1
+ import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import type { TimelineConfig } from "../plugin-config.ts"
4
+ import { PLUGIN_ROOT } from "../load-config.ts"
5
+ import type { LlmCallRecord } from "./types.ts"
6
+ import { rotateFileBySize, trimFileToMaxLines } from "./rotation.ts"
7
+
8
+ export const DEFAULT_TIMELINE_DIR = join(PLUGIN_ROOT, "logs")
9
+ export const TIMELINE_FILE_PREFIX = "timeline"
10
+
11
+ export type TimelineWriteOptions = Pick<
12
+ TimelineConfig,
13
+ "maxLinesPerFile" | "rotateMaxBytes" | "retainRotated"
14
+ >
15
+
16
+ export function resolveTimelineDir(config: TimelineConfig): string {
17
+ const raw = config.dir?.trim()
18
+ return raw.length > 0 ? raw : DEFAULT_TIMELINE_DIR
19
+ }
20
+
21
+ /** Local calendar day `YYYY-MM-DD` for daily log files. */
22
+ export function localDateKey(ms = Date.now()): string {
23
+ const d = new Date(ms)
24
+ const y = d.getFullYear()
25
+ const m = String(d.getMonth() + 1).padStart(2, "0")
26
+ const day = String(d.getDate()).padStart(2, "0")
27
+ return `${y}-${m}-${day}`
28
+ }
29
+
30
+ /** Active daily log: `logs/timeline-2026-05-31.jsonl`; rolls to `.jsonl.1` when over size. */
31
+ export function timelineDailyLogPath(logsDir: string, dateKey: string): string {
32
+ return join(logsDir, `${TIMELINE_FILE_PREFIX}-${dateKey}.jsonl`)
33
+ }
34
+
35
+ export function serializeRecord(record: LlmCallRecord): string {
36
+ return JSON.stringify(record) + "\n"
37
+ }
38
+
39
+ const TIMELINE_FILE_RE = /^timeline-\d{4}-\d{2}-\d{2}\.jsonl(\.\d+)?$/
40
+
41
+ /** Parsed daily log name: `roll` 0 = active file, `.1`…`.N` = older backups. */
42
+ export function parseTimelineLogBasename(name: string): { dateKey: string; roll: number } | null {
43
+ const m = /^timeline-(\d{4}-\d{2}-\d{2})\.jsonl(?:\.(\d+))?$/.exec(name)
44
+ if (!m) return null
45
+ return { dateKey: m[1], roll: m[2] ? Number.parseInt(m[2], 10) : 0 }
46
+ }
47
+
48
+ /** Sort key for purge: oldest calendar day first, then highest backup index. */
49
+ export function compareTimelineLogsForPurge(aPath: string, bPath: string): number {
50
+ const a = parseTimelineLogBasename(aPath.split(/[/\\]/).pop() ?? "")
51
+ const b = parseTimelineLogBasename(bPath.split(/[/\\]/).pop() ?? "")
52
+ if (!a || !b) return 0
53
+ if (a.dateKey !== b.dateKey) return a.dateKey.localeCompare(b.dateKey)
54
+ return b.roll - a.roll
55
+ }
56
+
57
+ async function listTimelineLogFiles(logsDir: string): Promise<{ path: string; mtimeMs: number }[]> {
58
+ let names: string[]
59
+ try {
60
+ names = await readdir(logsDir)
61
+ } catch {
62
+ return []
63
+ }
64
+ const entries: { path: string; mtimeMs: number }[] = []
65
+ for (const name of names) {
66
+ if (!TIMELINE_FILE_RE.test(name)) continue
67
+ const path = join(logsDir, name)
68
+ try {
69
+ entries.push({ path, mtimeMs: (await stat(path)).mtimeMs })
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+ return entries
75
+ }
76
+
77
+ /** Delete `timeline-*.jsonl*` older than `maxAgeDays` (by mtime). */
78
+ export async function purgeTimelineLogsOlderThan(
79
+ logsDir: string,
80
+ maxAgeDays: number,
81
+ ): Promise<void> {
82
+ if (maxAgeDays <= 0) return
83
+ const cutoff = Date.now() - maxAgeDays * 86_400_000
84
+ for (const { path, mtimeMs } of await listTimelineLogFiles(logsDir)) {
85
+ if (mtimeMs < cutoff) await unlink(path).catch(() => {})
86
+ }
87
+ }
88
+
89
+ /** Keep at most `maxLogFiles` timeline files; delete earliest logs (date, then backup roll). */
90
+ export async function purgeTimelineLogsOverCount(
91
+ logsDir: string,
92
+ maxLogFiles: number,
93
+ ): Promise<void> {
94
+ if (maxLogFiles <= 0) return
95
+ const entries = await listTimelineLogFiles(logsDir)
96
+ if (entries.length <= maxLogFiles) return
97
+ entries.sort((a, b) => {
98
+ const byLog = compareTimelineLogsForPurge(a.path, b.path)
99
+ if (byLog !== 0) return byLog
100
+ return a.mtimeMs - b.mtimeMs
101
+ })
102
+ const remove = entries.length - maxLogFiles
103
+ for (let i = 0; i < remove; i++) {
104
+ await unlink(entries[i].path).catch(() => {})
105
+ }
106
+ }
107
+
108
+ /** Age purge first, then enforce total file cap. */
109
+ export async function purgeTimelineLogDir(
110
+ logsDir: string,
111
+ opts: { maxAgeDays: number; maxLogFiles: number },
112
+ ): Promise<void> {
113
+ await purgeTimelineLogsOlderThan(logsDir, opts.maxAgeDays)
114
+ await purgeTimelineLogsOverCount(logsDir, opts.maxLogFiles)
115
+ }
116
+
117
+ export async function appendTimelineRecord(
118
+ logPath: string,
119
+ record: LlmCallRecord,
120
+ rotation?: TimelineWriteOptions,
121
+ ): Promise<void> {
122
+ await mkdir(dirname(logPath), { recursive: true })
123
+ const maxLines = rotation?.maxLinesPerFile ?? 0
124
+ const maxBytes = rotation?.rotateMaxBytes ?? 0
125
+ const retain = rotation?.retainRotated ?? 5
126
+
127
+ if (maxBytes > 0) {
128
+ await rotateFileBySize(logPath, maxBytes, retain)
129
+ }
130
+ await appendFile(logPath, serializeRecord(record), "utf8")
131
+ if (maxLines > 0) {
132
+ await trimFileToMaxLines(logPath, maxLines)
133
+ }
134
+ }
@@ -0,0 +1,78 @@
1
+ # TUI panel framework
2
+
3
+ Reusable OpenCode **sidebar panel** layout (borders, foldable sections, hit bar, theme palette). Inspired by [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache) page structure—no skills, slash commands, or `api.kv` logic.
4
+
5
+ [中文](README.zh-CN.md) · Design context: [docs/en/design.md](../../docs/en/design.md) § TUI panel framework.
6
+
7
+ ## Quick example
8
+
9
+ ```tsx
10
+ /** @jsxImportSource @opentui/solid */
11
+ import { createMemo, createSignal, Show } from "solid-js"
12
+ import {
13
+ buildPanelPalette,
14
+ createPanelLayout,
15
+ createSectionFold,
16
+ TuiMetricRow,
17
+ TuiPanel,
18
+ TuiPanelNoData,
19
+ TuiPanelSep,
20
+ TuiPanelTitle,
21
+ TuiSection,
22
+ } from "./tui-panel/index.ts"
23
+
24
+ export function MySidebar(props: { theme: Record<string, unknown> }) {
25
+ const pal = createMemo(() => buildPanelPalette(props.theme))
26
+ const [open, setOpen] = createSignal(true)
27
+ const detail = createSectionFold(true)
28
+ const layout = createPanelLayout({ border: () => true })
29
+ const hasData = () => true
30
+
31
+ return (
32
+ <TuiPanel pal={pal()} border layout={layout}>
33
+ <TuiPanelTitle
34
+ pal={pal()}
35
+ layout={layout}
36
+ open={open()}
37
+ onToggle={() => setOpen((o) => !o)}
38
+ title="My Panel"
39
+ version="0.1.0"
40
+ />
41
+ <Show when={open()}>
42
+ <Show
43
+ when={hasData()}
44
+ fallback={<TuiPanelNoData pal={pal()} layout={layout} message="No data..." />}
45
+ >
46
+ <TuiPanelSep pal={pal()} layout={layout} />
47
+ <TuiSection
48
+ pal={pal()}
49
+ layout={layout}
50
+ open={detail.open()}
51
+ title="Detail"
52
+ onToggle={detail.toggle}
53
+ >
54
+ <TuiMetricRow pal={pal()} layout={layout} label="Count:" value="42" />
55
+ </TuiSection>
56
+ </Show>
57
+ </Show>
58
+ </TuiPanel>
59
+ )
60
+ }
61
+ ```
62
+
63
+ ## API
64
+
65
+ | Export | Role |
66
+ |--------|------|
67
+ | `createPanelLayout` | Width from `onSizeChange`; `gauge`, `row()`, `sep` |
68
+ | `createSectionFold` | Section open/closed state |
69
+ | `TuiPanel` | Outer border + padding |
70
+ | `TuiPanelTitle` | Foldable title; optional `collapsed` summary |
71
+ | `TuiSection` | `▼` section header + separator fill |
72
+ | `TuiMetricRow` | Label left, value (+ unit) right |
73
+ | `TuiHitRow` | Hit bar + % + trend |
74
+ | `computeHitBarWidth` | Dynamic bar width |
75
+
76
+ Keep business data, i18n, and stats in your plugin modules; use this package for layout only.
77
+
78
+ Import `layout.ts` / `palette.ts` directly in tests to avoid pulling JSX through `index.ts`.
@@ -0,0 +1,76 @@
1
+ # TUI 面板框架
2
+
3
+ OpenCode **侧边栏面板**可复用布局(边框、折叠段、命中率条、主题调色板)。页面结构对齐 [opencode-visual-cache](https://www.npmjs.com/package/opencode-visual-cache),**不含** skills、斜杠命令、`api.kv` 等业务。
4
+
5
+ [English](README.md) · 设计背景:[docs/zh-CN/design.md](../../docs/zh-CN/design.md) § TUI 面板框架。
6
+
7
+ ## 快速用法
8
+
9
+ ```tsx
10
+ /** @jsxImportSource @opentui/solid */
11
+ import { createMemo, createSignal, Show } from "solid-js"
12
+ import {
13
+ buildPanelPalette,
14
+ createPanelLayout,
15
+ createSectionFold,
16
+ TuiMetricRow,
17
+ TuiPanel,
18
+ TuiPanelNoData,
19
+ TuiPanelSep,
20
+ TuiPanelTitle,
21
+ TuiSection,
22
+ } from "./tui-panel/index.ts"
23
+
24
+ export function MySidebar(props: { theme: Record<string, unknown> }) {
25
+ const pal = createMemo(() => buildPanelPalette(props.theme))
26
+ const [open, setOpen] = createSignal(true)
27
+ const detail = createSectionFold(true)
28
+ const layout = createPanelLayout({ border: () => true })
29
+ const hasData = () => true
30
+
31
+ return (
32
+ <TuiPanel pal={pal()} border layout={layout}>
33
+ <TuiPanelTitle
34
+ pal={pal()}
35
+ layout={layout}
36
+ open={open()}
37
+ onToggle={() => setOpen((o) => !o)}
38
+ title="My Panel"
39
+ version="0.1.0"
40
+ />
41
+ <Show when={open()}>
42
+ <Show
43
+ when={hasData()}
44
+ fallback={<TuiPanelNoData pal={pal()} layout={layout} message="No data..." />}
45
+ >
46
+ <TuiPanelSep pal={pal()} layout={layout} />
47
+ <TuiSection
48
+ pal={pal()}
49
+ layout={layout}
50
+ open={detail.open()}
51
+ title="Detail"
52
+ onToggle={detail.toggle}
53
+ >
54
+ <TuiMetricRow pal={pal()} layout={layout} label="Count:" value="42" />
55
+ </TuiSection>
56
+ </Show>
57
+ </Show>
58
+ </TuiPanel>
59
+ )
60
+ }
61
+ ```
62
+
63
+ ## API
64
+
65
+ | 导出 | 说明 |
66
+ |------|------|
67
+ | `createPanelLayout` | `onSizeChange` 测宽;`gauge`、`row()`、`sep` |
68
+ | `createSectionFold` | 区块折叠状态 |
69
+ | `TuiPanel` | 外框 + padding |
70
+ | `TuiPanelTitle` | 可折叠标题;可选 `collapsed` 摘要 |
71
+ | `TuiSection` | `▼` 区块标题 + 分隔线填充 |
72
+ | `TuiMetricRow` | 左标签右数值(可选 unit) |
73
+ | `TuiHitRow` | Hit 条 + % + 趋势 |
74
+ | `computeHitBarWidth` | 动态进度条宽度 |
75
+
76
+ 业务数据、i18n、统计逻辑放在插件自己的模块;本目录只负责页面骨架。测试里请直接 import `layout.ts` / `palette.ts`,避免经 `index.ts` 拉入 JSX。
@@ -0,0 +1,163 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Show, type JSX } from "solid-js"
3
+ import { padBeforeTitleSummary, sepAfterPrefix, visualWidth } from "./layout.ts"
4
+ import type { PanelLayout } from "./use-panel-layout.ts"
5
+ import type { PanelPalette } from "./palette.ts"
6
+
7
+ export function TuiPanel(props: {
8
+ pal: PanelPalette
9
+ border: boolean
10
+ layout: PanelLayout
11
+ children: JSX.Element
12
+ }) {
13
+ const bindRef = (el: { width?: number } | undefined) => {
14
+ props.layout.boxRef = el
15
+ }
16
+ return (
17
+ <box
18
+ ref={bindRef}
19
+ onSizeChange={props.layout.syncWidth}
20
+ border={props.border}
21
+ {...(props.border ? { borderColor: props.pal.border } : {})}
22
+ paddingTop={0}
23
+ paddingBottom={0}
24
+ paddingLeft={props.border ? 2 : 0}
25
+ paddingRight={props.border ? 2 : 0}
26
+ flexDirection="column"
27
+ gap={0}
28
+ width="100%"
29
+ >
30
+ {props.children}
31
+ </box>
32
+ )
33
+ }
34
+
35
+ export function TuiPanelTitle(props: {
36
+ pal: PanelPalette
37
+ layout: PanelLayout
38
+ open: boolean
39
+ onToggle: () => void
40
+ title: string
41
+ version?: string
42
+ collapsed?: JSX.Element
43
+ }) {
44
+ return (
45
+ <text onMouseUp={props.onToggle}>
46
+ <span style={{ fg: props.pal.muted }}>{props.open ? "\u25bc " : "\u25b6 "}</span>
47
+ <span style={{ fg: props.pal.primary }}>
48
+ <b>{props.title}</b>
49
+ <Show when={props.open && props.version}>
50
+ <span style={{ fg: props.pal.muted }}> (v{props.version})</span>
51
+ </Show>
52
+ </span>
53
+ <Show when={!props.open && props.collapsed}>{props.collapsed}</Show>
54
+ </text>
55
+ )
56
+ }
57
+
58
+ export function TuiTitleSummaryPad(props: {
59
+ layout: PanelLayout
60
+ titleWidth: number
61
+ summaryWidth: number
62
+ children: JSX.Element
63
+ }) {
64
+ const spaces = () =>
65
+ padBeforeTitleSummary(
66
+ props.layout.panelWidth(),
67
+ props.layout.gutter(),
68
+ props.titleWidth,
69
+ props.summaryWidth,
70
+ )
71
+ return (
72
+ <span>
73
+ {" ".repeat(spaces())}
74
+ {props.children}
75
+ </span>
76
+ )
77
+ }
78
+
79
+ export function TuiPanelSep(props: { pal: PanelPalette; layout: PanelLayout }) {
80
+ return <text fg={props.pal.muted}>{props.layout.sep()}</text>
81
+ }
82
+
83
+ export function TuiPanelNoData(props: {
84
+ pal: PanelPalette
85
+ layout: PanelLayout
86
+ message: string
87
+ }) {
88
+ return (
89
+ <>
90
+ <TuiPanelSep pal={props.pal} layout={props.layout} />
91
+ <text>
92
+ <span style={{ fg: props.pal.muted }}>{"> "}</span>
93
+ <span style={{ fg: props.pal.muted }}>{props.message}</span>
94
+ </text>
95
+ </>
96
+ )
97
+ }
98
+
99
+ export function TuiSection(props: {
100
+ pal: PanelPalette
101
+ layout: PanelLayout
102
+ open: boolean
103
+ title: string
104
+ suffix?: string
105
+ onToggle: () => void
106
+ children: JSX.Element
107
+ }) {
108
+ const prefix = () =>
109
+ `${props.open ? "\u25bc " : "\u25b6 "}${props.title}${props.suffix ?? ""}`
110
+ return (
111
+ <>
112
+ <text onMouseUp={props.onToggle}>
113
+ <span style={{ fg: props.pal.muted }}>{props.open ? "\u25bc " : "\u25b6 "}</span>
114
+ <span style={{ fg: props.pal.primary }}>
115
+ <b>{props.title}</b>
116
+ </span>
117
+ <Show when={props.suffix}>
118
+ <span style={{ fg: props.pal.muted }}>{props.suffix}</span>
119
+ </Show>
120
+ <span style={{ fg: props.pal.muted }}>
121
+ {sepAfterPrefix(prefix(), props.layout.gauge())}
122
+ </span>
123
+ </text>
124
+ <Show when={props.open}>{props.children}</Show>
125
+ </>
126
+ )
127
+ }
128
+
129
+ export function TuiMetricRow(props: {
130
+ pal: PanelPalette
131
+ layout: PanelLayout
132
+ label: string
133
+ value: string
134
+ unit?: string
135
+ fg?: string
136
+ }) {
137
+ const fg = props.fg ?? props.pal.muted
138
+ return (
139
+ <text fg={fg}>
140
+ {props.layout.row(props.label, props.value, props.unit ?? "")}
141
+ </text>
142
+ )
143
+ }
144
+
145
+ export function TuiHitRow(props: {
146
+ label: string
147
+ bar: string
148
+ pct: string
149
+ barColor: string
150
+ textColor: string
151
+ trend?: { text: string; color: string }
152
+ }) {
153
+ return (
154
+ <text>
155
+ <span style={{ fg: props.textColor }}>{props.label} </span>
156
+ <span style={{ fg: props.barColor }}>[{props.bar}] </span>
157
+ <span style={{ fg: props.textColor }}>{props.pct}</span>
158
+ <Show when={props.trend}>
159
+ <span style={{ fg: props.trend!.color }}> {props.trend!.text}</span>
160
+ </Show>
161
+ </text>
162
+ )
163
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reusable OpenCode TUI sidebar panel framework (visual-cache layout language).
3
+ * Domain plugins compose: TuiPanel + sections + metric rows + optional Hit row.
4
+ */
5
+
6
+ export {
7
+ MIN_PANEL_WIDTH,
8
+ DEFAULT_PANEL_WIDTH,
9
+ PANEL_GUTTER,
10
+ HEADER_PREFIX,
11
+ visualWidth,
12
+ visualPadEnd,
13
+ truncateVisual,
14
+ justifyRow,
15
+ justifyEnds,
16
+ computeHitBarWidth,
17
+ separatorLine,
18
+ sepAfterPrefix,
19
+ padBeforeTitleSummary,
20
+ } from "./layout.ts"
21
+
22
+ export { buildPanelPalette, themeColorToHex, type PanelPalette } from "./palette.ts"
23
+
24
+ export {
25
+ createPanelLayout,
26
+ createSectionFold,
27
+ type PanelLayout,
28
+ type PanelLayoutOptions,
29
+ type SectionFold,
30
+ } from "./use-panel-layout.ts"
31
+
32
+ export {
33
+ TuiPanel,
34
+ TuiPanelTitle,
35
+ TuiTitleSummaryPad,
36
+ TuiPanelSep,
37
+ TuiPanelNoData,
38
+ TuiSection,
39
+ TuiMetricRow,
40
+ TuiHitRow,
41
+ } from "./components.tsx"