Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
6ac882d
feat(tui): add dcp sidebar widget
Tarquinen Mar 8, 2026
c34a7b7
fix(tui): improve type safety and adopt plugin keybind API
Tarquinen Mar 9, 2026
46ba09b
chore(tui): bump dependencies and update readme image
Tarquinen Mar 9, 2026
afd7153
feat(lib): add scope support to Logger
Tarquinen Mar 10, 2026
95c38cd
feat(tui): event-driven sidebar refresh with reactivity fix
Tarquinen Mar 10, 2026
146b52d
chore(tui): add host runtime linking dev script
Tarquinen Mar 10, 2026
6579183
feat(tui): redesign sidebar layout with silent refresh and topic display
Tarquinen Mar 10, 2026
3474149
refactor(lib): extract shared analyzeTokens module
Tarquinen Mar 10, 2026
a34a0c8
feat(tui): add message bar, graph improvements, and compact token format
Tarquinen Mar 10, 2026
a65be99
refactor(tui): consolidate sidebar helpers
Tarquinen Mar 10, 2026
f54fd81
feat(tui): add all-time stats and rename context label
Tarquinen Mar 10, 2026
2cc2431
fix(tui): remove ctrl+h keybind from panel
Tarquinen Mar 10, 2026
420d45f
feat(tui): load tui config from dcp.jsonc
Tarquinen Mar 11, 2026
d7c1f31
refactor(tui): remove panel page and keep only sidebar widget
Tarquinen Mar 12, 2026
0d94089
fix(tui): use flexbox layout for sidebar bars to adapt to scrollbar w…
Tarquinen Mar 12, 2026
2dd460a
fix: lazy-load tui plugin to prevent server crash when tui deps are m…
Tarquinen Mar 12, 2026
1b03f49
fix(ci): skip devDependencies in security audit
Tarquinen Mar 12, 2026
7f4b3fb
feat(tui): add compression summary route with collapsible sections
Tarquinen Mar 12, 2026
4f68572
feat(tui): add expandable topic list in sidebar
Tarquinen Mar 12, 2026
77073e5
fix(tui): rename and reorder sidebar summary rows
Tarquinen Mar 12, 2026
fc365a9
chore: remove dead code
Tarquinen Mar 13, 2026
b143db0
fix(lib): pass session messages to compress notifications
Tarquinen Mar 23, 2026
6d4c795
refactor(tui): port sidebar plugin to snapshot api
Tarquinen Mar 23, 2026
f4c23a6
fix(tui): restore sidebar widget on new host layout
Tarquinen Mar 25, 2026
98cf898
chore(deps): update opencode sdk/plugin to 1.3.2
Tarquinen Mar 25, 2026
215da5e
fix(tui): align plugin with snapshot tui api
Tarquinen Mar 25, 2026
1d4bc3c
refactor(tui): align plugin with flat TuiPluginApi and updated slot/r…
Tarquinen Mar 27, 2026
3311950
fix(ui): revert compress notification to match dev
Tarquinen Mar 27, 2026
465c64d
fix(tui): restore dedicated tui entrypoint
Tarquinen Mar 29, 2026
a578bb7
fix(types): refresh vendored tui plugin shim
Tarquinen Mar 29, 2026
8be21b2
feat: add plugin install targets
Tarquinen Mar 29, 2026
3ef58aa
fix(tui): show summary token totals
Tarquinen Mar 29, 2026
ff9be14
docs: add tui plugin install config
Tarquinen Mar 29, 2026
a242072
v3.2.0-beta0 - Bump version
Tarquinen Mar 29, 2026
308ac0e
v3.2.1-beta0 - Fix TUI runtime deps
Tarquinen Mar 29, 2026
d181bda
v3.2.2-beta0 - Fix npm TUI source entry
Tarquinen Mar 29, 2026
834fb9e
chore: add package directories metadata
Tarquinen Mar 29, 2026
92728c6
fix(tui): bump opentui deps for audit
Tarquinen Mar 29, 2026
319191d
docs: add global plugin install command
Tarquinen Mar 29, 2026
45be2cd
docs: simplify installation instructions
Tarquinen Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ jobs:
run: npm run build

- name: Security audit
run: npm audit --audit-level=high
run: npm audit --omit=dev --audit-level=high
continue-on-error: false
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Automatically reduces token usage in OpenCode by managing conversation context.
Install with the OpenCode CLI:

