@tarquinen/opencode-dcp 3.1.12 → 3.1.13

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 (106) hide show
  1. package/README.md +14 -9
  2. package/dist/index.js +105 -39
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/context.d.ts +15 -0
  5. package/dist/lib/commands/context.d.ts.map +1 -1
  6. package/dist/lib/commands/help.d.ts +1 -0
  7. package/dist/lib/commands/help.d.ts.map +1 -1
  8. package/dist/lib/commands/manual.d.ts +1 -1
  9. package/dist/lib/commands/manual.d.ts.map +1 -1
  10. package/dist/lib/commands/stats.d.ts +10 -0
  11. package/dist/lib/commands/stats.d.ts.map +1 -1
  12. package/dist/lib/compress/pipeline.d.ts.map +1 -1
  13. package/dist/lib/hooks.d.ts.map +1 -1
  14. package/dist/lib/state/persistence.d.ts +3 -0
  15. package/dist/lib/state/persistence.d.ts.map +1 -1
  16. package/dist/lib/state/state.d.ts +1 -0
  17. package/dist/lib/state/state.d.ts.map +1 -1
  18. package/dist/lib/tui/commands.d.ts +3 -0
  19. package/dist/lib/tui/commands.d.ts.map +1 -0
  20. package/dist/lib/tui/data.d.ts +14 -0
  21. package/dist/lib/tui/data.d.ts.map +1 -0
  22. package/dist/lib/tui/dialogs.d.ts +30 -0
  23. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  24. package/dist/lib/tui/format.d.ts +4 -0
  25. package/dist/lib/tui/format.d.ts.map +1 -0
  26. package/dist/lib/tui/modals.d.ts +10 -0
  27. package/dist/lib/tui/modals.d.ts.map +1 -0
  28. package/dist/lib/tui/types.d.ts +15 -0
  29. package/dist/lib/tui/types.d.ts.map +1 -0
  30. package/dist/lib/tui/ui.d.ts +48 -0
  31. package/dist/lib/tui/ui.d.ts.map +1 -0
  32. package/dist/tui.d.ts +7 -0
  33. package/dist/tui.d.ts.map +1 -0
  34. package/lib/auth.ts +37 -0
  35. package/lib/commands/compression-targets.ts +137 -0
  36. package/lib/commands/context.ts +305 -0
  37. package/lib/commands/decompress.ts +275 -0
  38. package/lib/commands/help.ts +76 -0
  39. package/lib/commands/index.ts +11 -0
  40. package/lib/commands/manual.ts +127 -0
  41. package/lib/commands/recompress.ts +224 -0
  42. package/lib/commands/stats.ts +159 -0
  43. package/lib/commands/sweep.ts +268 -0
  44. package/lib/compress/index.ts +3 -0
  45. package/lib/compress/message-utils.ts +250 -0
  46. package/lib/compress/message.ts +145 -0
  47. package/lib/compress/pipeline.ts +108 -0
  48. package/lib/compress/protected-content.ts +208 -0
  49. package/lib/compress/range-utils.ts +308 -0
  50. package/lib/compress/range.ts +192 -0
  51. package/lib/compress/search.ts +267 -0
  52. package/lib/compress/state.ts +268 -0
  53. package/lib/compress/timing.ts +77 -0
  54. package/lib/compress/types.ts +108 -0
  55. package/lib/compress-permission.ts +25 -0
  56. package/lib/config.ts +1007 -0
  57. package/lib/hooks.ts +375 -0
  58. package/lib/host-permissions.ts +101 -0
  59. package/lib/logger.ts +226 -0
  60. package/lib/message-ids.ts +172 -0
  61. package/lib/messages/index.ts +8 -0
  62. package/lib/messages/inject/inject.ts +215 -0
  63. package/lib/messages/inject/subagent-results.ts +82 -0
  64. package/lib/messages/inject/utils.ts +374 -0
  65. package/lib/messages/priority.ts +102 -0
  66. package/lib/messages/prune.ts +233 -0
  67. package/lib/messages/query.ts +72 -0
  68. package/lib/messages/reasoning-strip.ts +40 -0
  69. package/lib/messages/shape.ts +50 -0
  70. package/lib/messages/sync.ts +124 -0
  71. package/lib/messages/utils.ts +185 -0
  72. package/lib/prompts/compress-message.ts +43 -0
  73. package/lib/prompts/compress-range.ts +60 -0
  74. package/lib/prompts/context-limit-nudge.ts +18 -0
  75. package/lib/prompts/extensions/nudge.ts +43 -0
  76. package/lib/prompts/extensions/system.ts +32 -0
  77. package/lib/prompts/extensions/tool.ts +35 -0
  78. package/lib/prompts/index.ts +29 -0
  79. package/lib/prompts/iteration-nudge.ts +6 -0
  80. package/lib/prompts/store.ts +467 -0
  81. package/lib/prompts/system.ts +33 -0
  82. package/lib/prompts/turn-nudge.ts +10 -0
  83. package/lib/protected-patterns.ts +128 -0
  84. package/lib/state/index.ts +4 -0
  85. package/lib/state/persistence.ts +305 -0
  86. package/lib/state/state.ts +208 -0
  87. package/lib/state/tool-cache.ts +98 -0
  88. package/lib/state/types.ts +111 -0
  89. package/lib/state/utils.ts +345 -0
  90. package/lib/strategies/deduplication.ts +127 -0
  91. package/lib/strategies/index.ts +2 -0
  92. package/lib/strategies/purge-errors.ts +88 -0
  93. package/lib/subagents/subagent-results.ts +74 -0
  94. package/lib/token-utils.ts +164 -0
  95. package/lib/tui/commands.ts +31 -0
  96. package/lib/tui/data.ts +73 -0
  97. package/lib/tui/dialogs.tsx +235 -0
  98. package/lib/tui/format.ts +25 -0
  99. package/lib/tui/modals.tsx +92 -0
  100. package/lib/tui/types.ts +16 -0
  101. package/lib/tui/ui.tsx +219 -0
  102. package/lib/ui/notification.ts +347 -0
  103. package/lib/ui/utils.ts +304 -0
  104. package/lib/update.ts +185 -0
  105. package/package.json +18 -5
  106. package/tui.tsx +26 -0
