Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
1 change: 1 addition & 0 deletions README-zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力:
| `/init` | 初始化 AGENTS.md 文件 |
| `/skills` | 列出可用 skills |
| `/mcp` | 查看 MCP 服务器状态和可用工具 |
| `/context` | 查看当前会话的上下文窗口用量明细 |
| `/undo` | 将代码和/或对话恢复到之前的状态 |
| `/exit` | 退出(也可用连续 `Ctrl+D`) |

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力:
| `/init` | 初始化 AGENTS.md 文件 |
| `/skills` | 列出可用 skills |
| `/mcp` | 查看 MCP 服务器状态和可用工具 |
| `/context` | 查看当前会话的上下文窗口用量明细 |
| `/undo` | 将代码和/或对话恢复到之前的状态 |
| `/exit` | 退出(也可用连续 `Ctrl+D`) |

Expand Down
147 changes: 147 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, ModelUsage> | null;
messageCount: number;
};

export type SessionMessageRole = "system" | "user" | "assistant" | "tool";

export type MessageMeta = {
Expand Down Expand Up @@ -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<ContextCategoryKey, string> = {
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<ContextCategoryKey, number> = {
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<void> {
await this.mcpManager.reconnect(name, config);
this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions();
Expand Down
125 changes: 125 additions & 0 deletions src/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/tests/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
"continue",
"undo",
"mcp",
"context",
"raw",
"exit",
]);
Expand Down
7 changes: 7 additions & 0 deletions src/ui/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type SlashCommandKind =
| "continue"
| "undo"
| "mcp"
| "context"
| "raw"
| "exit";

Expand Down Expand Up @@ -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",
Expand Down
Loading