From fb38560830a55c217122ebff5ee42836523d7c3b Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 14:23:04 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(mcp):=20=E9=9B=86=E6=88=90=E5=B9=B6?= =?UTF-8?q?=E5=B1=95=E7=A4=BAMCP=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=92=8C=E8=B5=84=E6=BA=90=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增McpStatusList组件,实现MCP服务器状态、工具、提示和资源的可视化展示 - 修改App组件,支持切换视图显示MCP状态列表 - MCP客户端增加对prompts和resources的分页列表及读取功能 - MCP管理器扩展,支持发现和管理prompts及resources,及工具列表变化的事件通知 - 优化MCP客户端,支持JSON-RPC通知的处理,完善请求超时控制 - 在session中添加MCP工具列表变化监听,实时更新工具定义数据 - 提供键盘操作支持,实现MCP状态列表的上下翻页和快速导航 - 美化MCP状态列表界面,显示服务器状态及能力的详细信息 --- src/mcp/mcp-client.ts | 133 ++++++++++++++++++++++-- src/mcp/mcp-manager.ts | 185 ++++++++++++++++++++++++++++++++- src/session.ts | 3 + src/ui/App.tsx | 39 ++----- src/ui/McpStatusList.tsx | 219 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 537 insertions(+), 42 deletions(-) create mode 100644 src/ui/McpStatusList.tsx diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index 0fad322..a086c1d 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -17,6 +17,12 @@ type JsonRpcResponse = { error?: { code: number; message: string; data?: unknown }; }; +type JsonRpcNotification = { + jsonrpc: "2.0"; + method: string; + params?: Record; +}; + export type McpToolDefinition = { name: string; description?: string; @@ -24,6 +30,7 @@ export type McpToolDefinition = { type: "object"; properties: Record; required?: string[]; + additionalProperties?: boolean; }; }; @@ -37,6 +44,58 @@ type CallToolResult = { isError?: boolean; }; +export type McpPromptArgument = { + name: string; + description?: string; + required?: boolean; +}; + +export type McpPromptDefinition = { + name: string; + description?: string; + arguments?: McpPromptArgument[]; +}; + +type ListPromptsResult = { + prompts: McpPromptDefinition[]; + nextCursor?: string; +}; + +export type McpPromptMessage = { + role: "user" | "assistant"; + content: { type: string; text?: string }; +}; + +type GetPromptResult = { + description?: string; + messages: McpPromptMessage[]; +}; + +export type McpResourceDefinition = { + uri: string; + name: string; + description?: string; + mimeType?: string; +}; + +type ListResourcesResult = { + resources: McpResourceDefinition[]; + nextCursor?: string; +}; + +export type McpResourceContent = { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +}; + +type ReadResourceResult = { + contents: McpResourceContent[]; +}; + +export type McpNotificationHandler = (method: string, params?: Record) => void; + export class McpClient { private process: ChildProcess | null = null; private reader: Interface | null = null; @@ -46,13 +105,17 @@ export class McpClient { { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout } >(); private stderrBuffer = ""; + private notificationHandler: McpNotificationHandler | null = null; constructor( private readonly serverName: string, private readonly command: string, private readonly args: string[] = [], - private readonly env?: Record - ) {} + private readonly env?: Record, + onNotification?: McpNotificationHandler + ) { + this.notificationHandler = onNotification ?? null; + } async connect(timeoutMs: number): Promise { return new Promise((resolve, reject) => { @@ -109,7 +172,7 @@ export class McpClient { this.sendRequest( "initialize", { - protocolVersion: "2024-11-05", + protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "deepcode-cli", version: "0.1.0" }, }, @@ -141,8 +204,50 @@ export class McpClient { throw this.withStderr(`MCP server "${this.serverName}" returned too many tools/list pages`); } - async callTool(name: string, args: Record): Promise { - return (await this.sendRequest("tools/call", { name, arguments: args })) as CallToolResult; + async callTool(name: string, args: Record, timeoutMs = 60_000): Promise { + return (await this.sendRequest("tools/call", { name, arguments: args }, timeoutMs)) as CallToolResult; + } + + async listPrompts(timeoutMs: number): Promise { + const prompts: McpPromptDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("prompts/list", params, timeoutMs)) as ListPromptsResult; + prompts.push(...(result.prompts ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return prompts; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many prompts/list pages`); + } + + async getPrompt(name: string, args: Record, timeoutMs = 30_000): Promise { + return (await this.sendRequest("prompts/get", { name, arguments: args }, timeoutMs)) as GetPromptResult; + } + + async listResources(timeoutMs: number): Promise { + const resources: McpResourceDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("resources/list", params, timeoutMs)) as ListResourcesResult; + resources.push(...(result.resources ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return resources; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many resources/list pages`); + } + + async readResource(uri: string, timeoutMs = 30_000): Promise { + return (await this.sendRequest("resources/read", { uri }, timeoutMs)) as ReadResourceResult; } disconnect(): void { @@ -195,7 +300,23 @@ export class McpClient { private handleLine(line: string): void { try { - const message = JSON.parse(line) as JsonRpcResponse; + const parsed: unknown = JSON.parse(line); + + // Handle notifications (no id field — server-initiated) + if (parsed && typeof parsed === "object" && !("id" in parsed)) { + const notification = parsed as JsonRpcNotification; + if (this.notificationHandler && typeof notification.method === "string") { + try { + this.notificationHandler(notification.method, notification.params); + } catch { + // Swallow handler errors to avoid crashing the reader loop + } + } + return; + } + + // Handle responses to our requests + const message = parsed as JsonRpcResponse; if (message.id !== undefined && this.pendingRequests.has(message.id)) { const pending = this.pendingRequests.get(message.id)!; this.pendingRequests.delete(message.id); diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 030d0d3..8121dbc 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -1,7 +1,8 @@ -import { McpClient, type McpToolDefinition } from "./mcp-client"; +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_CALL_TOOL_TIMEOUT_MS = 60_000; type McpToolEntry = { serverName: string; @@ -18,15 +19,32 @@ export type McpServerStatus = { error?: string; toolCount: number; tools: string[]; + promptCount: number; + prompts: string[]; + resourceCount: number; + resources: string[]; }; export class McpManager { private clients: McpClient[] = []; private tools: McpToolEntry[] = []; + private prompts: Array<{ + serverName: string; + namespacedName: string; + definition: McpPromptDefinition; + client: McpClient; + }> = []; + private resources: Array<{ + serverName: string; + namespacedName: string; + definition: McpResourceDefinition; + client: McpClient; + }> = []; private initialized = false; private disposed = false; private configuredServerNames: string[] = []; private serverStatuses: McpServerStatus[] = []; + private onToolsListChanged: (() => void) | null = null; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; @@ -48,6 +66,10 @@ export class McpManager { connected: false, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -65,7 +87,13 @@ export class McpManager { if (this.disposed) break; let client: McpClient | null = null; try { - client = new McpClient(name, config.command, config.args ?? [], config.env); + 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 + }); + } + }); await client.connect(MCP_STARTUP_TIMEOUT_MS); if (this.disposed) { client.disconnect(); @@ -73,6 +101,7 @@ export class McpManager { } this.clients.push(client); + // Discover tools const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); if (this.disposed) break; const toolNamespacedNames: string[] = []; @@ -87,12 +116,57 @@ export class McpManager { }); 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); + } + + // 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); + } + this.setStatus({ name, status: "ready", connected: true, toolCount: serverTools.length, tools: toolNamespacedNames, + promptCount: serverPrompts.length, + prompts: promptNamespacedNames, + resourceCount: serverResources.length, + resources: resourceNamespacedNames, }); } catch (err) { if (this.disposed) break; @@ -106,6 +180,10 @@ export class McpManager { error: message, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -122,6 +200,10 @@ export class McpManager { connected: false, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -150,7 +232,9 @@ export class McpManager { type: "object" as const, properties: t.definition.inputSchema.properties, required: t.definition.inputSchema.required, - additionalProperties: false, + ...(t.definition.inputSchema.additionalProperties !== undefined + ? { additionalProperties: t.definition.inputSchema.additionalProperties } + : {}), }, }, })); @@ -162,7 +246,8 @@ export class McpManager { async executeMcpTool( name: string, - args: Record + args: Record, + timeoutMs = MCP_CALL_TOOL_TIMEOUT_MS ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { const tool = this.tools.find((t) => t.namespacedName === name); if (!tool) { @@ -170,7 +255,7 @@ export class McpManager { } try { - const result = await tool.client.callTool(tool.originalName, args); + const result = await tool.client.callTool(tool.originalName, args, timeoutMs); const text = result.content .filter((c) => c.type === "text" && c.text) .map((c) => c.text) @@ -189,6 +274,64 @@ export class McpManager { } } + async getMcpPrompt( + name: string, + args: Record + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const prompt = this.prompts.find((p) => p.namespacedName === name); + if (!prompt) { + return { ok: false, name, error: `Unknown MCP prompt: ${name}` }; + } + + try { + const result = await prompt.client.getPrompt(prompt.definition.name, args); + const text = result.messages + .filter((m) => m.content.type === "text" && m.content.text) + .map((m) => `[${m.role}] ${m.content.text}`) + .join("\n"); + return { + ok: true, + name, + output: text || JSON.stringify(result), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async readMcpResource( + name: string, + uri: string + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const resource = this.resources.find((r) => r.namespacedName === name); + if (!resource) { + return { ok: false, name, error: `Unknown MCP resource: ${name}` }; + } + + try { + const result = await resource.client.readResource(uri); + const text = result.contents + .filter((c) => c.text) + .map((c) => c.text) + .join("\n"); + return { + ok: true, + name, + output: text || JSON.stringify(result.contents), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + disconnect(): void { this.disposed = true; for (const client of this.clients) { @@ -196,11 +339,43 @@ export class McpManager { } this.clients = []; this.tools = []; + this.prompts = []; + this.resources = []; this.serverStatuses = []; this.configuredServerNames = []; 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) { + const namespacedName = `mcp__${serverName}__${tool.name}`; + this.tools.push({ + serverName, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + 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?.(); + } + + setOnToolsListChanged(handler: () => void): void { + this.onToolsListChanged = handler; + } + private setStatus(status: McpServerStatus): void { if (this.disposed) return; const index = this.serverStatuses.findIndex((s) => s.name === status.name); diff --git a/src/session.ts b/src/session.ts index a174f9b..161ba98 100644 --- a/src/session.ts +++ b/src/session.ts @@ -203,6 +203,9 @@ export class SessionManager { } async initMcpServers(servers?: Record): Promise { + this.mcpManager.setOnToolsListChanged(() => { + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + }); await this.mcpManager.initialize(servers); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 757b40c..ffe89e4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -29,6 +29,7 @@ import { buildLoadingText } from "./loadingText"; import { findExpandedThinkingId } from "./thinkingState"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; +import { McpStatusList } from "./McpStatusList"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, @@ -39,7 +40,7 @@ import { buildExitSummaryText } from "./exitSummary"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; -type View = "chat" | "session-list"; +type View = "chat" | "session-list" | "mcp-status"; type AppProps = { projectRoot: string; @@ -67,6 +68,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [welcomeNonce, setWelcomeNonce] = useState(0); const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); + const [mcpStatuses, setMcpStatuses] = useState>([]); const messagesRef = useRef([]); messagesRef.current = messages; @@ -189,36 +191,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } if (submission.command === "mcp") { - process.stdout.write("\n"); - process.stdout.write(chalk.bold.cyan("MCP Server Status\n")); - process.stdout.write(chalk.dim("─────────────────\n")); - const statuses = sessionManager.getMcpStatus(); - if (statuses.length === 0) { - process.stdout.write(chalk.dim(" No MCP servers configured.\n")); - } else { - for (const s of statuses) { - if (s.status === "starting") { - process.stdout.write(`${chalk.yellow("●")} ${chalk.bold(s.name)} - Starting...`); - } else if (s.status === "failed") { - process.stdout.write(`${chalk.red("✖")} ${chalk.bold(s.name)} - Failed (${s.error ?? "unknown error"})`); - } else { - process.stdout.write(`${chalk.green("✔")} ${chalk.bold(s.name)} - Ready (${s.toolCount} tools)`); - } - process.stdout.write("\n"); - if (s.status === "ready" && s.tools.length > 0) { - for (const tool of s.tools) { - process.stdout.write(chalk.dim(` - ${tool}\n`)); - } - } - } - } - process.stdout.write(chalk.dim("─────────────────\n")); - process.stdout.write( - chalk.dim(` Total: ${statuses.filter((s) => s.status === "ready").length} ready, `) + - chalk.dim(`${statuses.filter((s) => s.status === "starting").length} starting, `) + - chalk.dim(`${statuses.filter((s) => s.status === "failed").length} failed\n`) - ); - process.stdout.write("\n"); + setShowWelcome(false); + setMcpStatuses(sessionManager.getMcpStatus()); + setView("mcp-status"); return; } @@ -471,6 +446,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R onSelect={(id) => void handleSelectSession(id)} onCancel={() => setView("chat")} /> + ) : view === "mcp-status" ? ( + setView("chat")} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( void; +}; + +type FlatItem = + | { kind: "server"; status: McpServerStatus; serverIndex: number } + | { kind: "tool"; name: string; serverName: string } + | { kind: "prompt"; name: string; serverName: string } + | { kind: "resource"; name: string; serverName: string }; + +function buildFlatItems(statuses: McpServerStatus[]): FlatItem[] { + const items: FlatItem[] = []; + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + items.push({ kind: "server", status, serverIndex: i }); + if (status.status === "ready") { + for (const tool of status.tools) { + items.push({ kind: "tool", name: tool, serverName: status.name }); + } + for (const prompt of status.prompts) { + items.push({ kind: "prompt", name: prompt, serverName: status.name }); + } + for (const resource of status.resources) { + items.push({ kind: "resource", name: resource, serverName: status.name }); + } + } + } + return items; +} + +export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { + const [index, setIndex] = useState(0); + const { columns, rows } = useWindowSize(); + + const flatItems = useMemo(() => buildFlatItems(statuses), [statuses]); + + const maxVisible = useMemo(() => { + const reservedLines = 8; + const linesPerItem = 2; + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + return Math.max(1, Math.floor(availableLines / linesPerItem)); + }, [rows]); + + const safeIndex = useMemo(() => { + if (flatItems.length === 0) return 0; + return Math.max(0, Math.min(index, flatItems.length - 1)); + }, [index, flatItems.length]); + + const scrollOffset = useMemo(() => { + if (safeIndex < maxVisible) return 0; + return safeIndex - maxVisible + 1; + }, [safeIndex, maxVisible]); + + const visibleItems = useMemo(() => { + return flatItems.slice(scrollOffset, scrollOffset + maxVisible); + }, [flatItems, scrollOffset, maxVisible]); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (flatItems.length === 0) { + return; + } + if (key.upArrow) { + setIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => Math.min(flatItems.length - 1, i + 1)); + return; + } + if (key.pageUp) { + setIndex((i) => Math.max(0, i - maxVisible)); + return; + } + if (key.pageDown) { + setIndex((i) => Math.min(flatItems.length - 1, i + maxVisible)); + return; + } + if (key.home) { + setIndex(0); + return; + } + if (key.end) { + setIndex(flatItems.length - 1); + } + }); + + const readyCount = statuses.filter((s) => s.status === "ready").length; + const startingCount = statuses.filter((s) => s.status === "starting").length; + const failedCount = statuses.filter((s) => s.status === "failed").length; + + if (statuses.length === 0) { + return ( + + Manage MCP servers + 0 servers + No MCP servers configured. + Add MCP servers to your settings to get started. + Esc to close + + ); + } + + return ( + + + {/* Header row */} + + + MCP Server Status + + + {" "} + ({readyCount} ready, {startingCount} starting, {failedCount} failed) + + + {/* Items list */} + + {visibleItems.map((item, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + + if (item.kind === "server") { + return ; + } + return ( + + ); + })} + {scrollOffset > 0 || scrollOffset + maxVisible < flatItems.length ? ( + + {scrollOffset > 0 ? … {scrollOffset} items above. : null} + {scrollOffset + maxVisible < flatItems.length ? ( + … {flatItems.length - scrollOffset - maxVisible} items below. + ) : null} + + ) : null} + + {/* Footer */} + + ↑/↓ navigate · PgUp/PgDn page · Esc cancel + + + + ); +} + +function ServerRow({ status, selected }: { status: McpServerStatus; selected: boolean }): React.ReactElement { + const icon = status.status === "ready" ? "✔" : status.status === "failed" ? "✖" : "●"; + const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow"; + const detail = + status.status === "ready" + ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` + : status.status === "failed" + ? `Failed (${status.error ?? "unknown error"})` + : "Starting..."; + + return ( + + {selected ? "› " : " "} + + {icon} + {status.name} + — {detail} + + + ); +} + +function CapabilityRow({ + kind, + name, + selected, +}: { + kind: "tool" | "prompt" | "resource"; + name: string; + selected: boolean; +}): React.ReactElement { + const prefix = kind === "tool" ? "🔧" : kind === "prompt" ? "📝" : "📦"; + return ( + + {selected ? "› " : " "} + + {prefix} {name} + + + ); +} From 91e52406293ff1e8683e15ec00eac98bed175f7d Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 15:40:35 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在FlatItem类型中添加错误信息项 - 失败状态的服务器添加对应错误消息 - 失败错误信息单独用ErrorRow组件渲染 - 更新无服务器时的提示样式,增强视觉层次感 - 为工具项添加左侧缩进优化排版布局 --- src/ui/McpStatusList.tsx | 43 ++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index b04d7af..d3e9048 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -9,6 +9,7 @@ type Props = { type FlatItem = | { kind: "server"; status: McpServerStatus; serverIndex: number } + | { kind: "error"; error: string; serverName: string } | { kind: "tool"; name: string; serverName: string } | { kind: "prompt"; name: string; serverName: string } | { kind: "resource"; name: string; serverName: string }; @@ -18,6 +19,10 @@ function buildFlatItems(statuses: McpServerStatus[]): FlatItem[] { for (let i = 0; i < statuses.length; i++) { const status = statuses[i]; items.push({ kind: "server", status, serverIndex: i }); + // 为失败的服务添加错误消息 + if (status.status === "failed" && status.error) { + items.push({ kind: "error", error: status.error, serverName: status.name }); + } if (status.status === "ready") { for (const tool of status.tools) { items.push({ kind: "tool", name: tool, serverName: status.name }); @@ -99,11 +104,17 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement if (statuses.length === 0) { return ( - - Manage MCP servers - 0 servers - No MCP servers configured. - Add MCP servers to your settings to get started. + + + + Manage MCP servers + + 0 servers + + + No MCP servers configured. + Add MCP servers to your settings to get started. + Esc to close ); @@ -149,6 +160,9 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement if (item.kind === "server") { return ; } + if (item.kind === "error") { + return ; + } return ( + {selected ? "› " : " "} {prefix} {name} @@ -217,3 +231,20 @@ function CapabilityRow({ ); } + +function ErrorRow({ error }: { error: string }): React.ReactElement { + // 将错误消息按行分割,每行单独显示 + const lines = error.split("\n").filter((line) => line.trim().length > 0); + + return ( + + {lines.map((line, index) => ( + + + {line} + + + ))} + + ); +} From 40025e495a6c19aa34939be78a4ace696a966ed7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 17:59:52 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(mcp):=20=E5=AE=9E=E6=97=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=92=8C=E5=B1=95=E7=A4=BA=20MCP=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MCP 状态变更回调,支持 UI 实时刷新显示 - MCP 管理器中添加状态变更事件处理机制 - 调整 UI 组件以支持状态计数的高亮显示 - 移除 MCP 初始化失败的控制台错误输出,避免信息泄露 - 优化退出命令行提示颜色为灰色,提升可读性 --- src/mcp/mcp-manager.ts | 14 +++++++++++--- src/session.ts | 7 +++++++ src/ui/App.tsx | 6 +++++- src/ui/McpStatusList.tsx | 21 +++++++++++++++------ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 8121dbc..5a9f553 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -45,6 +45,7 @@ export class McpManager { private configuredServerNames: string[] = []; private serverStatuses: McpServerStatus[] = []; private onToolsListChanged: (() => void) | null = null; + private onStatusChanged: (() => void) | null = null; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; @@ -172,7 +173,8 @@ export class McpManager { 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`); + // 不在控制台输出错误日志,避免暴露敏感信息 + // process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); this.setStatus({ name, status: "failed", @@ -376,13 +378,19 @@ export class McpManager { this.onToolsListChanged = handler; } + setOnStatusChanged(handler: () => void): void { + this.onStatusChanged = handler; + } + private setStatus(status: McpServerStatus): void { if (this.disposed) return; const index = this.serverStatuses.findIndex((s) => s.name === status.name); if (index === -1) { this.serverStatuses.push(status); - return; + } else { + this.serverStatuses[index] = status; } - this.serverStatuses[index] = status; + // 触发状态变更回调 + this.onStatusChanged?.(); } } diff --git a/src/session.ts b/src/session.ts index 161ba98..3f264b4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -166,6 +166,7 @@ type SessionManagerOptions = { onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + onMcpStatusChanged?: () => void; }; export type LlmStreamProgress = { @@ -184,6 +185,7 @@ export class SessionManager { private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + private readonly onMcpStatusChanged?: () => void; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); @@ -198,6 +200,7 @@ export class SessionManager { this.onAssistantMessage = options.onAssistantMessage; this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; + this.onMcpStatusChanged = options.onMcpStatusChanged; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); } @@ -206,6 +209,10 @@ export class SessionManager { this.mcpManager.setOnToolsListChanged(() => { this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); }); + // 设置状态变更回调,通知 UI 更新 + this.mcpManager.setOnStatusChanged(() => { + this.onMcpStatusChanged?.(); + }); await this.mcpManager.initialize(servers); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ffe89e4..709df67 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -94,6 +94,10 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R } setStreamProgress(progress); }, + onMcpStatusChanged: () => { + // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 + setMcpStatuses(sessionManager.getMcpStatus()); + }, }); }, [projectRoot]); @@ -156,7 +160,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const resolved = resolveCurrentSettings(projectRoot); const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); process.stdout.write("\n"); - process.stdout.write(chalk.green("> /exit ")); + process.stdout.write(chalk.rgb(128, 128, 128)("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index d3e9048..455c586 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -131,14 +131,23 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement > {/* Header row */} - - - MCP Server Status - + - {" "} - ({readyCount} ready, {startingCount} starting, {failedCount} failed) + Manage MCP servers + + ( + + {readyCount} ready, + + + {startingCount} starting, + + + {failedCount} failed + + ) + {/* Items list */} Date: Thu, 14 May 2026 23:26:21 +0800 Subject: [PATCH 04/11] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96=20MCP?= =?UTF-8?q?=20=E7=8A=B6=E6=80=81=E5=88=97=E8=A1=A8=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E5=8F=8A=E7=95=8C=E9=9D=A2=E7=BB=86=E8=8A=82=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 MCP 状态列表拆分为服务器列表和服务器详情两种视图 - 支持通过方向键分页和滚动,确保选中项始终可见 - 服务器详情视图新增工具、提示和资源的详细列表展示及导航支持 - 为启动状态服务器添加动态加载动画,提升交互体验 - 错误信息使用带红色边框的样式包裹,更加醒目 - 统一界面中选中项前缀符号由 "› " 改为 "> " - 修正 DropdownMenu 和 SlashCommandMenu 的注释与前缀符号 - 调整 SessionList 中选中标识符,保持样式一致 - 优化行宽计算逻辑,提升标签列自适应能力 - 改善布局间距和边框样式,增强视觉层次感 --- src/ui/AskUserQuestionPrompt.tsx | 2 +- src/ui/DropdownMenu.tsx | 4 +- src/ui/McpStatusList.tsx | 482 ++++++++++++++++++++++++------- src/ui/PromptInput.tsx | 6 +- src/ui/SessionList.tsx | 2 +- src/ui/SlashCommandMenu.tsx | 6 +- 6 files changed, 380 insertions(+), 122 deletions(-) diff --git a/src/ui/AskUserQuestionPrompt.tsx b/src/ui/AskUserQuestionPrompt.tsx index 952f9cf..7c76ae3 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/AskUserQuestionPrompt.tsx @@ -184,7 +184,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return ( - {isCursor ? "› " : " "} + {isCursor ? "> " : " "} {marker} {option.label} {option.isOther ? ( diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx index 3a963a5..d651720 100644 --- a/src/ui/DropdownMenu.tsx +++ b/src/ui/DropdownMenu.tsx @@ -82,7 +82,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ // 计算每个 item 实际需要的最大宽度 const maxContentWidth = Math.max( ...visibleItems.map((item) => { - let width = 2; // prefix "› " or " " + let width = 2; // prefix "> " or " " if (item.selected !== undefined) { width += 2; // "● " or "○ " } @@ -152,7 +152,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ - {isActive ? "› " : " "} + {isActive ? "> " : " "} {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} {item.statusIndicator ? ( {item.statusIndicator.symbol} diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index 455c586..e448b5c 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { McpServerStatus } from "../mcp/mcp-manager"; @@ -7,94 +7,160 @@ type Props = { onCancel: () => void; }; -type FlatItem = - | { kind: "server"; status: McpServerStatus; serverIndex: number } - | { kind: "error"; error: string; serverName: string } - | { kind: "tool"; name: string; serverName: string } - | { kind: "prompt"; name: string; serverName: string } - | { kind: "resource"; name: string; serverName: string }; - -function buildFlatItems(statuses: McpServerStatus[]): FlatItem[] { - const items: FlatItem[] = []; - for (let i = 0; i < statuses.length; i++) { - const status = statuses[i]; - items.push({ kind: "server", status, serverIndex: i }); - // 为失败的服务添加错误消息 - if (status.status === "failed" && status.error) { - items.push({ kind: "error", error: status.error, serverName: status.name }); - } - if (status.status === "ready") { - for (const tool of status.tools) { - items.push({ kind: "tool", name: tool, serverName: status.name }); - } - for (const prompt of status.prompts) { - items.push({ kind: "prompt", name: prompt, serverName: status.name }); - } - for (const resource of status.resources) { - items.push({ kind: "resource", name: resource, serverName: status.name }); - } +export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { + const { columns, rows } = useWindowSize(); + + // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) + const [viewMode, setViewMode] = useState<"server-list" | "server-detail">("server-list"); + // 选中的服务器索引 + const [selectedServerIndex, setSelectedServerIndex] = useState(0); + + // 返回服务器列表 + const goBack = useCallback(() => { + setViewMode("server-list"); + }, []); + + // 进入服务器详情 + const enterDetail = useCallback(() => { + const server = statuses[selectedServerIndex]; + if (server && server.status === "ready") { + setViewMode("server-detail"); } + }, [statuses, selectedServerIndex]); + + if (statuses.length === 0) { + return ( + + + + Manage MCP servers + + 0 servers + + + No MCP servers configured. + Add MCP servers to your settings to get started. + + Esc to close + + ); } - return items; -} -export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { - const [index, setIndex] = useState(0); - const { columns, rows } = useWindowSize(); + if (viewMode === "server-detail") { + return ( + + ); + } - const flatItems = useMemo(() => buildFlatItems(statuses), [statuses]); + return ( + + ); +} + +// ==================== 服务器列表视图 ==================== +function ServerListView({ + statuses, + selectedIndex, + onSelect, + onEnter, + onCancel, + rows, + columns, +}: { + statuses: McpServerStatus[]; + selectedIndex: number; + onSelect: (index: number) => void; + onEnter: () => void; + onCancel: () => void; + rows: number; + columns: number; +}): React.ReactElement { + const [scrollOffset, setScrollOffset] = useState(0); + const serverCount = statuses.length; const maxVisible = useMemo(() => { - const reservedLines = 8; - const linesPerItem = 2; + const reservedLines = 8; // header + footer + borders const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); - return Math.max(1, Math.floor(availableLines / linesPerItem)); + // 每个服务器占用 1 行(标题)+ 1 行(错误信息或统计)+ 1 行(间隔) + return Math.max(1, Math.floor(availableLines / 3)); }, [rows]); + // 计算标签列宽度:找到最长的服务器名称,加上前缀和图标 + const labelColumnWidth = useMemo(() => { + if (serverCount === 0) return 0; + const longestName = Math.max(...statuses.map((s) => s.name.length)); + const contentWidth = longestName + 5; // +2 for prefix "> " or " ", +3 for icon "✓ " + const maxAllowed = Math.max(15, Math.floor((columns - 6) * 0.4)); // 容器40%宽度,至少15列 + return Math.min(contentWidth, maxAllowed); + }, [statuses, serverCount, columns]); + const safeIndex = useMemo(() => { - if (flatItems.length === 0) return 0; - return Math.max(0, Math.min(index, flatItems.length - 1)); - }, [index, flatItems.length]); + if (serverCount === 0) return 0; + return Math.max(0, Math.min(selectedIndex, serverCount - 1)); + }, [selectedIndex, serverCount]); - const scrollOffset = useMemo(() => { - if (safeIndex < maxVisible) return 0; - return safeIndex - maxVisible + 1; - }, [safeIndex, maxVisible]); + // 自动滚动确保选中项可见 + React.useEffect(() => { + if (safeIndex < scrollOffset) { + setScrollOffset(safeIndex); + } else if (safeIndex >= scrollOffset + maxVisible) { + setScrollOffset(safeIndex - maxVisible + 1); + } + }, [safeIndex, scrollOffset, maxVisible]); - const visibleItems = useMemo(() => { - return flatItems.slice(scrollOffset, scrollOffset + maxVisible); - }, [flatItems, scrollOffset, maxVisible]); + const visibleServers = useMemo(() => { + return statuses.slice(scrollOffset, scrollOffset + maxVisible); + }, [statuses, scrollOffset, maxVisible]); useInput((input, key) => { if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { onCancel(); return; } - if (flatItems.length === 0) { + if (serverCount === 0) { return; } if (key.upArrow) { - setIndex((i) => Math.max(0, i - 1)); + onSelect(Math.max(0, selectedIndex - 1)); return; } if (key.downArrow) { - setIndex((i) => Math.min(flatItems.length - 1, i + 1)); + onSelect(Math.min(serverCount - 1, selectedIndex + 1)); return; } if (key.pageUp) { - setIndex((i) => Math.max(0, i - maxVisible)); + onSelect(Math.max(0, selectedIndex - maxVisible)); return; } if (key.pageDown) { - setIndex((i) => Math.min(flatItems.length - 1, i + maxVisible)); + onSelect(Math.min(serverCount - 1, selectedIndex + maxVisible)); return; } if (key.home) { - setIndex(0); + onSelect(0); return; } if (key.end) { - setIndex(flatItems.length - 1); + onSelect(serverCount - 1); + } + // Enter 键进入详情 + if (key.return) { + onEnter(); + return; } }); @@ -102,24 +168,6 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement const startingCount = statuses.filter((s) => s.status === "starting").length; const failedCount = statuses.filter((s) => s.status === "failed").length; - if (statuses.length === 0) { - return ( - - - - Manage MCP servers - - 0 servers - - - No MCP servers configured. - Add MCP servers to your settings to get started. - - Esc to close - - ); - } - return ( - {visibleItems.map((item, i) => { + {visibleServers.map((status, i) => { const actualIndex = scrollOffset + i; const isSelected = actualIndex === safeIndex; - if (item.kind === "server") { - return ; - } - if (item.kind === "error") { - return ; - } return ( - ); })} - {scrollOffset > 0 || scrollOffset + maxVisible < flatItems.length ? ( + {scrollOffset > 0 || scrollOffset + maxVisible < serverCount ? ( - {scrollOffset > 0 ? … {scrollOffset} items above. : null} - {scrollOffset + maxVisible < flatItems.length ? ( - … {flatItems.length - scrollOffset - maxVisible} items below. + {scrollOffset > 0 ? … {scrollOffset} servers above. : null} + {scrollOffset + maxVisible < serverCount ? ( + … {serverCount - scrollOffset - maxVisible} servers below. ) : null} ) : null} {/* Footer */} - - ↑/↓ navigate · PgUp/PgDn page · Esc cancel + + ↑/↓ navigate · Enter view details · Esc cancel ); } -function ServerRow({ status, selected }: { status: McpServerStatus; selected: boolean }): React.ReactElement { - const icon = status.status === "ready" ? "✔" : status.status === "failed" ? "✖" : "●"; +function ServerRow({ + status, + selected, + labelColumnWidth, +}: { + status: McpServerStatus; + 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 [dots, setDots] = React.useState(0); + React.useEffect(() => { + if (status.status !== "starting") return; + const interval = setInterval(() => { + setDots((d) => (d + 1) % 4); // 0 → 1 → 2 → 3 → 0 ... + }, 500); + return () => clearInterval(interval); + }, [status.status]); + const detail = status.status === "ready" ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` : status.status === "failed" - ? `Failed (${status.error ?? "unknown error"})` - : "Starting..."; + ? `Failed` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ... return ( - - {selected ? "› " : " "} - - {icon} - {status.name} - — {detail} - + + {/* Server row */} + + + + {selected ? "> " : " "} + {icon} + {status.name} + + + + {detail} + + + + {/* Error message for failed servers */} + {status.status === "failed" && status.error ? : null} ); } -function CapabilityRow({ - kind, - name, - selected, +// ==================== 服务器详情视图 ==================== +function ServerDetailView({ + server, + onBack, + onCancel, + rows, + columns, }: { - kind: "tool" | "prompt" | "resource"; - name: string; - selected: boolean; + server: McpServerStatus; + onBack: () => void; + onCancel: () => void; + rows: number; + columns: number; }): React.ReactElement { - const prefix = kind === "tool" ? "🔧" : kind === "prompt" ? "📝" : "📦"; + const [activeIndex, setActiveIndex] = useState(0); + + // 合并所有 items(tools, prompts, resources) + const allItems = useMemo(() => { + const items: { type: string; name: string }[] = []; + 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]); + + const totalItems = allItems.length; + + const maxVisible = useMemo(() => { + const reservedLines = 10; // header + title + stats + footer + borders + const availableLines = Math.max(0, Math.min(rows, 28) - 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) { + newStart = activeIndex - maxVisible + 1; + } + + console.log("maxVisible:", maxVisible); + + // 限制在合法范围内 + newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); + + // 更新 ref + visibleStartRef.current = newStart; + + return newStart; + }, [activeIndex, maxVisible, totalItems]); + + const visibleItems = allItems.slice(visibleStart, visibleStart + maxVisible); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (key.escape) { + onBack(); + return; + } + // Space 或 Enter 键返回一级菜单 + if (input === " " || key.return) { + onBack(); + return; + } + if (key.upArrow) { + setActiveIndex((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1)); + return; + } + if (key.pageUp) { + setActiveIndex((prev) => Math.max(0, prev - maxVisible)); + return; + } + if (key.pageDown) { + setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible)); + return; + } + if (key.home) { + setActiveIndex(0); + return; + } + if (key.end) { + setActiveIndex(totalItems - 1); + } + }); + + const icon = "✓"; + const color = "green"; + + return ( + + + {/* Header row */} + + {icon} + + {server.name} + + — Details + + {activeIndex + 1}/{totalItems} + + + {/* Server info */} + + + {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources + + + {/* Items list */} + + {visibleStart > 0 ? ( + + + + ) : ( + + )} + + {visibleItems.length === 0 ? ( + + No items available + + ) : ( + visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isSelected = actualIndex === activeIndex; + return ; + }) + )} + + {visibleStart > 0 || visibleStart + maxVisible < totalItems ? ( + + {totalItems - visibleStart - maxVisible > 0 ? : } + {visibleStart > 0 ? … {visibleStart} items above. : null} + {totalItems - visibleStart - maxVisible > 0 ? ( + … {totalItems - visibleStart - maxVisible} items below. + ) : null} + + ) : null} + + {/* Footer */} + + ↑/↓ scroll · Space/Enter back · Esc close + + + + ); +} + +function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { + const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + return ( - - {selected ? "› " : " "} - - {prefix} {name} + + {icon} + + {item.name} ); @@ -246,7 +496,15 @@ function ErrorRow({ error }: { error: string }): React.ReactElement { const lines = error.split("\n").filter((line) => line.trim().length > 0); return ( - + {lines.map((line, index) => ( diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 6a11431..6c0f8ea 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -722,7 +722,7 @@ export const PromptInput = React.memo(function PromptInput({ ({ key: skill.path || skill.name, @@ -742,8 +742,8 @@ export const PromptInput = React.memo(function PromptInput({ title={modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} helpText={ modelDropdownStep === "model" - ? "space/enter select model · esc to cancel" - : "space/enter apply · esc to cancel" + ? "Space/Enter select model · Esc to cancel" + : "Space/Enter apply · Esc to cancel" } items={modelDropdownItems.map((item) => ({ key: item.label, diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 67f2e10..fdbd1fe 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -126,7 +126,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return ( - {actualIndex === safeIndex ? "› " : " "} + {actualIndex === safeIndex ? "> " : " "} diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 9b79293..436be83 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -16,13 +16,13 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ maxVisible = 6, width, }: SlashCommandMenuProps): React.ReactElement | null { - // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) + // 计算标签列最佳宽度:包含前缀"> "或" "(2字符),不超过容器一半(扣除gap) const labelColumnWidth = React.useMemo(() => { if (items.length === 0) { return 0; } const longestLabel = Math.max(...items.map((s) => s.label.length)); - const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " + const contentWidth = longestLabel + 2; // +2 for prefix "> " or " " const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 return Math.min(contentWidth, maxAllowed); }, [items, width]); @@ -51,7 +51,7 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ - {actualIndex === activeIndex ? "› " : " "} + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} From b858c8755978480321f79dd8068376c7c45408d4 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 23:58:18 +0800 Subject: [PATCH 05/11] chore: update McpStatusList --- src/ui/McpStatusList.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index e448b5c..e7912d9 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -321,7 +321,7 @@ function ServerDetailView({ const maxVisible = useMemo(() => { const reservedLines = 10; // header + title + stats + footer + borders - const availableLines = Math.max(0, Math.min(rows, 28) - reservedLines); + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, availableLines); }, [rows]); @@ -344,8 +344,6 @@ function ServerDetailView({ newStart = activeIndex - maxVisible + 1; } - console.log("maxVisible:", maxVisible); - // 限制在合法范围内 newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); @@ -412,7 +410,7 @@ function ServerDetailView({ {/* Header row */} {icon} - + {server.name} — Details @@ -422,7 +420,7 @@ function ServerDetailView({ {/* Server info */} - + {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources @@ -455,7 +453,7 @@ function ServerDetailView({ visibleItems.map((item, idx) => { const actualIndex = visibleStart + idx; const isSelected = actualIndex === activeIndex; - return ; + return ; }) )} @@ -482,7 +480,7 @@ function ItemRow({ item, selected }: { item: { type: string; name: string }; sel const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; return ( - + {icon} {item.name} From 35b686d55fa1a6fcd91f3845d49b17d5975fcd61 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 13:34:27 +0800 Subject: [PATCH 06/11] =?UTF-8?q?test(session):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BB=A5=E5=8C=85=E6=8B=AC=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=92=8C=E8=B5=84=E6=BA=90=E8=AE=A1=E6=95=B0=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 getMcpStatus 的断言中添加 promptCount、prompts、resourceCount 和 resources 字段 - 确保各状态对象包含完整的提示和资源数组信息 - 维护现有工具和连接状态的正确性 - 扩展测试覆盖,支持提示和资源相关的状态字段验证 --- src/tests/session.test.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 2ac6c29..60147ea 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -410,7 +410,17 @@ rl.on("line", (line) => { const initPromise = manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); assert.deepEqual(manager.getMcpStatus(), [ - { name: "smoke", status: "starting", connected: false, toolCount: 0, tools: [] }, + { + name: "smoke", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, ]); await initPromise; @@ -422,6 +432,10 @@ rl.on("line", (line) => { connected: true, toolCount: 2, tools: ["mcp__smoke__echo", "mcp__smoke__count"], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }, ]); const mcpManager = (manager as any).mcpManager; @@ -457,7 +471,17 @@ test("SessionManager reports configured MCP servers as starting before initializ }); assert.deepEqual(manager.getMcpStatus(), [ - { name: "playwright", status: "starting", connected: false, toolCount: 0, tools: [] }, + { + name: "playwright", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, ]); }); From 5a996281dce9cb2ef6371374669aea304bdbec7c Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 13:37:13 +0800 Subject: [PATCH 07/11] =?UTF-8?q?refactor(ui):=20=E7=A7=BB=E9=99=A4McpStat?= =?UTF-8?q?usList=E4=B8=AD=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84React?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=92=8C=E6=96=87=E6=9C=AC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了未使用的useEffect导入,简化依赖引入 - 移除了界面中显示的当前索引及总数文本,减少冗余信息显示 - 优化了McpStatusList组件代码的可读性和整洁度 --- src/ui/McpStatusList.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index e7912d9..1f49bd6 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback, useEffect } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { McpServerStatus } from "../mcp/mcp-manager"; @@ -414,9 +414,6 @@ function ServerDetailView({ {server.name} — Details - - {activeIndex + 1}/{totalItems} - {/* Server info */} From bee349bb8ca700eefe96615cba4b51352bdc907b Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 14:09:28 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(mcp-client):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E7=89=88=E6=9C=AC=E6=A0=A1=E9=AA=8C=E5=92=8C?= =?UTF-8?q?JSON-RPC=E6=89=B9=E5=A4=84=E7=90=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在初始化时验证服务器返回的MCP协议版本,拒绝不支持的版本 - 支持处理JSON-RPC批量消息,逐条分发处理 - 抽离单条消息处理逻辑,统一处理通知和响应 - 防止通知处理器异常导致读取循环崩溃 fix(ui): 优化键盘交互提示与布局间距 - 移除Esc键作为取消操作,避免与Ctrl+C冲突 - 更新底部提示,明确Ctrl+C为关闭操作,Esc为返回操作 - 调整MessageView内容左边距,使间距更合理 --- src/mcp/mcp-client.ts | 70 ++++++++++++++++++++++++++++------------ src/ui/McpStatusList.tsx | 4 +-- src/ui/MessageView.tsx | 2 +- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index a086c1d..f89e11d 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -178,7 +178,19 @@ export class McpClient { }, timeoutMs ) - .then(() => { + .then((result) => { + // Validate protocol version from server response (per MCP spec §4.2.1.2) + const initResult = result as { protocolVersion?: string } | undefined; + const serverVersion = initResult?.protocolVersion; + if (serverVersion && serverVersion !== "2025-03-26" && serverVersion !== "2024-11-05") { + reject( + new Error( + `Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` + + `Client supports 2025-03-26 and 2024-11-05.` + ) + ); + return; + } // Send initialized notification this.sendNotification("notifications/initialized"); resolve(); @@ -302,36 +314,54 @@ export class McpClient { try { const parsed: unknown = JSON.parse(line); - // Handle notifications (no id field — server-initiated) - if (parsed && typeof parsed === "object" && !("id" in parsed)) { - const notification = parsed as JsonRpcNotification; - if (this.notificationHandler && typeof notification.method === "string") { - try { - this.notificationHandler(notification.method, notification.params); - } catch { - // Swallow handler errors to avoid crashing the reader loop + // Handle JSON-RPC batch (array of requests/notifications/responses) + // Per MCP 2025-03-26 §4.1.1.3: implementations MUST support receiving batches. + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item === "object") { + this.handleSingleMessage(item); } } return; } - // Handle responses to our requests - const message = parsed as JsonRpcResponse; - if (message.id !== undefined && this.pendingRequests.has(message.id)) { - const pending = this.pendingRequests.get(message.id)!; - this.pendingRequests.delete(message.id); - clearTimeout(pending.timer); - if (message.error) { - pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); - } else { - pending.resolve(message.result); - } + // Handle single message + if (parsed && typeof parsed === "object") { + this.handleSingleMessage(parsed); } } catch { // Ignore unparseable lines } } + private handleSingleMessage(msg: object): void { + // Handle notifications (no id field — server-initiated) + if (!("id" in msg)) { + const notification = msg as unknown as JsonRpcNotification; + if (this.notificationHandler && typeof notification.method === "string") { + try { + this.notificationHandler(notification.method, notification.params); + } catch { + // Swallow handler errors to avoid crashing the reader loop + } + } + return; + } + + // Handle responses to our requests + const message = msg as unknown as JsonRpcResponse; + if (message.id !== undefined && this.pendingRequests.has(message.id)) { + const pending = this.pendingRequests.get(message.id)!; + this.pendingRequests.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } + } + private withNpxYesArg(command: string, args: string[]): string[] { const executable = path .basename(command) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index 1f49bd6..0f5f906 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -356,7 +356,7 @@ function ServerDetailView({ const visibleItems = allItems.slice(visibleStart, visibleStart + maxVisible); useInput((input, key) => { - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + if (key.ctrl && (input === "c" || input === "C")) { onCancel(); return; } @@ -466,7 +466,7 @@ function ServerDetailView({ {/* Footer */} - ↑/↓ scroll · Space/Enter back · Esc close + ↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index acdc645..c8793fc 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -47,7 +47,7 @@ export function MessageView({ message, collapsed, width = 80 }: Props): React.Re return ( - + {content ? {renderMarkdown(content)} : null} From c81937434575bee3eb734fcf28d9bec01bad4667 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 15:10:15 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E6=97=A0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=97=B6=E6=8C=89Esc=E9=94=AE?= =?UTF-8?q?=E9=80=80=E5=87=BA=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在McpStatusList组件中监听键盘输入事件 - 当服务器列表为空时,按Esc键或Ctrl+C触发退出操作 - 确保用户可通过快捷键安全退出无服务器状态界面 --- src/ui/McpStatusList.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index 0f5f906..bdb1854 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -28,6 +28,13 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement } }, [statuses, selectedServerIndex]); + // 当没有服务器时,监听 Esc 键退出 + useInput((input, key) => { + if (statuses.length === 0 && (key.escape || (key.ctrl && (input === "c" || input === "C")))) { + onCancel(); + } + }); + if (statuses.length === 0) { return ( From a094524e8fd4e939b93e71d56743b0fd1a50806d Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 16:37:15 +0800 Subject: [PATCH 10/11] fix(ui): update string --- src/ui/McpStatusList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx index bdb1854..a09039d 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -241,7 +241,7 @@ function ServerListView({ {/* Footer */} - ↑/↓ navigate · Enter view details · Esc cancel + ↑/↓ navigate · Enter view details · Esc close From 9a2188407e71a66a46b1937a6798c094ae2a486f Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 15 May 2026 16:42:27 +0800 Subject: [PATCH 11/11] fix(ui): update string --- src/ui/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index eea8091..44d5a70 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -160,7 +160,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const resolved = resolveCurrentSettings(projectRoot); const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); process.stdout.write("\n"); - process.stdout.write(chalk.rgb(128, 128, 128)("> /exit ")); + process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n");