@@ -0,0 +1,88 @@
1
+ import { PluginConfig } from "../config"
2
+ import { Logger } from "../logger"
3
+ import type { SessionState, WithParts } from "../state"
4
+ import {
5
+ getFilePathsFromParameters,
6
+ isFilePathProtected,
7
+ isToolNameProtected,
8
+ } from "../protected-patterns"
9
+ import { getTotalToolTokens } from "../token-utils"
10
+
11
+ /**
12
+ * Purge Errors strategy - prunes tool inputs for tools that errored
13
+ * after they are older than a configurable number of turns.
14
+ * The error message is preserved, but the (potentially large) inputs
15
+ * are removed to save context.
16
+ *
17
+ * Modifies the session state in place to add pruned tool call IDs.
18
+ */
19
+ export const purgeErrors = (
20
+ state: SessionState,
21
+ logger: Logger,
22
+ config: PluginConfig,
23
+ messages: WithParts[],
24
+ ): void => {
25
+ if (state.manualMode && !config.manualMode.automaticStrategies) {
26
+ return
27
+ }
28
+
29
+ if (!config.strategies.purgeErrors.enabled) {
30
+ return
31
+ }
32
+
33
+ const allToolIds = state.toolIdList
34
+ if (allToolIds.length === 0) {
35
+ return
36
+ }
37
+
38
+ // Filter out IDs already pruned
39
+ const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
40
+
41
+ if (unprunedIds.length === 0) {
42
+ return
43
+ }
44
+
45
+ const protectedTools = config.strategies.purgeErrors.protectedTools
46
+ const turnThreshold = Math.max(1, config.strategies.purgeErrors.turns)
47
+
48
+ const newPruneIds: string[] = []
49
+
50
+ for (const id of unprunedIds) {
51
+ const metadata = state.toolParameters.get(id)
52
+ if (!metadata) {
53
+ continue
54
+ }
55
+
56
+ // Skip protected tools
57
+ if (isToolNameProtected(metadata.tool, protectedTools)) {
58
+ continue
59
+ }
60
+
61
+ const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
62
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
63
+ continue
64
+ }
65
+
66
+ // Only process error tools
67
+ if (metadata.status !== "error") {
68
+ continue
69
+ }
70
+
71
+ // Check if the tool is old enough to prune
72
+ const turnAge = state.currentTurn - metadata.turn
73
+ if (turnAge >= turnThreshold) {
74
+ newPruneIds.push(id)
75
+ }
76
+ }
77
+
78
+ if (newPruneIds.length > 0) {
79
+ state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
80
+ for (const id of newPruneIds) {
81
+ const entry = state.toolParameters.get(id)
82
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
83
+ }
84
+ logger.debug(
85
+ `Marked ${newPruneIds.length} error tool calls for pruning (older than ${turnThreshold} turns)`,
86
+ )
87
+ }
88
+ }
@@ -0,0 +1,74 @@
1
+ import type { WithParts } from "../state"
2
+
3
+ const SUB_AGENT_RESULT_BLOCK_REGEX = /(<task_result>\s*)([\s\S]*?)(\s*<\/task_result>)/i
4
+
5
+ export function getSubAgentId(part: any): string | null {
6
+ const sessionId = part?.state?.metadata?.sessionId
7
+ if (typeof sessionId !== "string") {
8
+ return null
9
+ }
10
+
11
+ const value = sessionId.trim()
12
+ return value.length > 0 ? value : null
13
+ }
14
+
15
+ export function buildSubagentResultText(messages: WithParts[]): string {
16
+ const assistantMessages = messages.filter((message) => message.info.role === "assistant")
17
+ if (assistantMessages.length === 0) {
18
+ return ""
19
+ }
20
+
21
+ const lastAssistant = assistantMessages[assistantMessages.length - 1]
22
+ const lastText = getLastTextPart(lastAssistant)
23
+
24
+ if (assistantMessages.length < 2) {
25
+ return lastText
26
+ }
27
+
28
+ const secondToLastAssistant = assistantMessages[assistantMessages.length - 2]
29
+ if (!assistantMessageHasCompressTool(secondToLastAssistant)) {
30
+ return lastText
31
+ }
32
+
33
+ const secondToLastText = getLastTextPart(secondToLastAssistant)
34
+ return [secondToLastText, lastText].filter((text) => text.length > 0).join("\n\n")
35
+ }
36
+
37
+ export function mergeSubagentResult(output: string, subAgentResultText: string): string {
38
+ if (!subAgentResultText || typeof output !== "string") {
39
+ return output
40
+ }
41
+
42
+ return output.replace(
43
+ SUB_AGENT_RESULT_BLOCK_REGEX,
44
+ (_match, openTag: string, _body: string, closeTag: string) =>
45
+ `${openTag}${subAgentResultText}${closeTag}`,
46
+ )
47
+ }
48
+
49
+ function getLastTextPart(message: WithParts): string {
50
+ const parts = Array.isArray(message.parts) ? message.parts : []
51
+ for (let index = parts.length - 1; index >= 0; index--) {
52
+ const part = parts[index]
53
+ if (part.type !== "text" || typeof part.text !== "string") {
54
+ continue
55
+ }
56
+
57
+ const text = part.text.trim()
58
+ if (!text) {
59
+ continue
60
+ }
61
+
62
+ return text
63
+ }
64
+
65
+ return ""
66
+ }
67
+
68
+ function assistantMessageHasCompressTool(message: WithParts): boolean {
69
+ const parts = Array.isArray(message.parts) ? message.parts : []
70
+ return parts.some(
71
+ (part) =>
72
+ part.type === "tool" && part.tool === "compress" && part.state?.status === "completed",
73
+ )
74
+ }
@@ -0,0 +1,164 @@
1
+ import { SessionState, WithParts } from "./state"
2
+ import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
3
+ import { Logger } from "./logger"
4
+ import * as _anthropicTokenizer from "@anthropic-ai/tokenizer"
5
+ const anthropicCountTokens = (_anthropicTokenizer.countTokens ??
6
+ (_anthropicTokenizer as any).default?.countTokens) as typeof _anthropicTokenizer.countTokens
7
+ import { getLastUserMessage } from "./messages/query"
8
+
9
+ export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number {
10
+ for (let i = messages.length - 1; i >= 0; i--) {
11
+ const msg = messages[i]
12
+ if (msg.info.role !== "assistant") {
13
+ continue
14
+ }
15
+
16
+ const assistantInfo = msg.info as AssistantMessage
17
+ if ((assistantInfo.tokens?.output || 0) <= 0) {
18
+ continue
19
+ }
20
+
21
+ if (
22
+ state.lastCompaction > 0 &&
23
+ (msg.info.time.created < state.lastCompaction ||
24
+ (msg.info.summary === true && msg.info.time.created === state.lastCompaction))
25
+ ) {
26
+ return 0
27
+ }
28
+
29
+ const input = assistantInfo.tokens?.input || 0
30
+ const output = assistantInfo.tokens?.output || 0
31
+ const reasoning = assistantInfo.tokens?.reasoning || 0
32
+ const cacheRead = assistantInfo.tokens?.cache?.read || 0
33
+ const cacheWrite = assistantInfo.tokens?.cache?.write || 0
34
+ return input + output + reasoning + cacheRead + cacheWrite
35
+ }
36
+
37
+ return 0
38
+ }
39
+
40
+ export function getCurrentParams(
41
+ state: SessionState,
42
+ messages: WithParts[],
43
+ logger: Logger,
44
+ ): {
45
+ providerId: string | undefined
46
+ modelId: string | undefined
47
+ agent: string | undefined
48
+ variant: string | undefined
49
+ } {
50
+ const userMsg = getLastUserMessage(messages)
51
+ if (!userMsg) {
52
+ logger.debug("No user message found when determining current params")
53
+ return {
54
+ providerId: undefined,
55
+ modelId: undefined,
56
+ agent: undefined,
57
+ variant: undefined,
58
+ }
59
+ }
60
+ const userInfo = userMsg.info as UserMessage
61
+ const agent: string = userInfo.agent
62
+ const providerId: string | undefined = userInfo.model.providerID
63
+ const modelId: string | undefined = userInfo.model.modelID
64
+ const variant: string | undefined = userInfo.model.variant
65
+
66
+ return { providerId, modelId, agent, variant }
67
+ }
68
+
69
+ export function countTokens(text: string): number {
70
+ if (!text) return 0
71
+ try {
72
+ return anthropicCountTokens(text)
73
+ } catch {
74
+ return Math.round(text.length / 4)
75
+ }
76
+ }
77
+
78
+ export function estimateTokensBatch(texts: string[]): number {
79
+ if (texts.length === 0) return 0
80
+ return countTokens(texts.join(" "))
81
+ }
82
+
83
+ export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]"
84
+
85
+ function stringifyToolContent(value: unknown): string {
86
+ return typeof value === "string" ? value : JSON.stringify(value)
87
+ }
88
+
89
+ export function extractCompletedToolOutput(part: any): string | undefined {
90
+ if (
91
+ part?.type !== "tool" ||
92
+ part.state?.status !== "completed" ||
93
+ part.state?.output === undefined
94
+ ) {
95
+ return undefined
96
+ }
97
+
98
+ if (part.state?.time?.compacted) {
99
+ return COMPACTED_TOOL_OUTPUT_PLACEHOLDER
100
+ }
101
+
102
+ return stringifyToolContent(part.state.output)
103
+ }
104
+
105
+ export function extractToolContent(part: any): string[] {
106
+ const contents: string[] = []
107
+
108
+ if (part?.type !== "tool") {
109
+ return contents
110
+ }
111
+
112
+ if (part.state?.input !== undefined) {
113
+ contents.push(stringifyToolContent(part.state.input))
114
+ }
115
+
116
+ const completedOutput = extractCompletedToolOutput(part)
117
+ if (completedOutput !== undefined) {
118
+ contents.push(completedOutput)
119
+ } else if (part.state?.status === "error" && part.state?.error) {
120
+ contents.push(stringifyToolContent(part.state.error))
121
+ }
122
+
123
+ return contents
124
+ }
125
+
126
+ export function countToolTokens(part: any): number {
127
+ const contents = extractToolContent(part)
128
+ return estimateTokensBatch(contents)
129
+ }
130
+
131
+ export function getTotalToolTokens(state: SessionState, toolIds: string[]): number {
132
+ let total = 0
133
+ for (const id of toolIds) {
134
+ const entry = state.toolParameters.get(id)
135
+ total += entry?.tokenCount ?? 0
136
+ }
137
+ return total
138
+ }
139
+
140
+ export function countMessageTextTokens(msg: WithParts): number {
141
+ const texts: string[] = []
142
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
143
+ for (const part of parts) {
144
+ if (part.type === "text") {
145
+ texts.push(part.text)
146
+ }
147
+ }
148
+ if (texts.length === 0) return 0
149
+ return estimateTokensBatch(texts)
150
+ }
151
+
152
+ export function countAllMessageTokens(msg: WithParts): number {
153
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
154
+ const texts: string[] = []
155
+ for (const part of parts) {
156
+ if (part.type === "text") {
157
+ texts.push(part.text)
158
+ } else {
159
+ texts.push(...extractToolContent(part))
160
+ }
161
+ }
162
+ if (texts.length === 0) return 0
163
+ return estimateTokensBatch(texts)
164
+ }
@@ -0,0 +1,31 @@
1
+ import type { DcpCommand, TuiApi } from "./types"
2
+
3
+ export function registerCommands(api: TuiApi, commands: DcpCommand[]) {
4
+ const keymap = (api as any).keymap
5
+ if (keymap?.registerLayer) {
6
+ keymap.registerLayer({
7
+ commands: commands.map((command) => ({
8
+ namespace: "palette",
9
+ name: command.name,
10
+ title: command.title,
11
+ desc: command.description,
12
+ category: "DCP",
13
+ slashName: command.slashName,
14
+ slashAliases: command.slashAliases,
15
+ run: command.run,
16
+ })),
17
+ })
18
+ return
19
+ }
20
+
21
+ api.command?.register(() =>
22
+ commands.map((command) => ({
23
+ title: command.title,
24
+ value: command.name,
25
+ description: command.description,
26
+ category: "DCP",
27
+ slash: { name: command.slashName, aliases: command.slashAliases },
28
+ onSelect: command.run,
29
+ })),
30
+ )
31
+ }
@@ -0,0 +1,73 @@
1
+ import { getConfig, type PluginConfig } from "../config"
2
+ import { Logger } from "../logger"
3
+ import { filterMessages } from "../messages/shape"
4
+ import { createSessionState, type SessionState, type WithParts } from "../state"
5
+ import { loadSessionState } from "../state/persistence"
6
+ import { findLastCompactionTimestamp, loadPruneMap, loadPruneMessagesState } from "../state/utils"
7
+ import type { TuiApi } from "./types"
8
+
9
+ export const logger = new Logger(false)
10
+
11
+ export function loadConfig(api: TuiApi): PluginConfig {
12
+ return getConfig({
13
+ client: api.client,
14
+ directory: api.state.path.directory,
15
+ worktree: api.state.path.worktree,
16
+ } as any)
17
+ }
18
+
19
+ export function activeSessionID(api: TuiApi): string | undefined {
20
+ const current = api.route.current
21
+ if (current.name !== "session") return undefined
22
+ const sessionID = current.params?.sessionID
23
+ return typeof sessionID === "string" ? sessionID : undefined
24
+ }
25
+
26
+ export function sessionMessages(api: TuiApi, sessionID: string): WithParts[] {
27
+ const messages = api.state.session.messages(sessionID)
28
+ return filterMessages(
29
+ messages.map((info) => ({
30
+ info,
31
+ parts: api.state.part(info.id),
32
+ })) as unknown as WithParts[],
33
+ )
34
+ }
35
+
36
+ export async function buildSessionState(
37
+ sessionID: string,
38
+ messages: WithParts[],
39
+ config: PluginConfig,
40
+ ): Promise<SessionState> {
41
+ const state = createSessionState()
42
+ state.sessionId = sessionID
43
+ state.manualMode = config.manualMode.enabled ? "active" : false
44
+ state.lastCompaction = findLastCompactionTimestamp(messages)
45
+
46
+ const persisted = await loadSessionState(sessionID, logger)
47
+ if (persisted) {
48
+ if (typeof persisted.manualMode === "boolean") {
49
+ state.manualMode = persisted.manualMode ? "active" : false
50
+ }
51
+
52
+ state.prune.tools = loadPruneMap(persisted.prune.tools)
53
+ state.prune.messages = loadPruneMessagesState(persisted.prune.messages)
54
+ state.nudges.contextLimitAnchors = new Set(persisted.nudges.contextLimitAnchors || [])
55
+ state.nudges.turnNudgeAnchors = new Set(persisted.nudges.turnNudgeAnchors || [])
56
+ state.nudges.iterationNudgeAnchors = new Set(persisted.nudges.iterationNudgeAnchors || [])
57
+ state.stats = {
58
+ pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
59
+ totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
60
+ }
61
+ }
62
+
63
+ return state
64
+ }
65
+
66
+ export async function loadSessionData(api: TuiApi, config: PluginConfig) {
67
+ const sessionID = activeSessionID(api)
68
+ if (!sessionID) return undefined
69
+
70
+ const messages = sessionMessages(api, sessionID)
71
+ const state = await buildSessionState(sessionID, messages, config)
72
+ return { state, messages }
73
+ }
@@ -0,0 +1,235 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { compressPermission } from "../compress-permission"
4
+ import { analyzeContextTokens } from "../commands/context"
5
+ import type { PluginConfig } from "../config"
6
+ import type { SessionState, WithParts } from "../state"
7
+ import { formatTokenCount } from "../ui/utils"
8
+ import { TextAttributes } from "@opentui/core"
9
+ import { formatDuration, formatRatio } from "./format"
10
+ import { ActionRow, Card, DcpFrame, Metric, Progress, PromptRow, StatusPill } from "./ui"
11
+ import type { StatsReport, TuiApi } from "./types"
12
+
13
+ export function StatusDialog(props: {
14
+ api: TuiApi
15
+ title: string
16
+ eyebrow: string
17
+ message: string
18
+ }) {
19
+ return (
20
+ <DcpFrame api={props.api} title={props.title} eyebrow={props.eyebrow}>
21
+ <box paddingTop={1} paddingBottom={1}>
22
+ <text fg={props.api.theme.current.textMuted}>{props.message}</text>
23
+ </box>
24
+ </DcpFrame>
25
+ )
26
+ }
27
+
28
+ export function ContextDialog(props: {
29
+ api: TuiApi
30
+ state: SessionState
31
+ messages: WithParts[]
32
+ onBack: () => void
33
+ }) {
34
+ const theme = props.api.theme.current
35
+ const breakdown = analyzeContextTokens(props.state, props.messages)
36
+ const total = Math.max(0, breakdown.total)
37
+ const activePruned = breakdown.prunedToolCount + breakdown.prunedMessageCount
38
+
39
+ return (
40
+ <DcpFrame api={props.api} title="Context" eyebrow="DCP" onBack={props.onBack}>
41
+ <Card theme={theme} title="Current">
42
+ <Metric
43
+ theme={theme}
44
+ label="Total in context"
45
+ value={`~${formatTokenCount(total)}`}
46
+ hint="tokens"
47
+ />
48
+ <Metric
49
+ theme={theme}
50
+ label="Tools in context"
51
+ value={`${breakdown.toolsInContextCount}`}
52
+ />
53
+ <Metric theme={theme} label="Active pruned targets" value={`${activePruned}`} />
54
+ <Metric
55
+ theme={theme}
56
+ label="Tokens pruned"
57
+ value={`~${formatTokenCount(breakdown.prunedTokens)}`}
58
+ hint="tokens"
59
+ />
60
+ </Card>
61
+ <Card theme={theme} title="Breakdown">
62
+ <Progress
63
+ theme={theme}
64
+ label="System"
65
+ value={breakdown.system}
66
+ total={total}
67
+ color="primary"
68
+ detail={`~${formatTokenCount(breakdown.system)} tokens`}
69
+ />
70
+ <Progress
71
+ theme={theme}
72
+ label="User"
73
+ value={breakdown.user}
74
+ total={total}
75
+ color="primary"
76
+ detail={`~${formatTokenCount(breakdown.user)} tokens`}
77
+ />
78
+ <Progress
79
+ theme={theme}
80
+ label="Assistant"
81
+ value={breakdown.assistant}
82
+ total={total}
83
+ color="primary"
84
+ detail={`~${formatTokenCount(breakdown.assistant)} tokens`}
85
+ />
86
+ <Progress
87
+ theme={theme}
88
+ label={`Tools (${breakdown.toolsInContextCount})`}
89
+ value={breakdown.tools}
90
+ total={total}
91
+ color="primary"
92
+ detail={`~${formatTokenCount(breakdown.tools)} tokens`}
93
+ />
94
+ </Card>
95
+ </DcpFrame>
96
+ )
97
+ }
98
+
99
+ export function StatsDialog(props: { api: TuiApi; report: StatsReport; onBack: () => void }) {
100
+ const theme = props.api.theme.current
101
+ const ratio = formatRatio(props.report.sessionTokens, props.report.sessionSummaryTokens)
102
+ return (
103
+ <DcpFrame api={props.api} title="Stats" eyebrow="DCP" onBack={props.onBack}>
104
+ <Card theme={theme} title="Session">
105
+ <Metric
106
+ theme={theme}
107
+ label="Tokens saved"
108
+ value={`~${formatTokenCount(props.report.sessionTokens)}`}
109
+ hint="tokens"
110
+ />
111
+ <Metric
112
+ theme={theme}
113
+ label="Summary size"
114
+ value={`~${formatTokenCount(props.report.sessionSummaryTokens)}`}
115
+ hint="tokens"
116
+ />
117
+ <Metric theme={theme} label="Compression ratio" value={ratio} />
118
+ <Metric
119
+ theme={theme}
120
+ label="Compression time"
121
+ value={formatDuration(props.report.sessionDurationMs)}
122
+ />
123
+ <Metric theme={theme} label="Tools pruned" value={`${props.report.sessionTools}`} />
124
+ <Metric
125
+ theme={theme}
126
+ label="Messages pruned"
127
+ value={`${props.report.sessionMessages}`}
128
+ />
129
+ </Card>
130
+ <Card theme={theme} title="All time">
131
+ <Metric
132
+ theme={theme}
133
+ label="Tokens saved"
134
+ value={`~${formatTokenCount(props.report.allTime.totalTokens)}`}
135
+ hint="tokens"
136
+ />
137
+ <Metric
138
+ theme={theme}
139
+ label="Tools pruned"
140
+ value={`${props.report.allTime.totalTools}`}
141
+ />
142
+ <Metric
143
+ theme={theme}
144
+ label="Messages pruned"
145
+ value={`${props.report.allTime.totalMessages}`}
146
+ />
147
+ <Metric
148
+ theme={theme}
149
+ label="Sessions with DCP history"
150
+ value={`${props.report.allTime.sessionCount}`}
151
+ />
152
+ </Card>
153
+ </DcpFrame>
154
+ )
155
+ }
156
+
157
+ export function PanelDialog(props: {
158
+ api: TuiApi
159
+ state: SessionState
160
+ config: PluginConfig
161
+ onContext: () => void
162
+ onStats: () => void
163
+ onManual: (enabled: boolean) => void
164
+ }) {
165
+ const theme = props.api.theme.current
166
+ const canCompress = compressPermission(props.state, props.config) !== "deny"
167
+ return (
168
+ <DcpFrame api={props.api} eyebrow="DCP">
169
+ <Card theme={theme} title="Views">
170
+ <box flexDirection="column" gap={1}>
171
+ <ActionRow
172
+ theme={theme}
173
+ title="Context"
174
+ detail="Token usage"
175
+ onClick={props.onContext}
176
+ />
177
+ <ActionRow
178
+ theme={theme}
179
+ title="Stats"
180
+ detail="Savings"
181
+ onClick={props.onStats}
182
+ />
183
+ </box>
184
+ </Card>
185
+ <Card theme={theme} title="Prompt">
186
+ {canCompress ? (
187
+ <PromptRow
188
+ theme={theme}
189
+ command="/dcp-compress [focus]"
190
+ description="Ask the model to compress"
191
+ accent="primary"
192
+ />
193
+ ) : (
194
+ <text fg={theme.textMuted}>Compression is denied by permissions.</text>
195
+ )}
196
+ </Card>
197
+ <Card theme={theme} title="Session State">
198
+ <ManualModeToggle api={props.api} state={props.state} onToggle={props.onManual} />
199
+ <StatusPill
200
+ theme={theme}
201
+ label="Compression command"
202
+ value={canCompress ? "enabled" : "disabled"}
203
+ accent={canCompress ? "success" : "warning"}
204
+ />
205
+ </Card>
206
+ </DcpFrame>
207
+ )
208
+ }
209
+
210
+ function ManualModeToggle(props: {
211
+ api: TuiApi
212
+ state: SessionState
213
+ onToggle: (enabled: boolean) => void
214
+ }) {
215
+ const theme = props.api.theme.current
216
+ const enabled = !!props.state.manualMode
217
+ const track = enabled ? theme.success : theme.error
218
+ return (
219
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
220
+ <box width={22}>
221
+ <text fg={theme.primary} attributes={TextAttributes.BOLD}>
222
+ Manual mode
223
+ </text>
224
+ </box>
225
+ <box
226
+ backgroundColor={track}
227
+ paddingLeft={1}
228
+ paddingRight={1}
229
+ onMouseUp={() => props.onToggle(!enabled)}
230
+ >
231
+ <text fg={theme.background}>{enabled ? " ■" : "■ "}</text>
232
+ </box>
233
+ </box>
234
+ )
235
+ }
@@ -0,0 +1,25 @@
1
+ export function formatDuration(ms: number): string {
2
+ const safeMs = Math.max(0, Math.round(ms))
3
+ if (safeMs < 1000) return `${safeMs} ms`
4
+
5
+ const totalSeconds = safeMs / 1000
6
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)} s`
7
+
8
+ const wholeSeconds = Math.floor(totalSeconds)
9
+ const hours = Math.floor(wholeSeconds / 3600)
10
+ const minutes = Math.floor((wholeSeconds % 3600) / 60)
11
+ const seconds = wholeSeconds % 60
12
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
13
+ return `${minutes}m ${seconds}s`
14
+ }
15
+
16
+ export function formatRatio(inputTokens: number, outputTokens: number): string {
17
+ if (inputTokens <= 0) return "0:1"
18
+ if (outputTokens <= 0) return "∞:1"
19
+ return `${Math.max(1, Math.round(inputTokens / outputTokens))}:1`
20
+ }
21
+
22
+ export function pct(value: number, total: number): string {
23
+ if (total <= 0) return "0.0%"
24
+ return `${((value / total) * 100).toFixed(1)}%`
25
+ }