Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
424c9c2
feat(tui): add dcp sidebar widget
Tarquinen Mar 8, 2026
55a6efe
fix(tui): improve type safety and adopt plugin keybind API
Tarquinen Mar 9, 2026
138bedc
chore(tui): bump dependencies and update readme image
Tarquinen Mar 9, 2026
7ddfeb4
feat(lib): add scope support to Logger
Tarquinen Mar 10, 2026
c5705b8
feat(tui): event-driven sidebar refresh with reactivity fix
Tarquinen Mar 10, 2026
2cfcba7
chore(tui): add host runtime linking dev script
Tarquinen Mar 10, 2026
cb9db79
feat(tui): redesign sidebar layout with silent refresh and topic display
Tarquinen Mar 10, 2026
49f3645
refactor(lib): extract shared analyzeTokens module
Tarquinen Mar 10, 2026
9fe0b01
feat(tui): add message bar, graph improvements, and compact token format
Tarquinen Mar 10, 2026
02a942e
refactor(tui): consolidate sidebar helpers
Tarquinen Mar 10, 2026
7b4842e
feat(tui): add all-time stats and rename context label
Tarquinen Mar 10, 2026
9347100
fix(tui): remove ctrl+h keybind from panel
Tarquinen Mar 10, 2026
a7eee11
feat(tui): load tui config from dcp.jsonc
Tarquinen Mar 11, 2026
7b24ba9
refactor(tui): remove panel page and keep only sidebar widget
Tarquinen Mar 12, 2026
62067b7
fix(tui): use flexbox layout for sidebar bars to adapt to scrollbar w…
Tarquinen Mar 12, 2026
02f1a75
fix: lazy-load tui plugin to prevent server crash when tui deps are m…
Tarquinen Mar 12, 2026
118c020
fix(ci): skip devDependencies in security audit
Tarquinen Mar 12, 2026
39b5542
feat(tui): add compression summary route with collapsible sections
Tarquinen Mar 12, 2026
8cae57f
feat(tui): add expandable topic list in sidebar
Tarquinen Mar 12, 2026
2e6d0b1
fix(tui): rename and reorder sidebar summary rows
Tarquinen Mar 12, 2026
ec7d2ae
chore: remove dead code
Tarquinen Mar 13, 2026
3e7a513
fix(lib): pass session messages to compress notifications
Tarquinen Mar 23, 2026
dc6beb6
refactor(tui): port sidebar plugin to snapshot api
Tarquinen Mar 23, 2026
4cbfafe
fix(tui): restore sidebar widget on new host layout
Tarquinen Mar 25, 2026
4661b23
chore(deps): update opencode sdk/plugin to 1.3.2
Tarquinen Mar 25, 2026
3f18231
fix(tui): align plugin with snapshot tui api
Tarquinen Mar 25, 2026
4ff97de
refactor(tui): align plugin with flat TuiPluginApi and updated slot/r…
Tarquinen Mar 27, 2026
1e81931
fix(ui): revert compress notification to match dev
Tarquinen Mar 27, 2026
9526804
fix(tui): restore dedicated tui entrypoint
Tarquinen Mar 29, 2026
787d4c5
fix(types): refresh vendored tui plugin shim
Tarquinen Mar 29, 2026
58cf4f8
feat: add plugin install targets
Tarquinen Mar 29, 2026
56e9ab0
fix(tui): show summary token totals
Tarquinen Mar 29, 2026
3ab6558
docs: add tui plugin install config
Tarquinen Mar 29, 2026
d1816bd
v3.2.0-beta0 - Bump version
Tarquinen Mar 29, 2026
5dbcb2d
v3.2.1-beta0 - Fix TUI runtime deps
Tarquinen Mar 29, 2026
85c792b
v3.2.2-beta0 - Fix npm TUI source entry
Tarquinen Mar 29, 2026
3b9397a
chore: add package directories metadata
Tarquinen Mar 29, 2026
5901d79
fix(tui): bump opentui deps for audit
Tarquinen Mar 29, 2026
aee5a8c
docs: add global plugin install command
Tarquinen Mar 29, 2026
57f160f
docs: simplify installation instructions
Tarquinen Mar 30, 2026
915e93c
change default mode to message
Tarquinen Apr 1, 2026
1376c56
v3.2.3-beta0 - Bump version
Tarquinen Apr 1, 2026
82090f3
format
Tarquinen Apr 1, 2026
a124dfc
v3.2.4-beta0 - Fix npm TUI packaging
Tarquinen Apr 1, 2026
b008f39
chore: switch npm publish selection to .npmignore
Tarquinen Apr 1, 2026
6d15061
chore(release): verify package contents for beta publish
Tarquinen Apr 1, 2026
1bfb76b
v3.2.8-beta0
Tarquinen Apr 1, 2026
59c450b
fix: align package exports and deps with opencode plugin loader conve…
Tarquinen Apr 2, 2026
b240a66
fix: remove unused fuzzball dependency to resolve audit vulnerability
Tarquinen Apr 2, 2026
13fa280
fix: align tui package deps
Tarquinen Apr 12, 2026
f3bdcf7
fix: drop stale tui shim
Tarquinen Apr 12, 2026
5b6cad2
fix: remove tui borders
Tarquinen Apr 12, 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
42 changes: 31 additions & 11 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
# Development files
# Dependencies
node_modules/

# Build artifacts
*.tgz

# Logs
logs/
dist/logs/
*.log
npm-debug.log*

# Local/dev files
.DS_Store
tsconfig.json
watch-logs.sh
bun.lock

# Documentation
ANALYSIS.md
docs/
notes/

# Source files (since we're shipping dist/)
index.ts
lib/

# Git
.git/
.gitignore
.github/

# OpenCode
# OpenCode local config
.opencode/

# Docs and local notes
ANALYSIS.md
docs/
notes/
SCHEMA_NOTES.md
assets/
CONTRIBUTING.md
.prettierrc
.repomixignore

# Tests and local helpers
tests/
tests/results/
test-update.ts
repomix-output.xml
scripts/
tui/node_modules/
tui/package-lock.json
tui/types/
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 from the 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 @@ -99,6 +99,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
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
import { configureClientAuth, isSecureMode } from "./lib/auth"
import { startAutoUpdate } from "./lib/update"

const id = "opencode-dynamic-context-pruning"

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

Expand Down
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