diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index 0fad322..f89e11d 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,13 +172,25 @@ export class McpClient { this.sendRequest( "initialize", { - protocolVersion: "2024-11-05", + protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "deepcode-cli", version: "0.1.0" }, }, 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(); @@ -141,8 +216,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,22 +312,56 @@ export class McpClient { private handleLine(line: string): void { try { - const message = JSON.parse(line) 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); + const parsed: unknown = JSON.parse(line); + + // 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 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/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts index 030d0d3..5a9f553 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,33 @@ 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; + private onStatusChanged: (() => void) | null = null; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; @@ -48,6 +67,10 @@ export class McpManager { connected: false, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -65,7 +88,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 +102,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,18 +117,64 @@ 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; 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", @@ -106,6 +182,10 @@ export class McpManager { error: message, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -122,6 +202,10 @@ export class McpManager { connected: false, toolCount: 0, tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], }); } } @@ -150,7 +234,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 +248,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 +257,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 +276,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,18 +341,56 @@ 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; + } + + 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 894ff80..63006e1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -161,6 +161,7 @@ type SessionManagerOptions = { onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + onMcpStatusChanged?: () => void; }; export type LlmStreamProgress = { @@ -183,6 +184,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(); @@ -197,11 +199,19 @@ 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); } async initMcpServers(servers?: Record): Promise { + 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/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: [], + }, ]); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3f32f56..44d5a70 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; @@ -92,6 +94,10 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R } setStreamProgress(progress); }, + onMcpStatusChanged: () => { + // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 + setMcpStatuses(sessionManager.getMcpStatus()); + }, }); }, [projectRoot]); @@ -154,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(34, 154, 195)("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); @@ -189,36 +195,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; } @@ -473,6 +452,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 ? ( - {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 new file mode 100644 index 0000000..a09039d --- /dev/null +++ b/src/ui/McpStatusList.tsx @@ -0,0 +1,519 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { McpServerStatus } from "../mcp/mcp-manager"; + +type Props = { + statuses: McpServerStatus[]; + onCancel: () => void; +}; + +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]); + + // 当没有服务器时,监听 Esc 键退出 + useInput((input, key) => { + if (statuses.length === 0 && (key.escape || (key.ctrl && (input === "c" || input === "C")))) { + onCancel(); + } + }); + + 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 + + ); + } + + if (viewMode === "server-detail") { + return ( + + ); + } + + 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; // header + footer + borders + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + // 每个服务器占用 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 (serverCount === 0) return 0; + return Math.max(0, Math.min(selectedIndex, serverCount - 1)); + }, [selectedIndex, serverCount]); + + // 自动滚动确保选中项可见 + React.useEffect(() => { + if (safeIndex < scrollOffset) { + setScrollOffset(safeIndex); + } else if (safeIndex >= scrollOffset + maxVisible) { + setScrollOffset(safeIndex - maxVisible + 1); + } + }, [safeIndex, 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 (serverCount === 0) { + return; + } + if (key.upArrow) { + onSelect(Math.max(0, selectedIndex - 1)); + return; + } + if (key.downArrow) { + onSelect(Math.min(serverCount - 1, selectedIndex + 1)); + return; + } + if (key.pageUp) { + onSelect(Math.max(0, selectedIndex - maxVisible)); + return; + } + if (key.pageDown) { + onSelect(Math.min(serverCount - 1, selectedIndex + maxVisible)); + return; + } + if (key.home) { + onSelect(0); + return; + } + if (key.end) { + onSelect(serverCount - 1); + } + // Enter 键进入详情 + if (key.return) { + onEnter(); + return; + } + }); + + 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; + + return ( + + + {/* Header row */} + + + Manage MCP servers + + + ( + + {readyCount} ready, + + + {startingCount} starting, + + + {failedCount} failed + + ) + + + {/* Items list */} + + {visibleServers.map((status, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + + return ( + + ); + })} + {scrollOffset > 0 || scrollOffset + maxVisible < serverCount ? ( + + {scrollOffset > 0 ? … {scrollOffset} servers above. : null} + {scrollOffset + maxVisible < serverCount ? ( + … {serverCount - scrollOffset - maxVisible} servers below. + ) : null} + + ) : null} + + {/* Footer */} + + ↑/↓ navigate · Enter view details · Esc close + + + + ); +} + +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` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ... + + return ( + + {/* Server row */} + + + + {selected ? "> " : " "} + {icon} + {status.name} + + + + {detail} + + + + {/* Error message for failed servers */} + {status.status === "failed" && status.error ? : null} + + ); +} + +// ==================== 服务器详情视图 ==================== +function ServerDetailView({ + server, + onBack, + onCancel, + rows, + columns, +}: { + server: McpServerStatus; + onBack: () => void; + onCancel: () => void; + rows: number; + columns: number; +}): React.ReactElement { + 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, 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) { + 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]); + + const visibleItems = allItems.slice(visibleStart, visibleStart + maxVisible); + + useInput((input, key) => { + if (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 + + {/* 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 back · Ctrl+C close + + + + ); +} + +function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { + const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + + return ( + + {icon} + + {item.name} + + + ); +} + +function ErrorRow({ error }: { error: string }): React.ReactElement { + // 将错误消息按行分割,每行单独显示 + const lines = error.split("\n").filter((line) => line.trim().length > 0); + + return ( + + {lines.map((line, index) => ( + + + {line} + + + ))} + + ); +} 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} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index eaf36fa..3965144 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -772,7 +772,7 @@ export const PromptInput = React.memo(function PromptInput({ ({ key: skill.path || skill.name, @@ -792,8 +792,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)}