@tarquinen/opencode-dcp 3.2.0-beta0 → 3.2.2-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.
@@ -0,0 +1,162 @@
1
+ import { SessionState, WithParts } from "./state"
2
+ import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
3
+ import { Logger } from "./logger"
4
+ import { countTokens as anthropicCountTokens } from "@anthropic-ai/tokenizer"
5
+ import { getLastUserMessage } from "./messages/query"
6
+
7
+ export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number {
8
+ for (let i = messages.length - 1; i >= 0; i--) {
9
+ const msg = messages[i]
10
+ if (msg.info.role !== "assistant") {
11
+ continue
12
+ }
13
+
14
+ const assistantInfo = msg.info as AssistantMessage
15
+ if ((assistantInfo.tokens?.output || 0) <= 0) {
16
+ continue
17
+ }
18
+
19
+ if (
20
+ state.lastCompaction > 0 &&
21
+ (msg.info.time.created < state.lastCompaction ||
22
+ (msg.info.summary === true && msg.info.time.created === state.lastCompaction))
23
+ ) {
24
+ return 0
25
+ }
26
+
27
+ const input = assistantInfo.tokens?.input || 0
28
+ const output = assistantInfo.tokens?.output || 0
29
+ const reasoning = assistantInfo.tokens?.reasoning || 0
30
+ const cacheRead = assistantInfo.tokens?.cache?.read || 0
31
+ const cacheWrite = assistantInfo.tokens?.cache?.write || 0
32
+ return input + output + reasoning + cacheRead + cacheWrite
33
+ }
34
+
35
+ return 0
36
+ }
37
+
38
+ export function getCurrentParams(
39
+ state: SessionState,
40
+ messages: WithParts[],
41
+ logger: Logger,
42
+ ): {
43
+ providerId: string | undefined
44
+ modelId: string | undefined
45
+ agent: string | undefined
46
+ variant: string | undefined
47
+ } {
48
+ const userMsg = getLastUserMessage(messages)
49
+ if (!userMsg) {
50
+ logger.debug("No user message found when determining current params")
51
+ return {
52
+ providerId: undefined,
53
+ modelId: undefined,
54
+ agent: undefined,
55
+ variant: state.variant,
56
+ }
57
+ }
58
+ const userInfo = userMsg.info as UserMessage
59
+ const agent: string = userInfo.agent
60
+ const providerId: string | undefined = userInfo.model.providerID
61
+ const modelId: string | undefined = userInfo.model.modelID
62
+ const variant: string | undefined = state.variant ?? userInfo.variant
63
+
64
+ return { providerId, modelId, agent, variant }
65
+ }
66
+
67
+ export function countTokens(text: string): number {
68
+ if (!text) return 0
69
+ try {
70
+ return anthropicCountTokens(text)
71
+ } catch {
72
+ return Math.round(text.length / 4)
73
+ }
74
+ }
75
+
76
+ export function estimateTokensBatch(texts: string[]): number {
77
+ if (texts.length === 0) return 0
78
+ return countTokens(texts.join(" "))
79
+ }
80
+
81
+ export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]"
82
+
83
+ function stringifyToolContent(value: unknown): string {
84
+ return typeof value === "string" ? value : JSON.stringify(value)
85
+ }
86
+
87
+ export function extractCompletedToolOutput(part: any): string | undefined {
88
+ if (
89
+ part?.type !== "tool" ||
90
+ part.state?.status !== "completed" ||
91
+ part.state?.output === undefined
92
+ ) {
93
+ return undefined
94
+ }
95
+
96
+ if (part.state?.time?.compacted) {
97
+ return COMPACTED_TOOL_OUTPUT_PLACEHOLDER
98
+ }
99
+
100
+ return stringifyToolContent(part.state.output)
101
+ }
102
+
103
+ export function extractToolContent(part: any): string[] {
104
+ const contents: string[] = []
105
+
106
+ if (part?.type !== "tool") {
107
+ return contents
108
+ }
109
+
110
+ if (part.state?.input !== undefined) {
111
+ contents.push(stringifyToolContent(part.state.input))
112
+ }
113
+
114
+ const completedOutput = extractCompletedToolOutput(part)
115
+ if (completedOutput !== undefined) {
116
+ contents.push(completedOutput)
117
+ } else if (part.state?.status === "error" && part.state?.error) {
118
+ contents.push(stringifyToolContent(part.state.error))
119
+ }
120
+
121
+ return contents
122
+ }
123
+
124
+ export function countToolTokens(part: any): number {
125
+ const contents = extractToolContent(part)
126
+ return estimateTokensBatch(contents)
127
+ }
128
+
129
+ export function getTotalToolTokens(state: SessionState, toolIds: string[]): number {
130
+ let total = 0
131
+ for (const id of toolIds) {
132
+ const entry = state.toolParameters.get(id)
133
+ total += entry?.tokenCount ?? 0
134
+ }
135
+ return total
136
+ }
137
+
138
+ export function countMessageTextTokens(msg: WithParts): number {
139
+ const texts: string[] = []
140
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
141
+ for (const part of parts) {
142
+ if (part.type === "text") {
143
+ texts.push(part.text)
144
+ }
145
+ }
146
+ if (texts.length === 0) return 0
147
+ return estimateTokensBatch(texts)
148
+ }
149
+
150
+ export function countAllMessageTokens(msg: WithParts): number {
151
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
152
+ const texts: string[] = []
153
+ for (const part of parts) {
154
+ if (part.type === "text") {
155
+ texts.push(part.text)
156
+ } else {
157
+ texts.push(...extractToolContent(part))
158
+ }
159
+ }
160
+ if (texts.length === 0) return 0
161
+ return estimateTokensBatch(texts)
162
+ }
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.0-beta0",
4
+ "version": "3.2.2-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": [
@@ -52,41 +52,38 @@
52
52
  "author": "tarquinen",
53
53
  "license": "AGPL-3.0-or-later",
54
54
  "peerDependencies": {
55
- "@opencode-ai/plugin": ">=1.2.0",
56
- "@opentui/core": ">=0.1.87",
57
- "@opentui/solid": ">=0.0.0-20260307",
58
- "solid-js": ">=1.9.9 <2"
59
- },
60
- "peerDependenciesMeta": {
61
- "@opentui/core": {
62
- "optional": true
63
- },
64
- "@opentui/solid": {
65
- "optional": true
66
- },
67
- "solid-js": {
68
- "optional": true
69
- }
55
+ "@opencode-ai/plugin": ">=1.2.0"
70
56
  },
71
57
  "dependencies": {
72
58
  "@anthropic-ai/tokenizer": "^0.0.4",
73
59
  "@opencode-ai/sdk": "^1.3.2",
60
+ "@opentui/core": "0.0.0-20260307-536c401b",
61
+ "@opentui/solid": "0.0.0-20260307-536c401b",
74
62
  "fuzzball": "^2.2.3",
75
63
  "jsonc-parser": "^3.3.1",
64
+ "solid-js": "1.9.9",
76
65
  "zod": "^4.3.6"
77
66
  },
78
67
  "devDependencies": {
79
68
  "@opencode-ai/plugin": "^1.3.2",
80
- "@opentui/core": "0.0.0-20260307-536c401b",
81
- "@opentui/solid": "0.0.0-20260307-536c401b",
82
69
  "@types/node": "^25.5.0",
83
70
  "prettier": "^3.8.1",
84
- "solid-js": "1.9.9",
85
71
  "tsx": "^4.21.0",
86
72
  "typescript": "^6.0.2"
87
73
  },
88
74
  "files": [
89
75
  "dist/",
76
+ "lib/analysis/",
77
+ "lib/state/",
78
+ "lib/config.ts",
79
+ "lib/logger.ts",
80
+ "lib/messages/query.ts",
81
+ "lib/token-utils.ts",
82
+ "tui/data/",
83
+ "tui/routes/",
84
+ "tui/shared/",
85
+ "tui/slots/",
86
+ "tui/index.tsx",
90
87
  "README.md",
91
88
  "LICENSE"
92
89
  ]
@@ -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
+ })
@@ -0,0 +1,9 @@
1
+ export const LABEL = "DCP"
2
+
3
+ export const NAMES = {
4
+ routes: {
5
+ summary: "dcp.summary",
6
+ },
7
+ } as const
8
+
9
+ export type DcpRouteNames = typeof NAMES
@@ -0,0 +1,58 @@
1
+ import type { RGBA } from "@opentui/core"
2
+
3
+ export type DcpColor = RGBA | string
4
+
5
+ export interface DcpPalette {
6
+ panel: DcpColor
7
+ base: DcpColor
8
+ surface: DcpColor
9
+ border: DcpColor
10
+ text: DcpColor
11
+ muted: DcpColor
12
+ accent: DcpColor
13
+ success: DcpColor
14
+ warning: DcpColor
15
+ }
16
+
17
+ export type DcpTone = "text" | "muted" | "accent" | "success" | "warning"
18
+
19
+ const defaults = {
20
+ panel: "#111111",
21
+ base: "#1d1d1d",
22
+ surface: "#171717",
23
+ border: "#4a4a4a",
24
+ text: "#f0f0f0",
25
+ muted: "#a5a5a5",
26
+ accent: "#5f87ff",
27
+ success: "#67b95f",
28
+ warning: "#d7a94b",
29
+ }
30
+
31
+ export const getPalette = (theme: Record<string, unknown>): DcpPalette => {
32
+ const get = (name: string, fallback: string): DcpColor => {
33
+ const value = theme[name]
34
+ if (typeof value === "string") return value
35
+ if (value && typeof value === "object") return value as RGBA
36
+ return fallback
37
+ }
38
+
39
+ return {
40
+ panel: get("backgroundPanel", defaults.panel),
41
+ base: get("backgroundElement", defaults.base),
42
+ surface: get("background", defaults.surface),
43
+ border: get("border", defaults.border),
44
+ text: get("text", defaults.text),
45
+ muted: get("textMuted", defaults.muted),
46
+ accent: get("primary", defaults.accent),
47
+ success: get("success", defaults.success),
48
+ warning: get("warning", defaults.warning),
49
+ }
50
+ }
51
+
52
+ export const toneColor = (palette: DcpPalette, tone: DcpTone = "text") => {
53
+ if (tone === "accent") return palette.accent
54
+ if (tone === "success") return palette.success
55
+ if (tone === "warning") return palette.warning
56
+ if (tone === "muted") return palette.muted
57
+ return palette.text
58
+ }