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.
- package/AGENTS.md +67 -0
- package/CONTRIBUTING.md +73 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/README.zh-CN.md +186 -0
- package/cache-hit.config.example.json +22 -0
- package/docs/README.md +8 -0
- package/docs/assets/cache-hit-panel.png +0 -0
- package/docs/en/design.md +228 -0
- package/docs/en/timeline.md +253 -0
- package/docs/zh-CN/design.md +229 -0
- package/docs/zh-CN/timeline.md +301 -0
- package/index.tsx +1 -0
- package/package.json +71 -0
- package/scripts/README.md +39 -0
- package/scripts/plot-hit-rate.ts +222 -0
- package/src/agents-view.tsx +55 -0
- package/src/cache-hit-rows.tsx +68 -0
- package/src/child-session-sync.ts +93 -0
- package/src/format-cache-ui.ts +21 -0
- package/src/format-cost.ts +90 -0
- package/src/format-tokens.ts +5 -0
- package/src/i18n.ts +82 -0
- package/src/load-config.ts +29 -0
- package/src/main-session-view.tsx +76 -0
- package/src/message-timing.ts +35 -0
- package/src/plugin-config.ts +116 -0
- package/src/plugin.tsx +33 -0
- package/src/session-list.ts +11 -0
- package/src/sidebar-host.tsx +121 -0
- package/src/stats.ts +141 -0
- package/src/timeline/collector.ts +156 -0
- package/src/timeline/records.ts +73 -0
- package/src/timeline/rotation.ts +47 -0
- package/src/timeline/types.ts +22 -0
- package/src/timeline/writer.ts +134 -0
- package/src/tui-panel/README.md +78 -0
- package/src/tui-panel/README.zh-CN.md +76 -0
- package/src/tui-panel/components.tsx +163 -0
- package/src/tui-panel/index.ts +41 -0
- package/src/tui-panel/layout.ts +107 -0
- package/src/tui-panel/palette.ts +93 -0
- package/src/tui-panel/use-panel-layout.ts +69 -0
- package/src/types.ts +71 -0
- package/src/use-cache-hit-metrics.ts +103 -0
- package/src/version.ts +1 -0
- 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"
|