compressed-context 0.0.0-stage → 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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +64 -6
  4. package/src/compression/active-summary-cache.ts +50 -0
  5. package/src/compression/adopt.ts +57 -0
  6. package/src/compression/auto.ts +372 -0
  7. package/src/compression/babel.ts +85 -0
  8. package/src/compression/block.ts +25 -0
  9. package/src/compression/cache-history.ts +75 -0
  10. package/src/compression/compaction.ts +189 -0
  11. package/src/compression/decompress-tool.ts +74 -0
  12. package/src/compression/decompress.ts +292 -0
  13. package/src/compression/deleted-sessions.ts +24 -0
  14. package/src/compression/distill.ts +220 -0
  15. package/src/compression/events.ts +275 -0
  16. package/src/compression/markdown.ts +53 -0
  17. package/src/compression/normalize.ts +114 -0
  18. package/src/compression/render.ts +70 -0
  19. package/src/compression/schema.ts +84 -0
  20. package/src/compression/select.ts +123 -0
  21. package/src/compression/session-message.ts +112 -0
  22. package/src/compression/settings-rpc.ts +111 -0
  23. package/src/compression/settings.ts +175 -0
  24. package/src/compression/state.ts +248 -0
  25. package/src/compression/summarize.ts +595 -0
  26. package/src/compression/summary.ts +73 -0
  27. package/src/compression/tool.ts +142 -0
  28. package/src/compression/transcript-store.ts +258 -0
  29. package/src/compression/transcript.ts +63 -0
  30. package/src/compression/validate.ts +81 -0
  31. package/src/i18n/en.ts +93 -0
  32. package/src/i18n/index.ts +28 -0
  33. package/src/i18n/types.ts +89 -0
  34. package/src/i18n/zh.ts +93 -0
  35. package/src/index.ts +108 -0
  36. package/src/shared/blocks.ts +17 -0
  37. package/src/shared/cache.ts +199 -0
  38. package/src/shared/format.ts +23 -0
  39. package/src/shared/media.ts +184 -0
  40. package/src/shared/parse.ts +25 -0
  41. package/src/shared/parts.ts +72 -0
  42. package/src/shared/rpc.ts +145 -0
  43. package/src/shared/scan.ts +32 -0
  44. package/src/shared/settings.ts +471 -0
  45. package/src/shared/stats.ts +142 -0
  46. package/src/shared/tokens.ts +259 -0
  47. package/src/tui/chart/cache-chart.ts +237 -0
  48. package/src/tui/chart/color.ts +58 -0
  49. package/src/tui/index.tsx +111 -0
  50. package/src/tui/panel/cache.tsx +203 -0
  51. package/src/tui/panel/data.ts +105 -0
  52. package/src/tui/panel/index.tsx +176 -0
  53. package/src/tui/panel/summaries.tsx +192 -0
  54. package/src/tui/settings/dialogs.tsx +215 -0
  55. package/src/tui/settings/options.ts +238 -0
  56. package/src/tui/settings.tsx +418 -0
  57. package/src/tui/types.ts +4 -0
