diff --git a/CHANGELOG.md b/CHANGELOG.md index 383b83d5d..eda1aa15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- MCP: Add loading indicators for MCP server connections — Shell displays a "Connecting to MCP servers..." spinner and Web shows a status message while MCP tools are being loaded - Web: Fix scrollable file list overflow in the toolbar changes panel - Core: Add `compaction_trigger_ratio` config option (default `0.85`) to control when auto-compaction triggers — compaction now fires when context usage reaches the configured ratio or when remaining space falls below `reserved_context_size`, whichever comes first - Core: Support custom instructions in `/compact` command (e.g., `/compact keep database discussions`) to guide what the compaction preserves diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index fe79f9ccf..c2a7091e9 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- MCP: Add loading indicators for MCP server connections — Shell displays a "Connecting to MCP servers..." spinner and Web shows a status message while MCP tools are being loaded - Web: Fix scrollable file list overflow in the toolbar changes panel - Core: Add `compaction_trigger_ratio` config option (default `0.85`) to control when auto-compaction triggers — compaction now fires when context usage reaches the configured ratio or when remaining space falls below `reserved_context_size`, whichever comes first - Core: Support custom instructions in `/compact` command (e.g., `/compact keep database discussions`) to guide what the compaction preserves diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 69880af63..3dbfaea5f 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- MCP:为 MCP 服务器连接添加加载指示器——Shell 在连接 MCP 服务器时显示 "Connecting to MCP servers..." 加载动画,Web 在 MCP 工具加载期间显示状态消息 - Web:修复工具栏变更面板中文件列表滚动溢出的问题 - Core:新增 `compaction_trigger_ratio` 配置项(默认 `0.85`),用于控制自动压缩的触发时机——当上下文用量达到配置比例或剩余空间低于 `reserved_context_size` 时触发压缩,以先满足的条件为准 - Core:`/compact` 命令支持自定义指令(如 `/compact keep database discussions`),可指导压缩时重点保留的内容 diff --git a/src/kimi_cli/acp/session.py b/src/kimi_cli/acp/session.py index c02848b0d..413a7836c 100644 --- a/src/kimi_cli/acp/session.py +++ b/src/kimi_cli/acp/session.py @@ -25,6 +25,8 @@ CompactionBegin, CompactionEnd, ContentPart, + MCPLoadingBegin, + MCPLoadingEnd, QuestionRequest, StatusUpdate, StepBegin, @@ -158,6 +160,10 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: pass case CompactionEnd(): pass + case MCPLoadingBegin(): + pass + case MCPLoadingEnd(): + pass case StatusUpdate(): pass case ThinkPart(think=think): diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 5dbf5d5bd..87f7a3755 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -48,6 +48,8 @@ CompactionBegin, CompactionEnd, ContentPart, + MCPLoadingBegin, + MCPLoadingEnd, StatusUpdate, StepBegin, StepInterrupted, @@ -357,7 +359,14 @@ async def _agent_loop(self) -> TurnOutcome: self._steer_queue.get_nowait() if isinstance(self._agent.toolset, KimiToolset): - await self._agent.toolset.wait_for_mcp_tools() + loading = self._agent.toolset.has_pending_mcp_tools() + if loading: + wire_send(MCPLoadingBegin()) + try: + await self._agent.toolset.wait_for_mcp_tools() + finally: + if loading: + wire_send(MCPLoadingEnd()) async def _pipe_approval_to_wire(): while True: diff --git a/src/kimi_cli/soul/toolset.py b/src/kimi_cli/soul/toolset.py index bd937e45d..3378b199e 100644 --- a/src/kimi_cli/soul/toolset.py +++ b/src/kimi_cli/soul/toolset.py @@ -338,6 +338,10 @@ async def _connect(): else: await _connect() + def has_pending_mcp_tools(self) -> bool: + """Return True if the background MCP tool-loading task is still running.""" + return self._mcp_loading_task is not None and not self._mcp_loading_task.done() + async def wait_for_mcp_tools(self) -> None: """Wait for background MCP tool loading to finish.""" task = self._mcp_loading_task diff --git a/src/kimi_cli/ui/shell/visualize.py b/src/kimi_cli/ui/shell/visualize.py index 9ef162240..5f64373c8 100644 --- a/src/kimi_cli/ui/shell/visualize.py +++ b/src/kimi_cli/ui/shell/visualize.py @@ -36,6 +36,8 @@ CompactionEnd, ContentPart, DiffDisplayBlock, + MCPLoadingBegin, + MCPLoadingEnd, QuestionRequest, ShellDisplayBlock, StatusUpdate, @@ -748,6 +750,7 @@ def __init__(self, initial_status: StatusUpdate, cancel_event: asyncio.Event | N self._mooning_spinner: Spinner | None = None self._compacting_spinner: Spinner | None = None + self._mcp_loading_spinner: Spinner | None = None self._current_content_block: _ContentBlock | None = None self._tool_call_blocks: dict[str, _ToolCallBlock] = {} @@ -852,7 +855,9 @@ def refresh_soon(self) -> None: def compose(self) -> RenderableType: """Compose the live view display content.""" blocks: list[RenderableType] = [] - if self._mooning_spinner is not None: + if self._mcp_loading_spinner is not None: + blocks.append(self._mcp_loading_spinner) + elif self._mooning_spinner is not None: blocks.append(self._mooning_spinner) elif self._compacting_spinner is not None: blocks.append(self._compacting_spinner) @@ -875,6 +880,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: if isinstance(msg, StepBegin): self.cleanup(is_interrupt=False) + self._mcp_loading_spinner = None self._mooning_spinner = Spinner("moon", "") self.refresh_soon() return @@ -895,6 +901,12 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: case CompactionEnd(): self._compacting_spinner = None self.refresh_soon() + case MCPLoadingBegin(): + self._mcp_loading_spinner = Spinner("dots", "Connecting to MCP servers...") + self.refresh_soon() + case MCPLoadingEnd(): + self._mcp_loading_spinner = None + self.refresh_soon() case StatusUpdate(): self._status_block.update(msg) case ContentPart(): diff --git a/src/kimi_cli/wire/types.py b/src/kimi_cli/wire/types.py index e7b646b67..ffac78926 100644 --- a/src/kimi_cli/wire/types.py +++ b/src/kimi_cli/wire/types.py @@ -88,6 +88,18 @@ class CompactionEnd(BaseModel): pass +class MCPLoadingBegin(BaseModel): + """Indicates that MCP tool loading is in progress.""" + + pass + + +class MCPLoadingEnd(BaseModel): + """Indicates that MCP tool loading has finished.""" + + pass + + class StatusUpdate(BaseModel): """ An update on the current status of the soul. @@ -349,6 +361,8 @@ def resolved(self) -> bool: | StepInterrupted | CompactionBegin | CompactionEnd + | MCPLoadingBegin + | MCPLoadingEnd | StatusUpdate | ContentPart | ToolCall @@ -431,6 +445,8 @@ def to_wire_message(self) -> WireMessage: "StepInterrupted", "CompactionBegin", "CompactionEnd", + "MCPLoadingBegin", + "MCPLoadingEnd", "StatusUpdate", "ContentPart", "ToolCall", diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index 0a41b392d..d0f239f17 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -14,6 +14,8 @@ CompactionBegin, CompactionEnd, ImageURLPart, + MCPLoadingBegin, + MCPLoadingEnd, QuestionItem, QuestionOption, QuestionRequest, @@ -87,6 +89,14 @@ async def test_wire_message_serde(): assert serialize_wire_message(msg) == snapshot({"type": "CompactionEnd", "payload": {}}) _test_serde(msg) + msg = MCPLoadingBegin() + assert serialize_wire_message(msg) == snapshot({"type": "MCPLoadingBegin", "payload": {}}) + _test_serde(msg) + + msg = MCPLoadingEnd() + assert serialize_wire_message(msg) == snapshot({"type": "MCPLoadingEnd", "payload": {}}) + _test_serde(msg) + msg = StatusUpdate(context_usage=0.5) assert serialize_wire_message(msg) == snapshot( { diff --git a/tests_e2e/test_wire_skills_mcp.py b/tests_e2e/test_wire_skills_mcp.py index 9995841e9..3e444a5ed 100644 --- a/tests_e2e/test_wire_skills_mcp.py +++ b/tests_e2e/test_wire_skills_mcp.py @@ -287,6 +287,8 @@ def ping(text: str) -> str: "type": "TurnBegin", "payload": {"user_input": "call mcp"}, }, + {"method": "event", "type": "MCPLoadingBegin", "payload": {}}, + {"method": "event", "type": "MCPLoadingEnd", "payload": {}}, {"method": "event", "type": "StepBegin", "payload": {"n": 1}}, { "method": "event", diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index d016c68ed..153877330 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -348,6 +348,9 @@ export function useSessionStream( // Track compaction indicator message so we can remove it on CompactionEnd const compactionMessageIdRef = useRef(null); + // Track MCP loading indicator message so we can remove it on MCPLoadingEnd + const mcpLoadingMessageIdRef = useRef(null); + // Wrapped setMessages const setMessages: typeof setMessagesInternal = useCallback((action) => { setMessagesInternal(action); @@ -1759,6 +1762,31 @@ export function useSessionStream( break; } + case "MCPLoadingBegin": { + const mcpMsgId = getNextMessageId("assistant"); + mcpLoadingMessageIdRef.current = mcpMsgId; + setMessages((prev) => [ + ...prev, + { + id: mcpMsgId, + role: "assistant", + variant: "status", + content: "Connecting to MCP servers…", + isStreaming: true, + }, + ]); + break; + } + + case "MCPLoadingEnd": { + const mcpMsgId = mcpLoadingMessageIdRef.current; + mcpLoadingMessageIdRef.current = null; + if (mcpMsgId) { + setMessages((prev) => prev.filter((m) => m.id !== mcpMsgId)); + } + break; + } + default: break; } @@ -2347,9 +2375,16 @@ export function useSessionStream( pendingApprovalRequestsRef.current.clear(); pendingQuestionRequestsRef.current.clear(); + // Remove lingering MCP loading indicator (e.g. MCPLoadingEnd was never received) + const mcpMsgId = mcpLoadingMessageIdRef.current; + if (mcpMsgId) { + mcpLoadingMessageIdRef.current = null; + setMessages((prev) => prev.filter((m) => m.id !== mcpMsgId)); + } + // Mark all streaming/subagent messages as complete completeStreamingMessages(); - }, [completeStreamingMessages, setAwaitingFirstResponse]); + }, [completeStreamingMessages, setAwaitingFirstResponse, setMessages]); // Send cancel request or disconnect if stream not ready const cancel = useCallback(() => { diff --git a/web/src/hooks/wireTypes.ts b/web/src/hooks/wireTypes.ts index 5eaa229d1..67deaaf63 100644 --- a/web/src/hooks/wireTypes.ts +++ b/web/src/hooks/wireTypes.ts @@ -153,6 +153,16 @@ export type CompactionEndEvent = { payload?: Record; }; +export type MCPLoadingBeginEvent = { + type: "MCPLoadingBegin"; + payload?: Record; +}; + +export type MCPLoadingEndEvent = { + type: "MCPLoadingEnd"; + payload?: Record; +}; + export type ApprovalRequestEvent = { type: "ApprovalRequest"; payload: { @@ -224,6 +234,8 @@ export type WireEvent = | SessionNoticeEvent | CompactionBeginEvent | CompactionEndEvent + | MCPLoadingBeginEvent + | MCPLoadingEndEvent | ApprovalRequestEvent | ApprovalRequestResolvedEvent | QuestionRequestEvent