From 8b56c92d189bb6bcf3dcab2a7bc8698c323d8088 Mon Sep 17 00:00:00 2001 From: jingyuan Date: Mon, 1 Jun 2026 00:34:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=94=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9A/context=20=E5=91=BD=E4=BB=A4=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E4=B8=8A=E4=B8=8B=E6=96=87=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=8D=A0=E7=94=A8=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-en.md | 1 + README-zh_CN.md | 1 + README.md | 1 + src/session.ts | 147 +++++++++++++++++ src/tests/session.test.ts | 125 +++++++++++++++ src/tests/slash-commands.test.ts | 1 + src/ui/core/slash-commands.ts | 7 + src/ui/views/App.tsx | 12 +- src/ui/views/ContextStatusView.tsx | 246 +++++++++++++++++++++++++++++ src/ui/views/PromptInput.tsx | 7 +- 10 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 src/ui/views/ContextStatusView.tsx diff --git a/README-en.md b/README-en.md index c1d4acb..3fad171 100644 --- a/README-en.md +++ b/README-en.md @@ -75,6 +75,7 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap | `/init` | Initialize an AGENTS.md file (LLM project instructions) | | `/skills` | List available skills | | `/mcp` | View MCP server status and available tools | +| `/context` | View context window usage breakdown for the current session | | `/undo` | Restore code and/or conversation to a previous point | | `/exit` | Quit (also `Ctrl+D` twice) | diff --git a/README-zh_CN.md b/README-zh_CN.md index 2643756..0c9d7ab 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -74,6 +74,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | | `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/context` | 查看当前会话的上下文窗口用量明细 | | `/undo` | 将代码和/或对话恢复到之前的状态 | | `/exit` | 退出(也可用连续 `Ctrl+D`) | diff --git a/README.md b/README.md index 2643756..0c9d7ab 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | | `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/context` | 查看当前会话的上下文窗口用量明细 | | `/undo` | 将代码和/或对话恢复到之前的状态 | | `/exit` | 退出(也可用连续 `Ctrl+D`) | diff --git a/src/session.ts b/src/session.ts index 358789e..1ea67ee 100644 --- a/src/session.ts +++ b/src/session.ts @@ -175,6 +175,28 @@ function getTotalTokens(usage: ModelUsage | null | undefined): number { return typeof totalTokens === "number" ? totalTokens : 0; } +function estimateTokensFromText(text: string | null | undefined): number { + if (!text) return 0; + const total = [...text].length; + const cjk = text.match(/[㐀-鿿豈-﫿぀-ヿ가-힯]/gu)?.length ?? 0; + return Math.ceil(cjk + (total - cjk) / 4); +} + +function estimateTokensFromUnknown(value: unknown): number { + if (value == null) return 0; + if (typeof value === "string") return estimateTokensFromText(value); + try { + return estimateTokensFromText(JSON.stringify(value)); + } catch { + return 0; + } +} + +// 各模型族的近似上下文窗口大小,仅用作进度条的分母。 +function getModelContextWindow(model: string): number { + return DEEPSEEK_V4_MODELS.has(model) ? 512 * 1024 : 128 * 1024; +} + export type SessionStatus = | "failed" | "pending" @@ -235,6 +257,39 @@ export type SessionsIndex = { originalPath: string; }; +export type ContextCategoryKey = + | "system_prompt" + | "tool_definitions" + | "mcp_tools" + | "agent_instructions" + | "default_skills" + | "loaded_skills" + | "user_messages" + | "assistant_messages" + | "thinking" + | "tool_results"; + +export type ContextCategoryUsage = { + key: ContextCategoryKey; + label: string; + tokens: number; +}; + +export type ContextStatus = { + model: string; + /** 来自最近一次 API 响应的权威活跃 token 数。 */ + activeTokens: number; + /** 各分类估算 token 之和;让 UI 在尚未发起 API 调用时也能展示分项占比。 */ + estimatedTotal: number; + /** 触发会话自动压缩(auto-compact)的阈值。 */ + compactThreshold: number; + /** 当前模型的启发式上下文窗口大小(用作进度条的分母)。 */ + contextWindow: number; + categories: ContextCategoryUsage[]; + usagePerModel: Record | null; + messageCount: number; +}; + export type SessionMessageRole = "system" | "user" | "assistant" | "tool"; export type MessageMeta = { @@ -363,6 +418,98 @@ export class SessionManager { return this.mcpManager.getStatus(); } + getContextStatus(sessionId: string | null): ContextStatus { + const model = this.getResolvedSettings().model; + const compactThreshold = getCompactPromptTokenThreshold(model); + const contextWindow = getModelContextWindow(model); + const promptToolOptions = this.getPromptToolOptions(); + + const labels: Record = { + system_prompt: "System prompt", + tool_definitions: "Built-in tools", + mcp_tools: "MCP tools", + agent_instructions: "Agent instructions (AGENTS.md)", + default_skills: "Default skills", + loaded_skills: "Loaded skills", + user_messages: "User messages", + assistant_messages: "Assistant replies", + thinking: "Thinking (reasoning)", + tool_results: "Tool results", + }; + const tokens: Record = { + system_prompt: 0, + tool_definitions: 0, + mcp_tools: 0, + agent_instructions: 0, + default_skills: 0, + loaded_skills: 0, + user_messages: 0, + assistant_messages: 0, + thinking: 0, + tool_results: 0, + }; + + // ── 静态(一次性)分类 —— 与会话状态无关 ───────────────────────────────── + tokens.system_prompt = + estimateTokensFromText(getSystemPrompt(this.projectRoot, promptToolOptions)) + + estimateTokensFromText(getRuntimeContext(this.projectRoot, promptToolOptions.model)); + tokens.default_skills = estimateTokensFromText(getDefaultSkillPrompt()); + tokens.agent_instructions = estimateTokensFromText(this.loadAgentInstructions()); + + // ── 工具定义(区分内置工具与 MCP 工具)───────────────────────────────── + const mcpToolNames = new Set(this.mcpToolDefinitions.map((t) => t.function.name)); + for (const tool of getTools(promptToolOptions, this.mcpToolDefinitions)) { + const bucket = mcpToolNames.has(tool.function.name) ? "mcp_tools" : "tool_definitions"; + tokens[bucket] += estimateTokensFromUnknown(tool); + } + + // ── 会话消息 —— 按语义角色拆分 ──────────────────────────────────────── + const messages = sessionId ? this.listSessionMessages(sessionId).filter((m) => !m.compacted) : []; + for (const message of messages) { + const params = message.messageParams as { tool_calls?: unknown; reasoning_content?: string } | null | undefined; + const contentTokens = estimateTokensFromText(message.content) + estimateTokensFromUnknown(message.contentParams); + + if (message.meta?.skill) { + tokens.loaded_skills += contentTokens; + continue; + } + switch (message.role) { + case "system": + // 已计入 system_prompt / agent_instructions,此处跳过以避免重复计数。 + break; + case "user": + tokens.user_messages += contentTokens; + break; + case "assistant": + tokens.assistant_messages += contentTokens + estimateTokensFromUnknown(params?.tool_calls); + tokens.thinking += estimateTokensFromText(params?.reasoning_content); + break; + case "tool": + tokens.tool_results += contentTokens; + break; + } + } + + const categories: ContextCategoryUsage[] = (Object.keys(labels) as ContextCategoryKey[]).map((key) => ({ + key, + label: labels[key], + tokens: tokens[key], + })); + const estimatedTotal = categories.reduce((sum, c) => sum + c.tokens, 0); + const session = sessionId ? this.getSession(sessionId) : null; + + return { + model, + activeTokens: session?.activeTokens ?? 0, + estimatedTotal, + compactThreshold, + contextWindow, + categories, + usagePerModel: session?.usagePerModel ?? null, + messageCount: messages.length, + }; + } + async reconnectMcpServer(name: string, config?: McpServerConfig): Promise { await this.mcpManager.reconnect(name, config); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 06808ed..b996c26 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -2906,6 +2906,131 @@ test("SessionManager.deleteSession does not affect other sessions", () => { assert.ok(messages.length > 0); }); +test("getContextStatus returns categories that sum to estimatedTotal", () => { + const workspace = createTempDir("deepcode-context-breakdown-"); + const home = createTempDir("deepcode-context-home-"); + setHomeDir(home); + + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ client: null, model: "test-model", thinkingEnabled: false }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const status = manager.getContextStatus(null); + + // 没有会话时 → 会话相关分类应全部为 0,但静态开销 + // (system prompt、tool 定义、运行时上下文)必须非 0。 + const sumCategories = status.categories.reduce((sum, c) => sum + c.tokens, 0); + assert.equal(sumCategories, status.estimatedTotal); + assert.ok(status.estimatedTotal > 0, "static overhead should produce non-zero estimate"); + assert.equal(status.activeTokens, 0); + assert.equal(status.messageCount, 0); + + const userTokens = status.categories.find((c) => c.key === "user_messages")?.tokens ?? -1; + const builtinTokens = status.categories.find((c) => c.key === "tool_definitions")?.tokens ?? -1; + assert.equal(userTokens, 0); + assert.ok(builtinTokens > 0, "built-in tool definitions should contribute tokens"); +}); + +test("getContextStatus threshold tracks model family", () => { + const workspace = createTempDir("deepcode-context-threshold-"); + const home = createTempDir("deepcode-context-threshold-home-"); + setHomeDir(home); + + const defaultManager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ client: null, model: "deepseek-chat", thinkingEnabled: false }), + getResolvedSettings: () => ({ model: "deepseek-chat" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + const v4Manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ client: null, model: "deepseek-v4-pro", thinkingEnabled: false }), + getResolvedSettings: () => ({ model: "deepseek-v4-pro" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + assert.equal(defaultManager.getContextStatus(null).compactThreshold, 128 * 1024); + assert.equal(v4Manager.getContextStatus(null).compactThreshold, 512 * 1024); + assert.ok(v4Manager.getContextStatus(null).contextWindow >= 512 * 1024); +}); + +test("getContextStatus attributes session messages to the right categories", () => { + const workspace = createTempDir("deepcode-context-msgs-"); + const home = createTempDir("deepcode-context-msgs-home-"); + setHomeDir(home); + + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ client: null, model: "test-model", thinkingEnabled: false }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const sessionId = createSessionAndMessages(manager, "ctx-session-1", "context test"); + const projectDir = (manager as any).getProjectStorage().projectDir; + const messagePath = path.join(projectDir, `${sessionId}.jsonl`); + const now = new Date().toISOString(); + + // 追加:一条 user 消息、一条带 reasoning_content 的 assistant 消息,以及一条 tool 结果 + const userMsg = { + id: "u-1", + sessionId, + role: "user", + content: "Please add a test for the new feature.", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }; + const assistantMsg = { + id: "a-1", + sessionId, + role: "assistant", + content: "Sure, here's the plan.", + contentParams: null, + messageParams: { reasoning_content: "Step one: read the file. Step two: write a test." }, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }; + const toolMsg = { + id: "t-1", + sessionId, + role: "tool", + content: "file contents go here ".repeat(20), + contentParams: null, + messageParams: null, + compacted: false, + visible: false, + createTime: now, + updateTime: now, + }; + fs.appendFileSync( + messagePath, + `${JSON.stringify(userMsg)}\n${JSON.stringify(assistantMsg)}\n${JSON.stringify(toolMsg)}\n`, + "utf8" + ); + + const status = manager.getContextStatus(sessionId); + const get = (key: string) => status.categories.find((c) => c.key === key)?.tokens ?? 0; + + assert.ok(get("user_messages") > 0, "user message should contribute tokens"); + assert.ok(get("assistant_messages") > 0, "assistant message should contribute tokens"); + assert.ok(get("thinking") > 0, "reasoning_content should land in thinking bucket"); + assert.ok(get("tool_results") > 0, "tool result should land in tool_results bucket"); + assert.ok(status.messageCount >= 4, "expected the seeded messages plus the helper-created one"); +}); + /** * Helper: creates a session and writes a few messages to it so we can test * that deleteSession removes both the index entry and the messages file. diff --git a/src/tests/slash-commands.test.ts b/src/tests/slash-commands.test.ts index 30d77ee..73f5645 100644 --- a/src/tests/slash-commands.test.ts +++ b/src/tests/slash-commands.test.ts @@ -28,6 +28,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "continue", "undo", "mcp", + "context", "raw", "exit", ]); diff --git a/src/ui/core/slash-commands.ts b/src/ui/core/slash-commands.ts index 04840ba..093071f 100644 --- a/src/ui/core/slash-commands.ts +++ b/src/ui/core/slash-commands.ts @@ -10,6 +10,7 @@ export type SlashCommandKind = | "continue" | "undo" | "mcp" + | "context" | "raw" | "exit"; @@ -71,6 +72,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/mcp", description: "Show MCP server status and available tools", }, + { + kind: "context", + name: "context", + label: "/context", + description: "View context window usage breakdown for the current session", + }, { kind: "raw", name: "raw", diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index bef803e..512848a 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -13,6 +13,7 @@ import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; +import { ContextStatusView } from "./ContextStatusView"; import { ProcessStdoutView } from "./ProcessStdoutView"; import { type AskUserQuestionAnswers, @@ -35,6 +36,7 @@ import { resolveCurrentSettings, writeModelConfigSelection } from "../../setting import { isCollapsedThinking } from "../core/thinking-state"; import { ANSI_CLEAR_SCREEN } from "../constants"; import type { + ContextStatus, LlmStreamProgress, MessageMeta, SessionEntry, @@ -46,7 +48,7 @@ import type { } from "../../session"; import { SessionManager } from "../../session"; -type View = "chat" | "session-list" | "undo" | "mcp-status"; +type View = "chat" | "session-list" | "undo" | "mcp-status" | "context-status"; type AppProps = { projectRoot: string; @@ -90,6 +92,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); + const [contextStatus, setContextStatus] = useState(null); const [showProcessStdout, setShowProcessStdout] = useState(false); rawModeRef.current = mode; @@ -298,6 +301,11 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl navigateToSubView("mcp-status"); return; } + if (submission.command === "context") { + setContextStatus(sessionManager.getContextStatus(sessionManager.getActiveSessionId())); + navigateToSubView("context-status"); + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -764,6 +772,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]); }} /> + ) : view === "context-status" && contextStatus ? ( + setView("chat")} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( ([ + "system_prompt", + "tool_definitions", + "mcp_tools", + "agent_instructions", + "default_skills", +]); + +// ── 格式化 ──────────────────────────────────────────────────────────────────── + +function formatTokens(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "0"; + if (n < 1000) return String(Math.round(n)); + if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +} + +function formatPercent(value: number, total: number): string { + if (total <= 0) return "0%"; + const pct = (value / total) * 100; + if (pct < 0.1) return "<0.1%"; + return `${pct.toFixed(pct < 10 ? 1 : 0)}%`; +} + +function progressColor(ratio: number): string { + if (ratio >= 0.9) return "red"; + if (ratio >= 0.75) return "#ff9900"; + if (ratio >= 0.5) return "yellow"; + return "green"; +} + +// ── 进度条 ───────────────────────────────────────────────────────────────────── + +function Bar({ value, total, width, color }: { value: number; total: number; width: number; color: string }) { + const ratio = total > 0 ? Math.max(0, Math.min(1, value / total)) : 0; + const filled = Math.round(ratio * width); + return ( + + {"█".repeat(filled)} + {"░".repeat(width - filled)} + + ); +} + +// ── 主组件 ───────────────────────────────────────────────────────────────────── + +export function ContextStatusView({ + status, + onCancel, +}: { + status: ContextStatus; + onCancel: () => void; +}): React.ReactElement { + useInput((input, key) => { + if (key.escape || key.return || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + } + }); + + const effectiveTokens = Math.max(status.estimatedTotal, status.activeTokens); + const ratio = effectiveTokens / Math.max(1, status.compactThreshold); + const remaining = Math.max(0, status.compactThreshold - effectiveTokens); + const color = progressColor(ratio); + + const sortedCategories = useMemo( + () => + [...status.categories].sort((a, b) => + (a.tokens === 0) === (b.tokens === 0) ? b.tokens - a.tokens : a.tokens === 0 ? 1 : -1 + ), + [status.categories] + ); + + const usageRows = useMemo(() => buildUsageRows(status.usagePerModel), [status.usagePerModel]); + + // 列宽 + const labelCol = 32; + const tokensCol = 10; + const percentCol = 7; + const barCol = CONTENT_WIDTH - labelCol - tokensCol - percentCol - 3; + + return ( + + + {/* 头部 */} + + + Context usage + + + {status.model} · {status.messageCount} messages + + + + {/* 进度 */} + + + + + {(ratio * 100).toFixed(1)}% + + + + 0 ? "API-reported" : "estimated"})`} + /> + + + + + {/* 分类表 */} + + +
Category
+
+ Tokens +
+
+ Share +
+
+ Distribution +
+
+ {sortedCategories.map((category) => { + const isZero = category.tokens === 0; + const isOverhead = OVERHEAD_KEYS.has(category.key); + const labelColor = isZero ? undefined : isOverhead ? "magenta" : BRAND_COLOR; + return ( + + + + {isOverhead ? "○ " : "● "} + {category.label} + + + + {formatTokens(category.tokens)} + + + {formatPercent(category.tokens, effectiveTokens)} + + + + + + ); + })} +
+ + {/* 各模型累计用量(紧凑单行) */} + {usageRows.length > 0 ? ( + + + Cumulative API usage + + {usageRows.map((row) => ( + + {row.modelName}: {row.reqs} reqs · {formatTokens(row.input)} in · {formatTokens(row.output)} out + {row.cached > 0 ? ` · ${formatTokens(row.cached)} cached` : ""} + + ))} + + ) : null} + + + Esc / Enter to close + +
+
+ ); +} + +// ── 子工具 ───────────────────────────────────────────────────────────────────── + +function StatLine({ label, value, suffix }: { label: string; value: number; suffix: string }) { + return ( + + + {label}: + + {value.toLocaleString("en-US")} + {suffix} + + ); +} + +function Header({ + width, + right, + paddingLeft, + children, +}: { + width: number; + right?: boolean; + paddingLeft?: number; + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} + +// ── usage 行构造 ────────────────────────────────────────────────────────────── + +type UsageRow = { modelName: string; reqs: number; input: number; output: number; cached: number }; + +function buildUsageRows(usagePerModel: Record | null): UsageRow[] { + if (!usagePerModel) return []; + const rows: UsageRow[] = []; + for (const [modelName, usage] of Object.entries(usagePerModel)) { + const reqs = numberOrZero(usage.total_reqs); + const input = numberOrZero(usage.prompt_tokens); + const output = numberOrZero(usage.completion_tokens); + const cached = + numberOrZero((usage.prompt_tokens_details as { cached_tokens?: unknown } | null)?.cached_tokens) || + numberOrZero(usage.prompt_cache_hit_tokens); + if (reqs || input || output || cached) rows.push({ modelName, reqs, input, output, cached }); + } + rows.sort((a, b) => b.reqs - a.reqs || a.modelName.localeCompare(b.modelName)); + return rows; +} + +function numberOrZero(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index b812a73..6f7f080 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -63,7 +63,7 @@ export type PromptSubmission = { selectedSkills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; - command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "context" | "exit"; }; export type PromptDraft = { @@ -665,6 +665,11 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "context") { + onSubmit({ text: "/context", imageUrls: [], command: "context" }); + resetPromptInput(); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER);