@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.6-beta0

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 (72) hide show
  1. package/index.ts +141 -0
  2. package/lib/analysis/tokens.ts +225 -0
  3. package/lib/auth.ts +37 -0
  4. package/lib/commands/compression-targets.ts +137 -0
  5. package/lib/commands/context.ts +132 -0
  6. package/lib/commands/decompress.ts +275 -0
  7. package/lib/commands/help.ts +76 -0
  8. package/lib/commands/index.ts +11 -0
  9. package/lib/commands/manual.ts +125 -0
  10. package/lib/commands/recompress.ts +224 -0
  11. package/lib/commands/stats.ts +148 -0
  12. package/lib/commands/sweep.ts +268 -0
  13. package/lib/compress/index.ts +3 -0
  14. package/lib/compress/message-utils.ts +250 -0
  15. package/lib/compress/message.ts +137 -0
  16. package/lib/compress/pipeline.ts +106 -0
  17. package/lib/compress/protected-content.ts +154 -0
  18. package/lib/compress/range-utils.ts +308 -0
  19. package/lib/compress/range.ts +180 -0
  20. package/lib/compress/search.ts +267 -0
  21. package/lib/compress/state.ts +268 -0
  22. package/lib/compress/timing.ts +77 -0
  23. package/lib/compress/types.ts +108 -0
  24. package/lib/compress-permission.ts +25 -0
  25. package/lib/config.ts +1071 -0
  26. package/lib/hooks.ts +378 -0
  27. package/lib/host-permissions.ts +101 -0
  28. package/lib/logger.ts +235 -0
  29. package/lib/message-ids.ts +172 -0
  30. package/lib/messages/index.ts +8 -0
  31. package/lib/messages/inject/inject.ts +215 -0
  32. package/lib/messages/inject/subagent-results.ts +82 -0
  33. package/lib/messages/inject/utils.ts +374 -0
  34. package/lib/messages/priority.ts +102 -0
  35. package/lib/messages/prune.ts +238 -0
  36. package/lib/messages/query.ts +56 -0
  37. package/lib/messages/reasoning-strip.ts +40 -0
  38. package/lib/messages/sync.ts +124 -0
  39. package/lib/messages/utils.ts +187 -0
  40. package/lib/prompts/compress-message.ts +42 -0
  41. package/lib/prompts/compress-range.ts +60 -0
  42. package/lib/prompts/context-limit-nudge.ts +18 -0
  43. package/lib/prompts/extensions/nudge.ts +43 -0
  44. package/lib/prompts/extensions/system.ts +32 -0
  45. package/lib/prompts/extensions/tool.ts +35 -0
  46. package/lib/prompts/index.ts +29 -0
  47. package/lib/prompts/iteration-nudge.ts +6 -0
  48. package/lib/prompts/store.ts +467 -0
  49. package/lib/prompts/system.ts +33 -0
  50. package/lib/prompts/turn-nudge.ts +10 -0
  51. package/lib/protected-patterns.ts +128 -0
  52. package/lib/state/index.ts +4 -0
  53. package/lib/state/persistence.ts +256 -0
  54. package/lib/state/state.ts +190 -0
  55. package/lib/state/tool-cache.ts +98 -0
  56. package/lib/state/types.ts +112 -0
  57. package/lib/state/utils.ts +334 -0
  58. package/lib/strategies/deduplication.ts +127 -0
  59. package/lib/strategies/index.ts +2 -0
  60. package/lib/strategies/purge-errors.ts +88 -0
  61. package/lib/subagents/subagent-results.ts +74 -0
  62. package/lib/token-utils.ts +162 -0
  63. package/lib/ui/notification.ts +346 -0
  64. package/lib/ui/utils.ts +287 -0
  65. package/package.json +9 -2
  66. package/tui/data/context.ts +177 -0
  67. package/tui/index.tsx +34 -0
  68. package/tui/routes/summary.tsx +175 -0
  69. package/tui/shared/names.ts +9 -0
  70. package/tui/shared/theme.ts +58 -0
  71. package/tui/shared/types.ts +38 -0
  72. package/tui/slots/sidebar-content.tsx +502 -0
