Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
409a381
feat(tui): add dcp sidebar widget
Tarquinen Mar 8, 2026
29ad37d
fix(tui): improve type safety and adopt plugin keybind API
Tarquinen Mar 9, 2026
dc67329
chore(tui): bump dependencies and update readme image
Tarquinen Mar 9, 2026
90e829f
feat(lib): add scope support to Logger
Tarquinen Mar 10, 2026
493f97a
feat(tui): event-driven sidebar refresh with reactivity fix
Tarquinen Mar 10, 2026
d5f55f7
chore(tui): add host runtime linking dev script
Tarquinen Mar 10, 2026
40a4654
feat(tui): redesign sidebar layout with silent refresh and topic display
Tarquinen Mar 10, 2026
ae75048
refactor(lib): extract shared analyzeTokens module
Tarquinen Mar 10, 2026
5a661ce
feat(tui): add message bar, graph improvements, and compact token format
Tarquinen Mar 10, 2026
636d5b3
refactor(tui): consolidate sidebar helpers
Tarquinen Mar 10, 2026
98358f1
feat(tui): add all-time stats and rename context label
Tarquinen Mar 10, 2026
788ee6e
fix(tui): remove ctrl+h keybind from panel
Tarquinen Mar 10, 2026
dacd356
feat(tui): load tui config from dcp.jsonc
Tarquinen Mar 11, 2026
ae6c3c2
refactor(tui): remove panel page and keep only sidebar widget
Tarquinen Mar 12, 2026
df8138a
fix(tui): use flexbox layout for sidebar bars to adapt to scrollbar w…
Tarquinen Mar 12, 2026
24d67d6
fix: lazy-load tui plugin to prevent server crash when tui deps are m…
Tarquinen Mar 12, 2026
4635d61
fix(ci): skip devDependencies in security audit
Tarquinen Mar 12, 2026
8537867
feat(tui): add compression summary route with collapsible sections
Tarquinen Mar 12, 2026
a607373
feat(tui): add expandable topic list in sidebar
Tarquinen Mar 12, 2026
c0170df
fix(tui): rename and reorder sidebar summary rows
Tarquinen Mar 12, 2026
76cfda8
chore: remove dead code
Tarquinen Mar 13, 2026
5dad419
fix(lib): pass session messages to compress notifications
Tarquinen Mar 23, 2026
510932e
refactor(tui): port sidebar plugin to snapshot api
Tarquinen Mar 23, 2026
e1c080b
fix(tui): restore sidebar widget on new host layout
Tarquinen Mar 25, 2026
d779f58
chore(deps): update opencode sdk/plugin to 1.3.2
Tarquinen Mar 25, 2026
c79afa7
fix(tui): align plugin with snapshot tui api
Tarquinen Mar 25, 2026
87f5acd
refactor(tui): align plugin with flat TuiPluginApi and updated slot/r…
Tarquinen Mar 27, 2026
7ed62d2
fix(ui): revert compress notification to match dev
Tarquinen Mar 27, 2026
4f7953c
fix(tui): restore dedicated tui entrypoint
Tarquinen Mar 29, 2026
5a1a0d1
fix(types): refresh vendored tui plugin shim
Tarquinen Mar 29, 2026
66bd601
feat: add plugin install targets
Tarquinen Mar 29, 2026
ab8a5a9
fix(tui): show summary token totals
Tarquinen Mar 29, 2026
703f3c9
docs: add tui plugin install config
Tarquinen Mar 29, 2026
d587301
v3.2.0-beta0 - Bump version
Tarquinen Mar 29, 2026
6889a29
v3.2.1-beta0 - Fix TUI runtime deps
Tarquinen Mar 29, 2026
da736e8
v3.2.2-beta0 - Fix npm TUI source entry
Tarquinen Mar 29, 2026
f5027b8
chore: add package directories metadata
Tarquinen Mar 29, 2026
48a287c
fix(tui): bump opentui deps for audit
Tarquinen Mar 29, 2026
dc6c195
docs: add global plugin install command
Tarquinen Mar 29, 2026
49384fe
docs: simplify installation instructions
Tarquinen Mar 30, 2026
f5024ae
change default mode to message
Tarquinen Apr 1, 2026
a9d2d28
v3.2.3-beta0 - Bump version
Tarquinen Apr 1, 2026
655d04a
format
Tarquinen Apr 1, 2026
0cfd4f1
v3.2.4-beta0 - Fix npm TUI packaging
Tarquinen Apr 1, 2026
32322c4
chore: switch npm publish selection to .npmignore
Tarquinen Apr 1, 2026
15f0ccd
chore(release): verify package contents for beta publish
Tarquinen Apr 1, 2026
29418bb
v3.2.8-beta0
Tarquinen Apr 1, 2026
0bd933a
fix: align package exports and deps with opencode plugin loader conve…
Tarquinen Apr 2, 2026
72d7c2f
fix: remove unused fuzzball dependency to resolve audit vulnerability
Tarquinen Apr 2, 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 @@ -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
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