@@ -0,0 +1,203 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+ import { createMemo, For, Show } from "solid-js"
3
+
4
+ import type { Messages } from "../../i18n/index.ts"
5
+ import { formatHitRate, type CacheSample } from "../../shared/cache.ts"
6
+ import { formatTokens } from "../../shared/format.ts"
7
+ import type { Settings } from "../../shared/settings.ts"
8
+ import {
9
+ CHART_HEIGHT,
10
+ CHART_WIDTH,
11
+ chartAxis,
12
+ hitRateColor,
13
+ renderBrailleChart,
14
+ xAxisLabels,
15
+ type ChartCell,
16
+ } from "../chart/cache-chart.ts"
17
+ import { themeHex } from "../chart/color.ts"
18
+ import type { UpdateSettings } from "../types.ts"
19
+
20
+ /** 输入:一行 chart cell;输出:可直接放进 `<text>` 的 span 序列。 */
21
+ function chartLine(row: readonly ChartCell[]) {
22
+ return row.map((cell) =>
23
+ cell.color === "" ? cell.char : <span style={{ fg: cell.color }}>{cell.char}</span>,
24
+ )
25
+ }
26
+
27
+ /**
28
+ * 输入:主题 + 文案 + 设置 + 写入函数 + 缓存样本。
29
+ * 输出:可折叠的缓存记录区(Braille 折线 + 最近 10 次日志)。
30
+ * 预期:样本由服务端持久化,这里只负责展示;0% 红 → 100% 绿。
31
+ */
32
+ export function CacheSection(props: {
33
+ readonly theme: Context["theme"]
34
+ readonly copy: Messages
35
+ readonly settings: Settings
36
+ readonly updateSettings: UpdateSettings
37
+ readonly samples: readonly CacheSample[]
38
+ }) {
39
+ const chartColors = createMemo(() => ({
40
+ success: themeHex(props.theme.text.feedback.success.base, [34, 197, 94]),
41
+ warning: themeHex(props.theme.text.feedback.warning.base, [234, 179, 8]),
42
+ error: themeHex(props.theme.text.feedback.error.base, [239, 68, 68]),
43
+ }))
44
+
45
+ const latestHit = createMemo(() => props.samples.at(-1)?.hitRate)
46
+ const axis = chartAxis(CHART_HEIGHT)
47
+ const chartRows = createMemo(() => {
48
+ if (props.samples.length === 0) return []
49
+ return renderBrailleChart(
50
+ props.samples.map((sample) => sample.hitRate),
51
+ CHART_WIDTH,
52
+ CHART_HEIGHT,
53
+ (pct) => hitRateColor(pct, chartColors()),
54
+ axis.gridRows,
55
+ themeHex(props.theme.text.muted, [128, 128, 128]),
56
+ )
57
+ })
58
+
59
+ /** 输入:折叠键;输出:无;预期:翻转后写回 TUI storage。 */
60
+ const toggleFold = (
61
+ key: "cacheExpanded" | "cacheChartExpanded" | "cacheLogExpanded",
62
+ ): void => {
63
+ void props.updateSettings((draft) => {
64
+ draft[key] = !draft[key]
65
+ })
66
+ }
67
+
68
+ return (
69
+ <Show when={props.settings.showCacheChart || props.settings.showCacheLog}>
70
+ <box width="100%" flexDirection="column">
71
+ <box
72
+ flexDirection="row"
73
+ height={1}
74
+ onMouseDown={() => {
75
+ toggleFold("cacheExpanded")
76
+ }}
77
+ >
78
+ <box flexDirection="row" gap={1}>
79
+ <text fg={props.theme.text.base}>{props.settings.cacheExpanded ? "▼" : "▶"}</text>
80
+ <text fg={props.theme.text.base}>
81
+ <b>{props.copy.cacheTitle}</b>
82
+ </text>
83
+ <Show
84
+ when={latestHit() !== undefined}
85
+ fallback={<text fg={props.theme.text.muted}>{"(--)"}</text>}
86
+ >
87
+ <text fg={hitRateColor(latestHit() ?? 0, chartColors())}>
88
+ {`(${formatHitRate(latestHit() ?? 0)}%)`}
89
+ </text>
90
+ </Show>
91
+ </box>
92
+ </box>
93
+ <Show when={props.settings.cacheExpanded}>
94
+ <box paddingLeft={2} flexDirection="column" width="100%">
95
+ <Show when={props.settings.showCacheChart}>
96
+ <box flexDirection="column" width="100%">
97
+ <box
98
+ flexDirection="row"
99
+ height={1}
100
+ gap={1}
101
+ onMouseDown={() => {
102
+ toggleFold("cacheChartExpanded")
103
+ }}
104
+ >
105
+ <text fg={props.theme.text.base}>
106
+ {props.settings.cacheChartExpanded ? "▼" : "▶"}
107
+ </text>
108
+ <text fg={props.theme.text.base}>{props.copy.cacheChart}</text>
109
+ </box>
110
+ <Show when={props.settings.cacheChartExpanded}>
111
+ <Show
112
+ when={chartRows().length > 0}
113
+ fallback={<text fg={props.theme.text.muted}>{props.copy.noData}</text>}
114
+ >
115
+ <For each={chartRows()}>
116
+ {(row, index) => {
117
+ const label = axis.labels.find((item) => item.row === index())?.text ?? " "
118
+ return (
119
+ <box flexDirection="row" height={1}>
120
+ <text fg={props.theme.text.muted}>{`${label} `}</text>
121
+ <text fg={props.theme.text.muted}>{"┤"}</text>
122
+ <text>{chartLine(row)}</text>
123
+ </box>
124
+ )
125
+ }}
126
+ </For>
127
+ <text fg={props.theme.text.muted}>{` 0% └${"─".repeat(CHART_WIDTH)}`}</text>
128
+ <text fg={props.theme.text.muted}>
129
+ {`${" ".repeat(6)}${xAxisLabels(props.samples.length, CHART_WIDTH)}`}
130
+ </text>
131
+ </Show>
132
+ </Show>
133
+ </box>
134
+ </Show>
135
+
136
+ <Show when={props.settings.showCacheLog}>
137
+ <box flexDirection="column" width="100%">
138
+ <box
139
+ flexDirection="row"
140
+ height={1}
141
+ gap={1}
142
+ onMouseDown={() => {
143
+ toggleFold("cacheLogExpanded")
144
+ }}
145
+ >
146
+ <text fg={props.theme.text.base}>
147
+ {props.settings.cacheLogExpanded ? "▼" : "▶"}
148
+ </text>
149
+ <text fg={props.theme.text.base}>{props.copy.cacheLog}</text>
150
+ </box>
151
+ <Show when={props.settings.cacheLogExpanded}>
152
+ <Show
153
+ when={props.samples.length > 0}
154
+ fallback={<text fg={props.theme.text.muted}>{props.copy.noData}</text>}
155
+ >
156
+ <box flexDirection="column" width="100%" paddingLeft={2}>
157
+ <box flexDirection="row" gap={1}>
158
+ <text width={5} textAlign="left" fg={props.theme.text.muted}>
159
+ {props.copy.cacheInput}
160
+ </text>
161
+ <text width={5} textAlign="left" fg={props.theme.text.muted}>
162
+ {props.copy.cacheOutput}
163
+ </text>
164
+ <text width={5} textAlign="left" fg={props.theme.text.muted}>
165
+ {props.copy.cacheCached}
166
+ </text>
167
+ <text width={7} textAlign="left" fg={props.theme.text.muted}>
168
+ {props.copy.cacheHit}
169
+ </text>
170
+ </box>
171
+ <For each={props.samples}>
172
+ {(sample) => (
173
+ <box flexDirection="row" gap={1}>
174
+ <text width={5} textAlign="left" fg={props.theme.text.muted}>
175
+ {formatTokens(sample.input)}
176
+ </text>
177
+ <text width={5} textAlign="left" fg={props.theme.text.muted}>
178
+ {formatTokens(sample.output)}
179
+ </text>
180
+ <text width={5} textAlign="left" fg={props.theme.text.muted}>
181
+ {formatTokens(sample.cached)}
182
+ </text>
183
+ <text
184
+ width={7}
185
+ textAlign="left"
186
+ fg={hitRateColor(sample.hitRate, chartColors())}
187
+ >
188
+ {`${formatHitRate(sample.hitRate)}%`}
189
+ </text>
190
+ </box>
191
+ )}
192
+ </For>
193
+ </box>
194
+ </Show>
195
+ </Show>
196
+ </box>
197
+ </Show>
198
+ </box>
199
+ </Show>
200
+ </box>
201
+ </Show>
202
+ )
203
+ }
@@ -0,0 +1,105 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+ import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
3
+
4
+ import type { SummaryBlock } from "../../shared/blocks.ts"
5
+ import type { CacheSample } from "../../shared/cache.ts"
6
+ import { contextRpc } from "../../shared/rpc.ts"
7
+ import type { Settings } from "../../shared/settings.ts"
8
+ import { parseStats, STATS_KEY, TERMINAL_KEY, type ContextStats } from "../../shared/stats.ts"
9
+
10
+ /** Panel 需要的服务端数据;全部是响应式 accessor。 */
11
+ export interface PanelData {
12
+ readonly stats: Accessor<ContextStats | undefined>
13
+ readonly terminal: Accessor<string | undefined>
14
+ readonly cache: Accessor<readonly CacheSample[]>
15
+ readonly blocks: Accessor<readonly SummaryBlock[]>
16
+ }
17
+
18
+ /**
19
+ * 输入:TUI 上下文 + sessionID + 设置 accessor。
20
+ * 输出:统计 / 终止原因 / 缓存历史 / 摘要块。
21
+ * 预期:挂载时拉取,usage/compaction/idle 时刷新;设置开关变化时重新拉取。
22
+ */
23
+ export function usePanelData(input: {
24
+ readonly context: Context
25
+ readonly sessionID: string
26
+ readonly settings: Accessor<Settings>
27
+ }): PanelData {
28
+ const [stats, setStats] = createSignal<ContextStats>()
29
+ const [terminal, setTerminal] = createSignal<string>()
30
+ const [cache, setCache] = createSignal<readonly CacheSample[]>([])
31
+ const [blocks, setBlocks] = createSignal<readonly SummaryBlock[]>([])
32
+ const rpc = () => contextRpc(input.context.client)
33
+
34
+ /** 输入:无;输出:无;预期:从服务端拉取该会话持久化的缓存历史。 */
35
+ const refreshCache = async (): Promise<void> => {
36
+ try {
37
+ const got = await rpc().cacheHistoryGet({ sessionID: input.sessionID })
38
+ setCache(got.samples)
39
+ } catch {
40
+ // server plugin not loaded;保留上一次数据
41
+ }
42
+ }
43
+
44
+ /** 输入:无;输出:无;预期:从服务端拉取 active 摘要链。 */
45
+ const refreshBlocks = async (): Promise<void> => {
46
+ if (!input.settings().showSummaries) return
47
+ try {
48
+ const got = await rpc().blocksGet({ sessionID: input.sessionID })
49
+ setBlocks(got.blocks)
50
+ } catch {
51
+ // server plugin not loaded;保留上一次数据
52
+ }
53
+ }
54
+
55
+ /** 输入:无;输出:无;预期:从服务端重新拉取 session metadata 里的统计。 */
56
+ const refresh = async (): Promise<void> => {
57
+ try {
58
+ const info = await input.context.client.session.get({ sessionID: input.sessionID })
59
+ setStats(parseStats(info.metadata?.[STATS_KEY]))
60
+ const rawTerminal = info.metadata?.[TERMINAL_KEY]
61
+ setTerminal(typeof rawTerminal === "string" ? rawTerminal : undefined)
62
+ } catch {
63
+ // 统计缺失不影响主界面,保持上一次的值
64
+ }
65
+ }
66
+
67
+ // 显示开关变化时重新拉取;首次挂载也会跑一次。
68
+ createEffect(() => {
69
+ if (input.settings().showCacheChart || input.settings().showCacheLog) void refreshCache()
70
+ })
71
+ createEffect(() => {
72
+ if (input.settings().showSummaries) void refreshBlocks()
73
+ })
74
+
75
+ createEffect(() => {
76
+ void refresh()
77
+ })
78
+
79
+ onCleanup(
80
+ input.context.data.on("session.usage.updated", (event) => {
81
+ if (event.data.sessionID !== input.sessionID) return
82
+ void refresh()
83
+ // 缓存历史由服务端在 usage.updated 时补写;这里同步刷新面板。
84
+ if (input.settings().showCacheChart || input.settings().showCacheLog) void refreshCache()
85
+ }),
86
+ )
87
+ onCleanup(
88
+ input.context.data.on("session.compaction.ended", (event) => {
89
+ if (event.data.sessionID !== input.sessionID) return
90
+ void refresh()
91
+ void refreshCache()
92
+ void refreshBlocks()
93
+ }),
94
+ )
95
+ onCleanup(
96
+ input.context.data.on("session.idle", (event) => {
97
+ if (event.data.sessionID !== input.sessionID) return
98
+ void refresh()
99
+ void refreshCache()
100
+ void refreshBlocks()
101
+ }),
102
+ )
103
+
104
+ return { stats, terminal, cache, blocks }
105
+ }
@@ -0,0 +1,176 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+ import { createMemo, For, Show } from "solid-js"
3
+
4
+ import { resolveMessages } from "../../i18n/index.ts"
5
+ import { formatTokens } from "../../shared/format.ts"
6
+ import { effectiveLimit, localeArgument, type Settings } from "../../shared/settings.ts"
7
+ import { scaleCategories } from "../../shared/stats.ts"
8
+ import { mix, toHex, toRgb, type Rgb } from "../chart/color.ts"
9
+ import type { UpdateSettings } from "../types.ts"
10
+ import { CacheSection } from "./cache.tsx"
11
+ import { usePanelData } from "./data.ts"
12
+ import { SummarySection } from "./summaries.tsx"
13
+
14
+ /**
15
+ * 输入:该分类占上下文总量的百分比 + 主题。
16
+ * 输出:0%-50%-100% 依次绿-黄-红的连续插值。
17
+ * 预期:每个百分比都有独立颜色;主题色不可解析时回退到内置色。
18
+ */
19
+ function occupancyColor(pct: number, theme: Context["theme"]): string {
20
+ const green = toRgb(theme.text.feedback.success.base) ?? ([34, 197, 94] as Rgb)
21
+ const yellow = toRgb(theme.text.feedback.warning.base) ?? ([234, 179, 8] as Rgb)
22
+ const red = toRgb(theme.text.feedback.error.base) ?? ([239, 68, 68] as Rgb)
23
+ const ratio = Math.min(1, Math.max(0, pct / 100))
24
+ if (ratio <= 0.5) return toHex(mix(green, yellow, ratio * 2))
25
+ return toHex(mix(yellow, red, (ratio - 0.5) * 2))
26
+ }
27
+
28
+ /**
29
+ * 输入:0-100 的百分比。
30
+ * 输出:OpenTUI 的百分比宽度类型 `${number}%`。
31
+ * 预期:先转字符串再断言——模板里直接插数字会被 lint 规则拦住,
32
+ * 而 OpenTUI 只接受 `${number}%` 这种字面量类型。
33
+ */
34
+ function percentWidth(value: number): `${number}%` {
35
+ const clamped = Math.min(100, Math.max(0, value))
36
+ return `${clamped.toString()}%` as `${number}%`
37
+ }
38
+
39
+ /**
40
+ * 输入:TUI 上下文 + sessionID + 设置。
41
+ * 输出:侧栏里的上下文分类面板(可折叠),外加缓存命中与摘要块。
42
+ * 预期:分类条总量交给内置 Context 区块;缓存历史由服务端持久化,TUI 只读。
43
+ */
44
+ export function Panel(props: {
45
+ readonly context: Context
46
+ readonly sessionID: string
47
+ readonly settings: Settings
48
+ readonly updateSettings: UpdateSettings
49
+ }) {
50
+ const theme = props.context.theme
51
+ const copy = createMemo(() => resolveMessages(localeArgument(props.settings.locale)))
52
+ const data = usePanelData({
53
+ context: props.context,
54
+ sessionID: props.sessionID,
55
+ settings: () => props.settings,
56
+ })
57
+
58
+ const session = createMemo(() => props.context.data.session.get(props.sessionID))
59
+ const messages = createMemo(() => props.context.data.session.message.list(props.sessionID))
60
+
61
+ /** 最后一条 assistant;模型上限和 usage 分开取,避免 provider 没给 usage 时连上限都丢。 */
62
+ const lastAssistant = createMemo(() => {
63
+ const list = messages()
64
+ for (let index = list.length - 1; index >= 0; index -= 1) {
65
+ const message = list[index]
66
+ if (message?.type === "assistant") return message
67
+ }
68
+ return undefined
69
+ })
70
+
71
+ /** 模型原生窗口:从最后一条 assistant 的模型解析。 */
72
+ const nativeLimit = createMemo(() => {
73
+ const message = lastAssistant()
74
+ if (message === undefined) return 0
75
+ const model = props.context.data.location.model
76
+ .list(session()?.location)
77
+ ?.find((item) => item.providerID === message.model.providerID && item.id === message.model.id)
78
+ return model?.limit.context ?? 0
79
+ })
80
+
81
+ /** 真实 prompt token:只算 input + cache,output/reasoning 不属于本轮输入。 */
82
+ const realUsed = createMemo(() => {
83
+ const tokens = lastAssistant()?.tokens
84
+ if (tokens === undefined) return undefined
85
+ const used = tokens.input + tokens.cache.read + tokens.cache.write
86
+ return used > 0 ? used : undefined
87
+ })
88
+
89
+ /** 上下文占用:真实 usage 优先,缺失时才回退到服务端估算。 */
90
+ const contextTokens = createMemo(() => realUsed() ?? data.stats()?.estimatedTotal ?? 0)
91
+
92
+ /** 实际生效的上下文上限:自定义优先,否则模型原生。 */
93
+ const limit = createMemo(() => effectiveLimit(props.settings, nativeLimit()))
94
+
95
+ /** 仅当用户自定义上限时展示百分比(auto 时内置区块已经显示了)。 */
96
+ const percent = createMemo(() => {
97
+ const max = limit()
98
+ if (max === undefined || max <= 0) return undefined
99
+ return Math.round((contextTokens() / max) * 100)
100
+ })
101
+
102
+ const rows = createMemo(() => {
103
+ const value = data.stats()
104
+ if (value === undefined || value.estimatedTotal <= 0) return []
105
+ return scaleCategories(value, contextTokens()).filter((row) => row.value > 0)
106
+ })
107
+
108
+ const toggleExpanded = (): void => {
109
+ void props.updateSettings((draft) => {
110
+ draft.expanded = !draft.expanded
111
+ })
112
+ }
113
+
114
+ return (
115
+ <Show when={props.settings.enabled}>
116
+ <box width="100%" flexDirection="column">
117
+ <box flexDirection="row" height={1} onMouseDown={toggleExpanded}>
118
+ <box flexDirection="row" gap={1}>
119
+ <text fg={theme.text.base}>{props.settings.expanded ? "▼" : "▶"}</text>
120
+ <text fg={theme.text.base}>
121
+ <b>{copy().title}</b>
122
+ </text>
123
+ <Show when={props.settings.contextLimit !== "auto" && percent() !== undefined}>
124
+ <text fg={occupancyColor(percent() ?? 0, theme)}>
125
+ {`(${(percent() ?? 0).toString()}%)`}
126
+ </text>
127
+ </Show>
128
+ </box>
129
+ </box>
130
+ <Show when={data.terminal()}>
131
+ <text fg={theme.text.feedback.error.base} wrapMode="word">
132
+ {`⚠ ${copy().terminal}`}
133
+ </text>
134
+ </Show>
135
+ <Show when={props.settings.expanded}>
136
+ <box flexDirection="column" width="100%" paddingLeft={2}>
137
+ <For each={rows()}>
138
+ {(row) => (
139
+ <box flexDirection="row" height={1} gap={1}>
140
+ <text fg={theme.text.muted} width={10} flexShrink={0}>
141
+ {copy().labels[row.key]}
142
+ </text>
143
+ <box flexGrow={1} height={1} backgroundColor={theme.background.raised.high}>
144
+ <box height={1} width={percentWidth(row.pct)} backgroundColor={occupancyColor(row.pct, theme)} />
145
+ </box>
146
+ <text fg={theme.text.muted} width={4} flexShrink={0} textAlign="right">
147
+ {`${row.pct.toString()}%`}
148
+ </text>
149
+ <text fg={theme.text.muted} width={7} flexShrink={0} textAlign="right">
150
+ {`${copy().approximate}${formatTokens(row.value)}`}
151
+ </text>
152
+ </box>
153
+ )}
154
+ </For>
155
+ </box>
156
+ </Show>
157
+
158
+ <CacheSection
159
+ theme={theme}
160
+ copy={copy()}
161
+ settings={props.settings}
162
+ updateSettings={props.updateSettings}
163
+ samples={data.cache()}
164
+ />
165
+ <SummarySection
166
+ context={props.context}
167
+ theme={theme}
168
+ copy={copy()}
169
+ settings={props.settings}
170
+ updateSettings={props.updateSettings}
171
+ blocks={data.blocks()}
172
+ />
173
+ </box>
174
+ </Show>
175
+ )
176
+ }
@@ -0,0 +1,192 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+ import { SyntaxStyle, type RGBA } from "@opentui/core"
3
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
4
+
5
+ import type { Messages } from "../../i18n/index.ts"
6
+ import type { SummaryBlock } from "../../shared/blocks.ts"
7
+ import { formatTimestamp, formatTokens } from "../../shared/format.ts"
8
+ import type { Settings } from "../../shared/settings.ts"
9
+ import type { UpdateSettings } from "../types.ts"
10
+
11
+ /** 输入:摘要等级;输出:对应主题色;T1 蓝、T2 黄、T3 红。 */
12
+ function tierColor(tier: SummaryBlock["tier"], theme: Context["theme"]): RGBA {
13
+ if (tier === 1) return theme.text.feedback.info.base
14
+ if (tier === 2) return theme.text.feedback.warning.base
15
+ return theme.text.feedback.error.base
16
+ }
17
+
18
+ /** 输入:无;输出:共享的 SyntaxStyle;预期:markdown 渲染器需要它,懒加载避免重复创建。 */
19
+ let sharedSyntaxStyle: SyntaxStyle | undefined
20
+ function getSyntaxStyle(): SyntaxStyle {
21
+ sharedSyntaxStyle ??= SyntaxStyle.create()
22
+ return sharedSyntaxStyle
23
+ }
24
+
25
+ /**
26
+ * 输入:TUI 上下文 + 主题 + 块。
27
+ * 输出:占满可用高度的阅读弹窗。
28
+ * 预期:标题保持 `[T1] b1 : title`;Babel 按纯文本显示,其余交给 markdown 渲染器,esc 关闭。
29
+ */
30
+ export function SummaryDialog(props: {
31
+ readonly context: Context
32
+ readonly theme: Context["theme"]
33
+ readonly block: SummaryBlock
34
+ }) {
35
+ const height = createMemo(() => Math.max(8, Math.min(30, props.context.renderer.height - 8)))
36
+ return (
37
+ <box paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
38
+ <box flexDirection="row" justifyContent="space-between" gap={1}>
39
+ <text fg={props.theme.text.base} wrapMode="word">
40
+ <b>{`[T${props.block.tier.toString()}] ${props.block.id} : ${props.block.title}`}</b>
41
+ </text>
42
+ <text
43
+ fg={props.theme.text.muted}
44
+ onMouseUp={() => {
45
+ props.context.ui.dialog.clear()
46
+ }}
47
+ >
48
+ esc
49
+ </text>
50
+ </box>
51
+ <scrollbox height={height()} scrollY={true} focused={true} width="100%">
52
+ <Show
53
+ when={props.block.format === "babel"}
54
+ fallback={
55
+ <markdown
56
+ content={props.block.body}
57
+ syntaxStyle={getSyntaxStyle()}
58
+ fg={props.theme.text.base}
59
+ conceal={true}
60
+ tableOptions={{ widthMode: "full", style: "columns" }}
61
+ />
62
+ }
63
+ >
64
+ <text fg={props.theme.text.base} wrapMode="word">
65
+ {props.block.body}
66
+ </text>
67
+ </Show>
68
+ </scrollbox>
69
+ </box>
70
+ )
71
+ }
72
+
73
+ /**
74
+ * 输入:主题 + 文案 + 设置 + 写入函数 + 摘要块。
75
+ * 输出:可折叠的摘要块列表;点击标题打开 markdown 阅读弹窗。
76
+ * 预期:只展示服务端返回的 active 链;hover 只让箭头动,标题和背景保持稳定。
77
+ */
78
+ export function SummarySection(props: {
79
+ readonly context: Context
80
+ readonly theme: Context["theme"]
81
+ readonly copy: Messages
82
+ readonly settings: Settings
83
+ readonly updateSettings: UpdateSettings
84
+ readonly blocks: readonly SummaryBlock[]
85
+ }) {
86
+ const [hovered, setHovered] = createSignal<string | undefined>(undefined)
87
+ const [frame, setFrame] = createSignal(0)
88
+
89
+ // 只有 hover 时跑两帧;鼠标移出或销毁时清理,避免后台定时器。
90
+ createEffect(() => {
91
+ if (hovered() === undefined) {
92
+ setFrame(0)
93
+ return
94
+ }
95
+ const timer = setInterval(() => setFrame((value) => (value + 1) % 2), 180)
96
+ onCleanup(() => {
97
+ clearInterval(timer)
98
+ })
99
+ })
100
+
101
+ /** 输入:无;输出:无;预期:翻转摘要区折叠状态。 */
102
+ const toggleFold = (): void => {
103
+ void props.updateSettings((draft) => {
104
+ draft.summariesExpanded = !draft.summariesExpanded
105
+ })
106
+ }
107
+
108
+ /** 输入:块;输出:无;预期:打开该块的 markdown 阅读弹窗。 */
109
+ const openSummary = (block: SummaryBlock): void => {
110
+ props.context.ui.dialog.show(() => (
111
+ <SummaryDialog context={props.context} theme={props.theme} block={block} />
112
+ ))
113
+ props.context.ui.dialog.set({ size: "large", centered: true })
114
+ }
115
+
116
+ return (
117
+ <Show when={props.settings.showSummaries}>
118
+ <box width="100%" flexDirection="column">
119
+ <box flexDirection="row" height={1} onMouseDown={toggleFold}>
120
+ <box flexDirection="row" gap={1}>
121
+ <text fg={props.theme.text.base}>{props.settings.summariesExpanded ? "▼" : "▶"}</text>
122
+ <text fg={props.theme.text.base}>
123
+ <b>{props.copy.summariesTitle}</b>
124
+ </text>
125
+ <text fg={props.theme.text.muted}>{`(${props.blocks.length.toString()})`}</text>
126
+ </box>
127
+ </box>
128
+ <Show when={props.settings.summariesExpanded}>
129
+ <Show
130
+ when={props.blocks.length > 0}
131
+ fallback={<text fg={props.theme.text.muted}>{props.copy.noData}</text>}
132
+ >
133
+ <box paddingLeft={2} flexDirection="column" width="100%">
134
+ <For each={props.blocks}>
135
+ {(block) => {
136
+ const meta = (): string => {
137
+ const parts: string[] = []
138
+ if (props.settings.showTimeline) parts.push(formatTimestamp(block.at))
139
+ if (block.sourceBlockIds !== undefined && block.sourceBlockIds.length > 0) {
140
+ parts.push(`merged ${block.sourceBlockIds.map((id) => `#${id}`).join(" + ")}`)
141
+ }
142
+ return parts.join(" ")
143
+ }
144
+ return (
145
+ <box flexDirection="column" width="100%">
146
+ <box
147
+ flexDirection="row"
148
+ gap={1}
149
+ onMouseOver={() => {
150
+ setHovered(block.id)
151
+ }}
152
+ onMouseOut={() => {
153
+ setHovered((current) => (current === block.id ? undefined : current))
154
+ }}
155
+ onMouseUp={() => {
156
+ openSummary(block)
157
+ }}
158
+ >
159
+ <text width={2} fg={tierColor(block.tier, props.theme)}>
160
+ {hovered() === block.id ? (frame() === 0 ? "▸ " : " ▸") : " "}
161
+ </text>
162
+ <text fg={props.theme.text.base} wrapMode="word" flexGrow={1}>
163
+ <span style={{ fg: tierColor(block.tier, props.theme) }}>
164
+ <b>{`[T${block.tier.toString()}]`}</b>
165
+ </span>
166
+ <span style={{ fg: props.theme.text.muted }}>
167
+ {` ${block.id} : `}
168
+ </span>
169
+ <span style={{ fg: props.theme.text.base }}>{block.title}</span>
170
+ <Show when={props.settings.showBlockTokens}>
171
+ <span style={{ fg: props.theme.text.muted }}>
172
+ {` ≈${formatTokens(block.tokensAfter)}`}
173
+ </span>
174
+ </Show>
175
+ </text>
176
+ </box>
177
+ <Show when={meta() !== ""}>
178
+ <text fg={props.theme.text.muted} wrapMode="word">
179
+ {` ${meta()}`}
180
+ </text>
181
+ </Show>
182
+ </box>
183
+ )
184
+ }}
185
+ </For>
186
+ </box>
187
+ </Show>
188
+ </Show>
189
+ </box>
190
+ </Show>
191
+ )
192
+ }