@@ -0,0 +1,287 @@
1
+ import { SessionState, ToolParameterEntry, WithParts } from "../state"
2
+ import { countTokens } from "../token-utils"
3
+ import { isIgnoredUserMessage } from "../messages/query"
4
+
5
+ function extractParameterKey(tool: string, parameters: any): string {
6
+ if (!parameters) return ""
7
+
8
+ if (tool === "read" && parameters.filePath) {
9
+ const offset = parameters.offset
10
+ const limit = parameters.limit
11
+ if (offset !== undefined && limit !== undefined) {
12
+ return `${parameters.filePath} (lines ${offset}-${offset + limit})`
13
+ }
14
+ if (offset !== undefined) {
15
+ return `${parameters.filePath} (lines ${offset}+)`
16
+ }
17
+ if (limit !== undefined) {
18
+ return `${parameters.filePath} (lines 0-${limit})`
19
+ }
20
+ return parameters.filePath
21
+ }
22
+
23
+ if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) {
24
+ return parameters.filePath
25
+ }
26
+
27
+ if (tool === "apply_patch" && typeof parameters.patchText === "string") {
28
+ const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g
29
+ const paths: string[] = []
30
+ let match
31
+ while ((match = pathRegex.exec(parameters.patchText)) !== null) {
32
+ paths.push(match[1].trim())
33
+ }
34
+ if (paths.length > 0) {
35
+ const uniquePaths = [...new Set(paths)]
36
+ const count = uniquePaths.length
37
+ const plural = count > 1 ? "s" : ""
38
+ if (count === 1) return uniquePaths[0]
39
+ if (count === 2) return uniquePaths.join(", ")
40
+ return `${count} file${plural}: ${uniquePaths[0]}, ${uniquePaths[1]}...`
41
+ }
42
+ return "patch"
43
+ }
44
+
45
+ if (tool === "list") {
46
+ return parameters.path || "(current directory)"
47
+ }
48
+
49
+ if (tool === "glob") {
50
+ if (parameters.pattern) {
51
+ const pathInfo = parameters.path ? ` in ${parameters.path}` : ""
52
+ return `"${parameters.pattern}"${pathInfo}`
53
+ }
54
+ return "(unknown pattern)"
55
+ }
56
+
57
+ if (tool === "grep") {
58
+ if (parameters.pattern) {
59
+ const pathInfo = parameters.path ? ` in ${parameters.path}` : ""
60
+ return `"${parameters.pattern}"${pathInfo}`
61
+ }
62
+ return "(unknown pattern)"
63
+ }
64
+
65
+ if (tool === "bash") {
66
+ if (parameters.description) return parameters.description
67
+ if (parameters.command) {
68
+ return parameters.command.length > 50
69
+ ? parameters.command.substring(0, 50) + "..."
70
+ : parameters.command
71
+ }
72
+ }
73
+
74
+ if (tool === "webfetch" && parameters.url) {
75
+ return parameters.url
76
+ }
77
+ if (tool === "websearch" && parameters.query) {
78
+ return `"${parameters.query}"`
79
+ }
80
+ if (tool === "codesearch" && parameters.query) {
81
+ return `"${parameters.query}"`
82
+ }
83
+
84
+ if (tool === "todowrite") {
85
+ return `${parameters.todos?.length || 0} todos`
86
+ }
87
+ if (tool === "todoread") {
88
+ return "read todo list"
89
+ }
90
+
91
+ if (tool === "task" && parameters.description) {
92
+ return parameters.description
93
+ }
94
+ if (tool === "skill" && parameters.name) {
95
+ return parameters.name
96
+ }
97
+
98
+ if (tool === "lsp") {
99
+ const op = parameters.operation || "lsp"
100
+ const path = parameters.filePath || ""
101
+ const line = parameters.line
102
+ const char = parameters.character
103
+ if (path && line !== undefined && char !== undefined) {
104
+ return `${op} ${path}:${line}:${char}`
105
+ }
106
+ if (path) {
107
+ return `${op} ${path}`
108
+ }
109
+ return op
110
+ }
111
+
112
+ if (tool === "question") {
113
+ const questions = parameters.questions
114
+ if (Array.isArray(questions) && questions.length > 0) {
115
+ const headers = questions
116
+ .map((q: any) => q.header || "")
117
+ .filter(Boolean)
118
+ .slice(0, 3)
119
+
120
+ const count = questions.length
121
+ const plural = count > 1 ? "s" : ""
122
+
123
+ if (headers.length > 0) {
124
+ const suffix = count > 3 ? ` (+${count - 3} more)` : ""
125
+ return `${count} question${plural}: ${headers.join(", ")}${suffix}`
126
+ }
127
+ return `${count} question${plural}`
128
+ }
129
+ return "question"
130
+ }
131
+
132
+ const paramStr = JSON.stringify(parameters)
133
+ if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") {
134
+ return ""
135
+ }
136
+
137
+ return paramStr.substring(0, 50)
138
+ }
139
+
140
+ export function formatStatsHeader(totalTokensSaved: number, pruneTokenCounter: number): string {
141
+ const totalTokensSavedStr = `~${formatTokenCount(totalTokensSaved + pruneTokenCounter)}`
142
+ return [`▣ DCP | ${totalTokensSavedStr} saved total`].join("\n")
143
+ }
144
+
145
+ export function formatTokenCount(tokens: number, compact?: boolean): string {
146
+ const suffix = compact ? "" : " tokens"
147
+ if (tokens >= 1000) {
148
+ return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + suffix
149
+ }
150
+ return tokens.toString() + suffix
151
+ }
152
+
153
+ export function truncate(str: string, maxLen: number = 60): string {
154
+ if (str.length <= maxLen) return str
155
+ return str.slice(0, maxLen - 3) + "..."
156
+ }
157
+
158
+ export function formatProgressBar(
159
+ messageIds: string[],
160
+ prunedMessages: Map<string, number>,
161
+ recentMessageIds: string[],
162
+ width: number = 50,
163
+ ): string {
164
+ const ACTIVE = "█"
165
+ const PRUNED = "░"
166
+ const RECENT = "⣿"
167
+ const recentSet = new Set(recentMessageIds)
168
+
169
+ const total = messageIds.length
170
+ if (total === 0) return `│${PRUNED.repeat(width)}│`
171
+
172
+ const bar = new Array(width).fill(ACTIVE)
173
+
174
+ for (let m = 0; m < total; m++) {
175
+ const msgId = messageIds[m]
176
+ const start = Math.floor((m / total) * width)
177
+ const end = Math.floor(((m + 1) / total) * width)
178
+
179
+ if (recentSet.has(msgId)) {
180
+ for (let i = start; i < end; i++) {
181
+ bar[i] = RECENT
182
+ }
183
+ } else if (prunedMessages.has(msgId)) {
184
+ for (let i = start; i < end; i++) {
185
+ bar[i] = PRUNED
186
+ }
187
+ }
188
+ }
189
+
190
+ return `│${bar.join("")}│`
191
+ }
192
+
193
+ export function cacheSystemPromptTokens(state: SessionState, messages: WithParts[]): void {
194
+ let firstInputTokens = 0
195
+ for (const msg of messages) {
196
+ if (msg.info.role !== "assistant") {
197
+ continue
198
+ }
199
+ const info = msg.info as any
200
+ const input = info?.tokens?.input || 0
201
+ const cacheRead = info?.tokens?.cache?.read || 0
202
+ const cacheWrite = info?.tokens?.cache?.write || 0
203
+ if (input > 0 || cacheRead > 0 || cacheWrite > 0) {
204
+ firstInputTokens = input + cacheRead + cacheWrite
205
+ break
206
+ }
207
+ }
208
+
209
+ if (firstInputTokens <= 0) {
210
+ state.systemPromptTokens = undefined
211
+ return
212
+ }
213
+
214
+ let firstUserText = ""
215
+ for (const msg of messages) {
216
+ if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
217
+ continue
218
+ }
219
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
220
+ for (const part of parts) {
221
+ if (part.type === "text" && !(part as any).ignored) {
222
+ firstUserText += part.text
223
+ }
224
+ }
225
+ break
226
+ }
227
+
228
+ const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens(firstUserText))
229
+ state.systemPromptTokens = estimatedSystemTokens > 0 ? estimatedSystemTokens : undefined
230
+ }
231
+
232
+ export function shortenPath(input: string, workingDirectory?: string): string {
233
+ const inPathMatch = input.match(/^(.+) in (.+)$/)
234
+ if (inPathMatch) {
235
+ const prefix = inPathMatch[1]
236
+ const pathPart = inPathMatch[2]
237
+ const shortenedPath = shortenSinglePath(pathPart, workingDirectory)
238
+ return `${prefix} in ${shortenedPath}`
239
+ }
240
+
241
+ return shortenSinglePath(input, workingDirectory)
242
+ }
243
+
244
+ function shortenSinglePath(path: string, workingDirectory?: string): string {
245
+ if (workingDirectory) {
246
+ if (path.startsWith(workingDirectory + "/")) {
247
+ return path.slice(workingDirectory.length + 1)
248
+ }
249
+ if (path === workingDirectory) {
250
+ return "."
251
+ }
252
+ }
253
+
254
+ return path
255
+ }
256
+
257
+ export function formatPrunedItemsList(
258
+ pruneToolIds: string[],
259
+ toolMetadata: Map<string, ToolParameterEntry>,
260
+ workingDirectory?: string,
261
+ ): string[] {
262
+ const lines: string[] = []
263
+
264
+ for (const id of pruneToolIds) {
265
+ const metadata = toolMetadata.get(id)
266
+
267
+ if (metadata) {
268
+ const paramKey = extractParameterKey(metadata.tool, metadata.parameters)
269
+ if (paramKey) {
270
+ // Use 60 char limit to match notification style
271
+ const displayKey = truncate(shortenPath(paramKey, workingDirectory), 60)
272
+ lines.push(`→ ${metadata.tool}: ${displayKey}`)
273
+ } else {
274
+ lines.push(`→ ${metadata.tool}`)
275
+ }
276
+ }
277
+ }
278
+
279
+ const knownCount = pruneToolIds.filter((id) => toolMetadata.has(id)).length
280
+ const unknownCount = pruneToolIds.length - knownCount
281
+
282
+ if (unknownCount > 0) {
283
+ lines.push(`→ (${unknownCount} tool${unknownCount > 1 ? "s" : ""} with unknown metadata)`)
284
+ }
285
+
286
+ return lines
287
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@tarquinen/opencode-dcp",
4
- "version": "3.2.5-beta0",
4
+ "version": "3.2.6-beta0",
5
5
  "type": "module",
6
6
  "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
7
7
  "main": "./dist/index.js",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "./tui": {
15
15
  "types": "./dist/tui/index.d.ts",
16
- "import": "./dist/tui/index.js"
16
+ "import": "./tui/index.tsx"
17
17
  }
18
18
  },
