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,107 @@
|
|
|
1
|
+
/** Terminal layout primitives (visual-width aware, opencode-visual-cache compatible). */
|
|
2
|
+
|
|
3
|
+
export const MIN_PANEL_WIDTH = 20
|
|
4
|
+
export const DEFAULT_PANEL_WIDTH = 28
|
|
5
|
+
export const PANEL_GUTTER = 6
|
|
6
|
+
export const UNIT_GAP = 1
|
|
7
|
+
export const HEADER_PREFIX = 2
|
|
8
|
+
|
|
9
|
+
export const HIT_LABEL_GAP = 1
|
|
10
|
+
export const HIT_BAR_BRACKETS = 2
|
|
11
|
+
export const HIT_BAR_GAP = 1
|
|
12
|
+
export const HIT_PCT_FIXED_WIDTH = 5
|
|
13
|
+
|
|
14
|
+
function charColumns(c: string): number {
|
|
15
|
+
const code = c.codePointAt(0) ?? 0
|
|
16
|
+
if (code < 0x20) return 0
|
|
17
|
+
if (code < 0x7f) return 1
|
|
18
|
+
if (code < 0xa0) return 0
|
|
19
|
+
if (
|
|
20
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
21
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
22
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
23
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
24
|
+
(code >= 0xfe10 && code <= 0xfe6f) ||
|
|
25
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
26
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
27
|
+
(code >= 0x1f300 && code <= 0x1f64f) ||
|
|
28
|
+
(code >= 0x20000 && code <= 0x3fffd)
|
|
29
|
+
)
|
|
30
|
+
return 2
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function visualWidth(s: string): number {
|
|
35
|
+
let w = 0
|
|
36
|
+
for (const c of s) w += charColumns(c)
|
|
37
|
+
return w
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function visualPadEnd(s: string, cols: number): string {
|
|
41
|
+
const pad = cols - visualWidth(s)
|
|
42
|
+
return pad > 0 ? s + " ".repeat(pad) : s
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function truncateVisual(s: string, maxCols: number): string {
|
|
46
|
+
if (visualWidth(s) <= maxCols) return s
|
|
47
|
+
let result = ""
|
|
48
|
+
let w = 0
|
|
49
|
+
for (const c of s) {
|
|
50
|
+
const cw = charColumns(c)
|
|
51
|
+
if (w + cw > maxCols - 1) {
|
|
52
|
+
result += "\u2026"
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
result += c
|
|
56
|
+
w += cw
|
|
57
|
+
}
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function computeHitBarWidth(
|
|
62
|
+
hitLabel: string,
|
|
63
|
+
rowWidth: number,
|
|
64
|
+
trendText: string,
|
|
65
|
+
showTrend: boolean,
|
|
66
|
+
): number {
|
|
67
|
+
const trendSpace = showTrend ? HIT_LABEL_GAP + visualWidth(trendText) : 0
|
|
68
|
+
const overhead =
|
|
69
|
+
visualWidth(hitLabel) +
|
|
70
|
+
HIT_LABEL_GAP +
|
|
71
|
+
HIT_BAR_BRACKETS +
|
|
72
|
+
HIT_BAR_GAP +
|
|
73
|
+
HIT_PCT_FIXED_WIDTH +
|
|
74
|
+
trendSpace
|
|
75
|
+
return Math.max(3, rowWidth - overhead)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function justifyEnds(label: string, right: string, rowWidth: number): string {
|
|
79
|
+
const gap = Math.max(1, rowWidth - visualWidth(label) - visualWidth(right))
|
|
80
|
+
return label + " ".repeat(gap) + right
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function justifyRow(label: string, value: string, rowWidth: number, unit = ""): string {
|
|
84
|
+
const used =
|
|
85
|
+
visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
|
|
86
|
+
const gap = Math.max(1, rowWidth - used)
|
|
87
|
+
return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function sepAfterPrefix(prefix: string, rowWidth: number): string {
|
|
91
|
+
const rest = Math.max(1, rowWidth - visualWidth(prefix))
|
|
92
|
+
return "\u2500".repeat(rest)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function separatorLine(width = 28): string {
|
|
96
|
+
return "\u2500".repeat(Math.max(8, width))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Spaces before right-aligned collapsed title summary. */
|
|
100
|
+
export function padBeforeTitleSummary(
|
|
101
|
+
panelWidth: number,
|
|
102
|
+
gutter: number,
|
|
103
|
+
titleWidth: number,
|
|
104
|
+
summaryWidth: number,
|
|
105
|
+
): number {
|
|
106
|
+
return Math.max(1, panelWidth - gutter - HEADER_PREFIX - titleWidth - summaryWidth)
|
|
107
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const FALLBACK = {
|
|
2
|
+
primary: "#8B9DAF",
|
|
3
|
+
text: "#C5C5BB",
|
|
4
|
+
muted: "#7A7A72",
|
|
5
|
+
success: "#9CAF8B",
|
|
6
|
+
warning: "#C5B88D",
|
|
7
|
+
error: "#B08A8A",
|
|
8
|
+
border: "#6B6B63",
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
function rgb(raw: unknown): { r: number; g: number; b: number } | null {
|
|
12
|
+
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
13
|
+
const h = raw.slice(1)
|
|
14
|
+
return {
|
|
15
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
16
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
17
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (raw && typeof raw === "object") {
|
|
21
|
+
const o = raw as Record<string, unknown>
|
|
22
|
+
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
23
|
+
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
|
|
24
|
+
return { r: Math.round(o.r * scale), g: Math.round(o.g * scale), b: Math.round(o.b * scale) }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saturation(r: number, g: number, b: number): number {
|
|
31
|
+
const max = Math.max(r, g, b) / 255
|
|
32
|
+
const min = Math.min(r, g, b) / 255
|
|
33
|
+
const delta = max - min
|
|
34
|
+
if (delta === 0) return 0
|
|
35
|
+
const L = (max + min) / 2
|
|
36
|
+
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
40
|
+
const c = rgb(raw)
|
|
41
|
+
if (!c) return fallback
|
|
42
|
+
const sat = saturation(c.r, c.g, c.b)
|
|
43
|
+
if (sat <= maxSat) {
|
|
44
|
+
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
45
|
+
}
|
|
46
|
+
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
47
|
+
let lo = 0,
|
|
48
|
+
hi = 1
|
|
49
|
+
for (let i = 0; i < 12; i++) {
|
|
50
|
+
const mid = (lo + hi) / 2
|
|
51
|
+
const nr = Math.round(c.r + (luma - c.r) * mid)
|
|
52
|
+
const ng = Math.round(c.g + (luma - c.g) * mid)
|
|
53
|
+
const nb = Math.round(c.b + (luma - c.b) * mid)
|
|
54
|
+
if (saturation(nr, ng, nb) > maxSat) lo = mid
|
|
55
|
+
else hi = mid
|
|
56
|
+
}
|
|
57
|
+
const nr = Math.round(c.r + (luma - c.r) * hi)
|
|
58
|
+
const ng = Math.round(c.g + (luma - c.g) * hi)
|
|
59
|
+
const nb = Math.round(c.b + (luma - c.b) * hi)
|
|
60
|
+
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAX_SAT = 0.28
|
|
64
|
+
|
|
65
|
+
export type PanelPalette = {
|
|
66
|
+
primary: string
|
|
67
|
+
text: string
|
|
68
|
+
muted: string
|
|
69
|
+
success: string
|
|
70
|
+
warning: string
|
|
71
|
+
error: string
|
|
72
|
+
border: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse OpenCode theme color to #rrggbb (for tests and optional callers). */
|
|
76
|
+
export function themeColorToHex(raw: unknown, fallback: string): string {
|
|
77
|
+
const c = rgb(raw)
|
|
78
|
+
if (!c) return fallback
|
|
79
|
+
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildPanelPalette(theme: Record<string, unknown>): PanelPalette {
|
|
83
|
+
const sat = (k: string, fb: string) => desaturateTo(theme[k], MAX_SAT, fb)
|
|
84
|
+
return {
|
|
85
|
+
primary: sat("primary", FALLBACK.primary),
|
|
86
|
+
text: sat("text", FALLBACK.text),
|
|
87
|
+
muted: sat("textMuted", FALLBACK.muted),
|
|
88
|
+
success: sat("success", FALLBACK.success),
|
|
89
|
+
warning: sat("warning", FALLBACK.warning),
|
|
90
|
+
error: sat("error", FALLBACK.error),
|
|
91
|
+
border: sat("border", FALLBACK.border),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PANEL_WIDTH,
|
|
4
|
+
justifyRow,
|
|
5
|
+
MIN_PANEL_WIDTH,
|
|
6
|
+
PANEL_GUTTER,
|
|
7
|
+
separatorLine,
|
|
8
|
+
} from "./layout.ts"
|
|
9
|
+
|
|
10
|
+
export type PanelLayoutOptions = {
|
|
11
|
+
border: Accessor<boolean>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Measured sidebar width + row helpers.
|
|
16
|
+
* Call once per panel instance (e.g. top of sidebar component), not per render branch.
|
|
17
|
+
*/
|
|
18
|
+
export function createPanelLayout(options: PanelLayoutOptions) {
|
|
19
|
+
const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
|
|
20
|
+
let boxEl: { width?: number } | undefined
|
|
21
|
+
|
|
22
|
+
const gutter = createMemo(() => (options.border() ? PANEL_GUTTER : 0))
|
|
23
|
+
const gauge = createMemo(() => Math.max(MIN_PANEL_WIDTH, panelWidth() - gutter()))
|
|
24
|
+
const sep = createMemo(() => separatorLine(gauge()))
|
|
25
|
+
|
|
26
|
+
const syncWidth = () => {
|
|
27
|
+
const w = boxEl?.width
|
|
28
|
+
if (typeof w === "number" && w > 0) {
|
|
29
|
+
const next = Math.max(MIN_PANEL_WIDTH, w)
|
|
30
|
+
setPanelWidth((prev) => (prev === next ? prev : next))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
createEffect(() => {
|
|
35
|
+
options.border()
|
|
36
|
+
syncWidth()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const row = (label: string, value: string, unit = "") => justifyRow(label, value, gauge(), unit)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
panelWidth,
|
|
43
|
+
gutter,
|
|
44
|
+
gauge,
|
|
45
|
+
sep,
|
|
46
|
+
row,
|
|
47
|
+
syncWidth,
|
|
48
|
+
get boxRef() {
|
|
49
|
+
return boxEl
|
|
50
|
+
},
|
|
51
|
+
set boxRef(el: { width?: number } | undefined) {
|
|
52
|
+
boxEl = el
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type PanelLayout = ReturnType<typeof createPanelLayout>
|
|
58
|
+
|
|
59
|
+
/** Independent fold state for a collapsible section. */
|
|
60
|
+
export function createSectionFold(initial = true) {
|
|
61
|
+
const [open, setOpen] = createSignal(initial)
|
|
62
|
+
return {
|
|
63
|
+
open,
|
|
64
|
+
setOpen,
|
|
65
|
+
toggle: () => setOpen((o) => !o),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SectionFold = ReturnType<typeof createSectionFold>
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type SessionSnapshot = {
|
|
2
|
+
model: string
|
|
3
|
+
input: number
|
|
4
|
+
output: number
|
|
5
|
+
reasoning: number
|
|
6
|
+
cacheRead: number
|
|
7
|
+
cacheWrite: number
|
|
8
|
+
cost: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SubAgentSummary = {
|
|
12
|
+
id: string
|
|
13
|
+
cost: number
|
|
14
|
+
input: number
|
|
15
|
+
output: number
|
|
16
|
+
reasoning: number
|
|
17
|
+
cacheRead: number
|
|
18
|
+
cacheWrite: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AssistantMessage = {
|
|
22
|
+
role?: string
|
|
23
|
+
id?: string
|
|
24
|
+
messageID?: string
|
|
25
|
+
modelID?: string
|
|
26
|
+
cost?: number
|
|
27
|
+
/** OpenCode SDK: true = summary/compaction message, not a full LLM pricing turn */
|
|
28
|
+
summary?: boolean
|
|
29
|
+
time?: {
|
|
30
|
+
created: number
|
|
31
|
+
completed?: number
|
|
32
|
+
}
|
|
33
|
+
tokens?: {
|
|
34
|
+
input?: number
|
|
35
|
+
output?: number
|
|
36
|
+
reasoning?: number
|
|
37
|
+
cache?: { read?: number; write?: number }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type OpenCodeTuiApi = {
|
|
42
|
+
state: {
|
|
43
|
+
path: { directory: string }
|
|
44
|
+
session: {
|
|
45
|
+
messages: (id: string) => unknown[] | undefined
|
|
46
|
+
get: (id: string) => { parentID?: string } | undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
client: {
|
|
50
|
+
session: {
|
|
51
|
+
list: (opts: { query: { directory: string } }) => Promise<unknown>
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
event: {
|
|
55
|
+
on: (
|
|
56
|
+
name: string,
|
|
57
|
+
fn: (event: { properties?: { info?: { sessionID?: string } } }) => void,
|
|
58
|
+
) => () => void
|
|
59
|
+
}
|
|
60
|
+
slots: {
|
|
61
|
+
register: (opts: {
|
|
62
|
+
order: number
|
|
63
|
+
slots: {
|
|
64
|
+
sidebar_content: (
|
|
65
|
+
ctx: { theme: { current: Record<string, unknown> } },
|
|
66
|
+
props: { session_id: string },
|
|
67
|
+
) => unknown
|
|
68
|
+
}
|
|
69
|
+
}) => void
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createMemo, type Accessor } from "solid-js"
|
|
2
|
+
import type { DisplayConfig } from "./plugin-config.ts"
|
|
3
|
+
import { getUiStrings, resolveLang } from "./i18n.ts"
|
|
4
|
+
import {
|
|
5
|
+
formatHitBar,
|
|
6
|
+
formatPercentOneDecimal,
|
|
7
|
+
formatRatioAsPercent,
|
|
8
|
+
formatTrendLabel,
|
|
9
|
+
} from "./format-cache-ui.ts"
|
|
10
|
+
import { computeHitBarWidth, visualWidth } from "./tui-panel/layout.ts"
|
|
11
|
+
import { buildPanelPalette, type PanelPalette } from "./tui-panel/palette.ts"
|
|
12
|
+
import type { PanelLayout } from "./tui-panel/use-panel-layout.ts"
|
|
13
|
+
import type { AssistantMessage, SessionSnapshot, SubAgentSummary } from "./types.ts"
|
|
14
|
+
import {
|
|
15
|
+
cacheHitRatio,
|
|
16
|
+
combinedCacheHitRatio,
|
|
17
|
+
computePerCallHitTrend,
|
|
18
|
+
mainSessionHasStats,
|
|
19
|
+
shortModelName,
|
|
20
|
+
} from "./stats.ts"
|
|
21
|
+
|
|
22
|
+
function activeLang(display: DisplayConfig) {
|
|
23
|
+
return display.lang === "auto" ? resolveLang("auto") : display.lang
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hitRateColor(pct: number, pal: PanelPalette): string {
|
|
27
|
+
if (pct >= 85) return pal.success
|
|
28
|
+
if (pct >= 70) return pal.warning
|
|
29
|
+
return pal.muted
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useCacheHitMetrics(props: {
|
|
33
|
+
theme: Accessor<Record<string, unknown>>
|
|
34
|
+
display: DisplayConfig
|
|
35
|
+
messages: Accessor<AssistantMessage[]>
|
|
36
|
+
main: Accessor<SessionSnapshot>
|
|
37
|
+
subAgents: Accessor<SubAgentSummary[]>
|
|
38
|
+
layout: PanelLayout
|
|
39
|
+
}) {
|
|
40
|
+
const pal = createMemo(() => buildPanelPalette(props.theme()))
|
|
41
|
+
const t = createMemo(() => getUiStrings(activeLang(props.display)))
|
|
42
|
+
const hitLabel = createMemo(() => props.display.mainHitLabel ?? t().hit)
|
|
43
|
+
const subs = createMemo(() => props.subAgents())
|
|
44
|
+
const main = createMemo(() => props.main())
|
|
45
|
+
const perCall = createMemo(() => computePerCallHitTrend(props.messages()))
|
|
46
|
+
const sessionRatio = createMemo(() => cacheHitRatio(main().cacheRead, main().input))
|
|
47
|
+
const combinedRatio = createMemo(() => combinedCacheHitRatio(main(), subs()))
|
|
48
|
+
|
|
49
|
+
const showCombinedHit = createMemo(() => {
|
|
50
|
+
if (subs().length === 0) return false
|
|
51
|
+
return Math.abs(combinedRatio() - sessionRatio()) >= 0.0005
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const mainHasStats = createMemo(() => mainSessionHasStats(main()))
|
|
55
|
+
const hasData = createMemo(() => mainHasStats() || subs().length > 0)
|
|
56
|
+
|
|
57
|
+
const trendLabel = createMemo(() =>
|
|
58
|
+
perCall().hasTrend ? formatTrendLabel(perCall().trendPercent) : "",
|
|
59
|
+
)
|
|
60
|
+
const bar = createMemo(() =>
|
|
61
|
+
formatHitBar(
|
|
62
|
+
perCall().hitPercent / 100,
|
|
63
|
+
computeHitBarWidth(hitLabel(), props.layout.gauge(), trendLabel(), perCall().hasTrend),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
const hitColor = createMemo(() => hitRateColor(perCall().hitPercent, pal()))
|
|
67
|
+
const trendFg = createMemo(() => {
|
|
68
|
+
const tr = perCall().trendPercent
|
|
69
|
+
if (Math.abs(tr) < 0.05) return pal().text
|
|
70
|
+
return tr > 0 ? pal().success : pal().error
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const collapsedHitSummary = createMemo(() => {
|
|
74
|
+
const right = perCall().hasTrend
|
|
75
|
+
? `${formatPercentOneDecimal(perCall().hitPercent)} ${t().hitFolded} ${trendLabel()}`
|
|
76
|
+
: `${formatPercentOneDecimal(perCall().hitPercent)} ${t().hitFolded}`
|
|
77
|
+
return { text: right, width: visualWidth(right) }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
pal,
|
|
82
|
+
t,
|
|
83
|
+
hitLabel,
|
|
84
|
+
subs,
|
|
85
|
+
main,
|
|
86
|
+
mainHasStats,
|
|
87
|
+
perCall,
|
|
88
|
+
sessionPct: createMemo(() => formatRatioAsPercent(sessionRatio())),
|
|
89
|
+
combinedPct: createMemo(() => formatRatioAsPercent(combinedRatio())),
|
|
90
|
+
showCombinedHit,
|
|
91
|
+
hasData,
|
|
92
|
+
trendLabel,
|
|
93
|
+
bar,
|
|
94
|
+
hitColor,
|
|
95
|
+
trendFg,
|
|
96
|
+
pctLabel: createMemo(() => formatPercentOneDecimal(perCall().hitPercent)),
|
|
97
|
+
modelShort: createMemo(() => shortModelName(main().model)),
|
|
98
|
+
totalSubCost: createMemo(() => subs().reduce((s, a) => s + a.cost, 0)),
|
|
99
|
+
collapsedHitSummary,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type CacheHitMetrics = ReturnType<typeof useCacheHitMetrics>
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PLUGIN_VERSION = "0.1.0"
|
package/src/widget.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
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"
|
|
5
|
+
import { PLUGIN_VERSION } from "./version.ts"
|
|
6
|
+
import { AgentsView } from "./agents-view.tsx"
|
|
7
|
+
import { MainSessionView } from "./main-session-view.tsx"
|
|
8
|
+
import { useCacheHitMetrics } from "./use-cache-hit-metrics.ts"
|
|
9
|
+
import {
|
|
10
|
+
createPanelLayout,
|
|
11
|
+
createSectionFold,
|
|
12
|
+
TuiPanel,
|
|
13
|
+
TuiPanelNoData,
|
|
14
|
+
TuiPanelSep,
|
|
15
|
+
TuiPanelTitle,
|
|
16
|
+
TuiSection,
|
|
17
|
+
TuiTitleSummaryPad,
|
|
18
|
+
visualWidth,
|
|
19
|
+
} from "./tui-panel/index.ts"
|
|
20
|
+
|
|
21
|
+
export function CacheHitSidebar(props: {
|
|
22
|
+
sessionId: Accessor<string>
|
|
23
|
+
theme: Record<string, unknown>
|
|
24
|
+
display: DisplayConfig
|
|
25
|
+
messages: Accessor<AssistantMessage[]>
|
|
26
|
+
main: Accessor<SessionSnapshot>
|
|
27
|
+
subAgents: Accessor<SubAgentSummary[]>
|
|
28
|
+
formatCost: (amount: number) => string
|
|
29
|
+
}) {
|
|
30
|
+
const [panelOpen, setPanelOpen] = createSignal(true)
|
|
31
|
+
const detail = createSectionFold(true)
|
|
32
|
+
const model = createSectionFold(true)
|
|
33
|
+
const agents = createSectionFold(true)
|
|
34
|
+
|
|
35
|
+
const borderOn = () => props.display.panelBorder
|
|
36
|
+
const layout = createPanelLayout({ border: borderOn })
|
|
37
|
+
|
|
38
|
+
const m = useCacheHitMetrics({
|
|
39
|
+
theme: () => props.theme,
|
|
40
|
+
display: props.display,
|
|
41
|
+
messages: props.messages,
|
|
42
|
+
main: props.main,
|
|
43
|
+
subAgents: props.subAgents,
|
|
44
|
+
layout,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const agentsSuffix = createMemo(() => {
|
|
48
|
+
const n = m.subs().length
|
|
49
|
+
if (n === 0) return ""
|
|
50
|
+
return ` (${n})${m.t().agentsScopeHint}`
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Show when={props.sessionId().length > 0}>
|
|
55
|
+
<TuiPanel pal={m.pal()} border={borderOn()} layout={layout}>
|
|
56
|
+
<TuiPanelTitle
|
|
57
|
+
pal={m.pal()}
|
|
58
|
+
layout={layout}
|
|
59
|
+
open={panelOpen()}
|
|
60
|
+
onToggle={() => setPanelOpen((o) => !o)}
|
|
61
|
+
title={m.t().title}
|
|
62
|
+
version={PLUGIN_VERSION}
|
|
63
|
+
collapsed={
|
|
64
|
+
<>
|
|
65
|
+
<Show when={m.hasData() && m.mainHasStats()}>
|
|
66
|
+
<TuiTitleSummaryPad
|
|
67
|
+
layout={layout}
|
|
68
|
+
titleWidth={visualWidth(m.t().title)}
|
|
69
|
+
summaryWidth={m.collapsedHitSummary().width}
|
|
70
|
+
>
|
|
71
|
+
<span style={{ fg: m.hitColor() }}>{m.collapsedHitSummary().text}</span>
|
|
72
|
+
</TuiTitleSummaryPad>
|
|
73
|
+
</Show>
|
|
74
|
+
<Show when={m.hasData() && !m.mainHasStats() && m.subs().length > 0}>
|
|
75
|
+
<TuiTitleSummaryPad
|
|
76
|
+
layout={layout}
|
|
77
|
+
titleWidth={visualWidth(m.t().title)}
|
|
78
|
+
summaryWidth={visualWidth(props.formatCost(m.totalSubCost()))}
|
|
79
|
+
>
|
|
80
|
+
<span style={{ fg: m.pal().success }}>{props.formatCost(m.totalSubCost())}</span>
|
|
81
|
+
</TuiTitleSummaryPad>
|
|
82
|
+
</Show>
|
|
83
|
+
</>
|
|
84
|
+
}
|
|
85
|
+
/>
|
|
86
|
+
|
|
87
|
+
<Show when={panelOpen()}>
|
|
88
|
+
<Show
|
|
89
|
+
when={m.hasData()}
|
|
90
|
+
fallback={<TuiPanelNoData pal={m.pal()} layout={layout} message={m.t().noData} />}
|
|
91
|
+
>
|
|
92
|
+
<TuiPanelSep pal={m.pal()} layout={layout} />
|
|
93
|
+
<MainSessionView
|
|
94
|
+
m={m}
|
|
95
|
+
layout={layout}
|
|
96
|
+
detail={detail}
|
|
97
|
+
model={model}
|
|
98
|
+
formatCost={props.formatCost}
|
|
99
|
+
/>
|
|
100
|
+
<Show when={m.subs().length > 0}>
|
|
101
|
+
<TuiSection
|
|
102
|
+
pal={m.pal()}
|
|
103
|
+
layout={layout}
|
|
104
|
+
open={agents.open()}
|
|
105
|
+
title={m.t().secAgents}
|
|
106
|
+
suffix={agentsSuffix()}
|
|
107
|
+
onToggle={agents.toggle}
|
|
108
|
+
>
|
|
109
|
+
<AgentsView m={m} layout={layout} formatCost={props.formatCost} />
|
|
110
|
+
</TuiSection>
|
|
111
|
+
</Show>
|
|
112
|
+
</Show>
|
|
113
|
+
</Show>
|
|
114
|
+
</TuiPanel>
|
|
115
|
+
</Show>
|
|
116
|
+
)
|
|
117
|
+
}
|