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,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
+ }