19
19
  "oc-plugin": [
@@ -22,6 +22,13 @@
22
22
  ],
23
23
  "files": [
24
24
  "dist/",
25
+ "index.ts",
26
+ "lib/**/*.ts",
27
+ "tui/index.tsx",
28
+ "tui/data/*.ts",
29
+ "tui/routes/*.tsx",
30
+ "tui/shared/*.ts",
31
+ "tui/slots/*.tsx",
25
32
  "README.md",
26
33
  "LICENSE",
27
34
  "dcp.schema.json"
@@ -0,0 +1,177 @@
1
+ import { Logger } from "../../lib/logger"
2
+ import {
3
+ createSessionState,
4
+ loadSessionState,
5
+ type SessionState,
6
+ type WithParts,
7
+ } from "../../lib/state"
8
+ import {
9
+ findLastCompactionTimestamp,
10
+ getActiveSummaryTokenUsage,
11
+ loadPruneMap,
12
+ loadPruneMessagesState,
13
+ } from "../../lib/state/utils"
14
+ import { loadAllSessionStats } from "../../lib/state/persistence"
15
+ import { analyzeTokens, emptyBreakdown } from "../../lib/analysis/tokens"
16
+ import type { DcpContextSnapshot, DcpTuiClient } from "../shared/types"
17
+
18
+ const snapshotCache = new Map<string, DcpContextSnapshot>()
19
+ const inflightSnapshots = new Map<string, Promise<DcpContextSnapshot>>()
20
+ const CACHE_TTL_MS = 5000
21
+
22
+ export const createPlaceholderContextSnapshot = (
23
+ sessionID?: string,
24
+ notes: string[] = [],
25
+ ): DcpContextSnapshot => ({
26
+ sessionID,
27
+ breakdown: emptyBreakdown(),
28
+ activeSummaryTokens: 0,
29
+ persisted: {
30
+ available: false,
31
+ activeBlockCount: 0,
32
+ activeBlocks: [],
33
+ },
34
+ messageStatuses: [],
35
+ allTimeStats: { totalTokensSaved: 0, sessionCount: 0 },
36
+ notes,
37
+ loadedAt: Date.now(),
38
+ })
39
+
40
+ function cleanBlockSummary(raw: string): string {
41
+ return raw
42
+ .replace(/^\s*\[Compressed conversation section\]\s*/i, "")
43
+ .replace(/(?:\r?\n)*<dcp-message-id>b\d+<\/dcp-message-id>\s*$/i, "")
44
+ .trim()
45
+ }
46
+
47
+ const buildState = async (
48
+ sessionID: string,
49
+ messages: WithParts[],
50
+ logger: Logger,
51
+ ): Promise<{ state: SessionState; persisted: Awaited<ReturnType<typeof loadSessionState>> }> => {
52
+ const state = createSessionState()
53
+ const persisted = await loadSessionState(sessionID, logger)
54
+
55
+ state.sessionId = sessionID
56
+ state.lastCompaction = findLastCompactionTimestamp(messages)
57
+ state.stats.pruneTokenCounter = 0
58
+ state.stats.totalPruneTokens = persisted?.stats?.totalPruneTokens || 0
59
+ state.prune.tools = loadPruneMap(persisted?.prune?.tools)
60
+ state.prune.messages = loadPruneMessagesState(persisted?.prune?.messages)
61
+
62
+ return {
63
+ state,
64
+ persisted,
65
+ }
66
+ }
67
+
68
+ const loadContextSnapshot = async (
69
+ client: DcpTuiClient,
70
+ logger: Logger,
71
+ sessionID?: string,
72
+ ): Promise<DcpContextSnapshot> => {
73
+ if (!sessionID) {
74
+ return createPlaceholderContextSnapshot(undefined, ["No active session."])
75
+ }
76
+
77
+ const messagesResult = await client.session.messages({ sessionID })
78
+ const rawMessages =
79
+ messagesResult && typeof messagesResult === "object" && "data" in messagesResult
80
+ ? messagesResult.data
81
+ : messagesResult
82
+ const messages = Array.isArray(rawMessages) ? (rawMessages as WithParts[]) : ([] as WithParts[])
83
+
84
+ const { state, persisted } = await buildState(sessionID, messages, logger)
85
+ const [{ breakdown, messageStatuses }, aggregated] = await Promise.all([
86
+ Promise.resolve(analyzeTokens(state, messages)),
87
+ loadAllSessionStats(logger),
88
+ ])
89
+
90
+ const allBlocks = Array.from(state.prune.messages.activeBlockIds)
91
+ .map((blockID) => state.prune.messages.blocksById.get(blockID))
92
+ .filter((block): block is NonNullable<typeof block> => !!block && !!block.topic)
93
+ .map((block) => ({ topic: block.topic, summary: cleanBlockSummary(block.summary) }))
94
+
95
+ const notes: string[] = []
96
+ if (persisted) {
97
+ notes.push("Using live session messages plus persisted DCP state.")
98
+ } else {
99
+ notes.push("No saved DCP state found for this session yet.")
100
+ }
101
+ if (messages.length === 0) {
102
+ notes.push("This session does not have any messages yet.")
103
+ }
104
+
105
+ return {
106
+ sessionID,
107
+ breakdown,
108
+ activeSummaryTokens: getActiveSummaryTokenUsage(state),
109
+ persisted: {
110
+ available: !!persisted,
111
+ activeBlockCount: state.prune.messages.activeBlockIds.size,
112
+ activeBlocks: allBlocks,
113
+ lastUpdated: persisted?.lastUpdated,
114
+ },
115
+ messageStatuses,
116
+ allTimeStats: {
117
+ totalTokensSaved: aggregated.totalTokens,
118
+ sessionCount: aggregated.sessionCount,
119
+ },
120
+ notes,
121
+ loadedAt: Date.now(),
122
+ }
123
+ }
124
+
125
+ export const peekContextSnapshot = (sessionID?: string): DcpContextSnapshot | undefined => {
126
+ if (!sessionID) return undefined
127
+ return snapshotCache.get(sessionID)
128
+ }
129
+
130
+ export const invalidateContextSnapshot = (sessionID?: string) => {
131
+ if (!sessionID) {
132
+ snapshotCache.clear()
133
+ inflightSnapshots.clear()
134
+ return
135
+ }
136
+ snapshotCache.delete(sessionID)
137
+ inflightSnapshots.delete(sessionID)
138
+ }
139
+
140
+ export const loadContextSnapshotCached = async (
141
+ client: DcpTuiClient,
142
+ logger: Logger,
143
+ sessionID?: string,
144
+ ): Promise<DcpContextSnapshot> => {
145
+ if (!sessionID) {
146
+ return createPlaceholderContextSnapshot(undefined, ["No active session."])
147
+ }
148
+
149
+ const cached = snapshotCache.get(sessionID)
150
+ if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
151
+ return cached
152
+ }
153
+
154
+ const inflight = inflightSnapshots.get(sessionID)
155
+ if (inflight) {
156
+ return inflight
157
+ }
158
+
159
+ const request = loadContextSnapshot(client, logger, sessionID)
160
+ .then((snapshot) => {
161
+ snapshotCache.set(sessionID, snapshot)
162
+ return snapshot
163
+ })
164
+ .catch((error) => {
165
+ logger.error("Failed to load TUI context snapshot", {
166
+ sessionID,
167
+ error: error instanceof Error ? error.message : String(error),
168
+ })
169
+ throw error
170
+ })
171
+ .finally(() => {
172
+ inflightSnapshots.delete(sessionID)
173
+ })
174
+
175
+ inflightSnapshots.set(sessionID, request)
176
+ return request
177
+ }
package/tui/index.tsx ADDED
@@ -0,0 +1,34 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui"
3
+ import { getConfigForDirectory } from "../lib/config"
4
+ import { Logger } from "../lib/logger"
5
+ import { createSidebarContentSlot } from "./slots/sidebar-content"
6
+ import { createSummaryRoute } from "./routes/summary"
7
+ import { NAMES } from "./shared/names"
8
+
9
+ const tui: TuiPlugin = async (api) => {
10
+ const config = getConfigForDirectory(api.state.path.directory, (title, message) => {
11
+ api.ui.toast({
12
+ title,
13
+ message,
14
+ variant: "warning",
15
+ duration: 7000,
16
+ })
17
+ })
18
+ if (!config.enabled) return
19
+
20
+ const logger = new Logger(config.tui.debug, "tui")
21
+
22
+ api.route.register([createSummaryRoute(api)])
23
+
24
+ if (config.tui.sidebar) {
25
+ api.slots.register(createSidebarContentSlot(api, NAMES, logger))
26
+ }
27
+ }
28
+
29
+ const id = "opencode-dynamic-context-pruning"
30
+
31
+ export default {
32
+ id,
33
+ tui,
34
+ }
@@ -0,0 +1,175 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createMemo, createSignal, For, Show } from "solid-js"
3
+ import { useKeyboard } from "@opentui/solid"
4
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
5
+ import { getPalette, type DcpPalette } from "../shared/theme"
6
+ import { LABEL, NAMES } from "../shared/names"
7
+
8
+ const SINGLE_BORDER = { type: "single" } as any
9
+
10
+ interface SummaryRouteParams {
11
+ topic?: string
12
+ summary?: string
13
+ sessionID?: string
14
+ }
15
+
16
+ interface CollapsibleSection {
17
+ label: string
18
+ content: string
19
+ }
20
+
21
+ interface ParsedSummary {
22
+ body: string
23
+ sections: CollapsibleSection[]
24
+ }
25
+
26
+ // These patterns must stay in sync with the exact headings produced by
27
+ // lib/compress (compactSummary output). If compression wording changes,
28
+ // update these patterns accordingly.
29
+ const SECTION_HEADINGS: { pattern: RegExp; label: string }[] = [
30
+ {
31
+ pattern: /\n*The following user messages were sent in this conversation verbatim:/,
32
+ label: "Protected User Messages",
33
+ },
34
+ {
35
+ pattern: /\n*The following protected tools were used in this conversation as well:/,
36
+ label: "Protected Tools",
37
+ },
38
+ {
39
+ pattern:
40
+ /\n*The following previously compressed summaries were also part of this conversation section:/,
41
+ label: "Included Compressed Summaries",
42
+ },
43
+ ]
44
+
45
+ function parseSummary(raw: string): ParsedSummary {
46
+ if (!raw) return { body: "", sections: [] }
47
+
48
+ const matches: { index: number; length: number; label: string }[] = []
49
+ for (const heading of SECTION_HEADINGS) {
50
+ const match = raw.match(heading.pattern)
51
+ if (match && match.index !== undefined) {
52
+ matches.push({ index: match.index, length: match[0].length, label: heading.label })
53
+ }
54
+ }
55
+
56
+ if (matches.length === 0) {
57
+ return { body: raw, sections: [] }
58
+ }
59
+
60
+ matches.sort((a, b) => a.index - b.index)
61
+
62
+ const body = raw.slice(0, matches[0].index).trimEnd()
63
+ const sections: CollapsibleSection[] = []
64
+
65
+ for (let i = 0; i < matches.length; i++) {
66
+ const start = matches[i].index + matches[i].length
67
+ const end = i + 1 < matches.length ? matches[i + 1].index : raw.length
68
+ const content = raw.slice(start, end).trim()
69
+ if (content) {
70
+ sections.push({ label: matches[i].label, content })
71
+ }
72
+ }
73
+
74
+ return { body, sections }
75
+ }
76
+
77
+ function CollapsibleSectionRow(props: { section: CollapsibleSection; palette: DcpPalette }) {
78
+ const [expanded, setExpanded] = createSignal(false)
79
+
80
+ return (
81
+ <box flexDirection="column" width="100%" marginTop={1}>
82
+ <box flexDirection="row" width="100%" height={1}>
83
+ <box
84
+ backgroundColor={props.palette.base}
85
+ height={1}
86
+ onMouseUp={() => setExpanded(!expanded())}
87
+ >
88
+ <text fg={props.palette.accent}>{expanded() ? " ▼ " : " ▶ "}</text>
89
+ </box>
90
+ <box height={1} paddingLeft={1}>
91
+ <text fg={props.palette.muted} onMouseUp={() => setExpanded(!expanded())}>
92
+ {props.section.label}
93
+ </text>
94
+ </box>
95
+ </box>
96
+ <Show when={expanded()}>
97
+ <box width="100%" marginTop={1} paddingLeft={2} flexDirection="column">
98
+ <text fg={props.palette.text}>{props.section.content}</text>
99
+ </box>
100
+ </Show>
101
+ </box>
102
+ )
103
+ }
104
+
105
+ function SummaryScreen(props: { api: TuiPluginApi; params: SummaryRouteParams }) {
106
+ const palette = createMemo(() => getPalette(props.api.theme.current as Record<string, unknown>))
107
+ const parsed = createMemo(() => parseSummary(props.params.summary || ""))
108
+
109
+ const keys = props.api.keybind.create({ close: "escape" })
110
+
111
+ useKeyboard((evt: any) => {
112
+ if (props.api.route.current.name !== NAMES.routes.summary) return
113
+ if (props.api.ui.dialog.open) return
114
+ const matched = keys.match("close", evt)
115
+ if (!matched) return
116
+ evt.preventDefault()
117
+ evt.stopPropagation()
118
+ const sessionID = props.params.sessionID
119
+ if (sessionID) {
120
+ props.api.route.navigate("session", { sessionID })
121
+ } else {
122
+ props.api.route.navigate("home")
123
+ }
124
+ })
125
+
126
+ return (
127
+ <box
128
+ flexDirection="column"
129
+ width="100%"
130
+ height="100%"
131
+ padding={1}
132
+ backgroundColor={palette().surface}
133
+ >
134
+ <box flexDirection="row" gap={1} alignItems="center">
135
+ <box paddingLeft={1} paddingRight={1} backgroundColor={palette().accent}>
136
+ <text fg={palette().panel}>
137
+ <b>{LABEL}</b>
138
+ </text>
139
+ </box>
140
+ <text fg={palette().accent}>
141
+ <b>{props.params.topic || "Compression Summary"}</b>
142
+ </text>
143
+ </box>
144
+
145
+ <box
146
+ flexGrow={1}
147
+ width="100%"
148
+ marginTop={1}
149
+ border={SINGLE_BORDER}
150
+ borderColor={palette().border}
151
+ padding={1}
152
+ flexDirection="column"
153
+ >
154
+ <text fg={palette().text}>{parsed().body || "(no summary available)"}</text>
155
+
156
+ <For each={parsed().sections}>
157
+ {(section) => <CollapsibleSectionRow section={section} palette={palette()} />}
158
+ </For>
159
+ </box>
160
+
161
+ <box marginTop={1}>
162
+ <text {...({ dim: true } as any)} fg={palette().muted}>
163
+ Press Escape to return
164
+ </text>
165
+ </box>
166
+ </box>
167
+ )
168
+ }
169
+
170
+ export const createSummaryRoute = (api: TuiPluginApi) => ({
171
+ name: NAMES.routes.summary,
172
+ render: (input: { params?: Record<string, unknown> }) => (
173
+ <SummaryScreen api={api} params={(input.params ?? {}) as SummaryRouteParams} />
174
+ ),
175
+ })