From e87f022dd42ab08f5929162a2bd742495e2e01a6 Mon Sep 17 00:00:00 2001
From: dengm
Date: Sat, 16 May 2026 18:22:28 +0800
Subject: [PATCH 01/70] feat: add manual MCP server reconnect with secondary
menu
Replace automatic retry with user-initiated reconnect:
- Failed servers show error details and a [Reconnect] option
- Reconnect reads latest config from disk (no restart needed)
- Single attempt per reconnect, no backoff/retry
---
src/mcp/mcp-client.ts | 34 ++++-
src/mcp/mcp-manager.ts | 266 +++++++++++++++++++++++---------------
src/session.ts | 4 +
src/tests/session.test.ts | 55 ++++++++
src/ui/App.tsx | 9 +-
src/ui/McpStatusList.tsx | 129 ++++++++++++------
6 files changed, 346 insertions(+), 151 deletions(-)
diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts
index 9636732..3651c88 100644
--- a/src/mcp/mcp-client.ts
+++ b/src/mcp/mcp-client.ts
@@ -106,19 +106,24 @@ export class McpClient {
>();
private stderrBuffer = "";
private notificationHandler: McpNotificationHandler | null = null;
+ private disconnectHandler: ((reason: string) => void) | null = null;
+ private intentionallyDisconnected = false;
constructor(
private readonly serverName: string,
private readonly command: string,
private readonly args: string[] = [],
private readonly env?: Record,
- onNotification?: McpNotificationHandler
+ onNotification?: McpNotificationHandler,
+ onDisconnect?: (reason: string) => void
) {
this.notificationHandler = onNotification ?? null;
+ this.disconnectHandler = onDisconnect ?? null;
}
async connect(timeoutMs: number): Promise {
return new Promise((resolve, reject) => {
+ this.intentionallyDisconnected = false;
const childEnv = {
...process.env,
...this.env,
@@ -144,17 +149,35 @@ export class McpClient {
});
}
+ let resolved = false;
+ const safeReject = (err: Error) => {
+ if (!resolved) {
+ resolved = true;
+ reject(err);
+ }
+ };
+
this.process.on("error", (err) => {
- reject(this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`));
+ safeReject(
+ this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)
+ );
});
this.process.on("close", (code) => {
- const error = this.withStderr(`MCP server "${this.serverName}" exited with code ${code}`);
+ const reason = `MCP server "${this.serverName}" exited with code ${code}`;
+ const error = this.withStderr(reason);
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pendingRequests.clear();
+ this.reader?.close();
+ this.reader = null;
+ this.process = null;
+ if (!this.intentionallyDisconnected && this.disconnectHandler) {
+ this.disconnectHandler(reason);
+ }
+ safeReject(error);
});
if (this.process.stderr) {
@@ -263,6 +286,7 @@ export class McpClient {
}
disconnect(): void {
+ this.intentionallyDisconnected = true;
if (this.reader) {
this.reader.close();
this.reader = null;
@@ -273,6 +297,10 @@ export class McpClient {
}
}
+ isConnected(): boolean {
+ return this.process !== null && this.process.exitCode === null;
+ }
+
private sendRequest(method: string, params: Record, timeoutMs = 30_000): Promise {
return new Promise((resolve, reject) => {
const id = this.nextId++;
diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts
index 5a9f553..217e3fc 100644
--- a/src/mcp/mcp-manager.ts
+++ b/src/mcp/mcp-manager.ts
@@ -1,7 +1,9 @@
import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client";
import type { McpServerConfig } from "../settings";
-const MCP_STARTUP_TIMEOUT_MS = 30_000;
+const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT
+ ? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10)
+ : 30_000;
const MCP_CALL_TOOL_TIMEOUT_MS = 60_000;
type McpToolEntry = {
@@ -14,7 +16,7 @@ type McpToolEntry = {
export type McpServerStatus = {
name: string;
- status: "starting" | "ready" | "failed";
+ status: "starting" | "ready" | "failed" | "reconnecting";
connected: boolean;
error?: string;
toolCount: number;
@@ -46,12 +48,10 @@ export class McpManager {
private serverStatuses: McpServerStatus[] = [];
private onToolsListChanged: (() => void) | null = null;
private onStatusChanged: (() => void) | null = null;
+ private serverConfigs: Record = {};
prepare(servers?: Record): void {
if (!servers || Object.keys(servers).length === 0) return;
- // Clear the disposed flag — a re-prepare means we are live again.
- // (disconnect() sets disposed=true to stop a stale initialize() loop,
- // but prepare+initialize must be able to start a new one.)
this.disposed = false;
for (const name of Object.keys(servers)) {
@@ -81,116 +81,175 @@ export class McpManager {
if (!servers || Object.keys(servers).length === 0) return;
- const entries = Object.entries(servers);
+ this.serverConfigs = servers;
this.prepare(servers);
- for (const [name, config] of entries) {
+ for (const [name, config] of Object.entries(servers)) {
if (this.disposed) break;
- let client: McpClient | null = null;
- try {
- client = new McpClient(name, config.command, config.args ?? [], config.env, (method) => {
+ await this.connectServer(name, config);
+ }
+ }
+
+ async reconnect(name: string, config?: McpServerConfig): Promise {
+ if (this.disposed) return;
+ const effectiveConfig = config ?? this.serverConfigs[name];
+ if (!effectiveConfig) return;
+ if (config) {
+ this.serverConfigs[name] = config;
+ }
+
+ this.setStatus({
+ name,
+ status: "reconnecting",
+ connected: false,
+ error: "Reconnecting...",
+ toolCount: 0,
+ tools: [],
+ promptCount: 0,
+ prompts: [],
+ resourceCount: 0,
+ resources: [],
+ });
+
+ await this.connectServer(name, effectiveConfig);
+ }
+
+ private async connectServer(name: string, config: McpServerConfig): Promise {
+ if (this.disposed) return;
+
+ // Clean up stale entries from previous connection attempts
+ this.clients = this.clients.filter((c) => c.isConnected());
+ this.tools = this.tools.filter((t) => t.serverName !== name);
+ this.prompts = this.prompts.filter((p) => p.serverName !== name);
+ this.resources = this.resources.filter((r) => r.serverName !== name);
+
+ let client: McpClient | null = null;
+ try {
+ client = new McpClient(
+ name,
+ config.command,
+ config.args ?? [],
+ config.env,
+ (method) => {
if (method === "notifications/tools/list_changed") {
- this.refreshServerTools(name, client!).catch(() => {
- // swallow refresh errors
- });
+ this.refreshServerTools(name, client!).catch(() => {});
+ }
+ },
+ (reason) => {
+ if (!this.disposed && this.serverConfigs[name]) {
+ this.onServerCrash(name, reason);
}
- });
- await client.connect(MCP_STARTUP_TIMEOUT_MS);
- if (this.disposed) {
- client.disconnect();
- break;
- }
- this.clients.push(client);
-
- // Discover tools
- const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS);
- if (this.disposed) break;
- const toolNamespacedNames: string[] = [];
- for (const tool of serverTools) {
- const namespacedName = `mcp__${name}__${tool.name}`;
- this.tools.push({
- serverName: name,
- originalName: tool.name,
- namespacedName,
- definition: tool,
- client,
- });
- toolNamespacedNames.push(namespacedName);
- }
-
- // Discover prompts
- let serverPrompts: McpPromptDefinition[] = [];
- try {
- serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS);
- } catch {
- // Server may not support prompts — safe to ignore
- }
- if (this.disposed) break;
- const promptNamespacedNames: string[] = [];
- for (const prompt of serverPrompts) {
- const namespacedName = `mcp__${name}__${prompt.name}`;
- this.prompts.push({
- serverName: name,
- namespacedName,
- definition: prompt,
- client,
- });
- promptNamespacedNames.push(namespacedName);
}
+ );
+ await client.connect(MCP_STARTUP_TIMEOUT_MS);
+ if (this.disposed) {
+ client.disconnect();
+ return;
+ }
+ this.clients.push(client);
- // Discover resources
- let serverResources: McpResourceDefinition[] = [];
- try {
- serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS);
- } catch {
- // Server may not support resources — safe to ignore
- }
- if (this.disposed) break;
- const resourceNamespacedNames: string[] = [];
- for (const resource of serverResources) {
- const namespacedName = `mcp__${name}__${resource.name}`;
- this.resources.push({
- serverName: name,
- namespacedName,
- definition: resource,
- client,
- });
- resourceNamespacedNames.push(namespacedName);
- }
+ const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS);
+ if (this.disposed) return;
+ const toolNamespacedNames: string[] = [];
+ for (const tool of serverTools) {
+ const namespacedName = `mcp__${name}__${tool.name}`;
+ this.tools.push({
+ serverName: name,
+ originalName: tool.name,
+ namespacedName,
+ definition: tool,
+ client,
+ });
+ toolNamespacedNames.push(namespacedName);
+ }
- this.setStatus({
- name,
- status: "ready",
- connected: true,
- toolCount: serverTools.length,
- tools: toolNamespacedNames,
- promptCount: serverPrompts.length,
- prompts: promptNamespacedNames,
- resourceCount: serverResources.length,
- resources: resourceNamespacedNames,
+ let serverPrompts: McpPromptDefinition[] = [];
+ try {
+ serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS);
+ } catch {
+ // server may not support prompts
+ }
+ if (this.disposed) return;
+ const promptNamespacedNames: string[] = [];
+ for (const prompt of serverPrompts) {
+ const namespacedName = `mcp__${name}__${prompt.name}`;
+ this.prompts.push({
+ serverName: name,
+ namespacedName,
+ definition: prompt,
+ client,
});
- } catch (err) {
- if (this.disposed) break;
- client?.disconnect();
- const message = err instanceof Error ? err.message : String(err);
- // 不在控制台输出错误日志,避免暴露敏感信息
- // process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`);
- this.setStatus({
- name,
- status: "failed",
- connected: false,
- error: message,
- toolCount: 0,
- tools: [],
- promptCount: 0,
- prompts: [],
- resourceCount: 0,
- resources: [],
+ promptNamespacedNames.push(namespacedName);
+ }
+
+ let serverResources: McpResourceDefinition[] = [];
+ try {
+ serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS);
+ } catch {
+ // server may not support resources
+ }
+ if (this.disposed) return;
+ const resourceNamespacedNames: string[] = [];
+ for (const resource of serverResources) {
+ const namespacedName = `mcp__${name}__${resource.name}`;
+ this.resources.push({
+ serverName: name,
+ namespacedName,
+ definition: resource,
+ client,
});
+ resourceNamespacedNames.push(namespacedName);
}
+
+ this.setStatus({
+ name,
+ status: "ready",
+ connected: true,
+ toolCount: serverTools.length,
+ tools: toolNamespacedNames,
+ promptCount: serverPrompts.length,
+ prompts: promptNamespacedNames,
+ resourceCount: serverResources.length,
+ resources: resourceNamespacedNames,
+ });
+ } catch (err) {
+ client?.disconnect();
+ const message = err instanceof Error ? err.message : String(err);
+ this.setStatus({
+ name,
+ status: "failed",
+ connected: false,
+ error: message,
+ toolCount: 0,
+ tools: [],
+ promptCount: 0,
+ prompts: [],
+ resourceCount: 0,
+ resources: [],
+ });
}
}
+ private onServerCrash(name: string, reason: string): void {
+ if (this.disposed) return;
+ this.clients = this.clients.filter((c) => c.isConnected());
+ this.tools = this.tools.filter((t) => t.serverName !== name);
+ this.prompts = this.prompts.filter((p) => p.serverName !== name);
+ this.resources = this.resources.filter((r) => r.serverName !== name);
+ this.setStatus({
+ name,
+ status: "failed",
+ connected: false,
+ error: reason,
+ toolCount: 0,
+ tools: [],
+ promptCount: 0,
+ prompts: [],
+ resourceCount: 0,
+ resources: [],
+ });
+ }
+
getStatus(): McpServerStatus[] {
const result = [...this.serverStatuses];
const knownNames = new Set(result.map((s) => s.name));
@@ -345,12 +404,12 @@ export class McpManager {
this.resources = [];
this.serverStatuses = [];
this.configuredServerNames = [];
+ this.serverConfigs = {};
this.initialized = false;
}
private async refreshServerTools(serverName: string, client: McpClient): Promise {
const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS);
- // Remove old tool entries for this server
this.tools = this.tools.filter((t) => t.serverName !== serverName);
const toolNamespacedNames: string[] = [];
for (const tool of serverTools) {
@@ -364,13 +423,11 @@ export class McpManager {
});
toolNamespacedNames.push(namespacedName);
}
- // Update status
const existing = this.serverStatuses.find((s) => s.name === serverName);
if (existing) {
existing.toolCount = serverTools.length;
existing.tools = toolNamespacedNames;
}
- // Notify listener
this.onToolsListChanged?.();
}
@@ -390,7 +447,6 @@ export class McpManager {
} else {
this.serverStatuses[index] = status;
}
- // 触发状态变更回调
this.onStatusChanged?.();
}
}
diff --git a/src/session.ts b/src/session.ts
index 095cd3a..9e97f86 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -255,6 +255,10 @@ export class SessionManager {
return this.mcpManager.getStatus();
}
+ async reconnectMcpServer(name: string, config?: McpServerConfig): Promise {
+ await this.mcpManager.reconnect(name, config);
+ }
+
dispose(): void {
this.mcpManager.disconnect();
}
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index 50d016c..8ecb85e 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -1540,6 +1540,61 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () =
assert.equal(session?.failReason, "interrupted");
});
+test("SessionManager marks MCP server as failed on single failed attempt (no auto-retry)", async () => {
+ const workspace = createTempDir("deepcode-mcp-fail-noworkspace-");
+ const serverPath = path.join(workspace, "mcp-server-fail.cjs");
+ fs.writeFileSync(serverPath, "process.exit(7);", "utf8");
+
+ const manager = createSessionManager(workspace, "machine-id-mcp-fail-no");
+ await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } });
+
+ const status = manager.getMcpStatus();
+ assert.equal(status.length, 1);
+ assert.equal(status[0]?.status, "failed");
+ assert.match(status[0]?.error ?? "", /exited with code 7/);
+
+ manager.dispose();
+});
+
+test("SessionManager reconnect succeeds on previously failed server", async () => {
+ const workspace = createTempDir("deepcode-mcp-reconn-ok-workspace-");
+ const serverPath = path.join(workspace, "mcp-server-ok.cjs");
+ fs.writeFileSync(
+ serverPath,
+ `
+const readline = require("readline");
+const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+function send(message) {
+ process.stdout.write(JSON.stringify(message) + "\\n");
+}
+rl.on("line", (line) => {
+ const request = JSON.parse(line);
+ if (!("id" in request)) return;
+ if (request.method === "initialize") {
+ send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: {} } });
+ return;
+ }
+ if (request.method === "tools/list") {
+ send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "ping", inputSchema: { type: "object", properties: {} } }] } });
+ return;
+ }
+ send({ jsonrpc: "2.0", id: request.id, result: { content: [] } });
+});
+`,
+ "utf8"
+ );
+
+ const manager = createSessionManager(workspace, "machine-id-mcp-reconn-ok");
+ await manager.initMcpServers({ fixable: { command: process.execPath, args: [serverPath] } });
+
+ const status = manager.getMcpStatus();
+ assert.equal(status.length, 1);
+ assert.equal(status[0]?.status, "ready");
+ assert.equal(status[0]?.toolCount, 1);
+
+ manager.dispose();
+});
+
function createSessionManager(projectRoot: string, machineId: string): SessionManager {
return new SessionManager({
projectRoot,
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index bafb412..1f12198 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -455,7 +455,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
onCancel={() => setView("chat")}
/>
) : view === "mcp-status" ? (
- setView("chat")} />
+ setView("chat")}
+ onReconnect={(name) => {
+ const latest = resolveCurrentSettings(projectRoot);
+ void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]);
+ }}
+ />
) : shouldShowQuestionPrompt && pendingQuestion && !busy ? (
void;
+ onReconnect: (name: string) => void;
};
-export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement {
+export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React.ReactElement {
const { columns, rows } = useWindowSize();
// 视图模式:server-list(服务器列表) 或 server-detail(服务器详情)
@@ -20,10 +21,10 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement
setViewMode("server-list");
}, []);
- // 进入服务器详情
+ // 进入服务器详情(允许 ready、failed、reconnecting 状态)
const enterDetail = useCallback(() => {
const server = statuses[selectedServerIndex];
- if (server && server.status === "ready") {
+ if (server && (server.status === "ready" || server.status === "failed" || server.status === "reconnecting")) {
setViewMode("server-detail");
}
}, [statuses, selectedServerIndex]);
@@ -59,6 +60,7 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement
server={statuses[selectedServerIndex]}
onBack={goBack}
onCancel={onCancel}
+ onReconnect={onReconnect}
rows={rows}
columns={columns}
/>
@@ -173,6 +175,7 @@ function ServerListView({
const readyCount = statuses.filter((s) => s.status === "ready").length;
const startingCount = statuses.filter((s) => s.status === "starting").length;
+ const reconnectingCount = statuses.filter((s) => s.status === "reconnecting").length;
const failedCount = statuses.filter((s) => s.status === "failed").length;
return (
@@ -198,6 +201,11 @@ function ServerListView({
{startingCount} starting,
+ {reconnectingCount > 0 && (
+
+ {reconnectingCount} reconnecting,
+
+ )}
{failedCount} failed
@@ -257,15 +265,23 @@ function ServerRow({
selected: boolean;
labelColumnWidth: number;
}): React.ReactElement {
- const icon = status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : "●";
- const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow";
+ const icon =
+ status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : status.status === "reconnecting" ? "↻" : "●";
+ const color =
+ status.status === "ready"
+ ? "green"
+ : status.status === "failed"
+ ? "red"
+ : status.status === "reconnecting"
+ ? "#ff9900"
+ : "yellow";
// 加载动画:循环显示 (空) → . → .. → ... → (空) → ...
const [dots, setDots] = React.useState(0);
React.useEffect(() => {
- if (status.status !== "starting") return;
+ if (status.status !== "starting" && status.status !== "reconnecting") return;
const interval = setInterval(() => {
- setDots((d) => (d + 1) % 4); // 0 → 1 → 2 → 3 → 0 ...
+ setDots((d) => (d + 1) % 4);
}, 500);
return () => clearInterval(interval);
}, [status.status]);
@@ -275,7 +291,9 @@ function ServerRow({
? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)`
: status.status === "failed"
? `Failed`
- : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ...
+ : status.status === "reconnecting"
+ ? `Reconnecting${dots > 0 ? ".".repeat(dots) : " "}`
+ : "Starting" + (dots > 0 ? ".".repeat(dots) : " ");
return (
@@ -293,8 +311,10 @@ function ServerRow({
- {/* Error message for failed servers */}
- {status.status === "failed" && status.error ? : null}
+ {/* Error message for failed or reconnecting servers */}
+ {(status.status === "failed" || status.status === "reconnecting") && status.error ? (
+
+ ) : null}
);
}
@@ -304,59 +324,54 @@ function ServerDetailView({
server,
onBack,
onCancel,
+ onReconnect,
rows,
columns,
}: {
server: McpServerStatus;
onBack: () => void;
onCancel: () => void;
+ onReconnect: (name: string) => void;
rows: number;
columns: number;
}): React.ReactElement {
- const [activeIndex, setActiveIndex] = useState(0);
+ const [activeIndex, setActiveIndex] = React.useState(0);
+ const hasReconnect = server.status === "failed";
+ const canScroll = server.status === "ready";
- // 合并所有 items(tools, prompts, resources)
+ // 合并所有 items(tools, prompts, resources)+ Reconnect 选项
const allItems = useMemo(() => {
const items: { type: string; name: string }[] = [];
+ if (hasReconnect) {
+ items.push({ type: "action", name: "Reconnect" });
+ }
server.tools.forEach((tool) => items.push({ type: "tool", name: tool }));
server.prompts.forEach((prompt) => items.push({ type: "prompt", name: prompt }));
server.resources.forEach((resource) => items.push({ type: "resource", name: resource }));
return items;
- }, [server]);
+ }, [server, hasReconnect]);
const totalItems = allItems.length;
const maxVisible = useMemo(() => {
- const reservedLines = 10; // header + title + stats + footer + borders
+ const reservedLines = 12; // header + title + stats + error + footer + borders
const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines);
return Math.max(1, availableLines);
}, [rows]);
- // 使用 ref 跟踪 visibleStart,避免循环依赖
const visibleStartRef = React.useRef(0);
- // 计算可见窗口起始位置:当 activeIndex 超出可见区域时才滚动(类似终端光标行为)
const visibleStart = useMemo(() => {
if (totalItems === 0) return 0;
-
const currentStart = visibleStartRef.current;
let newStart = currentStart;
-
- // 如果 activeIndex 在当前可见窗口之前,滚动到 activeIndex
if (activeIndex < currentStart) {
newStart = activeIndex;
- }
- // 如果 activeIndex 在当前可见窗口之后,滚动到 activeIndex
- else if (activeIndex >= currentStart + maxVisible) {
+ } else if (activeIndex >= currentStart + maxVisible) {
newStart = activeIndex - maxVisible + 1;
}
-
- // 限制在合法范围内
newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible)));
-
- // 更新 ref
visibleStartRef.current = newStart;
-
return newStart;
}, [activeIndex, maxVisible, totalItems]);
@@ -371,11 +386,16 @@ function ServerDetailView({
onBack();
return;
}
- // Space 或 Enter 键返回一级菜单
- if (input === " " || key.return) {
+ if (key.return || input === " ") {
+ if (activeIndex === 0 && hasReconnect) {
+ onReconnect(server.name);
+ onBack();
+ return;
+ }
onBack();
return;
}
+ if (!canScroll && !hasReconnect) return;
if (key.upArrow) {
setActiveIndex((prev) => Math.max(0, prev - 1));
return;
@@ -384,25 +404,33 @@ function ServerDetailView({
setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1));
return;
}
- if (key.pageUp) {
+ if (key.pageUp && canScroll) {
setActiveIndex((prev) => Math.max(0, prev - maxVisible));
return;
}
- if (key.pageDown) {
+ if (key.pageDown && canScroll) {
setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible));
return;
}
- if (key.home) {
+ if (key.home && canScroll) {
setActiveIndex(0);
return;
}
- if (key.end) {
+ if (key.end && canScroll) {
setActiveIndex(totalItems - 1);
}
});
- const icon = "✓";
- const color = "green";
+ const statusIcon =
+ server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●";
+ const statusColor =
+ server.status === "ready"
+ ? "green"
+ : server.status === "failed"
+ ? "red"
+ : server.status === "reconnecting"
+ ? "#ff9900"
+ : "yellow";
return (
{/* Header row */}
- {icon}
+ {statusIcon}
{server.name}
- — Details
+ — {server.status === "ready" ? "Details" : "Status"}
{/* Server info */}
- {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources
+ {server.status === "ready"
+ ? `${server.toolCount} tools, ${server.promptCount} prompts, ${server.resourceCount} resources`
+ : `Status: ${server.status}`}
+ {/* Error for failed/reconnecting */}
+ {server.error && (server.status === "failed" || server.status === "reconnecting") ? (
+
+
+
+ ) : null}
{/* Items list */}
{/* Footer */}
- ↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close
+
+ {hasReconnect
+ ? "Enter to reconnect · Esc back · Ctrl+C close"
+ : canScroll
+ ? "↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close"
+ : "Space/Enter back · Esc back · Ctrl+C close"}
+
@@ -481,13 +523,16 @@ function ServerDetailView({
}
function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement {
- const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦";
+ const isAction = item.type === "action";
+ const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦";
+ const color = isAction && selected ? "#ff9900" : selected ? "#229ac3" : undefined;
return (
+ {selected ? "> " : " "}
{icon}
-
- {item.name}
+
+ {isAction ? `[${item.name}]` : item.name}
);
From 52dafba25903dc70258d7e59dbe86e283a0f091f Mon Sep 17 00:00:00 2001
From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com>
Date: Mon, 18 May 2026 09:50:38 +0800
Subject: [PATCH 02/70] fix: re-apply dynamic modifier parsing for Shift+Enter
after upstream sync
Upstream v0.1.21 reverted PR #70. Re-apply:
- isShiftReturn() / isReturn() dynamic CSI modifier bit parsing
- Kitty progressive enhancement (ESC[>1u) alongside xterm modifyOtherKeys
- Clear input when key.return is true (safety net)
---
src/tests/promptInputKeys.test.ts | 6 ++---
src/ui/prompt/cursor.ts | 4 +--
src/ui/prompt/useTerminalInput.ts | 43 ++++++++++++++++++++++++++++---
3 files changed, 44 insertions(+), 9 deletions(-)
diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts
index 69d2075..8952a3d 100644
--- a/src/tests/promptInputKeys.test.ts
+++ b/src/tests/promptInputKeys.test.ts
@@ -80,7 +80,7 @@ test("parseTerminalInput keeps BS payload for meta+backspace", () => {
test("parseTerminalInput recognizes shifted return sequences", () => {
const { input, key } = parseTerminalInput("\u001B\r");
- assert.equal(input, "\r");
+ assert.equal(input, "");
assert.equal(key.return, true);
assert.equal(key.shift, true);
assert.equal(key.meta, false);
@@ -108,8 +108,8 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => {
});
test("terminal extended key helpers request and restore modifyOtherKeys mode", () => {
- assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m");
- assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m");
+ assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m\u001B[>1u");
+ assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m\u001B[ {
diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts
index 2668470..59b24f2 100644
--- a/src/ui/prompt/cursor.ts
+++ b/src/ui/prompt/cursor.ts
@@ -41,11 +41,11 @@ function disableTerminalFocusReporting(): string {
}
export function enableTerminalExtendedKeys(): string {
- return "\u001B[>4;1m";
+ return "\u001B[>4;1m\u001B[>1u";
}
export function disableTerminalExtendedKeys(): string {
- return "\u001B[>4;0m";
+ return "\u001B[>4;0m\u001B[
Date: Mon, 18 May 2026 10:22:22 +0800
Subject: [PATCH 03/70] fix: refresh mcpToolDefinitions cache after MCP
reconnect
After reconnectMcpServer succeeds, SessionManager's cached
mcpToolDefinitions was stale, causing "Unknown MCP tool" errors
when the model tried to call reconnected tools.
---
src/session.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/session.ts b/src/session.ts
index eddfe5c..0527ba8 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -261,6 +261,7 @@ export class SessionManager {
async reconnectMcpServer(name: string, config?: McpServerConfig): Promise {
await this.mcpManager.reconnect(name, config);
+ this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions();
}
dispose(): void {
From 47d3c21abe3c3582d24e7c1109bdf19e0818c90d Mon Sep 17 00:00:00 2001
From: hcyang
Date: Mon, 18 May 2026 18:13:34 +0800
Subject: [PATCH 04/70] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20/raw=20?=
=?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E5=8F=8A=E7=9B=B8=E5=85=B3?=
=?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=92=8C=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 RawMode 功能,包括 Normal、Lite 和 Raw scrollback 模式
- App 组件中集成 RawMode 上下文及切换逻辑,支持在 Raw 模式下直接向 stdout 渲染消息
- 增加 RawModeExitPrompt 组件,支持按 ESC 退出原始模式
- 新增 RawModelDropdown 组件,提供原始模式选择下拉菜单
- 在 PromptInput 中集成原始模式选择交互及状态管理
- 调整消息视图实现,拆分 MessageView 到 compoments 目录,支持根据 RawMode 呈现不同内容
- 新建 AppContainer 组件,包装 App 并提供版本上下文和 RawModeProvider
- 修改 SlashCommand 体系,支持内置 /raw 命令及对应测试覆盖
- 更新 cli 入口,使用 AppContainer 替换直接渲染 App,传递版本信息
- 移除旧 MessageView 文件,重构消息渲染逻辑
- 优化 SlashCommandMenu 显示,支持命令参数提示显示
- 更新相关测试,支持原始模式功能验证
---
src/cli.tsx | 4 +-
src/tests/messageView.test.ts | 51 +--
src/tests/slashCommands.test.ts | 9 +-
src/ui/App.tsx | 69 +++-
src/ui/AppContainer.tsx | 21 ++
src/ui/MessageView.tsx | 355 ------------------
src/ui/PromptInput.tsx | 27 +-
src/ui/SlashCommandMenu.tsx | 5 +-
src/ui/WelcomeScreen.tsx | 11 +-
src/ui/compoments/MessageView/index.tsx | 183 +++++++++
.../{ => compoments/MessageView}/markdown.ts | 0
src/ui/compoments/MessageView/types.ts | 19 +
src/ui/compoments/MessageView/utils.ts | 255 +++++++++++++
src/ui/compoments/RawModeExitPrompt/index.tsx | 15 +
src/ui/compoments/RawModelDropdown/index.tsx | 55 +++
src/ui/compoments/index.ts | 3 +
src/ui/contexts/AppContext.tsx | 15 +
src/ui/contexts/RawModeContext.tsx | 40 ++
src/ui/contexts/index.ts | 3 +
src/ui/index.ts | 5 +-
src/ui/slashCommands.ts | 22 +-
21 files changed, 750 insertions(+), 417 deletions(-)
create mode 100644 src/ui/AppContainer.tsx
delete mode 100644 src/ui/MessageView.tsx
create mode 100644 src/ui/compoments/MessageView/index.tsx
rename src/ui/{ => compoments/MessageView}/markdown.ts (100%)
create mode 100644 src/ui/compoments/MessageView/types.ts
create mode 100644 src/ui/compoments/MessageView/utils.ts
create mode 100644 src/ui/compoments/RawModeExitPrompt/index.tsx
create mode 100644 src/ui/compoments/RawModelDropdown/index.tsx
create mode 100644 src/ui/compoments/index.ts
create mode 100644 src/ui/contexts/AppContext.tsx
create mode 100644 src/ui/contexts/RawModeContext.tsx
create mode 100644 src/ui/contexts/index.ts
diff --git a/src/cli.tsx b/src/cli.tsx
index 435499a..e8e8659 100644
--- a/src/cli.tsx
+++ b/src/cli.tsx
@@ -1,8 +1,8 @@
import React from "react";
import { render } from "ink";
-import { App } from "./ui";
import { setShellIfWindows } from "./common/shell-utils";
import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck";
+import AppContainer from "./ui/AppContainer";
const args = process.argv.slice(2);
const packageInfo = readPackageInfo();
@@ -81,7 +81,7 @@ async function main(): Promise {
const appInitialPrompt = initialPrompt;
initialPrompt = undefined;
const inkInstance = render(
- {
const lines = parseDiffPreview(
@@ -25,45 +26,29 @@ test("parseDiffPreview keeps nonstandard context lines", () => {
test("MessageView summarizes thinking content across lines", () => {
assert.equal(
- getThinkingParams({
- content: "Plan:\n\nInspect the code and update tests",
- }),
+ buildThinkingSummary("Plan:\n\nInspect the code and update tests", null, RawMode.Lite),
"Plan: Inspect the code and update tests"
);
});
-test("MessageView removes a trailing colon from thinking summaries", () => {
- assert.equal(getThinkingParams({ content: "Planning:" }), "Planning");
+test("MessageView removes a trailing colon from thinking summary", () => {
+ assert.equal(buildThinkingSummary("Planning:", null, RawMode.Lite), "Planning");
});
-test("MessageView falls back to a reasoning placeholder for hidden reasoning content", () => {
+test("MessageView falls back to a reasoning placeholder for hidden reasoning content in Lite mode", () => {
assert.equal(
- getThinkingParams({
- content: "",
- messageParams: { reasoning_content: "hidden chain of thought" },
- }),
+ buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Lite),
"(reasoning...)"
);
});
-function getThinkingParams(overrides: Partial): string {
- const view = MessageView({ message: buildAssistantMessage(overrides) }) as any;
- return view.props.children.props.params;
-}
-
-function buildAssistantMessage(overrides: Partial): SessionMessage {
- return {
- id: "message-1",
- sessionId: "session-1",
- role: "assistant",
- content: "",
- contentParams: null,
- messageParams: null,
- compacted: false,
- visible: true,
- createTime: "2026-01-01T00:00:00.000Z",
- updateTime: "2026-01-01T00:00:00.000Z",
- meta: { asThinking: true },
- ...overrides,
- };
-}
+test("MessageView shows full reasoning content in Normal/Raw mode", () => {
+ assert.equal(
+ buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.None),
+ "hidden chain of thought"
+ );
+ assert.equal(
+ buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Raw),
+ "hidden chain of thought"
+ );
+});
diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts
index bba5244..34b48d0 100644
--- a/src/tests/slashCommands.test.ts
+++ b/src/tests/slashCommands.test.ts
@@ -19,7 +19,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
assert.equal(items[0].kind, "skill");
assert.equal(items[0].name, "skill-writer");
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
- assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "exit"]);
+ assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "raw", "exit"]);
});
test("filterSlashCommands matches partial prefixes", () => {
@@ -80,6 +80,13 @@ test("findExactSlashCommand returns built-in /model", () => {
assert.equal(item?.kind, "model");
});
+test("findExactSlashCommand returns built-in /raw", () => {
+ const items = buildSlashCommands(skills);
+ const item = findExactSlashCommand(items, "/raw");
+ assert.ok(item);
+ assert.equal(item?.kind, "raw");
+});
+
test("findExactSlashCommand returns the matching skill", () => {
const items = buildSlashCommands(skills);
const item = findExactSlashCommand(items, "/code-review");
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index e56111f..1c9bac4 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -6,10 +6,10 @@ import * as os from "os";
import * as path from "path";
import OpenAI from "openai";
import {
- SessionManager,
type LlmStreamProgress,
type MessageMeta,
type SessionEntry,
+ SessionManager,
type SessionMessage,
type SessionStatus,
type SkillInfo,
@@ -17,13 +17,13 @@ import {
} from "../session";
import {
applyModelConfigSelection,
- resolveSettingsSources,
type DeepcodingSettings,
type ModelConfigSelection,
type ResolvedDeepcodingSettings,
+ resolveSettingsSources,
} from "../settings";
import { PromptInput, type PromptSubmission } from "./PromptInput";
-import { MessageView } from "./MessageView";
+import { MessageView, RawModeExitPrompt } from "./compoments";
import { SessionList } from "./SessionList";
import { buildLoadingText } from "./loadingText";
import { findExpandedThinkingId } from "./thinkingState";
@@ -32,11 +32,13 @@ import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
import { McpStatusList } from "./McpStatusList";
import { ProcessStdoutView } from "./ProcessStdoutView";
import {
+ type AskUserQuestionAnswers,
findPendingAskUserQuestion,
formatAskUserQuestionAnswers,
- type AskUserQuestionAnswers,
} from "./askUserQuestion";
import { buildExitSummaryText } from "./exitSummary";
+import { RawMode, useRawModeContext } from "./contexts";
+import { renderMessageToStdout } from "./compoments/MessageView/utils";
const DEFAULT_MODEL = "deepseek-v4-pro";
const DEFAULT_BASE_URL = "https://api.deepseek.com";
@@ -45,12 +47,11 @@ type View = "chat" | "session-list" | "mcp-status";
type AppProps = {
projectRoot: string;
- version?: string;
initialPrompt?: string;
onRestart?: () => void;
};
-export function App({ projectRoot, version = "", initialPrompt, onRestart }: AppProps): React.ReactElement {
+export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement {
const { exit } = useApp();
const { stdout, write } = useStdout();
const { columns } = useWindowSize();
@@ -75,6 +76,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
const [showProcessStdout, setShowProcessStdout] = useState(false);
const processStdoutRef = useRef
Deep Code CLI
-[English](./README_en.md) · 中文
+[English](README-en.md) · 中文
From 3fef0fc5137af49f218237fa0e919159fb231122 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 10:09:38 +0800
Subject: [PATCH 12/70] feat(notify): pass STATUS, FAIL_REASON, BODY as env
vars to notify hook
- Add NotifyContext type with status, failReason, body fields
- buildNotifyEnv injects STATUS, FAIL_REASON, BODY when provided
- maybeNotifyTaskCompletion extracts last assistant message as BODY
- launchNotifyScript accepts optional context parameter
- Add unit tests for new context env var injection
- Update docs with env variable table and iTerm2/macOS notify examples
---
docs/configuration.md | 34 ++++++
docs/configuration_en.md | 34 ++++++
src/common/notify.ts | 38 ++++++-
src/session.ts | 18 +++-
src/tests/session.test.ts | 144 ++++++++++++++++++++++++++
src/tests/settings-and-notify.test.ts | 65 +++++++++++-
6 files changed, 324 insertions(+), 9 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index f8e52c3..45aaab0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -67,12 +67,46 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。
+通知脚本执行时,会通过环境变量注入以下上下文信息:
+
+| 环境变量 | 说明 |
+|----------|------|
+| `DURATION` | 会话耗时,单位秒(整数) |
+| `STATUS` | 会话状态:`"completed"` 或 `"failed"` |
+| `FAIL_REASON` | 失败原因(仅失败时设置) |
+| `BODY` | 最后一条 AI 助手回复的文本内容 |
+| `TITLE` | 会话标题(对应 resume 列表中的标题) |
+
```json
{
"notify": "/path/to/slack-notify.sh"
}
```
+**iTerm2 终端通知示例**:
+
+如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`):
+
+```bash
+#!/bin/bash
+# iTerm2 OSC 9 通知
+echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+**macOS 系统通知示例**:
+
+```bash
+#!/bin/bash
+# macOS 系统通知
+osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
+```
+
#### `webSearchTool` — 自定义联网搜索
Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径:
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 369f8e4..606fcab 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -67,12 +67,46 @@ When thinking mode is enabled, controls the depth of the model’s reasoning:
Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message).
+The following context is injected as environment variables when the notify script runs:
+
+| Variable | Description |
+|----------|-------------|
+| `DURATION` | Session duration in seconds (integer) |
+| `STATUS` | Session status: `"completed"` or `"failed"` |
+| `FAIL_REASON` | Failure reason (only set on failure) |
+| `BODY` | The text content of the last AI assistant reply |
+| `TITLE` | Session title (matches the resume list title) |
+
```json
{
"notify": "/path/to/slack-notify.sh"
}
```
+**iTerm2 Notification Example**:
+
+On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`):
+
+```bash
+#!/bin/bash
+# iTerm2 OSC 9 notification
+echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+**macOS System Notification Example**:
+
+```bash
+#!/bin/bash
+# macOS system notification
+osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
+```
+
#### `webSearchTool` — Custom Web Search
Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script:
diff --git a/src/common/notify.ts b/src/common/notify.ts
index 8878c50..d1b541b 100644
--- a/src/common/notify.ts
+++ b/src/common/notify.ts
@@ -16,11 +16,40 @@ export function formatDurationSeconds(durationMs: number): string {
return String(Math.floor(safeMs / 1000));
}
-export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
- return {
+export type NotifyContext = {
+ status?: string;
+ failReason?: string;
+ body?: string;
+ title?: string;
+};
+
+export function buildNotifyEnv(
+ durationMs: number,
+ baseEnv: NodeJS.ProcessEnv = process.env,
+ context: NotifyContext = {}
+): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = {
...baseEnv,
DURATION: formatDurationSeconds(durationMs),
};
+ delete env.STATUS;
+ delete env.FAIL_REASON;
+ delete env.BODY;
+ delete env.TITLE;
+
+ if (context.status) {
+ env.STATUS = context.status;
+ }
+ if (context.failReason) {
+ env.FAIL_REASON = context.failReason;
+ }
+ if (context.body) {
+ env.BODY = context.body;
+ }
+ if (context.title) {
+ env.TITLE = context.title;
+ }
+ return env;
}
export function launchNotifyScript(
@@ -28,7 +57,8 @@ export function launchNotifyScript(
durationMs: number,
workingDirectory?: string,
spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn,
- configuredEnv: Record = {}
+ configuredEnv: Record = {},
+ context: NotifyContext = {}
): void {
const commandPath = notifyPath?.trim();
if (!commandPath) {
@@ -38,7 +68,7 @@ export function launchNotifyScript(
const options = {
cwd: workingDirectory,
detached: process.platform !== "win32",
- env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }),
+ env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context),
stdio: "ignore" as const,
};
diff --git a/src/session.ts b/src/session.ts
index 96a9adb..3a6e13b 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -2124,7 +2124,23 @@ ${skillMd}
return;
}
- launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv);
+ // Find the last assistant message body for the BODY env variable.
+ let body: string | undefined;
+ const messages = this.listSessionMessages(sessionId);
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg && msg.role === "assistant" && msg.content) {
+ body = msg.content;
+ break;
+ }
+ }
+
+ launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, {
+ status: session.status,
+ failReason: session.failReason ?? undefined,
+ body,
+ title: session.summary ?? undefined,
+ });
}
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index b7eadae..d079949 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -783,6 +783,68 @@ test("reporting a new prompt does not warn when the background request fails", a
assert.deepEqual(warnings, []);
});
+test(
+ "SessionManager notifies successful completion with session context",
+ { skip: process.platform === "win32" },
+ async () => {
+ const workspace = createTempDir("deepcode-notify-success-workspace-");
+ const home = createTempDir("deepcode-notify-success-home-");
+ setHomeDir(home);
+
+ const notifyOutput = path.join(workspace, "notify.jsonl");
+ const notifyScript = createNotifyRecorderScript(workspace);
+ const manager = createNotifyingSessionManager(
+ workspace,
+ [createChatResponse("final answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })],
+ notifyScript,
+ notifyOutput
+ );
+
+ await manager.createSession({ text: "notify success" });
+
+ const records = await waitForNotifyRecords(notifyOutput, 1);
+ assert.equal(records[0]?.STATUS, "completed");
+ assert.equal(records[0]?.FAIL_REASON, null);
+ assert.equal(records[0]?.BODY, "final answer");
+ assert.equal(records[0]?.TITLE, "notify success");
+ assert.match(String(records[0]?.DURATION), /^\d+$/);
+ }
+);
+
+test(
+ "SessionManager notifies failed completion with failure context",
+ { skip: process.platform === "win32" },
+ async () => {
+ const workspace = createTempDir("deepcode-notify-failure-workspace-");
+ const home = createTempDir("deepcode-notify-failure-home-");
+ setHomeDir(home);
+
+ const notifyOutput = path.join(workspace, "notify.jsonl");
+ const notifyScript = createNotifyRecorderScript(workspace);
+ const manager = createNotifyingSessionManager(
+ workspace,
+ [
+ createChatResponse("first answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ new Error("second request failed"),
+ ],
+ notifyScript,
+ notifyOutput
+ );
+
+ const sessionId = await manager.createSession({ text: "notify failure" });
+ await waitForNotifyRecords(notifyOutput, 1);
+ await manager.replySession(sessionId, { text: "second prompt" });
+
+ const records = await waitForNotifyRecords(notifyOutput, 2);
+ const failedRecord = records[1];
+ assert.equal(failedRecord?.STATUS, "failed");
+ assert.equal(failedRecord?.FAIL_REASON, "second request failed");
+ assert.equal(failedRecord?.BODY, "first answer");
+ assert.notEqual(failedRecord?.BODY, "stale-body");
+ assert.equal(failedRecord?.TITLE, "notify failure");
+ }
+);
+
test("replySession continues without appending /continue as a user message", async () => {
const workspace = createTempDir("deepcode-continue-workspace-");
const home = createTempDir("deepcode-continue-home-");
@@ -1657,6 +1719,49 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa
});
}
+function createNotifyingSessionManager(
+ projectRoot: string,
+ responses: unknown[],
+ notifyPath: string,
+ notifyOutput: string
+): SessionManager {
+ const client = {
+ chat: {
+ completions: {
+ create: async () => {
+ const response = responses.shift();
+ assert.ok(response, "expected a queued chat response");
+ if (response instanceof Error) {
+ throw response;
+ }
+ return response;
+ },
+ },
+ },
+ };
+
+ return new SessionManager({
+ projectRoot,
+ createOpenAIClient: () => ({
+ client: client as any,
+ model: "test-model",
+ baseURL: "https://api.deepseek.com",
+ thinkingEnabled: false,
+ notify: notifyPath,
+ env: {
+ NOTIFY_OUTPUT: notifyOutput,
+ STATUS: "stale-status",
+ FAIL_REASON: "stale-failure",
+ BODY: "stale-body",
+ TITLE: "stale-title",
+ },
+ }),
+ getResolvedSettings: () => ({ model: "test-model" }),
+ renderMarkdown: (text) => text,
+ onAssistantMessage: () => {},
+ });
+}
+
function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager {
const client = {
chat: {
@@ -1740,6 +1845,45 @@ function createTempDir(prefix: string): string {
return dir;
}
+function createNotifyRecorderScript(dir: string): string {
+ const scriptPath = path.join(dir, "notify-recorder.cjs");
+ fs.writeFileSync(
+ scriptPath,
+ `#!/usr/bin/env node
+const fs = require("fs");
+const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"];
+const record = {};
+for (const key of keys) {
+ record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null;
+}
+fs.appendFileSync(process.env.NOTIFY_OUTPUT, JSON.stringify(record) + "\\n", "utf8");
+`,
+ "utf8"
+ );
+ fs.chmodSync(scriptPath, 0o755);
+ return scriptPath;
+}
+
+async function waitForNotifyRecords(
+ outputPath: string,
+ expectedCount: number
+): Promise>> {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (fs.existsSync(outputPath)) {
+ const records = fs
+ .readFileSync(outputPath, "utf8")
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as Record);
+ if (records.length >= expectedCount) {
+ return records;
+ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ }
+ assert.fail(`expected ${expectedCount} notify records in ${outputPath}`);
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts
index 6990288..202f849 100644
--- a/src/tests/settings-and-notify.test.ts
+++ b/src/tests/settings-and-notify.test.ts
@@ -1,6 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify";
+import {
+ buildNotifyEnv,
+ formatDurationSeconds,
+ launchNotifyScript,
+ type NotifyContext,
+ type NotifySpawn,
+} from "../common/notify";
import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings";
const TEST_PROCESS_ENV = {};
@@ -358,14 +364,52 @@ test("formatDurationSeconds preserves sub-second precision and trims trailing ze
assert.equal(formatDurationSeconds(4000), "4");
});
-test("buildNotifyEnv injects DURATION", () => {
+test("buildNotifyEnv injects DURATION without context", () => {
const env = buildNotifyEnv(2750, { HOME: "/tmp/home" });
assert.equal(env.HOME, "/tmp/home");
assert.equal(env.DURATION, "2");
+ assert.equal(env.STATUS, undefined);
+ assert.equal(env.FAIL_REASON, undefined);
+ assert.equal(env.BODY, undefined);
+ assert.equal(env.TITLE, undefined);
+});
+
+test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => {
+ const context: NotifyContext = {
+ status: "failed",
+ failReason: "API key not found",
+ body: "Hello, this is the last assistant message.",
+ title: "Fix login bug",
+ };
+ const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context);
+ assert.equal(env.HOME, "/tmp/home");
+ assert.equal(env.DURATION, "5");
+ assert.equal(env.STATUS, "failed");
+ assert.equal(env.FAIL_REASON, "API key not found");
+ assert.equal(env.BODY, "Hello, this is the last assistant message.");
+ assert.equal(env.TITLE, "Fix login bug");
+});
+
+test("buildNotifyEnv omits optional context fields when not provided", () => {
+ const env = buildNotifyEnv(
+ 1000,
+ {
+ HOME: "/tmp/home",
+ STATUS: "stale-status",
+ FAIL_REASON: "stale-failure",
+ BODY: "stale-body",
+ TITLE: "stale-title",
+ },
+ { status: "completed" }
+ );
+ assert.equal(env.STATUS, "completed");
+ assert.equal(env.FAIL_REASON, undefined);
+ assert.equal(env.BODY, undefined);
+ assert.equal(env.TITLE, undefined);
});
test(
- "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts",
+ "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts",
{ skip: process.platform === "win32" },
() => {
const calls: Array<{
@@ -390,7 +434,13 @@ test(
};
};
- launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" });
+ const context: NotifyContext = {
+ status: "completed",
+ body: "Task finished successfully.",
+ title: "Fix login bug",
+ };
+
+ launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }, context);
assert.equal(calls.length, 2);
assert.equal(calls[0]?.command, "/tmp/notify.sh");
@@ -398,9 +448,16 @@ test(
assert.equal(calls[0]?.options.cwd, "/tmp/project");
assert.equal(calls[0]?.options.env?.DURATION, "2");
assert.equal(calls[0]?.options.env?.WEBHOOK, "configured");
+ assert.equal(calls[0]?.options.env?.STATUS, "completed");
+ assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined);
+ assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully.");
+ assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug");
assert.equal(calls[1]?.command, "/bin/sh");
assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]);
assert.equal(calls[1]?.options.cwd, "/tmp/project");
assert.equal(calls[1]?.options.env?.DURATION, "2");
+ assert.equal(calls[1]?.options.env?.STATUS, "completed");
+ assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully.");
+ assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug");
}
);
From a3ff70e82d548a8c1273ea377844f078cbd0ae00 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 10:43:55 +0800
Subject: [PATCH 13/70] docs(notify): add Windows Terminal, Linux, and msg
popup notification examples; add edge-case tests
- Expand OSC 9 example to cover both iTerm2 and Windows Terminal
- Add .bat example for Windows Terminal users
- Add Linux notify-send example
- Add Windows msg popup notification example
- Add tests for empty-string rejection and special character preservation
---
docs/configuration.md | 32 +++++++++++++++++++++++----
docs/configuration_en.md | 32 +++++++++++++++++++++++----
src/tests/settings-and-notify.test.ts | 27 ++++++++++++++++++++++
3 files changed, 83 insertions(+), 8 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 45aaab0..7c2880c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,14 +83,14 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
}
```
-**iTerm2 终端通知示例**:
+**终端内通知示例(支持 iTerm2 / Windows Terminal)**:
-如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`):
+如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。创建以下脚本(如 `~/.deepcode/notify.sh`):
```bash
#!/bin/bash
-# iTerm2 OSC 9 通知
-echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+# iTerm2 / Windows Terminal OSC 9 通知
+printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
```
```json
@@ -99,6 +99,14 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
}
```
+Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本:
+
+```batch
+@echo off
+REM Windows Terminal OSC 9 通知
+echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
+```
+
**macOS 系统通知示例**:
```bash
@@ -107,6 +115,22 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
```
+**Linux 系统通知示例**(需安装 `libnotify-bin`):
+
+```bash
+#!/bin/bash
+# Linux notify-send 通知
+notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s"
+```
+
+**Windows msg 弹窗通知示例**:
+
+```batch
+@echo off
+REM Windows msg 弹窗通知
+msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
+```
+
#### `webSearchTool` — 自定义联网搜索
Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径:
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 606fcab..5d931f4 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -83,14 +83,14 @@ The following context is injected as environment variables when the notify scrip
}
```
-**iTerm2 Notification Example**:
+**Terminal Notification Example (iTerm2 / Windows Terminal)**:
-On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`):
+On iTerm2 or Windows Terminal you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. Create a script (e.g., `~/.deepcode/notify.sh`):
```bash
#!/bin/bash
-# iTerm2 OSC 9 notification
-echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+# iTerm2 / Windows Terminal OSC 9 notification
+printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
```
```json
@@ -99,6 +99,14 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
}
```
+Windows users on Git Bash can use the same script; alternatively create a `.bat` script:
+
+```batch
+@echo off
+REM Windows Terminal OSC 9 notification
+echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
+```
+
**macOS System Notification Example**:
```bash
@@ -107,6 +115,22 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
```
+**Linux System Notification Example** (requires `libnotify-bin`):
+
+```bash
+#!/bin/bash
+# Linux notify-send notification
+notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s"
+```
+
+**Windows msg Popup Notification Example**:
+
+```batch
+@echo off
+REM Windows msg popup notification
+msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
+```
+
#### `webSearchTool` — Custom Web Search
Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script:
diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts
index 202f849..1707aff 100644
--- a/src/tests/settings-and-notify.test.ts
+++ b/src/tests/settings-and-notify.test.ts
@@ -408,6 +408,33 @@ test("buildNotifyEnv omits optional context fields when not provided", () => {
assert.equal(env.TITLE, undefined);
});
+test("buildNotifyEnv ignores empty strings in context", () => {
+ const env = buildNotifyEnv(
+ 1000,
+ { HOME: "/tmp/home" },
+ {
+ status: "",
+ failReason: "",
+ body: "",
+ title: "",
+ }
+ );
+ assert.equal(env.STATUS, undefined);
+ assert.equal(env.FAIL_REASON, undefined);
+ assert.equal(env.BODY, undefined);
+ assert.equal(env.TITLE, undefined);
+});
+
+test("buildNotifyEnv preserves special characters in body and title", () => {
+ const context: NotifyContext = {
+ body: 'Line 1\nLine 2\tindented "quoted"',
+ title: "Fix: login & signup (urgent)",
+ };
+ const env = buildNotifyEnv(1000, {}, context);
+ assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"');
+ assert.equal(env.TITLE, "Fix: login & signup (urgent)");
+});
+
test(
"launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts",
{ skip: process.platform === "win32" },
From 479606f6a7087398302334996e95cb8eb2d841b3 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 13:33:28 +0800
Subject: [PATCH 14/70] docs(notify): replace terminal notification examples
with Feishu webhook example
- Remove iTerm2/Windows Terminal OSC 9, macOS osascript, Linux notify-send, and Windows msg examples (OSC 9 is not compatible with current spawn+stdio:ignore architecture)
- Add Feishu (Lark) webhook notification example in both Chinese and English docs
- Keep the env variable table (DURATION, STATUS, FAIL_REASON, BODY, TITLE) unchanged
---
docs/configuration.md | 62 ++++++++++++++--------------------------
docs/configuration_en.md | 62 ++++++++++++++--------------------------
2 files changed, 44 insertions(+), 80 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 7c2880c..b05a44f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,53 +83,35 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
}
```
-**终端内通知示例(支持 iTerm2 / Windows Terminal)**:
+**飞书 Webhook 通知示例**:
-如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。创建以下脚本(如 `~/.deepcode/notify.sh`):
+`node` 构建 JSON(自动转义特殊字符),`curl` 发送:
```bash
#!/bin/bash
-# iTerm2 / Windows Terminal OSC 9 通知
-printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
-```
-
-```json
-{
- "notify": "/Users/you/.deepcode/notify.sh"
-}
-```
-
-Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本:
-
-```batch
-@echo off
-REM Windows Terminal OSC 9 通知
-echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
-```
-
-**macOS 系统通知示例**:
-
-```bash
-#!/bin/bash
-# macOS 系统通知
-osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
-```
-
-**Linux 系统通知示例**(需安装 `libnotify-bin`):
+WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
+
+STATUS="${STATUS:-completed}"
+TITLE="${TITLE:-Untitled}"
+DURATION="${DURATION:-0}"
+BODY="${BODY:-(no output)}"
+
+PAYLOAD=$(node -e "
+process.stdout.write(JSON.stringify({
+ msg_type: 'interactive',
+ card: {
+ header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
+ elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
+ }
+}))
+")
-```bash
-#!/bin/bash
-# Linux notify-send 通知
-notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s"
+curl -s -X POST "$WEBHOOK_URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
```
-**Windows msg 弹窗通知示例**:
-
-```batch
-@echo off
-REM Windows msg 弹窗通知
-msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
-```
+将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。更多变量参考上表。同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。
#### `webSearchTool` — 自定义联网搜索
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 5d931f4..4f2f94d 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -83,53 +83,35 @@ The following context is injected as environment variables when the notify scrip
}
```
-**Terminal Notification Example (iTerm2 / Windows Terminal)**:
+**Feishu (Lark) Webhook Notification Example**:
-On iTerm2 or Windows Terminal you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. Create a script (e.g., `~/.deepcode/notify.sh`):
+`node` builds the JSON (auto-escapes special characters), `curl` sends it:
```bash
#!/bin/bash
-# iTerm2 / Windows Terminal OSC 9 notification
-printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
-```
-
-```json
-{
- "notify": "/Users/you/.deepcode/notify.sh"
-}
-```
-
-Windows users on Git Bash can use the same script; alternatively create a `.bat` script:
-
-```batch
-@echo off
-REM Windows Terminal OSC 9 notification
-echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
-```
-
-**macOS System Notification Example**:
-
-```bash
-#!/bin/bash
-# macOS system notification
-osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
-```
-
-**Linux System Notification Example** (requires `libnotify-bin`):
+WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
+
+STATUS="${STATUS:-completed}"
+TITLE="${TITLE:-Untitled}"
+DURATION="${DURATION:-0}"
+BODY="${BODY:-(no output)}"
+
+PAYLOAD=$(node -e "
+process.stdout.write(JSON.stringify({
+ msg_type: 'interactive',
+ card: {
+ header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
+ elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
+ }
+}))
+")
-```bash
-#!/bin/bash
-# Linux notify-send notification
-notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s"
+curl -s -X POST "$WEBHOOK_URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
```
-**Windows msg Popup Notification Example**:
-
-```batch
-@echo off
-REM Windows msg popup notification
-msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
-```
+Replace `WEBHOOK_URL` with your Feishu bot webhook URL. See the table above for all available variables. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format.
#### `webSearchTool` — Custom Web Search
From 7e5eeda26829b14eb3ed503b550db06c1145acf6 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Tue, 19 May 2026 15:05:00 +0800
Subject: [PATCH 15/70] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20raw=20?=
=?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E6=B6=88=E6=81=AF=E7=9B=B4=E6=8E=A5?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 Raw 模式下,使用 process.stdout.write 直接输出所有可见消息
- 清屏并重置光标位置,避免 Ink 组件干扰
- 显示提示信息,指导用户按 ESC 退出 raw 模式
- 优化终端尺寸变化时的重绘逻辑
- 更新依赖,确保 raw 模式变动触发重新渲染
---
src/ui/App.tsx | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 9189df6..e39fd03 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -434,8 +434,31 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
}
lastRenderedColumnsRef.current = stableColumns;
+ if (mode === RawMode.Raw) {
+ // In raw mode, re-render all messages directly to stdout at the new width.
+ // Use process.stdout.write instead of writeRef to avoid Ink interference.
+ process.stdout.write("\u001B[2J\u001B[3J\u001B[H");
+ const activeSessionId = sessionManager.getActiveSessionId();
+ const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : [];
+ for (const msg of allMessages) {
+ process.stdout.write("\n");
+ process.stdout.write(renderMessageToStdout(msg, mode) + "\n\n");
+ }
+ if (allMessages.length > 0) {
+ process.stdout.write("\n\n");
+ process.stdout.write(chalk.dim("Press ESC to exit raw mode"));
+ } else {
+ process.stdout.write("\n");
+ process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)"));
+ process.stdout.write("\n\n");
+ process.stdout.write(chalk.dim("Press ESC to exit raw mode"));
+ }
+ return;
+ }
+
// Force full redraw on terminal resize to avoid stale wrapped rows.
writeRef.current("\u001B[2J\u001B[H");
+
setMessages([]);
setShowWelcome(false);
setWelcomeNonce((n) => n + 1);
@@ -447,7 +470,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
setMessages(nextMessages);
setShowWelcome(true);
}, 0);
- }, [busy, sessionManager, stableColumns, stdout]);
+ }, [busy, mode, sessionManager, stableColumns, stdout]);
+
const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]);
const promptHistory = useMemo(() => {
return messages
From faf10c3e087d214bf863e9df14040176e30de821 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Tue, 19 May 2026 15:12:08 +0800
Subject: [PATCH 16/70] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=AE=BD=E5=BA=A6=E7=9B=B8=E5=85=B3=E7=9A=84?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E5=BC=95=E7=94=A8=E7=AE=A1=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 合并并调整了关于窗口宽度columns的使用,去除了stableColumns状态
- 引用lastRenderedColumnsRef改为直接使用columns,避免延迟更新
- 将多个相关的useRef(writeRef、rawModeRef、messagesRef、processStdoutRef)移至同一位置声明
- 调整useEffect依赖项,改为监听columns代替stableColumns
- 优化RawMode下消息重绘逻辑,确保宽度变化时重新渲染
- 统一了screenWidth的计算逻辑,简化代码结构
---
src/ui/App.tsx | 30 ++++++++++++------------------
1 file changed, 12 insertions(+), 18 deletions(-)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index e39fd03..582abaf 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -55,7 +55,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
const { exit } = useApp();
const { stdout, write } = useStdout();
const { columns } = useWindowSize();
+ const { mode, setMode } = useRawModeContext();
const initialPromptSubmittedRef = useRef(false);
+ const processStdoutRef = useRef