diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa778741..daa511355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method +- Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes +- Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state - Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending - Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set - Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with "Imported" filter toggle, `kimi export ` CLI command, and delete support for imported sessions with AlertDialog confirmation diff --git a/docs/en/customization/wire-mode.md b/docs/en/customization/wire-mode.md index 31481e092..b3f358f74 100644 --- a/docs/en/customization/wire-mode.md +++ b/docs/en/customization/wire-mode.md @@ -94,6 +94,8 @@ interface InitializeParams { interface ClientCapabilities { /** Whether the client can handle QuestionRequest messages */ supports_question?: boolean + /** Whether the client supports plan mode */ + supports_plan_mode?: boolean } interface ClientInfo { @@ -293,6 +295,57 @@ If no turn is in progress: {"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}} ``` +### `set_plan_mode` + +::: info Added +Added in Wire 1.4. +::: + +- **Direction**: Client → Agent +- **Type**: Request (requires response) + +Set plan mode to a specific state. After calling, the agent updates plan mode and sends a `StatusUpdate` event with the new state. + +This feature requires capability negotiation: the client must declare `capabilities.supports_plan_mode: true` during `initialize` for the agent to enable plan mode tools (`EnterPlanMode`, `ExitPlanMode`). If the client does not declare support, these tools are automatically hidden from the LLM's tool list. + +Plan mode state is persisted to the session, so it survives process restarts and is restored when the session resumes. + +```typescript +/** set_plan_mode request parameters */ +interface SetPlanModeParams { + /** Whether to enable plan mode */ + enabled: boolean +} + +/** set_plan_mode response result */ +interface SetPlanModeResult { + /** Fixed as "ok" */ + status: "ok" + /** Plan mode state after the call */ + plan_mode: boolean +} +``` + +**Request example** + +```json +{"jsonrpc": "2.0", "method": "set_plan_mode", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "params": {"enabled": true}} +``` + +**Success response example** + +```json +{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "ok", "plan_mode": true}} +``` + +**Error response example** + +If plan mode is not supported in the current environment: + +```json +{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "Plan mode is not supported"}} +``` + ### `cancel` - **Direction**: Client → Agent @@ -484,10 +537,16 @@ Status update. interface StatusUpdate { /** Context usage ratio, float between 0-1, may be absent in JSON */ context_usage?: number | null + /** Number of tokens currently in the context, may be absent in JSON */ + context_tokens?: number | null + /** Maximum number of tokens the context can hold, may be absent in JSON */ + max_context_tokens?: number | null /** Token usage stats for current step, may be absent in JSON */ token_usage?: TokenUsage | null /** Message ID for current step, may be absent in JSON */ message_id?: string | null + /** Whether plan mode (read-only) is active, null means no change, may be absent in JSON */ + plan_mode?: boolean | null } interface TokenUsage { diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 68aa007b9..c8c74f1dc 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,9 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method +- Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes +- Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state - Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending - Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set - Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with "Imported" filter toggle, `kimi export ` CLI command, and delete support for imported sessions with AlertDialog confirmation diff --git a/docs/zh/customization/wire-mode.md b/docs/zh/customization/wire-mode.md index 84f9a7970..88c91bb57 100644 --- a/docs/zh/customization/wire-mode.md +++ b/docs/zh/customization/wire-mode.md @@ -94,6 +94,8 @@ interface InitializeParams { interface ClientCapabilities { /** 是否支持处理 QuestionRequest 消息 */ supports_question?: boolean + /** 是否支持 Plan 模式 */ + supports_plan_mode?: boolean } interface ClientInfo { @@ -293,6 +295,57 @@ interface SteerResult { {"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}} ``` +### `set_plan_mode` + +::: info 新增 +新增于 Wire 1.4。 +::: + +- **方向**:Client → Agent +- **类型**:Request(需要响应) + +将 Plan 模式设置为指定状态。调用后 Agent 会更新 Plan 模式并通过 `StatusUpdate` 事件通知新的状态。 + +此功能需要能力协商:Client 在 `initialize` 时通过 `capabilities.supports_plan_mode: true` 声明支持后,Agent 才会启用 Plan 模式相关工具(`EnterPlanMode`、`ExitPlanMode`)。如果 Client 未声明支持,这些工具会从 LLM 的工具列表中自动隐藏。 + +Plan 模式状态会持久化到会话中,因此在进程重启后可以恢复。 + +```typescript +/** set_plan_mode 请求参数 */ +interface SetPlanModeParams { + /** 是否启用 Plan 模式 */ + enabled: boolean +} + +/** set_plan_mode 响应结果 */ +interface SetPlanModeResult { + /** 固定为 "ok" */ + status: "ok" + /** 调用后的 Plan 模式状态 */ + plan_mode: boolean +} +``` + +**请求示例** + +```json +{"jsonrpc": "2.0", "method": "set_plan_mode", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "params": {"enabled": true}} +``` + +**成功响应示例** + +```json +{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "ok", "plan_mode": true}} +``` + +**错误响应示例** + +如果当前环境不支持 Plan 模式: + +```json +{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "Plan mode is not supported"}} +``` + ### `cancel` - **方向**:Client → Agent @@ -484,10 +537,16 @@ interface StepBegin { interface StatusUpdate { /** 上下文使用率,0-1 之间的浮点数,JSON 中可能不存在 */ context_usage?: number | null + /** 当前上下文中的 token 数量,JSON 中可能不存在 */ + context_tokens?: number | null + /** 上下文可容纳的最大 token 数量,JSON 中可能不存在 */ + max_context_tokens?: number | null /** 当前步骤的 token 用量统计,JSON 中可能不存在 */ token_usage?: TokenUsage | null /** 当前步骤的消息 ID,JSON 中可能不存在 */ message_id?: string | null + /** Plan 模式是否激活,null 表示状态未变更,JSON 中可能不存在 */ + plan_mode?: boolean | null } interface TokenUsage { diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index a210939ec..e13c0091c 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,9 @@ ## 未发布 +- Web:新增 Web UI 中的 Plan 模式切换——在输入工具栏中添加开关控件,Plan 模式激活时输入框显示蓝色虚线边框,并支持通过 `set_plan_mode` Wire 协议方法设置 Plan 模式 +- Core:Plan 模式状态跨会话持久化——将 `plan_mode` 保存到 `SessionState`,会话恢复时自动还原 +- Core:修复工具触发的 Plan 模式变更未正确反映在 StatusUpdate 中的问题——在 `EnterPlanMode`/`ExitPlanMode` 工具执行后发送更新的 `StatusUpdate`,确保客户端看到最新状态 - Core:修复部分 Linux 系统(如内核版本 6.8.0-101)上 HTTP 请求头包含尾部空白/换行符导致连接错误的问题——发送前对 ASCII 请求头值执行空白裁剪 - Core:修复 OpenAI Responses provider 隐式发送 `reasoning.effort=null` 导致需要推理的 Responses 兼容端点报错的问题——现在仅在显式设置时才发送推理参数 - Vis:新增会话下载、导入、导出与删除功能——在会话浏览器和详情页支持一键 ZIP 下载,支持将 ZIP 文件导入到独立的 `~/.kimi/imported_sessions/` 目录并通过"Imported"筛选器切换查看,新增 `kimi export ` CLI 命令,支持删除导入的会话并提供 AlertDialog 二次确认 diff --git a/src/kimi_cli/session_state.py b/src/kimi_cli/session_state.py index f691acd22..20cdfc388 100644 --- a/src/kimi_cli/session_state.py +++ b/src/kimi_cli/session_state.py @@ -30,6 +30,7 @@ class SessionState(BaseModel): approval: ApprovalStateData = Field(default_factory=ApprovalStateData) dynamic_subagents: list[DynamicSubagentSpec] = Field(default_factory=_default_dynamic_subagents) additional_dirs: list[str] = Field(default_factory=list) + plan_mode: bool = False def load_session_state(session_dir: Path) -> SessionState: diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index e693bf829..4b105a298 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -128,9 +128,11 @@ def __init__( self._checkpoint_with_user_message = False self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue() - self._plan_mode: bool = False + self._plan_mode: bool = self._runtime.session.state.plan_mode self._plan_session_id: str | None = None self._pending_plan_activation_injection: bool = False + if self._plan_mode: + self._ensure_plan_session_id() self._injection_providers: list[DynamicInjectionProvider] = [ PlanModeInjectionProvider(), ] @@ -231,12 +233,17 @@ def _ensure_plan_session_id(self) -> None: def _set_plan_mode(self, enabled: bool, *, source: Literal["manual", "tool"]) -> bool: """Update plan mode state for either manual or tool-driven toggles.""" + if enabled == self._plan_mode: + return self._plan_mode self._plan_mode = enabled if enabled: self._ensure_plan_session_id() self._pending_plan_activation_injection = source == "manual" else: self._pending_plan_activation_injection = False + # Persist plan mode to session state so it survives process restarts + self._runtime.session.state.plan_mode = self._plan_mode + self._runtime.session.save_state() return self._plan_mode def get_plan_file_path(self) -> Path | None: @@ -271,13 +278,16 @@ async def toggle_plan_mode(self) -> bool: return self._set_plan_mode(not self._plan_mode, source="tool") async def toggle_plan_mode_from_manual(self) -> bool: - """Toggle plan mode from UI/manual entry points. + """Toggle plan mode from UI/manual entry points (slash command, keybinding).""" + return self._set_plan_mode(not self._plan_mode, source="manual") + + async def set_plan_mode_from_manual(self, enabled: bool) -> bool: + """Set plan mode to a specific state from UI/manual entry points. - Manual toggles do not append a synthetic history message. Instead, entering - plan mode schedules a one-shot injection for the next LLM step, and exiting - plan mode clears that pending injection if it has not been used yet. + Unlike toggle, this accepts the desired state directly, avoiding + race conditions when the caller already knows the target value. """ - return self._set_plan_mode(not self._plan_mode, source="manual") + return self._set_plan_mode(enabled, source="manual") def consume_pending_plan_activation_injection(self) -> bool: """Consume the next-step activation reminder scheduled by a manual toggle.""" @@ -650,7 +660,9 @@ async def _kosong_step_with_retry() -> StepResult: result = await _kosong_step_with_retry() logger.debug("Got step result: {result}", result=result) - status_update = StatusUpdate(token_usage=result.usage, message_id=result.id) + status_update = StatusUpdate( + token_usage=result.usage, message_id=result.id, plan_mode=self._plan_mode + ) if result.usage is not None: # mark the token count for the context before the step await self._context.update_token_count(result.usage.input) @@ -661,9 +673,15 @@ async def _kosong_step_with_retry() -> StepResult: wire_send(status_update) # wait for all tool results (may be interrupted) + plan_mode_before_tools = self._plan_mode results = await result.tool_results() logger.debug("Got tool results: {results}", results=results) + # If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution, + # send a corrected StatusUpdate so the client sees the up-to-date state. + if self._plan_mode != plan_mode_before_tools: + wire_send(StatusUpdate(plan_mode=self._plan_mode)) + # shield the context manipulation from interruption await asyncio.shield(self._grow_context(result, results)) diff --git a/src/kimi_cli/soul/slash.py b/src/kimi_cli/soul/slash.py index 68732b768..2bdf94caa 100644 --- a/src/kimi_cli/soul/slash.py +++ b/src/kimi_cli/soul/slash.py @@ -109,10 +109,12 @@ async def plan(soul: KimiSoul, args: str): await soul.toggle_plan_mode_from_manual() plan_path = soul.get_plan_file_path() wire_send(TextPart(text=f"Plan mode ON. Plan file: {plan_path}")) + wire_send(StatusUpdate(plan_mode=soul.plan_mode)) elif subcmd == "off": if soul.plan_mode: await soul.toggle_plan_mode_from_manual() wire_send(TextPart(text="Plan mode OFF. All tools are now available.")) + wire_send(StatusUpdate(plan_mode=soul.plan_mode)) elif subcmd == "view": content = soul.read_current_plan() if content: @@ -135,6 +137,7 @@ async def plan(soul: KimiSoul, args: str): ) else: wire_send(TextPart(text="Plan mode OFF. All tools are now available.")) + wire_send(StatusUpdate(plan_mode=soul.plan_mode)) @registry.command(name="add-dir") diff --git a/src/kimi_cli/wire/jsonrpc.py b/src/kimi_cli/wire/jsonrpc.py index 3f55e5e61..78cd1e098 100644 --- a/src/kimi_cli/wire/jsonrpc.py +++ b/src/kimi_cli/wire/jsonrpc.py @@ -135,6 +135,18 @@ def _serialize(self) -> dict[str, Any]: raise NotImplementedError("Steer message serialization is not implemented.") +class _SetPlanModeParams(BaseModel): + enabled: bool + + model_config = ConfigDict(extra="ignore") + + +class JSONRPCSetPlanModeMessage(_MessageBase): + method: Literal["set_plan_mode"] = "set_plan_mode" + id: str + params: _SetPlanModeParams + + class JSONRPCCancelMessage(_MessageBase): method: Literal["cancel"] = "cancel" id: str @@ -185,10 +197,11 @@ def _validate_params(cls, value: Any) -> Request: | JSONRPCPromptMessage | JSONRPCSteerMessage | JSONRPCReplayMessage + | JSONRPCSetPlanModeMessage | JSONRPCCancelMessage ) JSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage) -JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "cancel"} +JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "set_plan_mode", "cancel"} type JSONRPCOutMessage = ( JSONRPCSuccessResponse diff --git a/src/kimi_cli/wire/protocol.py b/src/kimi_cli/wire/protocol.py index c39c72c92..e0d9ae36c 100644 --- a/src/kimi_cli/wire/protocol.py +++ b/src/kimi_cli/wire/protocol.py @@ -1,2 +1,2 @@ -WIRE_PROTOCOL_VERSION: str = "1.3" +WIRE_PROTOCOL_VERSION: str = "1.4" WIRE_PROTOCOL_LEGACY_VERSION: str = "1.1" diff --git a/src/kimi_cli/wire/server.py b/src/kimi_cli/wire/server.py index e7f42e4fd..b0c6de9e1 100644 --- a/src/kimi_cli/wire/server.py +++ b/src/kimi_cli/wire/server.py @@ -26,6 +26,7 @@ QuestionRequest, QuestionResponse, Request, + StatusUpdate, ToolCallRequest, is_event, is_request, @@ -47,6 +48,7 @@ JSONRPCPromptMessage, JSONRPCReplayMessage, JSONRPCRequestMessage, + JSONRPCSetPlanModeMessage, JSONRPCSteerMessage, JSONRPCSuccessResponse, Statuses, @@ -294,6 +296,8 @@ async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None: resp = await self._handle_replay(msg) case JSONRPCSteerMessage(): resp = await self._handle_steer(msg) + case JSONRPCSetPlanModeMessage(): + resp = await self._handle_set_plan_mode(msg) case JSONRPCCancelMessage(): resp = await self._handle_cancel(msg) case JSONRPCSuccessResponse() | JSONRPCErrorResponse(): @@ -552,6 +556,29 @@ async def _handle_steer( result={"status": Statuses.STEERED}, ) + async def _handle_set_plan_mode( + self, msg: JSONRPCSetPlanModeMessage + ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse: + if not isinstance(self._soul, KimiSoul): + return JSONRPCErrorResponse( + id=msg.id, + error=JSONRPCErrorObject( + code=ErrorCodes.INVALID_STATE, + message="Plan mode is not supported", + ), + ) + + new_state = await self._soul.set_plan_mode_from_manual(msg.params.enabled) + + status = StatusUpdate(plan_mode=new_state) + await self._send_msg(JSONRPCEventMessage(params=status)) + # Persist to wire file so replay reconstructs plan mode state + await self._soul.wire_file.append_message(status) + return JSONRPCSuccessResponse( + id=msg.id, + result={"status": "ok", "plan_mode": new_state}, + ) + async def _handle_replay( self, msg: JSONRPCReplayMessage ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse: diff --git a/src/kimi_cli/wire/types.py b/src/kimi_cli/wire/types.py index 83a3acf7f..e30c10979 100644 --- a/src/kimi_cli/wire/types.py +++ b/src/kimi_cli/wire/types.py @@ -116,6 +116,8 @@ class StatusUpdate(BaseModel): """The token usage statistics of the current step.""" message_id: str | None = None """The message ID of the current step.""" + plan_mode: bool | None = None + """Whether plan mode (read-only) is active. None means no change.""" class SubagentEvent(BaseModel): diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index 12306d3b2..81225411f 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -107,6 +107,7 @@ async def test_wire_message_serde(): "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": None, }, } ) diff --git a/tests_e2e/test_mcp_cli.py b/tests_e2e/test_mcp_cli.py index 1e3bc6590..72089938e 100644 --- a/tests_e2e/test_mcp_cli.py +++ b/tests_e2e/test_mcp_cli.py @@ -113,13 +113,22 @@ def ping(text: str) -> str: listed = _run_cli(["mcp", "list"], env) assert listed.returncode == 0, _normalize_cli_output(listed.stderr, replace=replacements) assert _normalize_cli_output(listed.stdout, replace=replacements) == snapshot( - "MCP config file: /.kimi/mcp.json\n test (stdio): \n" + """\ +MCP config file: /.kimi/mcp.json + test (stdio): +""" ) tested = _run_cli(["mcp", "test", "test"], env) assert tested.returncode == 0, _normalize_cli_output(tested.stderr, replace=replacements) assert _normalize_cli_output(tested.stdout, replace=replacements) == snapshot( - "Testing connection to 'test'...\n✓ Connected to 'test'\n Available tools: 1\n Tools:\n - ping: pong the input text\n" + """\ +Testing connection to 'test'... +✓ Connected to 'test' + Available tools: 1 + Tools: + - ping: pong the input text +""" ) removed = _run_cli(["mcp", "remove", "test"], env) @@ -134,7 +143,10 @@ def ping(text: str) -> str: listed_empty.stderr, replace=replacements ) assert _normalize_cli_output(listed_empty.stdout, replace=replacements) == snapshot( - "MCP config file: /.kimi/mcp.json\nNo MCP servers configured.\n" + """\ +MCP config file: /.kimi/mcp.json +No MCP servers configured. +""" ) @@ -209,7 +221,11 @@ def test_mcp_http_management_and_auth_errors(tmp_path: Path) -> None: list_http = _run_cli(["mcp", "list"], env) assert list_http.returncode == 0, _normalize_cli_output(list_http.stderr) assert _normalize_cli_output(list_http.stdout) == snapshot( - "MCP config file: /.kimi/mcp.json\n remote (http): https://example.com/mcp\n oauth (http): https://example.com/oauth [authorization required - run: mcp auth oauth]\n" + """\ +MCP config file: /.kimi/mcp.json + remote (http): https://example.com/mcp + oauth (http): https://example.com/oauth [authorization required - run: mcp auth oauth] +""" ) auth_http = _run_cli(["mcp", "auth", "remote"], env) diff --git a/tests_e2e/test_wire_approvals_tools.py b/tests_e2e/test_wire_approvals_tools.py index 7415580e4..564aff63a 100644 --- a/tests_e2e/test_wire_approvals_tools.py +++ b/tests_e2e/test_wire_approvals_tools.py @@ -118,6 +118,7 @@ def test_shell_approval_approve(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -166,6 +167,7 @@ def test_shell_approval_approve(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -240,6 +242,7 @@ def test_shell_approval_reject(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -364,6 +367,7 @@ def test_approve_for_session(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -412,6 +416,7 @@ def test_approve_for_session(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -445,6 +450,7 @@ def test_approve_for_session(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -476,6 +482,7 @@ def test_approve_for_session(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -547,6 +554,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -578,6 +586,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -825,6 +834,7 @@ def test_display_block_todo(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -858,6 +868,7 @@ def test_display_block_todo(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -939,6 +950,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -972,6 +984,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -1043,6 +1056,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -1074,6 +1088,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -1159,6 +1174,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -1190,6 +1206,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, diff --git a/tests_e2e/test_wire_config.py b/tests_e2e/test_wire_config.py index 25af4ca9a..01e38f9e5 100644 --- a/tests_e2e/test_wire_config.py +++ b/tests_e2e/test_wire_config.py @@ -79,6 +79,7 @@ def test_config_string(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -167,6 +168,7 @@ def test_model_override(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, diff --git a/tests_e2e/test_wire_prompt.py b/tests_e2e/test_wire_prompt.py index 0edf9f4a8..851ea5ce3 100644 --- a/tests_e2e/test_wire_prompt.py +++ b/tests_e2e/test_wire_prompt.py @@ -89,6 +89,7 @@ def test_basic_prompt_events(tmp_path) -> None: "input_cache_creation": 0, }, "message_id": "scripted-1", + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -291,6 +292,7 @@ def test_max_steps_reached(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -366,6 +368,7 @@ def test_status_update_fields(tmp_path) -> None: "input_cache_creation": 0, }, "message_id": "scripted-1", + "plan_mode": False, }, } ) @@ -462,6 +465,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -510,6 +514,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index 2fbeed214..d73694e0e 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -36,12 +36,12 @@ def test_initialize_handshake(tmp_path) -> None: try: resp = send_initialize(wire) result = _as_dict(resp.get("result")) - assert result.get("protocol_version") == "1.3" + assert result.get("protocol_version") == "1.4" assert "slash_commands" in result assert normalize_response(resp) == snapshot( { "result": { - "protocol_version": "1.3", + "protocol_version": "1.4", "server": {"name": "Kimi Code CLI", "version": ""}, "slash_commands": [ { @@ -128,7 +128,7 @@ def test_initialize_external_tool_conflict(tmp_path) -> None: assert normalize_response(resp) == snapshot( { "result": { - "protocol_version": "1.3", + "protocol_version": "1.4", "server": {"name": "Kimi Code CLI", "version": ""}, "slash_commands": [ { @@ -288,6 +288,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -328,6 +329,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -378,6 +380,7 @@ def test_prompt_without_initialize(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, diff --git a/tests_e2e/test_wire_sessions.py b/tests_e2e/test_wire_sessions.py index f102748a8..7099e5f52 100644 --- a/tests_e2e/test_wire_sessions.py +++ b/tests_e2e/test_wire_sessions.py @@ -195,6 +195,7 @@ def test_clear_context_rotates(tmp_path) -> None: "max_context_tokens": 100000, "token_usage": None, "message_id": None, + "plan_mode": None, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -271,6 +272,7 @@ def test_manual_compact(tmp_path) -> None: "max_context_tokens": 100000, "token_usage": None, "message_id": None, + "plan_mode": None, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -425,6 +427,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -473,6 +476,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, diff --git a/tests_e2e/test_wire_skills_mcp.py b/tests_e2e/test_wire_skills_mcp.py index e5f297b94..0f67b6e81 100644 --- a/tests_e2e/test_wire_skills_mcp.py +++ b/tests_e2e/test_wire_skills_mcp.py @@ -116,6 +116,7 @@ def test_skill_prompt_injects_skill_text(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -204,6 +205,7 @@ def test_flow_skill(tmp_path) -> None: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, @@ -318,6 +320,7 @@ def ping(text: str) -> str: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, { @@ -366,6 +369,7 @@ def ping(text: str) -> str: "max_context_tokens": None, "token_usage": None, "message_id": None, + "plan_mode": False, }, }, {"method": "event", "type": "TurnEnd", "payload": {}}, diff --git a/vis/src/features/wire-viewer/integrity-check.tsx b/vis/src/features/wire-viewer/integrity-check.tsx index c06c581b6..036b57696 100644 --- a/vis/src/features/wire-viewer/integrity-check.tsx +++ b/vis/src/features/wire-viewer/integrity-check.tsx @@ -102,7 +102,7 @@ export function computeIntegrity(events: WireEvent[]): IntegrityResult { } } else if (ev.type === "ApprovalResponse") { totalPairable++; - const id = ev.payload.id as string | undefined; + const id = ev.payload.request_id as string | undefined; if (id && approvalMap.has(id)) { approvalMap.delete(id); } else { diff --git a/web/src/features/chat/chat-workspace-container.tsx b/web/src/features/chat/chat-workspace-container.tsx index 82540117a..b0ceccd19 100644 --- a/web/src/features/chat/chat-workspace-container.tsx +++ b/web/src/features/chat/chat-workspace-container.tsx @@ -122,6 +122,8 @@ export function ChatWorkspaceContainer({ currentStep, isConnected: isStreamConnected, isReplayingHistory, + planMode, + sendSetPlanMode, slashCommands, } = sessionStream; @@ -310,6 +312,10 @@ export function ChatWorkspaceContainer({ [status, isUploadingFiles, selectedSessionId, uploadFilesToSession, sendMessage, enqueue], ); + const handlePlanModeChange = useCallback((enabled: boolean) => { + sendSetPlanMode(enabled); + }, [sendSetPlanMode]); + const handleForkSession = useCallback( async (turnIndex: number) => { if (!(selectedSessionId && onForkSession)) { @@ -378,6 +384,8 @@ export function ChatWorkspaceContainer({ onOpenSidebar={onOpenSidebar} onRenameSession={onRenameSession} slashCommands={slashCommands} + planMode={planMode} + onPlanModeChange={handlePlanModeChange} onForkSession={onForkSession ? handleForkSession : undefined} /> ); diff --git a/web/src/features/chat/chat.tsx b/web/src/features/chat/chat.tsx index 19584a091..46df065dd 100644 --- a/web/src/features/chat/chat.tsx +++ b/web/src/features/chat/chat.tsx @@ -80,6 +80,10 @@ type ChatWorkspaceProps = { onRenameSession?: (sessionId: string, newTitle: string) => Promise; /** Available slash commands */ slashCommands?: SlashCommandDef[]; + /** Whether plan mode is active */ + planMode?: boolean; + /** Callback to set plan mode */ + onPlanModeChange?: (enabled: boolean) => void; /** Maximum context size for the current model (tokens) */ maxContextSize?: number; /** Fork session at a specific turn */ @@ -112,6 +116,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ onRenameSession, maxContextSize, slashCommands = [], + planMode = false, + onPlanModeChange, onForkSession, }: ChatWorkspaceProps): ReactElement { const [blocksExpanded, setBlocksExpanded] = useState(false); @@ -308,6 +314,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ gitDiffStats={gitDiffStats} isGitDiffLoading={isGitDiffLoading} slashCommands={slashCommands} + planMode={planMode} + onPlanModeChange={onPlanModeChange} activityStatus={activityStatus} usagePercent={usagePercent} usedTokens={usedTokens} diff --git a/web/src/features/chat/components/chat-prompt-composer.tsx b/web/src/features/chat/components/chat-prompt-composer.tsx index da71d67b8..ba2d051dd 100644 --- a/web/src/features/chat/components/chat-prompt-composer.tsx +++ b/web/src/features/chat/components/chat-prompt-composer.tsx @@ -62,6 +62,8 @@ type ChatPromptComposerProps = { gitDiffStats?: GitDiffStats | null; isGitDiffLoading?: boolean; slashCommands?: SlashCommandDef[]; + planMode?: boolean; + onPlanModeChange?: (enabled: boolean) => void; activityStatus?: ActivityDetail; usagePercent?: number; usedTokens?: number; @@ -83,6 +85,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ gitDiffStats, isGitDiffLoading, slashCommands = [], + planMode = false, + onPlanModeChange, activityStatus, usagePercent, usedTokens, @@ -193,6 +197,7 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ gitDiffStats={gitDiffStats} isGitDiffLoading={isGitDiffLoading} workDir={currentSession?.workDir} + planMode={planMode} activityStatus={activityStatus} usagePercent={usagePercent} usedTokens={usedTokens} @@ -202,7 +207,10 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ - + {isStreaming ? (
diff --git a/web/src/features/chat/components/prompt-toolbar/index.tsx b/web/src/features/chat/components/prompt-toolbar/index.tsx index 4fd6048e4..f20f51be6 100644 --- a/web/src/features/chat/components/prompt-toolbar/index.tsx +++ b/web/src/features/chat/components/prompt-toolbar/index.tsx @@ -25,6 +25,7 @@ type PromptToolbarProps = { gitDiffStats?: GitDiffStats | null; isGitDiffLoading?: boolean; workDir?: string | null; + planMode?: boolean; activityStatus?: ActivityDetail; usagePercent?: number; usedTokens?: number; @@ -38,6 +39,7 @@ export const PromptToolbar = memo(function PromptToolbarComponent({ gitDiffStats, isGitDiffLoading, workDir, + planMode = false, activityStatus, usagePercent, usedTokens, @@ -75,7 +77,7 @@ export const PromptToolbar = memo(function PromptToolbarComponent({ setActiveTab((prev) => (prev === tab ? null : tab)); }, []); - if (!(hasTabs || activityStatus || hasContext)) return null; + if (!(hasTabs || activityStatus || hasContext || planMode)) return null; return (
@@ -97,7 +99,7 @@ export const PromptToolbar = memo(function PromptToolbarComponent({ {/* ── Tab bar ── */}
- {activityStatus && ( +{activityStatus && ( )} diff --git a/web/src/features/chat/components/question-dialog.tsx b/web/src/features/chat/components/question-dialog.tsx index c9aad20d9..5eb8e6306 100644 --- a/web/src/features/chat/components/question-dialog.tsx +++ b/web/src/features/chat/components/question-dialog.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Kbd } from "@/components/ui/kbd"; import { cn } from "@/lib/utils"; +import { MessageResponse } from "@/components/ai-elements/message"; import type { LiveMessage } from "@/hooks/types"; import type { QuestionItem } from "@/hooks/wireTypes"; @@ -435,6 +438,21 @@ export function QuestionDialog({ )}
+ {/* Plan body preview */} + {currentQuestion.body && ( + + + + Plan Preview + + +
+ {currentQuestion.body} +
+
+
+ )} + {/* Options */}
{options.map((option, idx) => { @@ -485,7 +503,7 @@ export function QuestionDialog({ {/* "Other" — inline input */}
handleOptionClick(otherIndex)} - className="pointer-events-auto" + className="mt-0.5 pointer-events-auto" tabIndex={-1} /> ) : ( @@ -503,47 +521,59 @@ export function QuestionDialog({ {options.length + 1}. )} - { - setOtherText(e.target.value); - if (!isMultiSelect) { - setSelectedIndex(otherIndex); - } else if (!multiSelected.has(otherIndex)) { - setMultiSelected((prev) => new Set(prev).add(otherIndex)); - } - }} - onFocus={() => { - setSelectedIndex(otherIndex); - if (isMultiSelect && !multiSelected.has(otherIndex)) { - setMultiSelected((prev) => new Set(prev).add(otherIndex)); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing) { - e.preventDefault(); - handleSubmitCurrent(); - } else if (e.key === "Escape") { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex(Math.max(otherIndex - 1, 0)); - } else if (e.key === "ArrowDown") { - // Already at last position — no-op but prevent default - e.preventDefault(); - } - }} - placeholder="Type your answer..." - className={cn( - "flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50", - "border-0 outline-none ring-0 focus:ring-0 focus:outline-none", - "py-0 h-auto", - disableActions && "opacity-50 cursor-not-allowed", +
+ {currentQuestion.other_label && ( + + {currentQuestion.other_label} + + )} + {currentQuestion.other_description && ( + + {currentQuestion.other_description} + )} - disabled={disableActions} - /> + { + setOtherText(e.target.value); + if (!isMultiSelect) { + setSelectedIndex(otherIndex); + } else if (!multiSelected.has(otherIndex)) { + setMultiSelected((prev) => new Set(prev).add(otherIndex)); + } + }} + onFocus={() => { + setSelectedIndex(otherIndex); + if (isMultiSelect && !multiSelected.has(otherIndex)) { + setMultiSelected((prev) => new Set(prev).add(otherIndex)); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + handleSubmitCurrent(); + } else if (e.key === "Escape") { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex(Math.max(otherIndex - 1, 0)); + } else if (e.key === "ArrowDown") { + // Already at last position — no-op but prevent default + e.preventDefault(); + } + }} + placeholder={currentQuestion.other_label || "Type your answer..."} + className={cn( + "flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50", + "border-0 outline-none ring-0 focus:ring-0 focus:outline-none", + "py-0 h-auto", + disableActions && "opacity-50 cursor-not-allowed", + )} + disabled={disableActions} + /> +
diff --git a/web/src/features/chat/global-config-controls.tsx b/web/src/features/chat/global-config-controls.tsx index 590287190..4dcd1c39f 100644 --- a/web/src/features/chat/global-config-controls.tsx +++ b/web/src/features/chat/global-config-controls.tsx @@ -44,10 +44,14 @@ function getThinkingState(model: ConfigModel | null): ThinkingState { export type GlobalConfigControlsProps = { className?: string; + planMode?: boolean; + onPlanModeChange?: (enabled: boolean) => void; }; export function GlobalConfigControls({ className, + planMode = false, + onPlanModeChange, }: GlobalConfigControlsProps): ReactElement { const { config, isLoading, isUpdating, error, refresh, update } = useGlobalConfig(); @@ -273,6 +277,31 @@ export function GlobalConfigControls({ thinkingToggle )} + {onPlanModeChange && ( + <> +
+ + +
+ + Plan + + +
+
+ + {planMode + ? "Plan mode is active. The model will only read and plan, not modify files." + : "Enable plan mode for read-only research and planning."} + +
+ + )} + {(lastBusySkip && lastBusySkip.length > 0) || error ? (
) : null} diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 763ce36ce..b268fd63f 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -236,6 +236,10 @@ type UseSessionStreamReturn = { clearMessages: () => void; /** Connection error if any */ error: Error | null; + /** Whether plan mode is active */ + planMode: boolean; + /** Set plan mode via silent RPC (no context message) */ + sendSetPlanMode: (enabled: boolean) => void; /** Available slash commands from the server */ slashCommands: SlashCommandDef[]; }; @@ -279,6 +283,7 @@ export function useSessionStream( ); const [contextUsage, setContextUsage] = useState(0); const [tokenUsage, setTokenUsage] = useState(null); + const [planMode, setPlanMode] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); @@ -791,6 +796,7 @@ export function useSessionStream( setCurrentStep(0); setContextUsage(0); setTokenUsage(null); + setPlanMode(false); setError(null); setSessionStatus(null); lastStatusSeqRef.current = null; @@ -1615,6 +1621,11 @@ export function useSessionStream( setTokenUsage(nextTokenUsage); } + const nextPlanMode = event.payload.plan_mode; + if (typeof nextPlanMode === "boolean") { + setPlanMode(nextPlanMode); + } + // If we have a message_id, create a special message to display it const messageId = event.payload.message_id; if (messageId) { @@ -1826,13 +1837,14 @@ export function useSessionStream( method: "initialize", id, params: { - protocol_version: "1.3", + protocol_version: "1.4", client: { name: "kiwi", version: kimiCliVersion, }, capabilities: { supports_question: true, + supports_plan_mode: true, }, }, }; @@ -2611,6 +2623,20 @@ export function useSessionStream( resetStateRef.current(true); }, [setMessages]); + // Set plan mode via silent RPC (no context message) + const sendSetPlanMode = useCallback((enabled: boolean) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + return; + } + const message: JsonRpcRequest = { + jsonrpc: "2.0", + method: "set_plan_mode", + id: uuidV4(), + params: { enabled }, + }; + wsRef.current.send(JSON.stringify(message)); + }, []); + // Auto-connect when sessionId changes useLayoutEffect(() => { /** @@ -2693,6 +2719,8 @@ export function useSessionStream( setMessages, clearMessages, error, + planMode, + sendSetPlanMode, slashCommands, }; } diff --git a/web/src/hooks/wireTypes.ts b/web/src/hooks/wireTypes.ts index 67deaaf63..9cff17b2e 100644 --- a/web/src/hooks/wireTypes.ts +++ b/web/src/hooks/wireTypes.ts @@ -130,6 +130,7 @@ export type StatusUpdateEvent = { context_usage: number | null; token_usage?: TokenUsage | null; message_id?: string; + plan_mode?: boolean | null; }; }; @@ -197,6 +198,9 @@ export type QuestionItem = { header: string; options: QuestionOption[]; multi_select: boolean; + body?: string; + other_label?: string; + other_description?: string; }; export type QuestionRequestEvent = {