```bash
opencode plugin @tarquinen/opencode-dcp@latest --global
opencode plugin @tarquinen/opencode-dcp@beta --global
```

This installs the package and adds it to your global OpenCode config.
Expand Down
21 changes: 21 additions & 0 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@
}
}
},
"tui": {
"type": "object",
"description": "Configuration for the DCP TUI integration",
"additionalProperties": false,
"properties": {
"sidebar": {
"type": "boolean",
"default": true,
"description": "Show the DCP sidebar widget in the TUI"
},
"debug": {
"type": "boolean",
"default": false,
"description": "Enable debug/error logging for the DCP TUI"
}
},
"default": {
"sidebar": true,
"debug": false
}
},
"experimental": {
"type": "object",
"description": "Experimental settings that may change in future releases",
Expand Down
9 changes: 7 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
} from "./lib/hooks"
import { configureClientAuth, isSecureMode } from "./lib/auth"

const plugin: Plugin = (async (ctx) => {
const id = "opencode-dynamic-context-pruning"

const server: Plugin = (async (ctx) => {
const config = getConfig(ctx)

if (!config.enabled) {
Expand Down Expand Up @@ -132,4 +134,7 @@ const plugin: Plugin = (async (ctx) => {
}
}) satisfies Plugin

export default plugin
export default {
id,
server,
}
225 changes: 225 additions & 0 deletions lib/analysis/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* Shared Token Analysis
* Computes a breakdown of token usage across categories for a session.
*
* TOKEN CALCULATION STRATEGY
* ==========================
* We minimize tokenizer estimation by leveraging API-reported values wherever possible.
*
* WHAT WE GET FROM THE API (exact):
* - tokens.input : Input tokens for each assistant response
* - tokens.output : Output tokens generated (includes text + tool calls)
* - tokens.reasoning: Reasoning tokens used
* - tokens.cache : Cache read/write tokens
*
* HOW WE CALCULATE EACH CATEGORY:
*
* SYSTEM = firstAssistant.input + cache.read + cache.write - tokenizer(firstUserMessage)
* The first response's total input (input + cache.read + cache.write)
* contains system + first user message. On the first request of a
* session, the system prompt appears in cache.write (cache creation),
* not cache.read.
*
* TOOLS = tokenizer(toolInputs + toolOutputs) - prunedTokens
* We must tokenize tools anyway for pruning decisions.
*
* USER = tokenizer(all user messages)
* User messages are typically small, so estimation is acceptable.
*
* ASSISTANT = total - system - user - tools
* Calculated as residual. This absorbs:
* - Assistant text output tokens
* - Reasoning tokens (if persisted by the model)
* - Any estimation errors
*
* TOTAL = input + output + reasoning + cache.read + cache.write
* Matches opencode's UI display.
*
* WHY ASSISTANT IS THE RESIDUAL:
* If reasoning tokens persist in context (model-dependent), they semantically
* belong with "Assistant" since reasoning IS assistant-generated content.
*/

import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
import type { SessionState, WithParts } from "../state"
import { isIgnoredUserMessage } from "../messages/query"
import { isMessageCompacted } from "../state/utils"
import { countTokens, extractCompletedToolOutput } from "../token-utils"

export type MessageStatus = "active" | "pruned"

export interface TokenBreakdown {
system: number
user: number
assistant: number
tools: number
toolCount: number
toolsInContextCount: number
prunedTokens: number
prunedToolCount: number
prunedMessageCount: number
total: number
messageCount: number
}

export interface TokenAnalysis {
breakdown: TokenBreakdown
messageStatuses: MessageStatus[]
}

export function emptyBreakdown(): TokenBreakdown {
return {
system: 0,
user: 0,
assistant: 0,
tools: 0,
toolCount: 0,
toolsInContextCount: 0,
prunedTokens: 0,
prunedToolCount: 0,
prunedMessageCount: 0,
total: 0,
messageCount: 0,
}
}

export function analyzeTokens(state: SessionState, messages: WithParts[]): TokenAnalysis {
const breakdown = emptyBreakdown()
const messageStatuses: MessageStatus[] = []
breakdown.prunedTokens = state.stats.totalPruneTokens

let firstAssistant: AssistantMessage | undefined
for (const msg of messages) {
if (msg.info.role !== "assistant") continue
const assistantInfo = msg.info as AssistantMessage
if (
assistantInfo.tokens?.input > 0 ||
assistantInfo.tokens?.cache?.read > 0 ||
assistantInfo.tokens?.cache?.write > 0
) {
firstAssistant = assistantInfo
break
}
}

let lastAssistant: AssistantMessage | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role !== "assistant") continue
const assistantInfo = msg.info as AssistantMessage
if (assistantInfo.tokens?.output > 0) {
lastAssistant = assistantInfo
break
}
}

const apiInput = lastAssistant?.tokens?.input || 0
const apiOutput = lastAssistant?.tokens?.output || 0
const apiReasoning = lastAssistant?.tokens?.reasoning || 0
const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0
const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0
breakdown.total = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite

const userTextParts: string[] = []
const toolInputParts: string[] = []
const toolOutputParts: string[] = []
const allToolIds = new Set<string>()
const activeToolIds = new Set<string>()
const prunedByMessageToolIds = new Set<string>()
const allMessageIds = new Set<string>()

let firstUserText = ""
let foundFirstUser = false

for (const msg of messages) {
const ignoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg)
if (ignoredUser) continue

allMessageIds.add(msg.info.id)
const parts = Array.isArray(msg.parts) ? msg.parts : []
const compacted = isMessageCompacted(state, msg)
const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
const messagePruned = !!pruneEntry && pruneEntry.activeBlockIds.length > 0
const messageActive = !compacted && !messagePruned

breakdown.messageCount += 1
messageStatuses.push(messageActive ? "active" : "pruned")

for (const part of parts) {
if (part.type === "tool") {
const toolPart = part as ToolPart
if (toolPart.callID) {
allToolIds.add(toolPart.callID)
if (!compacted) activeToolIds.add(toolPart.callID)
if (messagePruned) prunedByMessageToolIds.add(toolPart.callID)
}

const toolPruned = toolPart.callID && state.prune.tools.has(toolPart.callID)
if (!compacted && !toolPruned) {
if (toolPart.state?.input) {
const inputText =
typeof toolPart.state.input === "string"
? toolPart.state.input
: JSON.stringify(toolPart.state.input)
toolInputParts.push(inputText)
}
const outputText = extractCompletedToolOutput(toolPart)
if (outputText !== undefined) {
toolOutputParts.push(outputText)
}
}
continue
}

if (part.type === "text" && msg.info.role === "user" && !compacted) {
const textPart = part as TextPart
const text = textPart.text || ""
userTextParts.push(text)
if (!foundFirstUser) firstUserText += text
}
}

if (msg.info.role === "user" && !foundFirstUser) {
foundFirstUser = true
}
}

const prunedByToolIds = new Set<string>()
for (const toolID of allToolIds) {
if (state.prune.tools.has(toolID)) prunedByToolIds.add(toolID)
}

const prunedToolIds = new Set<string>([...prunedByToolIds, ...prunedByMessageToolIds])
breakdown.toolCount = allToolIds.size
breakdown.toolsInContextCount = [...activeToolIds].filter(
(id) => !prunedByToolIds.has(id),
).length
breakdown.prunedToolCount = prunedToolIds.size

for (const [messageID, entry] of state.prune.messages.byMessageId) {
if (allMessageIds.has(messageID) && entry.activeBlockIds.length > 0) {
breakdown.prunedMessageCount += 1
}
}

const firstUserTokens = countTokens(firstUserText)
breakdown.user = countTokens(userTextParts.join("\n"))
const toolInputTokens = countTokens(toolInputParts.join("\n"))
const toolOutputTokens = countTokens(toolOutputParts.join("\n"))

if (firstAssistant) {
const firstInput =
(firstAssistant.tokens?.input || 0) +
(firstAssistant.tokens?.cache?.read || 0) +
(firstAssistant.tokens?.cache?.write || 0)
breakdown.system = Math.max(0, firstInput - firstUserTokens)
}

breakdown.tools = toolInputTokens + toolOutputTokens
breakdown.assistant = Math.max(
0,
breakdown.total - breakdown.system - breakdown.user - breakdown.tools,
)

return { breakdown, messageStatuses }
}
Loading
Loading