Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c58b42a
feat(tui): add dcp sidebar widget
Tarquinen Mar 8, 2026
4c442b7
fix(tui): improve type safety and adopt plugin keybind API
Tarquinen Mar 9, 2026
82505c1
chore(tui): bump dependencies and update readme image
Tarquinen Mar 9, 2026
66ad3eb
feat(lib): add scope support to Logger
Tarquinen Mar 10, 2026
f5455de
feat(tui): event-driven sidebar refresh with reactivity fix
Tarquinen Mar 10, 2026
149976e
chore(tui): add host runtime linking dev script
Tarquinen Mar 10, 2026
32efb0f
feat(tui): redesign sidebar layout with silent refresh and topic display
Tarquinen Mar 10, 2026
8e8e7b7
refactor(lib): extract shared analyzeTokens module
Tarquinen Mar 10, 2026
c32f7dc
feat(tui): add message bar, graph improvements, and compact token format
Tarquinen Mar 10, 2026
8efce12
refactor(tui): consolidate sidebar helpers
Tarquinen Mar 10, 2026
596a182
fix(lib): use isMessageCompacted for compress notification bar
Tarquinen Mar 10, 2026
da44c5d
feat(tui): add all-time stats and rename context label
Tarquinen Mar 10, 2026
a5add5c
fix(tui): remove ctrl+h keybind from panel
Tarquinen Mar 10, 2026
740ffcf
feat(tui): load tui config from dcp.jsonc
Tarquinen Mar 11, 2026
422eb25
refactor(tui): remove panel page and keep only sidebar widget
Tarquinen Mar 12, 2026
61a6780
fix(tui): use flexbox layout for sidebar bars to adapt to scrollbar w…
Tarquinen Mar 12, 2026
e00865c
fix: lazy-load tui plugin to prevent server crash when tui deps are m…
Tarquinen Mar 12, 2026
e174463
fix(ci): skip devDependencies in security audit
Tarquinen Mar 12, 2026
903ea8c
feat(tui): add compression summary route with collapsible sections
Tarquinen Mar 12, 2026
dbe55c5
feat(tui): add expandable topic list in sidebar
Tarquinen Mar 12, 2026
46ae8bc
fix(tui): rename and reorder sidebar summary rows
Tarquinen Mar 12, 2026
411fe26
chore: remove dead code
Tarquinen Mar 13, 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
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
12 changes: 10 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import {
} from "./lib/hooks"
import { configureClientAuth, isSecureMode } from "./lib/auth"

const plugin: Plugin = (async (ctx) => {
let tuiPlugin: Record<string, unknown> = {}
try {
tuiPlugin = (await import("./tui/index")).default
} catch {}

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

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

export default plugin
export default {
server,
...tuiPlugin,
}
228 changes: 228 additions & 0 deletions lib/analysis/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* 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 { isMessageCompacted } from "../shared-utils"
import { isIgnoredUserMessage } from "../messages/utils"
import { countTokens } from "../strategies/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)
}
if (toolPart.state?.status === "completed" && toolPart.state?.output) {
const outputText =
typeof toolPart.state.output === "string"
? toolPart.state.output
: JSON.stringify(toolPart.state.output)
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