From 09406d452c66df952501988173bb6deb00542a3e Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 15:12:32 -0300 Subject: [PATCH 01/22] feat(web): add YOLO mode support to web interface Add ability to view and toggle YOLO mode from the web UI: - Add GET/POST /api/sessions/{id}/yolo endpoints - Add useYoloMode hook for state management - Add YOLO mode toggle in global config controls - Add YoloStatus and UpdateYoloRequest models - Update wire protocol with set_yolo_mode JSON-RPC method - Update StatusUpdate to include yolo_mode field --- src/kimi_cli/utils/pyinstaller.py | 4 +- src/kimi_cli/web/api/sessions.py | 57 ++++++++ src/kimi_cli/web/models.py | 15 ++ src/kimi_cli/web/runner/process.py | 19 +++ src/kimi_cli/wire/jsonrpc.py | 23 ++- src/kimi_cli/wire/server.py | 28 ++++ src/kimi_cli/wire/types.py | 2 + tests/core/test_wire_message.py | 1 + tests/utils/test_pyinstaller_utils.py | 10 ++ tests_e2e/test_wire_approvals_tools.py | 17 +++ tests_e2e/test_wire_config.py | 2 + tests_e2e/test_wire_prompt.py | 5 + tests_e2e/test_wire_protocol.py | 3 + tests_e2e/test_wire_sessions.py | 4 + tests_e2e/test_wire_skills_mcp.py | 6 + web/package-lock.json | 25 +--- .../chat/chat-workspace-container.tsx | 8 ++ web/src/features/chat/chat.tsx | 8 ++ .../chat/components/approval-dialog.tsx | 2 +- .../chat/components/chat-prompt-composer.tsx | 6 +- .../chat/components/session-files-panel.tsx | 2 +- .../features/chat/global-config-controls.tsx | 29 ++++ web/src/hooks/useSessionStream.ts | 27 ++++ web/src/hooks/useYoloMode.ts | 132 ++++++++++++++++++ web/src/hooks/wireTypes.ts | 1 + web/src/lib/api/models/UpdateYoloRequest.ts | 65 +++++++++ web/src/lib/api/models/YoloStatus.ts | 74 ++++++++++ web/src/lib/api/models/index.ts | 2 + 28 files changed, 549 insertions(+), 28 deletions(-) create mode 100644 web/src/hooks/useYoloMode.ts create mode 100644 web/src/lib/api/models/UpdateYoloRequest.ts create mode 100644 web/src/lib/api/models/YoloStatus.ts diff --git a/src/kimi_cli/utils/pyinstaller.py b/src/kimi_cli/utils/pyinstaller.py index d03a985ed..bec40d2f3 100644 --- a/src/kimi_cli/utils/pyinstaller.py +++ b/src/kimi_cli/utils/pyinstaller.py @@ -2,7 +2,9 @@ from PyInstaller.utils.hooks import collect_data_files, collect_submodules -hiddenimports = collect_submodules("kimi_cli.tools") + ["setproctitle"] +hiddenimports = ( + collect_submodules("kimi_cli.tools") + collect_submodules("kimi_cli.cli") + ["setproctitle"] +) datas = ( collect_data_files( "kimi_cli", diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index aca686d88..68531cf62 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -21,8 +21,10 @@ from starlette.websockets import WebSocket, WebSocketDisconnect from kimi_cli import logger +from kimi_cli.config import load_config from kimi_cli.metadata import load_metadata, save_metadata from kimi_cli.session import Session as KimiCLISession +from kimi_cli.session_state import load_session_state, save_session_state from kimi_cli.utils.subprocess_env import get_clean_env from kimi_cli.web.auth import is_origin_allowed, is_private_ip, verify_token from kimi_cli.web.models import ( @@ -33,6 +35,8 @@ Session, SessionStatus, UpdateSessionRequest, + UpdateYoloRequest, + YoloStatus, ) from kimi_cli.web.runner.messages import new_session_status_message, send_history_complete from kimi_cli.web.runner.process import KimiCLIRunner @@ -332,6 +336,14 @@ async def create_session(request: CreateSessionRequest | None = None) -> Session else: work_dir = KaosPath.unsafe_from_local_path(Path.home()) kimi_cli_session = await KimiCLISession.create(work_dir=work_dir) + + # Apply default_yolo config setting to new session + config = load_config() + if config.default_yolo: + state = load_session_state(kimi_cli_session.dir) + state.approval.yolo = True + save_session_state(state, kimi_cli_session.dir) + context_file = kimi_cli_session.dir / "context.jsonl" invalidate_sessions_cache() invalidate_work_dirs_cache() @@ -871,6 +883,51 @@ async def generate_session_title( return GenerateTitleResponse(title=title) +@router.get("/{session_id}/yolo", summary="Get YOLO mode status") +async def get_yolo_status(session_id: UUID) -> YoloStatus: + """Get the YOLO (auto-approve) mode status for a session.""" + session = load_session_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + session_dir = session.kimi_cli_session.dir + state = load_session_state(session_dir) + + return YoloStatus( + enabled=state.approval.yolo, + auto_approve_actions=list(state.approval.auto_approve_actions), + ) + + +@router.post("/{session_id}/yolo", summary="Update YOLO mode status") +async def update_yolo_status( + session_id: UUID, + request: UpdateYoloRequest, + runner: KimiCLIRunner = Depends(get_runner), +) -> YoloStatus: + """Enable or disable YOLO (auto-approve) mode for a session.""" + session = load_session_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + session_dir = session.kimi_cli_session.dir + state = load_session_state(session_dir) + + # Update state + state.approval.yolo = request.enabled + save_session_state(state, session_dir) + + # If session is running, notify the worker to update runtime state + session_process = runner.get_session(session_id) + if session_process is not None and session_process.is_alive: + await session_process.set_yolo_mode(request.enabled) + + return YoloStatus( + enabled=state.approval.yolo, + auto_approve_actions=list(state.approval.auto_approve_actions), + ) + + @router.websocket("/{session_id}/stream") async def session_stream( session_id: UUID, diff --git a/src/kimi_cli/web/models.py b/src/kimi_cli/web/models.py index c33c4938d..4b483d0f5 100644 --- a/src/kimi_cli/web/models.py +++ b/src/kimi_cli/web/models.py @@ -96,3 +96,18 @@ class GenerateTitleResponse(BaseModel): """Generate title response.""" title: str + + +class YoloStatus(BaseModel): + """YOLO (auto-approve) mode status.""" + + enabled: bool = Field(..., description="Whether YOLO mode is enabled") + auto_approve_actions: list[str] = Field( + default_factory=list, description="List of auto-approved action types" + ) + + +class UpdateYoloRequest(BaseModel): + """Update YOLO mode request.""" + + enabled: bool = Field(..., description="Enable or disable YOLO mode") diff --git a/src/kimi_cli/web/runner/process.py b/src/kimi_cli/web/runner/process.py index 91bfe7eb0..fdbeaaa17 100644 --- a/src/kimi_cli/web/runner/process.py +++ b/src/kimi_cli/web/runner/process.py @@ -614,6 +614,25 @@ async def _close_all_websockets(self) -> None: # Ignore errors closing already-disconnected WebSockets pass + async def set_yolo_mode(self, enabled: bool) -> None: + """Set YOLO mode for the running session. + + Sends a set_yolo_mode message to the worker process. + """ + if not self.is_alive: + return + + message = json.dumps( + { + "jsonrpc": "2.0", + "method": "set_yolo_mode", + "id": str(uuid4()), + "params": {"enabled": enabled}, + }, + ensure_ascii=False, + ) + await self.send_message(message) + async def remove_websocket(self, ws: WebSocket) -> None: """Remove a WebSocket connection from this session.""" async with self._ws_lock: diff --git a/src/kimi_cli/wire/jsonrpc.py b/src/kimi_cli/wire/jsonrpc.py index bf802055f..a9f1e9124 100644 --- a/src/kimi_cli/wire/jsonrpc.py +++ b/src/kimi_cli/wire/jsonrpc.py @@ -161,6 +161,18 @@ class JSONRPCSetPlanModeMessage(_MessageBase): params: _SetPlanModeParams +class _SetYoloModeParams(BaseModel): + enabled: bool + + model_config = ConfigDict(extra="ignore") + + +class JSONRPCSetYoloModeMessage(_MessageBase): + method: Literal["set_yolo_mode"] = "set_yolo_mode" + id: str + params: _SetYoloModeParams + + class JSONRPCCancelMessage(_MessageBase): method: Literal["cancel"] = "cancel" id: str @@ -212,10 +224,19 @@ def _validate_params(cls, value: Any) -> Request: | JSONRPCSteerMessage | JSONRPCReplayMessage | JSONRPCSetPlanModeMessage + | JSONRPCSetYoloModeMessage | JSONRPCCancelMessage ) JSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage) -JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "set_plan_mode", "cancel"} +JSONRPC_IN_METHODS = { + "initialize", + "prompt", + "steer", + "replay", + "set_plan_mode", + "set_yolo_mode", + "cancel", +} type JSONRPCOutMessage = ( JSONRPCSuccessResponse diff --git a/src/kimi_cli/wire/server.py b/src/kimi_cli/wire/server.py index 9d62f6136..2e9eefac9 100644 --- a/src/kimi_cli/wire/server.py +++ b/src/kimi_cli/wire/server.py @@ -52,6 +52,7 @@ JSONRPCReplayMessage, JSONRPCRequestMessage, JSONRPCSetPlanModeMessage, + JSONRPCSetYoloModeMessage, JSONRPCSteerMessage, JSONRPCSuccessResponse, Statuses, @@ -365,6 +366,8 @@ async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None: resp = await self._handle_steer(msg) case JSONRPCSetPlanModeMessage(): resp = await self._handle_set_plan_mode(msg) + case JSONRPCSetYoloModeMessage(): + resp = await self._handle_set_yolo_mode(msg) case JSONRPCCancelMessage(): resp = await self._handle_cancel(msg) case JSONRPCSuccessResponse() | JSONRPCErrorResponse(): @@ -763,6 +766,31 @@ async def _handle_set_plan_mode( result={"status": "ok", "plan_mode": new_state}, ) + async def _handle_set_yolo_mode( + self, msg: JSONRPCSetYoloModeMessage + ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse: + if not isinstance(self._soul, KimiSoul): + return JSONRPCErrorResponse( + id=msg.id, + error=JSONRPCErrorObject( + code=ErrorCodes.INVALID_STATE, + message="YOLO mode is not supported", + ), + ) + + # Update the approval state + self._soul.runtime.approval.set_yolo(msg.params.enabled) + new_state = self._soul.runtime.approval.is_yolo() + + status = StatusUpdate(yolo_mode=new_state) + await self._send_msg(JSONRPCEventMessage(params=status)) + # Persist to wire file so replay reconstructs yolo mode state + await self._soul.wire_file.append_message(status) + return JSONRPCSuccessResponse( + id=msg.id, + result={"status": "ok", "yolo_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 33eb5098d..57ce42804 100644 --- a/src/kimi_cli/wire/types.py +++ b/src/kimi_cli/wire/types.py @@ -174,6 +174,8 @@ class StatusUpdate(BaseModel): """The message ID of the current step.""" plan_mode: bool | None = None """Whether plan mode (read-only) is active. None means no change.""" + yolo_mode: bool | None = None + """Whether YOLO (auto-approve) mode is active. None means no change.""" mcp_status: MCPStatusSnapshot | None = None """The current MCP startup snapshot. None means no change.""" diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index b7b7674ac..6eec449bc 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -150,6 +150,7 @@ async def test_wire_message_serde(): "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": True, "connected": 0, diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 140c46112..1ed4ab690 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -146,6 +146,16 @@ def test_pyinstaller_hiddenimports(): assert sorted(hiddenimports) == snapshot( [ + "kimi_cli.cli", + "kimi_cli.cli.__main__", + "kimi_cli.cli._lazy_group", + "kimi_cli.cli.export", + "kimi_cli.cli.info", + "kimi_cli.cli.mcp", + "kimi_cli.cli.plugin", + "kimi_cli.cli.toad", + "kimi_cli.cli.vis", + "kimi_cli.cli.web", "kimi_cli.tools", "kimi_cli.tools.agent", "kimi_cli.tools.ask_user", diff --git a/tests_e2e/test_wire_approvals_tools.py b/tests_e2e/test_wire_approvals_tools.py index 19a41d3c6..bc66f2e81 100644 --- a/tests_e2e/test_wire_approvals_tools.py +++ b/tests_e2e/test_wire_approvals_tools.py @@ -119,6 +119,7 @@ def test_shell_approval_approve(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -174,6 +175,7 @@ def test_shell_approval_approve(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -250,6 +252,7 @@ def test_shell_approval_reject(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -381,6 +384,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -440,6 +444,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -475,6 +480,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -508,6 +514,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -581,6 +588,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -614,6 +622,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -884,6 +893,7 @@ def test_display_block_todo(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -919,6 +929,7 @@ def test_display_block_todo(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1002,6 +1013,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1037,6 +1049,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1110,6 +1123,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1143,6 +1157,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1230,6 +1245,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1263,6 +1279,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_config.py b/tests_e2e/test_wire_config.py index ef494a03b..44134fc00 100644 --- a/tests_e2e/test_wire_config.py +++ b/tests_e2e/test_wire_config.py @@ -80,6 +80,7 @@ def test_config_string(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -170,6 +171,7 @@ def test_model_override(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_prompt.py b/tests_e2e/test_wire_prompt.py index d6058717a..aa79f9499 100644 --- a/tests_e2e/test_wire_prompt.py +++ b/tests_e2e/test_wire_prompt.py @@ -90,6 +90,7 @@ def test_basic_prompt_events(tmp_path) -> None: }, "message_id": "scripted-1", "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -294,6 +295,7 @@ def test_max_steps_reached(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -376,6 +378,7 @@ def test_status_update_fields(tmp_path) -> None: }, "message_id": "scripted-1", "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, } @@ -474,6 +477,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -529,6 +533,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index b1e042e87..fc27d280a 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -325,6 +325,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -367,6 +368,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -419,6 +421,7 @@ def test_prompt_without_initialize(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_sessions.py b/tests_e2e/test_wire_sessions.py index ef63b30d5..81dc9cc72 100644 --- a/tests_e2e/test_wire_sessions.py +++ b/tests_e2e/test_wire_sessions.py @@ -223,6 +223,7 @@ def test_clear_context_rotates(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": None, }, }, @@ -308,6 +309,7 @@ def test_manual_compact(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": None, }, }, @@ -464,6 +466,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -497,6 +500,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_skills_mcp.py b/tests_e2e/test_wire_skills_mcp.py index 88b49376d..6adda9cc1 100644 --- a/tests_e2e/test_wire_skills_mcp.py +++ b/tests_e2e/test_wire_skills_mcp.py @@ -117,6 +117,7 @@ def test_skill_prompt_injects_skill_text(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -208,6 +209,7 @@ def test_flow_skill(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -306,6 +308,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": True, "connected": 0, @@ -326,6 +329,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": False, "connected": 1, @@ -362,6 +366,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -417,6 +422,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/web/package-lock.json b/web/package-lock.json index 7c06e36ba..77241853a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -187,7 +187,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -948,7 +947,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1875,7 +1873,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4919,7 +4916,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4935,7 +4931,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4946,7 +4941,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5611,7 +5605,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6302,7 +6295,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6712,7 +6704,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7147,8 +7138,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7439,7 +7429,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9806,7 +9795,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -10443,8 +10431,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -11553,7 +11540,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11917,7 +11903,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11927,7 +11912,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13021,7 +13005,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14134,7 +14117,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14232,7 +14214,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -14587,7 +14568,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14947,7 +14927,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/features/chat/chat-workspace-container.tsx b/web/src/features/chat/chat-workspace-container.tsx index b0ceccd19..d15d38601 100644 --- a/web/src/features/chat/chat-workspace-container.tsx +++ b/web/src/features/chat/chat-workspace-container.tsx @@ -124,6 +124,8 @@ export function ChatWorkspaceContainer({ isReplayingHistory, planMode, sendSetPlanMode, + yoloMode, + sendSetYoloMode, slashCommands, } = sessionStream; @@ -316,6 +318,10 @@ export function ChatWorkspaceContainer({ sendSetPlanMode(enabled); }, [sendSetPlanMode]); + const handleYoloModeChange = useCallback((enabled: boolean) => { + sendSetYoloMode(enabled); + }, [sendSetYoloMode]); + const handleForkSession = useCallback( async (turnIndex: number) => { if (!(selectedSessionId && onForkSession)) { @@ -386,6 +392,8 @@ export function ChatWorkspaceContainer({ slashCommands={slashCommands} planMode={planMode} onPlanModeChange={handlePlanModeChange} + yoloMode={yoloMode} + onYoloModeChange={handleYoloModeChange} onForkSession={onForkSession ? handleForkSession : undefined} /> ); diff --git a/web/src/features/chat/chat.tsx b/web/src/features/chat/chat.tsx index 9a839b56b..3092f1d5c 100644 --- a/web/src/features/chat/chat.tsx +++ b/web/src/features/chat/chat.tsx @@ -86,6 +86,10 @@ type ChatWorkspaceProps = { planMode?: boolean; /** Callback to set plan mode */ onPlanModeChange?: (enabled: boolean) => void; + /** Whether yolo mode is active */ + yoloMode?: boolean; + /** Callback to set yolo mode */ + onYoloModeChange?: (enabled: boolean) => void; /** Maximum context size for the current model (tokens) */ maxContextSize?: number; /** Fork session at a specific turn */ @@ -120,6 +124,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ slashCommands = [], planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, onForkSession, }: ChatWorkspaceProps): ReactElement { const [blocksExpanded, setBlocksExpanded] = useState(false); @@ -341,6 +347,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ slashCommands={slashCommands} planMode={planMode} onPlanModeChange={onPlanModeChange} + yoloMode={yoloMode} + onYoloModeChange={onYoloModeChange} activityStatus={activityStatus} usagePercent={usagePercent} usedTokens={usedTokens} diff --git a/web/src/features/chat/components/approval-dialog.tsx b/web/src/features/chat/components/approval-dialog.tsx index f9284ef12..f632396f7 100644 --- a/web/src/features/chat/components/approval-dialog.tsx +++ b/web/src/features/chat/components/approval-dialog.tsx @@ -284,7 +284,7 @@ export function ApprovalDialog({ )} > {feedbackMode ? "Cancel feedback" : "Decline with feedback"} - {!feedbackMode && !approvalPending && ( + {!(feedbackMode || approvalPending) && ( 4 )} diff --git a/web/src/features/chat/components/chat-prompt-composer.tsx b/web/src/features/chat/components/chat-prompt-composer.tsx index ba2d051dd..0f08cd462 100644 --- a/web/src/features/chat/components/chat-prompt-composer.tsx +++ b/web/src/features/chat/components/chat-prompt-composer.tsx @@ -64,6 +64,8 @@ type ChatPromptComposerProps = { slashCommands?: SlashCommandDef[]; planMode?: boolean; onPlanModeChange?: (enabled: boolean) => void; + yoloMode?: boolean; + onYoloModeChange?: (enabled: boolean) => void; activityStatus?: ActivityDetail; usagePercent?: number; usedTokens?: number; @@ -87,6 +89,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ slashCommands = [], planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, activityStatus, usagePercent, usedTokens, @@ -304,7 +308,7 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ - + {isStreaming ? (
diff --git a/web/src/features/chat/components/session-files-panel.tsx b/web/src/features/chat/components/session-files-panel.tsx index 1464f2ee5..83c864142 100644 --- a/web/src/features/chat/components/session-files-panel.tsx +++ b/web/src/features/chat/components/session-files-panel.tsx @@ -251,7 +251,7 @@ export function SessionFilesPanel({
) : null} - {!isLoading && !error && entries.length === 0 ? ( + {!(isLoading || error) && entries.length === 0 ? (
No files in this directory. diff --git a/web/src/features/chat/global-config-controls.tsx b/web/src/features/chat/global-config-controls.tsx index 4103725ee..8713723cd 100644 --- a/web/src/features/chat/global-config-controls.tsx +++ b/web/src/features/chat/global-config-controls.tsx @@ -46,12 +46,16 @@ export type GlobalConfigControlsProps = { className?: string; planMode?: boolean; onPlanModeChange?: (enabled: boolean) => void; + yoloMode?: boolean; + onYoloModeChange?: (enabled: boolean) => void; }; export function GlobalConfigControls({ className, planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, }: GlobalConfigControlsProps): ReactElement { const { config, isLoading, isUpdating, error, refresh, update } = useGlobalConfig(); @@ -304,6 +308,31 @@ export function GlobalConfigControls({ )} + {onYoloModeChange && ( + <> +
+ + +
+ + YOLO + + +
+
+ + {yoloMode + ? "YOLO mode is active. All actions will be auto-approved without confirmation." + : "Enable YOLO mode to auto-approve all actions without confirmation."} + +
+ + )} + {(lastBusySkip && lastBusySkip.length > 0) || error ? (
) : null} diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 8772d7aa4..748e182e5 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -242,6 +242,10 @@ type UseSessionStreamReturn = { planMode: boolean; /** Set plan mode via silent RPC (no context message) */ sendSetPlanMode: (enabled: boolean) => void; + /** Whether YOLO (auto-approve) mode is active */ + yoloMode: boolean; + /** Set YOLO mode via silent RPC (no context message) */ + sendSetYoloMode: (enabled: boolean) => void; /** Available slash commands from the server */ slashCommands: SlashCommandDef[]; }; @@ -286,6 +290,7 @@ export function useSessionStream( const [contextUsage, setContextUsage] = useState(0); const [tokenUsage, setTokenUsage] = useState(null); const [planMode, setPlanMode] = useState(false); + const [yoloMode, setYoloMode] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); @@ -853,6 +858,7 @@ export function useSessionStream( setContextUsage(0); setTokenUsage(null); setPlanMode(false); + setYoloMode(false); setError(null); setSessionStatus(null); lastStatusSeqRef.current = null; @@ -1735,6 +1741,11 @@ export function useSessionStream( setPlanMode(nextPlanMode); } + const nextYoloMode = event.payload.yolo_mode; + if (typeof nextYoloMode === "boolean") { + setYoloMode(nextYoloMode); + } + // If we have a message_id, create a special message to display it const messageId = event.payload.message_id; if (messageId) { @@ -2815,6 +2826,20 @@ export function useSessionStream( wsRef.current.send(JSON.stringify(message)); }, []); + // Set YOLO mode via silent RPC (no context message) + const sendSetYoloMode = useCallback((enabled: boolean) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + return; + } + const message: JsonRpcRequest = { + jsonrpc: "2.0", + method: "set_yolo_mode", + id: uuidV4(), + params: { enabled }, + }; + wsRef.current.send(JSON.stringify(message)); + }, []); + // Auto-connect when sessionId changes useLayoutEffect(() => { /** @@ -2899,6 +2924,8 @@ export function useSessionStream( error, planMode, sendSetPlanMode, + yoloMode, + sendSetYoloMode, slashCommands, }; } diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts new file mode 100644 index 000000000..3adbdb0de --- /dev/null +++ b/web/src/hooks/useYoloMode.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getApiBaseUrl } from "@/hooks/utils"; +import { getAuthHeader } from "@/lib/auth"; +import type { YoloStatus } from "@/lib/api/models"; + +export type UseYoloModeReturn = { + yoloStatus: YoloStatus | null; + isLoading: boolean; + isUpdating: boolean; + error: string | null; + refresh: () => Promise; + setYoloMode: (enabled: boolean) => Promise; +}; + +export function useYoloMode(sessionId: string | null): UseYoloModeReturn { + const [yoloStatus, setYoloStatus] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + const isInitializedRef = useRef(false); + const sessionIdRef = useRef(sessionId); + + // Keep ref in sync for effect comparison + useEffect(() => { + sessionIdRef.current = sessionId; + }, [sessionId]); + + const refresh = useCallback(async () => { + if (!sessionId) { + setYoloStatus(null); + return; + } + + setIsLoading(true); + setError(null); + try { + const response = await fetch( + `${getApiBaseUrl()}/api/sessions/${sessionId}/yolo`, + { + headers: { + ...getAuthHeader(), + }, + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.detail || `Failed to load YOLO status: ${response.status}`, + ); + } + + const data = await response.json(); + setYoloStatus({ + enabled: data.enabled, + autoApproveActions: data.auto_approve_actions, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load YOLO status"; + setError(message); + console.error("[useYoloMode] Failed to load YOLO status:", err); + } finally { + setIsLoading(false); + } + }, [sessionId]); + + const setYoloMode = useCallback( + async (enabled: boolean) => { + if (!sessionId) { + throw new Error("No session selected"); + } + + setIsUpdating(true); + setError(null); + try { + const response = await fetch( + `${getApiBaseUrl()}/api/sessions/${sessionId}/yolo`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeader(), + }, + body: JSON.stringify({ enabled }), + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.detail || `Failed to update YOLO mode: ${response.status}`, + ); + } + + const data = await response.json(); + setYoloStatus({ + enabled: data.enabled, + autoApproveActions: data.auto_approve_actions, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to update YOLO mode"; + setError(message); + console.error("[useYoloMode] Failed to update YOLO mode:", err); + throw err; + } finally { + setIsUpdating(false); + } + }, + [sessionId], + ); + + // Load initial data + useEffect(() => { + if (isInitializedRef.current && sessionIdRef.current === sessionId) { + return; + } + isInitializedRef.current = true; + refresh(); + }, [sessionId, refresh]); + + return { + yoloStatus, + isLoading, + isUpdating, + error, + refresh, + setYoloMode, + }; +} diff --git a/web/src/hooks/wireTypes.ts b/web/src/hooks/wireTypes.ts index f35b378a4..ee88be9ad 100644 --- a/web/src/hooks/wireTypes.ts +++ b/web/src/hooks/wireTypes.ts @@ -131,6 +131,7 @@ export type StatusUpdateEvent = { token_usage?: TokenUsage | null; message_id?: string; plan_mode?: boolean | null; + yolo_mode?: boolean | null; }; }; diff --git a/web/src/lib/api/models/UpdateYoloRequest.ts b/web/src/lib/api/models/UpdateYoloRequest.ts new file mode 100644 index 000000000..a1aab9cdf --- /dev/null +++ b/web/src/lib/api/models/UpdateYoloRequest.ts @@ -0,0 +1,65 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Kimi Code CLI Web Interface + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * Update YOLO mode request. + * @export + * @interface UpdateYoloRequest + */ +export interface UpdateYoloRequest { + /** + * Enable or disable YOLO mode + * @type {boolean} + * @memberof UpdateYoloRequest + */ + enabled: boolean; +} + +/** + * Check if a given object implements the UpdateYoloRequest interface. + */ +export function instanceOfUpdateYoloRequest( + value: object, +): value is UpdateYoloRequest { + if (!("enabled" in value) || value["enabled"] === undefined) return false; + return true; +} + +export function UpdateYoloRequestFromJSON(json: any): UpdateYoloRequest { + return UpdateYoloRequestFromJSONTyped(json, false); +} + +export function UpdateYoloRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UpdateYoloRequest { + if (json == null) { + return json; + } + return { + enabled: json["enabled"], + }; +} + +export function UpdateYoloRequestToJSON( + value?: UpdateYoloRequest | null, +): any { + if (value == null) { + return value; + } + return { + enabled: value["enabled"], + }; +} diff --git a/web/src/lib/api/models/YoloStatus.ts b/web/src/lib/api/models/YoloStatus.ts new file mode 100644 index 000000000..cf9f8d399 --- /dev/null +++ b/web/src/lib/api/models/YoloStatus.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Kimi Code CLI Web Interface + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * YOLO (auto-approve) mode status. + * @export + * @interface YoloStatus + */ +export interface YoloStatus { + /** + * Whether YOLO mode is enabled + * @type {boolean} + * @memberof YoloStatus + */ + enabled: boolean; + /** + * List of auto-approved action types + * @type {Array} + * @memberof YoloStatus + */ + autoApproveActions?: Array; +} + +/** + * Check if a given object implements the YoloStatus interface. + */ +export function instanceOfYoloStatus( + value: object, +): value is YoloStatus { + if (!("enabled" in value) || value["enabled"] === undefined) return false; + return true; +} + +export function YoloStatusFromJSON(json: any): YoloStatus { + return YoloStatusFromJSONTyped(json, false); +} + +export function YoloStatusFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): YoloStatus { + if (json == null) { + return json; + } + return { + enabled: json["enabled"], + autoApproveActions: + json["auto_approve_actions"] == null + ? undefined + : json["auto_approve_actions"], + }; +} + +export function YoloStatusToJSON(value?: YoloStatus | null): any { + if (value == null) { + return value; + } + return { + enabled: value["enabled"], + auto_approve_actions: value["autoApproveActions"], + }; +} diff --git a/web/src/lib/api/models/index.ts b/web/src/lib/api/models/index.ts index 80183dae6..be0e21ca4 100644 --- a/web/src/lib/api/models/index.ts +++ b/web/src/lib/api/models/index.ts @@ -20,6 +20,8 @@ export * from './UpdateConfigTomlResponse'; export * from './UpdateGlobalConfigRequest'; export * from './UpdateGlobalConfigResponse'; export * from './UpdateSessionRequest'; +export * from './UpdateYoloRequest'; export * from './UploadSessionFileResponse'; export * from './ValidationError'; export * from './ValidationErrorLocInner'; +export * from './YoloStatus'; From d0904190b1e2b20fb860e9cb0e00b976af15692e Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 15:31:22 -0300 Subject: [PATCH 02/22] docs: add changelog entry for YOLO mode web support --- CHANGELOG.md | 1 + docs/en/release-notes/changelog.md | 1 + docs/zh/release-notes/changelog.md | 1 + 3 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd59759e..061584347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Web: Add YOLO mode support to web interface — users can now toggle auto-approve mode directly from the web UI; adds `/api/sessions/{id}/yolo` endpoints and `yolo_mode` field to `StatusUpdate` (Wire 1.9) - Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710) - ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES) - Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 76c3d921a..6198176d1 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 +- Web: Add YOLO mode support to web interface — users can now toggle auto-approve mode directly from the web UI; adds `/api/sessions/{id}/yolo` endpoints and `yolo_mode` field to `StatusUpdate` (Wire 1.9) - Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710) - ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES) - Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index e59a29819..569245b50 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- Web:Web 界面新增 YOLO 模式支持——用户可直接在 Web UI 中开关自动审批模式;新增 `/api/sessions/{id}/yolo` 接口,并在 `StatusUpdate` 中增加 `yolo_mode` 字段(Wire 1.9) - Todo:重构 `SetTodoList` 工具,支持状态持久化并防止工具调用风暴——待办事项现在会持久化到会话状态(主 Agent)和独立状态文件(子 Agent);新增查询模式(省略 `todos` 参数可读取当前状态)和清空模式(传 `[]` 清空);工具描述中增加了防风暴指导,防止在没有实际进展的情况下反复调用(修复 #1710) - ReadFile:每次读取返回文件总行数,并支持负数 `line_offset` 实现 tail 模式——工具现在会在消息中报告 `Total lines in file: N.`,方便模型规划后续读取;负数 `line_offset`(如 `-100`)通过滑动窗口读取文件末尾 N 行,适用于无需 Shell 命令即可查看最新日志输出的场景;绝对值上限为 1000(MAX_LINES) - Shell:修复 Markdown 渲染中行内代码和代码块出现黑色背景的问题——`NEUTRAL_MARKDOWN_THEME` 现在将所有 Rich 默认的 `markdown.*` 样式覆盖为 `"none"`,防止 Rich 内置的 `"cyan on black"` 在非黑色背景终端上泄露 From c6e9fb493e4c795c245ef06467a2573e548497ed Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 15:46:14 -0300 Subject: [PATCH 03/22] docs: add YOLO mode toggle documentation in Web UI reference --- docs/en/reference/kimi-web.md | 3 ++- docs/zh/reference/kimi-web.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/kimi-web.md b/docs/en/reference/kimi-web.md index 7049b4e1c..f954ab575 100644 --- a/docs/en/reference/kimi-web.md +++ b/docs/en/reference/kimi-web.md @@ -188,9 +188,10 @@ Web UI provides a unified prompt toolbar above the input box, displaying various - **File changes**: Detects Git repository status, showing the number of new, modified, and deleted files (including untracked files). Click to view a detailed list of changes - **Todo list**: When the `SetTodoList` tool is active, shows task progress with support for expanding to view the detailed list - **Plan mode**: Toggle plan mode on/off from the input toolbar. When plan mode is active, the composer displays a dashed blue border. Plan mode can also be set programmatically via the `set_plan_mode` Wire protocol method +- **YOLO mode**: Toggle YOLO (auto-approve) mode on/off from the input toolbar. When YOLO mode is active, the composer displays a dashed yellow border and all operations are automatically approved without confirmation ::: info Changed -Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. Plan mode toggle added in version 1.20. +Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. Plan mode toggle added in version 1.20. YOLO mode toggle added in version 1.30. ::: ### Open-in functionality diff --git a/docs/zh/reference/kimi-web.md b/docs/zh/reference/kimi-web.md index 0ce22037f..8dea1b4f5 100644 --- a/docs/zh/reference/kimi-web.md +++ b/docs/zh/reference/kimi-web.md @@ -188,9 +188,10 @@ Web UI 在输入框上方提供统一的提示工具栏,以可折叠标签页 - **文件变更**:检测 Git 仓库状态,显示新增、修改和删除的文件数量(包含未跟踪文件),点击可查看详细的变更列表 - **待办事项**:当 `SetTodoList` 工具处于活动状态时,显示任务进度,支持展开查看详细列表 - **Plan 模式**:在输入工具栏中切换 Plan 模式开关。Plan 模式激活时,输入框显示蓝色虚线边框。也可以通过 `set_plan_mode` Wire 协议方法程序化设置 +- **YOLO 模式**:在输入工具栏中切换 YOLO(自动审批)模式开关。YOLO 模式激活时,输入框显示黄色虚线边框,所有操作将自动批准无需确认 ::: info 变更 -Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。1.20 版本新增 Plan 模式切换。 +Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。1.20 版本新增 Plan 模式切换。1.30 版本新增 YOLO 模式切换。 ::: ### Open-in 功能 From a3445130e6dcd456a11b67349037429ec29c32de Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 16:03:39 -0300 Subject: [PATCH 04/22] Update web/src/hooks/useYoloMode.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Signed-off-by: Lucas Pacheco --- web/src/hooks/useYoloMode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 3adbdb0de..00c4878c5 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -117,6 +117,7 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { if (isInitializedRef.current && sessionIdRef.current === sessionId) { return; } + sessionIdRef.current = sessionId; isInitializedRef.current = true; refresh(); }, [sessionId, refresh]); From c263feaab3405d5c77cc7ece3d780db1ab832457 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 16:35:21 -0300 Subject: [PATCH 05/22] fix(web): initialize YOLO mode from server config on session connect Integrate useYoloMode hook into useSessionStream to fetch the initial YOLO mode state from the server API. This ensures the UI correctly reflects the default_yolo config setting when a session connects, instead of defaulting to false and waiting for a StatusUpdate event. Changes: - Import useYoloMode hook in useSessionStream.ts - Add yoloStatus fetching and sync to yoloMode state via useEffect --- .gitignore | 4 +++- web/src/hooks/useSessionStream.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f00b02169..fd80c51cb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,6 @@ node_modules/ static/ .memo/ .entire -.claude \ No newline at end of file +.claude +.local/ + diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 748e182e5..8498c8e58 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -138,6 +138,7 @@ import { createMessageId, getApiBaseUrl } from "./utils"; import { kimiCliVersion } from "@/lib/version"; import { handleToolResult, useToolEventsStore, type TodoItem } from "@/features/tool/store"; import { v4 as uuidV4 } from "uuid"; +import { useYoloMode } from "@/hooks/useYoloMode"; // Regex patterns moved to top level for performance const DATA_URL_MEDIA_TYPE_REGEX = /^data:([^;,]+)[;,]/; @@ -298,6 +299,16 @@ export function useSessionStream( const [isReplayingHistory, setIsReplayingHistory] = useState(true); const [slashCommands, setSlashCommands] = useState([]); + // Fetch initial YOLO mode state from the server API + const { yoloStatus } = useYoloMode(sessionId); + + // Update yoloMode state when yoloStatus changes (initial load or refresh) + useEffect(() => { + if (yoloStatus !== null) { + setYoloMode(yoloStatus.enabled); + } + }, [yoloStatus]); + // Refs /** * The single source of truth for "which WebSocket is allowed to mutate React state". From 6ff001cfcc4aea87d1e57b479cd7497c6146c856 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 16:48:44 -0300 Subject: [PATCH 06/22] fix(web): remove problematic ref-sync effect in useYoloMode hook The separate useEffect that synced sessionIdRef.current was updating the ref BEFORE the load effect could compare them. This caused the guard condition to always be true after first mount, skipping refresh() on session switches and displaying stale YOLO data. Remove the ref-sync effect so the load effect can properly compare old vs new sessionId before deciding to fetch. --- web/src/hooks/useYoloMode.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 00c4878c5..3aff5c3b8 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -21,11 +21,6 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { const isInitializedRef = useRef(false); const sessionIdRef = useRef(sessionId); - // Keep ref in sync for effect comparison - useEffect(() => { - sessionIdRef.current = sessionId; - }, [sessionId]); - const refresh = useCallback(async () => { if (!sessionId) { setYoloStatus(null); From 1519ec80a206ddb9ca2b20789f27f9c08ef4d51c Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 18:55:09 -0300 Subject: [PATCH 07/22] fix(web): add YOLO mode yellow dashed border to prompt composer --- web/src/features/chat/components/chat-prompt-composer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/features/chat/components/chat-prompt-composer.tsx b/web/src/features/chat/components/chat-prompt-composer.tsx index 0f08cd462..1cd7436ef 100644 --- a/web/src/features/chat/components/chat-prompt-composer.tsx +++ b/web/src/features/chat/components/chat-prompt-composer.tsx @@ -213,7 +213,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ accept="*" className={cn( "w-full [&_[data-slot=input-group]]:border [&_[data-slot=input-group]]:border-border", - planMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600" + planMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600", + yoloMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-amber-200 dark:[&_[data-slot=input-group]]:!border-amber-600" )} multiple maxFiles={MEDIA_CONFIG.maxCount} From 3e94503122db11cf10e69fe3c2a081381d5595a2 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 18:55:49 -0300 Subject: [PATCH 08/22] fix(web): add AbortController to useYoloMode hook to prevent race conditions Add AbortController to cancel in-flight fetch requests when sessionId changes. This prevents stale YOLO status from overwriting the correct state after a session switch. Changes: - Add abortControllerRef to track the current fetch - Pass signal to fetch call in refresh callback - Check for AbortError in catch block and skip state updates - Add cleanup function in useEffect to abort on session change/unmount --- web/src/hooks/useYoloMode.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 3aff5c3b8..1ca5de7f1 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -20,6 +20,7 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { const isInitializedRef = useRef(false); const sessionIdRef = useRef(sessionId); + const abortControllerRef = useRef(null); const refresh = useCallback(async () => { if (!sessionId) { @@ -27,6 +28,15 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { return; } + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new AbortController for this request + const controller = new AbortController(); + abortControllerRef.current = controller; + setIsLoading(true); setError(null); try { @@ -36,6 +46,7 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { headers: { ...getAuthHeader(), }, + signal: controller.signal, }, ); @@ -52,12 +63,19 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { autoApproveActions: data.auto_approve_actions, }); } catch (err) { + // Skip state updates if the request was aborted + if (err instanceof Error && err.name === "AbortError") { + return; + } const message = err instanceof Error ? err.message : "Failed to load YOLO status"; setError(message); console.error("[useYoloMode] Failed to load YOLO status:", err); } finally { - setIsLoading(false); + // Only clear loading state if this request wasn't aborted + if (abortControllerRef.current === controller) { + setIsLoading(false); + } } }, [sessionId]); @@ -115,6 +133,14 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { sessionIdRef.current = sessionId; isInitializedRef.current = true; refresh(); + + // Cleanup: abort any in-flight request when sessionId changes or component unmounts + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; }, [sessionId, refresh]); return { From 27b3f7cb4c1656c18cefa809027da75ff3bff3dc Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 19:16:59 -0300 Subject: [PATCH 09/22] fix(web): preserve YOLO mode state when reconnecting to same session Add previousSessionIdRef to track the last connected session ID. Modify resetState to accept preserveYoloMode parameter. On reconnect to the same session, preserve YOLO mode state to avoid showing the toggle as OFF while approvals are still auto-approved. Changes: - Add previousSessionIdRef to track session changes - Update resetState signature to accept preserveYoloMode - Check if reconnecting to same session in connect() and preserve yoloMode --- web/src/hooks/useSessionStream.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 8498c8e58..9afc6e9dd 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -331,7 +331,7 @@ export function useSessionStream( const connectRef = useRef<() => void>(() => undefined); const disconnectRef = useRef<() => void>(() => undefined); const reconnectRef = useRef<() => void>(() => undefined); - const resetStateRef = useRef<(preserveSlashCommands?: boolean) => void>(() => undefined); + const resetStateRef = useRef<(preserveSlashCommands?: boolean, preserveYoloMode?: boolean) => void>(() => undefined); const historyCompleteTimeoutRef = useRef(null); const isReplayingRef = useRef(true); // Track if we're still replaying history const pendingMessageRef = useRef(null); // Message to send after connection @@ -341,6 +341,7 @@ export function useSessionStream( const lastWsMessageTimeRef = useRef(0); // Last time a WS message was received const watchdogIntervalRef = useRef(null); // Stale connection watchdog const statusRef = useRef("ready"); // Synced copy of status for watchdog + const previousSessionIdRef = useRef(null); // Track previous session for reconnect detection // First turn tracking for auto-rename (simplified: backend reads from wire.jsonl) const hasTurnStartedRef = useRef(false); // Whether at least one turn has started @@ -858,7 +859,7 @@ export function useSessionStream( }, []); // Reset all state - const resetState = useCallback((preserveSlashCommands = false) => { + const resetState = useCallback((preserveSlashCommands = false, preserveYoloMode = false) => { resetStepState(); currentToolCallsRef.current?.clear(); currentToolCallIdRef.current = null; @@ -869,7 +870,9 @@ export function useSessionStream( setContextUsage(0); setTokenUsage(null); setPlanMode(false); - setYoloMode(false); + if (!preserveYoloMode) { + setYoloMode(false); + } setError(null); setSessionStatus(null); lastStatusSeqRef.current = null; @@ -2419,8 +2422,12 @@ export function useSessionStream( watchdogIntervalRef.current = null; } + // Check if we're reconnecting to the same session (preserve yoloMode) or switching sessions (reset yoloMode) + const isReconnectingToSameSession = previousSessionIdRef.current === sessionId; + previousSessionIdRef.current = sessionId; + awaitingIdleRef.current = false; - resetState(true); // preserve slashCommands on reconnect + resetState(true, isReconnectingToSameSession); // preserve slashCommands on reconnect, preserve yoloMode on same-session reconnect setMessages([]); setStatus("submitted"); setAwaitingFirstResponse(Boolean(pendingMessageRef.current)); From 83bbe37056a91dc4cc89d079159c653805efe634 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 19:17:06 -0300 Subject: [PATCH 10/22] fix(web): avoid race condition in update_yolo_status endpoint Only save session state to disk when the worker is NOT alive. When the worker IS alive, skip the disk save and let the worker handle persistence via its notify_change callback. This prevents the REST handler's save from overwriting concurrent worker changes to session state (title, plan mode, todos, etc.). Changes: - Move save_session_state inside else branch - Only save directly when session_process is None or not alive - When worker is alive, rely on set_yolo_mode -> worker persistence --- src/kimi_cli/web/api/sessions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 68531cf62..364486a89 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -915,12 +915,16 @@ async def update_yolo_status( # Update state state.approval.yolo = request.enabled - save_session_state(state, session_dir) - # If session is running, notify the worker to update runtime state + # If session is running, notify the worker to update runtime state. + # The worker will persist state via its notify_change callback, so we skip + # saving here to avoid race conditions with concurrent worker changes. session_process = runner.get_session(session_id) if session_process is not None and session_process.is_alive: await session_process.set_yolo_mode(request.enabled) + else: + # Only save directly to disk when the worker is not alive + save_session_state(state, session_dir) return YoloStatus( enabled=state.approval.yolo, From 77f2b1ba98fac3839157822bd3d2e984aff9e03b Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 19:58:09 -0300 Subject: [PATCH 11/22] fix(web): guard against stale YOLO hydration overwriting WebSocket updates Add hasReceivedLiveYoloUpdateRef to track if we've received a live yolo_mode update from WebSocket. Only apply yoloStatus from HTTP fetch if no live update has been received yet. This prevents stale HTTP responses from overwriting newer real-time state when the user toggles YOLO quickly or another client toggles it. Changes: - Add hasReceivedLiveYoloUpdateRef to track live WebSocket updates - Guard yoloStatus sync useEffect to only apply before live updates - Set flag when StatusUpdate.yolo_mode is received from WebSocket - Reset flag in resetState when switching sessions --- web/src/hooks/useSessionStream.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 9afc6e9dd..61f4a341e 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -303,8 +303,10 @@ export function useSessionStream( const { yoloStatus } = useYoloMode(sessionId); // Update yoloMode state when yoloStatus changes (initial load or refresh) + // Only apply if we haven't received a live update from WebSocket to avoid + // stale HTTP responses overwriting newer real-time state useEffect(() => { - if (yoloStatus !== null) { + if (yoloStatus !== null && !hasReceivedLiveYoloUpdateRef.current) { setYoloMode(yoloStatus.enabled); } }, [yoloStatus]); @@ -342,6 +344,7 @@ export function useSessionStream( const watchdogIntervalRef = useRef(null); // Stale connection watchdog const statusRef = useRef("ready"); // Synced copy of status for watchdog const previousSessionIdRef = useRef(null); // Track previous session for reconnect detection + const hasReceivedLiveYoloUpdateRef = useRef(false); // Track if we've received live yolo_mode from WebSocket // First turn tracking for auto-rename (simplified: backend reads from wire.jsonl) const hasTurnStartedRef = useRef(false); // Whether at least one turn has started @@ -872,6 +875,7 @@ export function useSessionStream( setPlanMode(false); if (!preserveYoloMode) { setYoloMode(false); + hasReceivedLiveYoloUpdateRef.current = false; } setError(null); setSessionStatus(null); @@ -1757,6 +1761,7 @@ export function useSessionStream( const nextYoloMode = event.payload.yolo_mode; if (typeof nextYoloMode === "boolean") { + hasReceivedLiveYoloUpdateRef.current = true; setYoloMode(nextYoloMode); } From e33c69111a4916059b250c1972351a9146960eb8 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 15:12:32 -0300 Subject: [PATCH 12/22] feat(web): add YOLO mode support to web interface Add ability to view and toggle YOLO mode from the web UI: - Add GET/POST /api/sessions/{id}/yolo endpoints - Add useYoloMode hook for state management - Add YOLO mode toggle in global config controls - Add YoloStatus and UpdateYoloRequest models - Update wire protocol with set_yolo_mode JSON-RPC method - Update StatusUpdate to include yolo_mode field --- src/kimi_cli/utils/pyinstaller.py | 4 +- src/kimi_cli/web/api/sessions.py | 57 ++++++++ src/kimi_cli/web/models.py | 15 ++ src/kimi_cli/web/runner/process.py | 19 +++ src/kimi_cli/wire/jsonrpc.py | 23 ++- src/kimi_cli/wire/server.py | 28 ++++ src/kimi_cli/wire/types.py | 2 + tests/core/test_wire_message.py | 1 + tests/utils/test_pyinstaller_utils.py | 10 ++ tests_e2e/test_wire_approvals_tools.py | 17 +++ tests_e2e/test_wire_config.py | 2 + tests_e2e/test_wire_prompt.py | 5 + tests_e2e/test_wire_protocol.py | 3 + tests_e2e/test_wire_sessions.py | 4 + tests_e2e/test_wire_skills_mcp.py | 6 + web/package-lock.json | 25 +--- .../chat/chat-workspace-container.tsx | 8 ++ web/src/features/chat/chat.tsx | 8 ++ .../chat/components/approval-dialog.tsx | 2 +- .../chat/components/chat-prompt-composer.tsx | 6 +- .../chat/components/session-files-panel.tsx | 2 +- .../features/chat/global-config-controls.tsx | 29 ++++ web/src/hooks/useSessionStream.ts | 27 ++++ web/src/hooks/useYoloMode.ts | 132 ++++++++++++++++++ web/src/hooks/wireTypes.ts | 1 + web/src/lib/api/models/UpdateYoloRequest.ts | 65 +++++++++ web/src/lib/api/models/YoloStatus.ts | 74 ++++++++++ web/src/lib/api/models/index.ts | 2 + 28 files changed, 549 insertions(+), 28 deletions(-) create mode 100644 web/src/hooks/useYoloMode.ts create mode 100644 web/src/lib/api/models/UpdateYoloRequest.ts create mode 100644 web/src/lib/api/models/YoloStatus.ts diff --git a/src/kimi_cli/utils/pyinstaller.py b/src/kimi_cli/utils/pyinstaller.py index d03a985ed..bec40d2f3 100644 --- a/src/kimi_cli/utils/pyinstaller.py +++ b/src/kimi_cli/utils/pyinstaller.py @@ -2,7 +2,9 @@ from PyInstaller.utils.hooks import collect_data_files, collect_submodules -hiddenimports = collect_submodules("kimi_cli.tools") + ["setproctitle"] +hiddenimports = ( + collect_submodules("kimi_cli.tools") + collect_submodules("kimi_cli.cli") + ["setproctitle"] +) datas = ( collect_data_files( "kimi_cli", diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 11573715d..75134722c 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -21,8 +21,10 @@ from starlette.websockets import WebSocket, WebSocketDisconnect from kimi_cli import logger +from kimi_cli.config import load_config from kimi_cli.metadata import load_metadata, save_metadata from kimi_cli.session import Session as KimiCLISession +from kimi_cli.session_state import load_session_state, save_session_state from kimi_cli.utils.subprocess_env import get_clean_env from kimi_cli.web.auth import is_origin_allowed, is_private_ip, verify_token from kimi_cli.web.models import ( @@ -33,6 +35,8 @@ Session, SessionStatus, UpdateSessionRequest, + UpdateYoloRequest, + YoloStatus, ) from kimi_cli.web.runner.messages import new_session_status_message, send_history_complete from kimi_cli.web.runner.process import KimiCLIRunner @@ -332,6 +336,14 @@ async def create_session(request: CreateSessionRequest | None = None) -> Session else: work_dir = KaosPath.unsafe_from_local_path(Path.home()) kimi_cli_session = await KimiCLISession.create(work_dir=work_dir) + + # Apply default_yolo config setting to new session + config = load_config() + if config.default_yolo: + state = load_session_state(kimi_cli_session.dir) + state.approval.yolo = True + save_session_state(state, kimi_cli_session.dir) + context_file = kimi_cli_session.dir / "context.jsonl" invalidate_sessions_cache() invalidate_work_dirs_cache() @@ -869,6 +881,51 @@ async def generate_session_title( return GenerateTitleResponse(title=title) +@router.get("/{session_id}/yolo", summary="Get YOLO mode status") +async def get_yolo_status(session_id: UUID) -> YoloStatus: + """Get the YOLO (auto-approve) mode status for a session.""" + session = load_session_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + session_dir = session.kimi_cli_session.dir + state = load_session_state(session_dir) + + return YoloStatus( + enabled=state.approval.yolo, + auto_approve_actions=list(state.approval.auto_approve_actions), + ) + + +@router.post("/{session_id}/yolo", summary="Update YOLO mode status") +async def update_yolo_status( + session_id: UUID, + request: UpdateYoloRequest, + runner: KimiCLIRunner = Depends(get_runner), +) -> YoloStatus: + """Enable or disable YOLO (auto-approve) mode for a session.""" + session = load_session_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + session_dir = session.kimi_cli_session.dir + state = load_session_state(session_dir) + + # Update state + state.approval.yolo = request.enabled + save_session_state(state, session_dir) + + # If session is running, notify the worker to update runtime state + session_process = runner.get_session(session_id) + if session_process is not None and session_process.is_alive: + await session_process.set_yolo_mode(request.enabled) + + return YoloStatus( + enabled=state.approval.yolo, + auto_approve_actions=list(state.approval.auto_approve_actions), + ) + + @router.websocket("/{session_id}/stream") async def session_stream( session_id: UUID, diff --git a/src/kimi_cli/web/models.py b/src/kimi_cli/web/models.py index c33c4938d..4b483d0f5 100644 --- a/src/kimi_cli/web/models.py +++ b/src/kimi_cli/web/models.py @@ -96,3 +96,18 @@ class GenerateTitleResponse(BaseModel): """Generate title response.""" title: str + + +class YoloStatus(BaseModel): + """YOLO (auto-approve) mode status.""" + + enabled: bool = Field(..., description="Whether YOLO mode is enabled") + auto_approve_actions: list[str] = Field( + default_factory=list, description="List of auto-approved action types" + ) + + +class UpdateYoloRequest(BaseModel): + """Update YOLO mode request.""" + + enabled: bool = Field(..., description="Enable or disable YOLO mode") diff --git a/src/kimi_cli/web/runner/process.py b/src/kimi_cli/web/runner/process.py index 91bfe7eb0..fdbeaaa17 100644 --- a/src/kimi_cli/web/runner/process.py +++ b/src/kimi_cli/web/runner/process.py @@ -614,6 +614,25 @@ async def _close_all_websockets(self) -> None: # Ignore errors closing already-disconnected WebSockets pass + async def set_yolo_mode(self, enabled: bool) -> None: + """Set YOLO mode for the running session. + + Sends a set_yolo_mode message to the worker process. + """ + if not self.is_alive: + return + + message = json.dumps( + { + "jsonrpc": "2.0", + "method": "set_yolo_mode", + "id": str(uuid4()), + "params": {"enabled": enabled}, + }, + ensure_ascii=False, + ) + await self.send_message(message) + async def remove_websocket(self, ws: WebSocket) -> None: """Remove a WebSocket connection from this session.""" async with self._ws_lock: diff --git a/src/kimi_cli/wire/jsonrpc.py b/src/kimi_cli/wire/jsonrpc.py index bf802055f..a9f1e9124 100644 --- a/src/kimi_cli/wire/jsonrpc.py +++ b/src/kimi_cli/wire/jsonrpc.py @@ -161,6 +161,18 @@ class JSONRPCSetPlanModeMessage(_MessageBase): params: _SetPlanModeParams +class _SetYoloModeParams(BaseModel): + enabled: bool + + model_config = ConfigDict(extra="ignore") + + +class JSONRPCSetYoloModeMessage(_MessageBase): + method: Literal["set_yolo_mode"] = "set_yolo_mode" + id: str + params: _SetYoloModeParams + + class JSONRPCCancelMessage(_MessageBase): method: Literal["cancel"] = "cancel" id: str @@ -212,10 +224,19 @@ def _validate_params(cls, value: Any) -> Request: | JSONRPCSteerMessage | JSONRPCReplayMessage | JSONRPCSetPlanModeMessage + | JSONRPCSetYoloModeMessage | JSONRPCCancelMessage ) JSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage) -JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "set_plan_mode", "cancel"} +JSONRPC_IN_METHODS = { + "initialize", + "prompt", + "steer", + "replay", + "set_plan_mode", + "set_yolo_mode", + "cancel", +} type JSONRPCOutMessage = ( JSONRPCSuccessResponse diff --git a/src/kimi_cli/wire/server.py b/src/kimi_cli/wire/server.py index 9d62f6136..2e9eefac9 100644 --- a/src/kimi_cli/wire/server.py +++ b/src/kimi_cli/wire/server.py @@ -52,6 +52,7 @@ JSONRPCReplayMessage, JSONRPCRequestMessage, JSONRPCSetPlanModeMessage, + JSONRPCSetYoloModeMessage, JSONRPCSteerMessage, JSONRPCSuccessResponse, Statuses, @@ -365,6 +366,8 @@ async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None: resp = await self._handle_steer(msg) case JSONRPCSetPlanModeMessage(): resp = await self._handle_set_plan_mode(msg) + case JSONRPCSetYoloModeMessage(): + resp = await self._handle_set_yolo_mode(msg) case JSONRPCCancelMessage(): resp = await self._handle_cancel(msg) case JSONRPCSuccessResponse() | JSONRPCErrorResponse(): @@ -763,6 +766,31 @@ async def _handle_set_plan_mode( result={"status": "ok", "plan_mode": new_state}, ) + async def _handle_set_yolo_mode( + self, msg: JSONRPCSetYoloModeMessage + ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse: + if not isinstance(self._soul, KimiSoul): + return JSONRPCErrorResponse( + id=msg.id, + error=JSONRPCErrorObject( + code=ErrorCodes.INVALID_STATE, + message="YOLO mode is not supported", + ), + ) + + # Update the approval state + self._soul.runtime.approval.set_yolo(msg.params.enabled) + new_state = self._soul.runtime.approval.is_yolo() + + status = StatusUpdate(yolo_mode=new_state) + await self._send_msg(JSONRPCEventMessage(params=status)) + # Persist to wire file so replay reconstructs yolo mode state + await self._soul.wire_file.append_message(status) + return JSONRPCSuccessResponse( + id=msg.id, + result={"status": "ok", "yolo_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 500104b11..d2804e06e 100644 --- a/src/kimi_cli/wire/types.py +++ b/src/kimi_cli/wire/types.py @@ -174,6 +174,8 @@ class StatusUpdate(BaseModel): """The message ID of the current step.""" plan_mode: bool | None = None """Whether plan mode (read-only) is active. None means no change.""" + yolo_mode: bool | None = None + """Whether YOLO (auto-approve) mode is active. None means no change.""" mcp_status: MCPStatusSnapshot | None = None """The current MCP startup snapshot. None means no change.""" diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index b7b7674ac..6eec449bc 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -150,6 +150,7 @@ async def test_wire_message_serde(): "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": True, "connected": 0, diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 140c46112..1ed4ab690 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -146,6 +146,16 @@ def test_pyinstaller_hiddenimports(): assert sorted(hiddenimports) == snapshot( [ + "kimi_cli.cli", + "kimi_cli.cli.__main__", + "kimi_cli.cli._lazy_group", + "kimi_cli.cli.export", + "kimi_cli.cli.info", + "kimi_cli.cli.mcp", + "kimi_cli.cli.plugin", + "kimi_cli.cli.toad", + "kimi_cli.cli.vis", + "kimi_cli.cli.web", "kimi_cli.tools", "kimi_cli.tools.agent", "kimi_cli.tools.ask_user", diff --git a/tests_e2e/test_wire_approvals_tools.py b/tests_e2e/test_wire_approvals_tools.py index 19a41d3c6..bc66f2e81 100644 --- a/tests_e2e/test_wire_approvals_tools.py +++ b/tests_e2e/test_wire_approvals_tools.py @@ -119,6 +119,7 @@ def test_shell_approval_approve(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -174,6 +175,7 @@ def test_shell_approval_approve(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -250,6 +252,7 @@ def test_shell_approval_reject(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -381,6 +384,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -440,6 +444,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -475,6 +480,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -508,6 +514,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -581,6 +588,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -614,6 +622,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -884,6 +893,7 @@ def test_display_block_todo(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -919,6 +929,7 @@ def test_display_block_todo(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1002,6 +1013,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1037,6 +1049,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1110,6 +1123,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1143,6 +1157,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1230,6 +1245,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1263,6 +1279,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_config.py b/tests_e2e/test_wire_config.py index ef494a03b..44134fc00 100644 --- a/tests_e2e/test_wire_config.py +++ b/tests_e2e/test_wire_config.py @@ -80,6 +80,7 @@ def test_config_string(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -170,6 +171,7 @@ def test_model_override(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_prompt.py b/tests_e2e/test_wire_prompt.py index d6058717a..aa79f9499 100644 --- a/tests_e2e/test_wire_prompt.py +++ b/tests_e2e/test_wire_prompt.py @@ -90,6 +90,7 @@ def test_basic_prompt_events(tmp_path) -> None: }, "message_id": "scripted-1", "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -294,6 +295,7 @@ def test_max_steps_reached(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -376,6 +378,7 @@ def test_status_update_fields(tmp_path) -> None: }, "message_id": "scripted-1", "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, } @@ -474,6 +477,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -529,6 +533,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index 602da121b..457ab724b 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -325,6 +325,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -367,6 +368,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -419,6 +421,7 @@ def test_prompt_without_initialize(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_sessions.py b/tests_e2e/test_wire_sessions.py index ef63b30d5..81dc9cc72 100644 --- a/tests_e2e/test_wire_sessions.py +++ b/tests_e2e/test_wire_sessions.py @@ -223,6 +223,7 @@ def test_clear_context_rotates(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": None, }, }, @@ -308,6 +309,7 @@ def test_manual_compact(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": None, }, }, @@ -464,6 +466,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -497,6 +500,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_skills_mcp.py b/tests_e2e/test_wire_skills_mcp.py index 88b49376d..6adda9cc1 100644 --- a/tests_e2e/test_wire_skills_mcp.py +++ b/tests_e2e/test_wire_skills_mcp.py @@ -117,6 +117,7 @@ def test_skill_prompt_injects_skill_text(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -208,6 +209,7 @@ def test_flow_skill(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -306,6 +308,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": True, "connected": 0, @@ -326,6 +329,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": False, "connected": 1, @@ -362,6 +366,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -417,6 +422,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/web/package-lock.json b/web/package-lock.json index 7c06e36ba..77241853a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -187,7 +187,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -948,7 +947,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1875,7 +1873,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4919,7 +4916,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4935,7 +4931,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4946,7 +4941,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5611,7 +5605,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6302,7 +6295,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6712,7 +6704,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7147,8 +7138,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7439,7 +7429,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9806,7 +9795,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -10443,8 +10431,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -11553,7 +11540,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11917,7 +11903,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11927,7 +11912,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13021,7 +13005,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14134,7 +14117,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14232,7 +14214,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -14587,7 +14568,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14947,7 +14927,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/features/chat/chat-workspace-container.tsx b/web/src/features/chat/chat-workspace-container.tsx index b0ceccd19..d15d38601 100644 --- a/web/src/features/chat/chat-workspace-container.tsx +++ b/web/src/features/chat/chat-workspace-container.tsx @@ -124,6 +124,8 @@ export function ChatWorkspaceContainer({ isReplayingHistory, planMode, sendSetPlanMode, + yoloMode, + sendSetYoloMode, slashCommands, } = sessionStream; @@ -316,6 +318,10 @@ export function ChatWorkspaceContainer({ sendSetPlanMode(enabled); }, [sendSetPlanMode]); + const handleYoloModeChange = useCallback((enabled: boolean) => { + sendSetYoloMode(enabled); + }, [sendSetYoloMode]); + const handleForkSession = useCallback( async (turnIndex: number) => { if (!(selectedSessionId && onForkSession)) { @@ -386,6 +392,8 @@ export function ChatWorkspaceContainer({ slashCommands={slashCommands} planMode={planMode} onPlanModeChange={handlePlanModeChange} + yoloMode={yoloMode} + onYoloModeChange={handleYoloModeChange} onForkSession={onForkSession ? handleForkSession : undefined} /> ); diff --git a/web/src/features/chat/chat.tsx b/web/src/features/chat/chat.tsx index 9a839b56b..3092f1d5c 100644 --- a/web/src/features/chat/chat.tsx +++ b/web/src/features/chat/chat.tsx @@ -86,6 +86,10 @@ type ChatWorkspaceProps = { planMode?: boolean; /** Callback to set plan mode */ onPlanModeChange?: (enabled: boolean) => void; + /** Whether yolo mode is active */ + yoloMode?: boolean; + /** Callback to set yolo mode */ + onYoloModeChange?: (enabled: boolean) => void; /** Maximum context size for the current model (tokens) */ maxContextSize?: number; /** Fork session at a specific turn */ @@ -120,6 +124,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ slashCommands = [], planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, onForkSession, }: ChatWorkspaceProps): ReactElement { const [blocksExpanded, setBlocksExpanded] = useState(false); @@ -341,6 +347,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ slashCommands={slashCommands} planMode={planMode} onPlanModeChange={onPlanModeChange} + yoloMode={yoloMode} + onYoloModeChange={onYoloModeChange} activityStatus={activityStatus} usagePercent={usagePercent} usedTokens={usedTokens} diff --git a/web/src/features/chat/components/approval-dialog.tsx b/web/src/features/chat/components/approval-dialog.tsx index f9284ef12..f632396f7 100644 --- a/web/src/features/chat/components/approval-dialog.tsx +++ b/web/src/features/chat/components/approval-dialog.tsx @@ -284,7 +284,7 @@ export function ApprovalDialog({ )} > {feedbackMode ? "Cancel feedback" : "Decline with feedback"} - {!feedbackMode && !approvalPending && ( + {!(feedbackMode || approvalPending) && ( 4 )} diff --git a/web/src/features/chat/components/chat-prompt-composer.tsx b/web/src/features/chat/components/chat-prompt-composer.tsx index ba2d051dd..0f08cd462 100644 --- a/web/src/features/chat/components/chat-prompt-composer.tsx +++ b/web/src/features/chat/components/chat-prompt-composer.tsx @@ -64,6 +64,8 @@ type ChatPromptComposerProps = { slashCommands?: SlashCommandDef[]; planMode?: boolean; onPlanModeChange?: (enabled: boolean) => void; + yoloMode?: boolean; + onYoloModeChange?: (enabled: boolean) => void; activityStatus?: ActivityDetail; usagePercent?: number; usedTokens?: number; @@ -87,6 +89,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ slashCommands = [], planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, activityStatus, usagePercent, usedTokens, @@ -304,7 +308,7 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ - + {isStreaming ? (
diff --git a/web/src/features/chat/components/session-files-panel.tsx b/web/src/features/chat/components/session-files-panel.tsx index 1464f2ee5..83c864142 100644 --- a/web/src/features/chat/components/session-files-panel.tsx +++ b/web/src/features/chat/components/session-files-panel.tsx @@ -251,7 +251,7 @@ export function SessionFilesPanel({
) : null} - {!isLoading && !error && entries.length === 0 ? ( + {!(isLoading || error) && entries.length === 0 ? (
No files in this directory. diff --git a/web/src/features/chat/global-config-controls.tsx b/web/src/features/chat/global-config-controls.tsx index 4103725ee..8713723cd 100644 --- a/web/src/features/chat/global-config-controls.tsx +++ b/web/src/features/chat/global-config-controls.tsx @@ -46,12 +46,16 @@ export type GlobalConfigControlsProps = { className?: string; planMode?: boolean; onPlanModeChange?: (enabled: boolean) => void; + yoloMode?: boolean; + onYoloModeChange?: (enabled: boolean) => void; }; export function GlobalConfigControls({ className, planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, }: GlobalConfigControlsProps): ReactElement { const { config, isLoading, isUpdating, error, refresh, update } = useGlobalConfig(); @@ -304,6 +308,31 @@ export function GlobalConfigControls({ )} + {onYoloModeChange && ( + <> +
+ + +
+ + YOLO + + +
+
+ + {yoloMode + ? "YOLO mode is active. All actions will be auto-approved without confirmation." + : "Enable YOLO mode to auto-approve all actions without confirmation."} + +
+ + )} + {(lastBusySkip && lastBusySkip.length > 0) || error ? (
) : null} diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 306f9d6bb..1da82c276 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -242,6 +242,10 @@ type UseSessionStreamReturn = { planMode: boolean; /** Set plan mode via silent RPC (no context message) */ sendSetPlanMode: (enabled: boolean) => void; + /** Whether YOLO (auto-approve) mode is active */ + yoloMode: boolean; + /** Set YOLO mode via silent RPC (no context message) */ + sendSetYoloMode: (enabled: boolean) => void; /** Available slash commands from the server */ slashCommands: SlashCommandDef[]; }; @@ -286,6 +290,7 @@ export function useSessionStream( const [contextUsage, setContextUsage] = useState(0); const [tokenUsage, setTokenUsage] = useState(null); const [planMode, setPlanMode] = useState(false); + const [yoloMode, setYoloMode] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); @@ -853,6 +858,7 @@ export function useSessionStream( setContextUsage(0); setTokenUsage(null); setPlanMode(false); + setYoloMode(false); setError(null); setSessionStatus(null); lastStatusSeqRef.current = null; @@ -1735,6 +1741,11 @@ export function useSessionStream( setPlanMode(nextPlanMode); } + const nextYoloMode = event.payload.yolo_mode; + if (typeof nextYoloMode === "boolean") { + setYoloMode(nextYoloMode); + } + // If we have a message_id, create a special message to display it const messageId = event.payload.message_id; if (messageId) { @@ -2815,6 +2826,20 @@ export function useSessionStream( wsRef.current.send(JSON.stringify(message)); }, []); + // Set YOLO mode via silent RPC (no context message) + const sendSetYoloMode = useCallback((enabled: boolean) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + return; + } + const message: JsonRpcRequest = { + jsonrpc: "2.0", + method: "set_yolo_mode", + id: uuidV4(), + params: { enabled }, + }; + wsRef.current.send(JSON.stringify(message)); + }, []); + // Auto-connect when sessionId changes useLayoutEffect(() => { /** @@ -2899,6 +2924,8 @@ export function useSessionStream( error, planMode, sendSetPlanMode, + yoloMode, + sendSetYoloMode, slashCommands, }; } diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts new file mode 100644 index 000000000..3adbdb0de --- /dev/null +++ b/web/src/hooks/useYoloMode.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getApiBaseUrl } from "@/hooks/utils"; +import { getAuthHeader } from "@/lib/auth"; +import type { YoloStatus } from "@/lib/api/models"; + +export type UseYoloModeReturn = { + yoloStatus: YoloStatus | null; + isLoading: boolean; + isUpdating: boolean; + error: string | null; + refresh: () => Promise; + setYoloMode: (enabled: boolean) => Promise; +}; + +export function useYoloMode(sessionId: string | null): UseYoloModeReturn { + const [yoloStatus, setYoloStatus] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + const isInitializedRef = useRef(false); + const sessionIdRef = useRef(sessionId); + + // Keep ref in sync for effect comparison + useEffect(() => { + sessionIdRef.current = sessionId; + }, [sessionId]); + + const refresh = useCallback(async () => { + if (!sessionId) { + setYoloStatus(null); + return; + } + + setIsLoading(true); + setError(null); + try { + const response = await fetch( + `${getApiBaseUrl()}/api/sessions/${sessionId}/yolo`, + { + headers: { + ...getAuthHeader(), + }, + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.detail || `Failed to load YOLO status: ${response.status}`, + ); + } + + const data = await response.json(); + setYoloStatus({ + enabled: data.enabled, + autoApproveActions: data.auto_approve_actions, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load YOLO status"; + setError(message); + console.error("[useYoloMode] Failed to load YOLO status:", err); + } finally { + setIsLoading(false); + } + }, [sessionId]); + + const setYoloMode = useCallback( + async (enabled: boolean) => { + if (!sessionId) { + throw new Error("No session selected"); + } + + setIsUpdating(true); + setError(null); + try { + const response = await fetch( + `${getApiBaseUrl()}/api/sessions/${sessionId}/yolo`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeader(), + }, + body: JSON.stringify({ enabled }), + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.detail || `Failed to update YOLO mode: ${response.status}`, + ); + } + + const data = await response.json(); + setYoloStatus({ + enabled: data.enabled, + autoApproveActions: data.auto_approve_actions, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to update YOLO mode"; + setError(message); + console.error("[useYoloMode] Failed to update YOLO mode:", err); + throw err; + } finally { + setIsUpdating(false); + } + }, + [sessionId], + ); + + // Load initial data + useEffect(() => { + if (isInitializedRef.current && sessionIdRef.current === sessionId) { + return; + } + isInitializedRef.current = true; + refresh(); + }, [sessionId, refresh]); + + return { + yoloStatus, + isLoading, + isUpdating, + error, + refresh, + setYoloMode, + }; +} diff --git a/web/src/hooks/wireTypes.ts b/web/src/hooks/wireTypes.ts index f35b378a4..ee88be9ad 100644 --- a/web/src/hooks/wireTypes.ts +++ b/web/src/hooks/wireTypes.ts @@ -131,6 +131,7 @@ export type StatusUpdateEvent = { token_usage?: TokenUsage | null; message_id?: string; plan_mode?: boolean | null; + yolo_mode?: boolean | null; }; }; diff --git a/web/src/lib/api/models/UpdateYoloRequest.ts b/web/src/lib/api/models/UpdateYoloRequest.ts new file mode 100644 index 000000000..a1aab9cdf --- /dev/null +++ b/web/src/lib/api/models/UpdateYoloRequest.ts @@ -0,0 +1,65 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Kimi Code CLI Web Interface + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * Update YOLO mode request. + * @export + * @interface UpdateYoloRequest + */ +export interface UpdateYoloRequest { + /** + * Enable or disable YOLO mode + * @type {boolean} + * @memberof UpdateYoloRequest + */ + enabled: boolean; +} + +/** + * Check if a given object implements the UpdateYoloRequest interface. + */ +export function instanceOfUpdateYoloRequest( + value: object, +): value is UpdateYoloRequest { + if (!("enabled" in value) || value["enabled"] === undefined) return false; + return true; +} + +export function UpdateYoloRequestFromJSON(json: any): UpdateYoloRequest { + return UpdateYoloRequestFromJSONTyped(json, false); +} + +export function UpdateYoloRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UpdateYoloRequest { + if (json == null) { + return json; + } + return { + enabled: json["enabled"], + }; +} + +export function UpdateYoloRequestToJSON( + value?: UpdateYoloRequest | null, +): any { + if (value == null) { + return value; + } + return { + enabled: value["enabled"], + }; +} diff --git a/web/src/lib/api/models/YoloStatus.ts b/web/src/lib/api/models/YoloStatus.ts new file mode 100644 index 000000000..cf9f8d399 --- /dev/null +++ b/web/src/lib/api/models/YoloStatus.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Kimi Code CLI Web Interface + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * YOLO (auto-approve) mode status. + * @export + * @interface YoloStatus + */ +export interface YoloStatus { + /** + * Whether YOLO mode is enabled + * @type {boolean} + * @memberof YoloStatus + */ + enabled: boolean; + /** + * List of auto-approved action types + * @type {Array} + * @memberof YoloStatus + */ + autoApproveActions?: Array; +} + +/** + * Check if a given object implements the YoloStatus interface. + */ +export function instanceOfYoloStatus( + value: object, +): value is YoloStatus { + if (!("enabled" in value) || value["enabled"] === undefined) return false; + return true; +} + +export function YoloStatusFromJSON(json: any): YoloStatus { + return YoloStatusFromJSONTyped(json, false); +} + +export function YoloStatusFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): YoloStatus { + if (json == null) { + return json; + } + return { + enabled: json["enabled"], + autoApproveActions: + json["auto_approve_actions"] == null + ? undefined + : json["auto_approve_actions"], + }; +} + +export function YoloStatusToJSON(value?: YoloStatus | null): any { + if (value == null) { + return value; + } + return { + enabled: value["enabled"], + auto_approve_actions: value["autoApproveActions"], + }; +} diff --git a/web/src/lib/api/models/index.ts b/web/src/lib/api/models/index.ts index 80183dae6..be0e21ca4 100644 --- a/web/src/lib/api/models/index.ts +++ b/web/src/lib/api/models/index.ts @@ -20,6 +20,8 @@ export * from './UpdateConfigTomlResponse'; export * from './UpdateGlobalConfigRequest'; export * from './UpdateGlobalConfigResponse'; export * from './UpdateSessionRequest'; +export * from './UpdateYoloRequest'; export * from './UploadSessionFileResponse'; export * from './ValidationError'; export * from './ValidationErrorLocInner'; +export * from './YoloStatus'; From 47921b61858a05887da656737d433437ad2f6f08 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 15:31:22 -0300 Subject: [PATCH 13/22] docs: add changelog entry for YOLO mode web support --- CHANGELOG.md | 1 + docs/en/release-notes/changelog.md | 1 + docs/zh/release-notes/changelog.md | 1 + 3 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index def4e305d..f82e84c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Only write entries that are worth mentioning to users. - Core: Add shared `file_filter` module — unifies file mention logic between shell and web UIs via `src/kimi_cli/utils/file_filter.py`, providing consistent path filtering, ignored directory exclusion, and git-aware file discovery - Shell: Prevent path traversal in file mention scope parameter — the `scope` parameter in file completer requests is now validated to prevent directory traversal attacks - Web: Restore unfiltered directory listing in file browser API — file browser endpoint no longer applies git-aware filtering, ensuring all files are visible in the web UI file picker +- Web: Add YOLO mode support to web interface — users can now toggle auto-approve mode directly from the web UI; adds `/api/sessions/{id}/yolo` endpoints and `yolo_mode` field to `StatusUpdate` (Wire 1.9) - Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710) - ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES) - Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 7ab68c6c6..32bf82528 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -15,6 +15,7 @@ This page documents the changes in each Kimi Code CLI release. - Core: Add shared `file_filter` module — unifies file mention logic between shell and web UIs via `src/kimi_cli/utils/file_filter.py`, providing consistent path filtering, ignored directory exclusion, and git-aware file discovery - Shell: Prevent path traversal in file mention scope parameter — the `scope` parameter in file completer requests is now validated to prevent directory traversal attacks - Web: Restore unfiltered directory listing in file browser API — file browser endpoint no longer applies git-aware filtering, ensuring all files are visible in the web UI file picker +- Web: Add YOLO mode support to web interface — users can now toggle auto-approve mode directly from the web UI; adds `/api/sessions/{id}/yolo` endpoints and `yolo_mode` field to `StatusUpdate` (Wire 1.9) - Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710) - ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES) - Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 34d66cbd9..963088d8e 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -15,6 +15,7 @@ - Core:新增共享的 `file_filter` 模块——通过 `src/kimi_cli/utils/file_filter.py` 统一 Shell 和 Web 的文件引用逻辑,提供一致的路径过滤、忽略目录排除和 Git 感知文件发现 - Shell:防止文件引用 scope 参数的路径遍历——文件补全器请求中的 `scope` 参数现在会经过验证,防止目录遍历攻击 - Web:恢复文件浏览器 API 中的未过滤目录列表——文件浏览器端点不再应用 Git 感知过滤,确保 Web UI 文件选择器中显示所有文件 +- Web:Web 界面新增 YOLO 模式支持——用户可直接在 Web UI 中开关自动审批模式;新增 `/api/sessions/{id}/yolo` 接口,并在 `StatusUpdate` 中增加 `yolo_mode` 字段(Wire 1.9) - Todo:重构 `SetTodoList` 工具,支持状态持久化并防止工具调用风暴——待办事项现在会持久化到会话状态(主 Agent)和独立状态文件(子 Agent);新增查询模式(省略 `todos` 参数可读取当前状态)和清空模式(传 `[]` 清空);工具描述中增加了防风暴指导,防止在没有实际进展的情况下反复调用(修复 #1710) - ReadFile:每次读取返回文件总行数,并支持负数 `line_offset` 实现 tail 模式——工具现在会在消息中报告 `Total lines in file: N.`,方便模型规划后续读取;负数 `line_offset`(如 `-100`)通过滑动窗口读取文件末尾 N 行,适用于无需 Shell 命令即可查看最新日志输出的场景;绝对值上限为 1000(MAX_LINES) - Shell:修复 Markdown 渲染中行内代码和代码块出现黑色背景的问题——`NEUTRAL_MARKDOWN_THEME` 现在将所有 Rich 默认的 `markdown.*` 样式覆盖为 `"none"`,防止 Rich 内置的 `"cyan on black"` 在非黑色背景终端上泄露 From 989e5a3b168e01c2f5d8cfa85245f27a2d458c76 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 16:03:39 -0300 Subject: [PATCH 14/22] Update web/src/hooks/useYoloMode.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Signed-off-by: Lucas Pacheco --- web/src/hooks/useYoloMode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 3adbdb0de..00c4878c5 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -117,6 +117,7 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { if (isInitializedRef.current && sessionIdRef.current === sessionId) { return; } + sessionIdRef.current = sessionId; isInitializedRef.current = true; refresh(); }, [sessionId, refresh]); From 7de90051124ff305a1a8a1ca0d8e6fec20598295 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 16:35:21 -0300 Subject: [PATCH 15/22] fix(web): initialize YOLO mode from server config on session connect Integrate useYoloMode hook into useSessionStream to fetch the initial YOLO mode state from the server API. This ensures the UI correctly reflects the default_yolo config setting when a session connects, instead of defaulting to false and waiting for a StatusUpdate event. Changes: - Import useYoloMode hook in useSessionStream.ts - Add yoloStatus fetching and sync to yoloMode state via useEffect --- .gitignore | 4 +++- web/src/hooks/useSessionStream.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f00b02169..fd80c51cb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,6 @@ node_modules/ static/ .memo/ .entire -.claude \ No newline at end of file +.claude +.local/ + diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 1da82c276..36cf732cd 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -138,6 +138,7 @@ import { createMessageId, getApiBaseUrl } from "./utils"; import { kimiCliVersion } from "@/lib/version"; import { handleToolResult, useToolEventsStore, type TodoItem } from "@/features/tool/store"; import { v4 as uuidV4 } from "uuid"; +import { useYoloMode } from "@/hooks/useYoloMode"; // Regex patterns moved to top level for performance const DATA_URL_MEDIA_TYPE_REGEX = /^data:([^;,]+)[;,]/; @@ -298,6 +299,16 @@ export function useSessionStream( const [isReplayingHistory, setIsReplayingHistory] = useState(true); const [slashCommands, setSlashCommands] = useState([]); + // Fetch initial YOLO mode state from the server API + const { yoloStatus } = useYoloMode(sessionId); + + // Update yoloMode state when yoloStatus changes (initial load or refresh) + useEffect(() => { + if (yoloStatus !== null) { + setYoloMode(yoloStatus.enabled); + } + }, [yoloStatus]); + // Refs /** * The single source of truth for "which WebSocket is allowed to mutate React state". From 5eef9afa4dd2c63cca8537fbeaee6cb41e16c294 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 16:48:44 -0300 Subject: [PATCH 16/22] fix(web): remove problematic ref-sync effect in useYoloMode hook The separate useEffect that synced sessionIdRef.current was updating the ref BEFORE the load effect could compare them. This caused the guard condition to always be true after first mount, skipping refresh() on session switches and displaying stale YOLO data. Remove the ref-sync effect so the load effect can properly compare old vs new sessionId before deciding to fetch. --- web/src/hooks/useYoloMode.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 00c4878c5..3aff5c3b8 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -21,11 +21,6 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { const isInitializedRef = useRef(false); const sessionIdRef = useRef(sessionId); - // Keep ref in sync for effect comparison - useEffect(() => { - sessionIdRef.current = sessionId; - }, [sessionId]); - const refresh = useCallback(async () => { if (!sessionId) { setYoloStatus(null); From eea1d170f9fcf79f40f3f88d50f7bc0fe98dd886 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 18:55:09 -0300 Subject: [PATCH 17/22] fix(web): add YOLO mode yellow dashed border to prompt composer --- web/src/features/chat/components/chat-prompt-composer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/features/chat/components/chat-prompt-composer.tsx b/web/src/features/chat/components/chat-prompt-composer.tsx index 0f08cd462..1cd7436ef 100644 --- a/web/src/features/chat/components/chat-prompt-composer.tsx +++ b/web/src/features/chat/components/chat-prompt-composer.tsx @@ -213,7 +213,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ accept="*" className={cn( "w-full [&_[data-slot=input-group]]:border [&_[data-slot=input-group]]:border-border", - planMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600" + planMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600", + yoloMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-amber-200 dark:[&_[data-slot=input-group]]:!border-amber-600" )} multiple maxFiles={MEDIA_CONFIG.maxCount} From 1f8cf2449501a7c3bd9cfd4af8740b2451249509 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Sun, 5 Apr 2026 18:55:49 -0300 Subject: [PATCH 18/22] fix(web): add AbortController to useYoloMode hook to prevent race conditions Add AbortController to cancel in-flight fetch requests when sessionId changes. This prevents stale YOLO status from overwriting the correct state after a session switch. Changes: - Add abortControllerRef to track the current fetch - Pass signal to fetch call in refresh callback - Check for AbortError in catch block and skip state updates - Add cleanup function in useEffect to abort on session change/unmount --- web/src/hooks/useYoloMode.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 3aff5c3b8..1ca5de7f1 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -20,6 +20,7 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { const isInitializedRef = useRef(false); const sessionIdRef = useRef(sessionId); + const abortControllerRef = useRef(null); const refresh = useCallback(async () => { if (!sessionId) { @@ -27,6 +28,15 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { return; } + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new AbortController for this request + const controller = new AbortController(); + abortControllerRef.current = controller; + setIsLoading(true); setError(null); try { @@ -36,6 +46,7 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { headers: { ...getAuthHeader(), }, + signal: controller.signal, }, ); @@ -52,12 +63,19 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { autoApproveActions: data.auto_approve_actions, }); } catch (err) { + // Skip state updates if the request was aborted + if (err instanceof Error && err.name === "AbortError") { + return; + } const message = err instanceof Error ? err.message : "Failed to load YOLO status"; setError(message); console.error("[useYoloMode] Failed to load YOLO status:", err); } finally { - setIsLoading(false); + // Only clear loading state if this request wasn't aborted + if (abortControllerRef.current === controller) { + setIsLoading(false); + } } }, [sessionId]); @@ -115,6 +133,14 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { sessionIdRef.current = sessionId; isInitializedRef.current = true; refresh(); + + // Cleanup: abort any in-flight request when sessionId changes or component unmounts + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; }, [sessionId, refresh]); return { From 18c4f8a9f662215e2ab04aaa61ea129343946d88 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Wed, 8 Apr 2026 11:10:17 -0300 Subject: [PATCH 19/22] fix(web): fix empty useEffect body for YOLO mode hydration The useEffect that syncs initial YOLO mode state from the REST API had an empty body due to a merge conflict resolution error. Add the missing setYoloMode(yoloStatus.enabled) call so the toggle correctly reflects the server state on initial page load and session switch. --- web/src/hooks/useSessionStream.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 2b50c99a7..7a324c301 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -307,6 +307,8 @@ export function useSessionStream( // stale HTTP responses overwriting newer real-time state useEffect(() => { if (yoloStatus !== null && !hasReceivedLiveYoloUpdateRef.current) { + setYoloMode(yoloStatus.enabled); + } }, [yoloStatus]); // Refs From 3638038308233b9c84ea393c202b5dc0233f9772 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Wed, 8 Apr 2026 16:52:36 -0300 Subject: [PATCH 20/22] fix(web): persist YOLO status to wire.jsonl for stopped sessions When POST /api/sessions/{id}/yolo runs against a stopped session, append a StatusUpdate(yolo_mode=...) record to wire.jsonl in addition to saving session_state.json. This ensures replay emits the correct YOLO state on reconnect, preventing the toggle from showing stale values until the next live update. --- src/kimi_cli/web/api/sessions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index b4c5bf081..9b295b2eb 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -55,6 +55,7 @@ JSONRPCPromptMessage, ) from kimi_cli.wire.serde import deserialize_wire_message +from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import is_request router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -923,6 +924,10 @@ async def update_yolo_status( else: # Only save directly to disk when the worker is not alive save_session_state(state, session_dir) + # Also persist a status update to wire.jsonl so replay shows correct YOLO state + wire_file = WireFile(session_dir / "wire.jsonl") + from kimi_cli.wire.types import StatusUpdate + await wire_file.append_message(StatusUpdate(yolo_mode=request.enabled)) return YoloStatus( enabled=state.approval.yolo, From fb6fcf79d5296e7ffd3978df40bd0f1497c5c07f Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Wed, 8 Apr 2026 17:00:53 -0300 Subject: [PATCH 21/22] test(web): add unit tests for YOLO mode API endpoints Add comprehensive tests for the YOLO mode REST API: - GET /api/sessions/{id}/yolo endpoint tests - POST /api/sessions/{id}/yolo endpoint tests - Tests for stopped sessions (saves to disk and wire.jsonl) - Tests for running sessions (notifies worker via set_yolo_mode) - Wire replay tests to verify StatusUpdate records are persisted - StatusUpdate serialization tests with yolo_mode field --- tests/core/test_wire_message.py | 37 +++ tests/web/test_sessions_yolo.py | 383 ++++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 tests/web/test_sessions_yolo.py diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index 6eec449bc..4789cfc1c 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -169,6 +169,43 @@ async def test_wire_message_serde(): ) _test_serde(msg) + # StatusUpdate with yolo_mode explicitly set + msg = StatusUpdate(yolo_mode=True) + assert serialize_wire_message(msg) == snapshot( + { + "type": "StatusUpdate", + "payload": { + "context_usage": None, + "context_tokens": None, + "max_context_tokens": None, + "token_usage": None, + "message_id": None, + "plan_mode": None, + "yolo_mode": True, + "mcp_status": None, + }, + } + ) + _test_serde(msg) + + msg = StatusUpdate(yolo_mode=False) + assert serialize_wire_message(msg) == snapshot( + { + "type": "StatusUpdate", + "payload": { + "context_usage": None, + "context_tokens": None, + "max_context_tokens": None, + "token_usage": None, + "message_id": None, + "plan_mode": None, + "yolo_mode": False, + "mcp_status": None, + }, + } + ) + _test_serde(msg) + msg = Notification( id="n1234567", category="task", diff --git a/tests/web/test_sessions_yolo.py b/tests/web/test_sessions_yolo.py new file mode 100644 index 000000000..1e3a98550 --- /dev/null +++ b/tests/web/test_sessions_yolo.py @@ -0,0 +1,383 @@ +"""Tests for sessions YOLO mode API endpoints.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from kimi_cli.session_state import ApprovalStateData, SessionState, save_session_state +from kimi_cli.web.app import create_app +from kimi_cli.web.runner.process import KimiCLIRunner, SessionProcess +from kimi_cli.wire.file import WireFile +from kimi_cli.wire.types import StatusUpdate + + +def create_mock_runner() -> KimiCLIRunner: + """Create a mock KimiCLIRunner with no running sessions.""" + mock_runner = MagicMock(spec=KimiCLIRunner) + mock_runner.get_session.return_value = None + return mock_runner + + +def create_mock_runner_with_running_session() -> KimiCLIRunner: + """Create a mock KimiCLIRunner with a running session.""" + mock_runner = MagicMock(spec=KimiCLIRunner) + mock_process = MagicMock(spec=SessionProcess) + mock_process.is_alive = True + mock_process.set_yolo_mode = AsyncMock() + mock_runner.get_session.return_value = mock_process + return mock_runner + + +def create_test_session(share_dir: Path) -> tuple[UUID, Path]: + """Create a test session with a temporary directory.""" + from kimi_cli.metadata import Metadata, WorkDirMeta, save_metadata + + session_id = uuid4() + work_dir = share_dir / "work" + work_dir.mkdir() + + metadata = Metadata(work_dirs=[WorkDirMeta(path=str(work_dir))]) + save_metadata(metadata) + + session_dir = metadata.work_dirs[0].sessions_dir / str(session_id) + session_dir.mkdir(parents=True) + + # Create a minimal context.jsonl file + (session_dir / "context.jsonl").write_text("{}", encoding="utf-8") + + return session_id, session_dir + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + """Create a test client with isolated metadata and mock runner.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + app = create_app() + app.state.runner = create_mock_runner() + return TestClient(app) + + +class TestGetYoloStatus: + """Tests for GET /api/sessions/{session_id}/yolo endpoint.""" + + def test_get_yolo_status_session_not_found(self, client: TestClient) -> None: + """Test 404 response when session does not exist.""" + response = client.get(f"/api/sessions/{uuid4()}/yolo") + + assert response.status_code == 404 + assert response.json()["detail"] == "Session not found" + + def test_get_yolo_status_default_false(self, client: TestClient, tmp_path: Path) -> None: + """Test that default YOLO status is false.""" + session_id, _ = create_test_session(tmp_path) + + response = client.get(f"/api/sessions/{session_id}/yolo") + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + assert data["auto_approve_actions"] == [] + + def test_get_yolo_status_enabled(self, client: TestClient, tmp_path: Path) -> None: + """Test getting YOLO status when enabled.""" + session_id, session_dir = create_test_session(tmp_path) + + # Set YOLO mode to enabled in session state + state = SessionState( + approval=ApprovalStateData( + yolo=True, + auto_approve_actions={"Shell", "WriteFile"}, + ), + ) + save_session_state(state, session_dir) + + response = client.get(f"/api/sessions/{session_id}/yolo") + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + assert set(data["auto_approve_actions"]) == {"Shell", "WriteFile"} + + +class TestUpdateYoloStatus: + """Tests for POST /api/sessions/{session_id}/yolo endpoint.""" + + def test_update_yolo_status_session_not_found(self, client: TestClient) -> None: + """Test 404 response when session does not exist.""" + response = client.post( + f"/api/sessions/{uuid4()}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Session not found" + + def test_update_yolo_status_enable_stopped_session( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test enabling YOLO mode for a stopped session.""" + session_id, session_dir = create_test_session(tmp_path) + + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + + # Verify session state was saved + state_file = session_dir / "state.json" + assert state_file.exists() + state_data = json.loads(state_file.read_text(encoding="utf-8")) + assert state_data["approval"]["yolo"] is True + + def test_update_yolo_status_disable_stopped_session( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test disabling YOLO mode for a stopped session.""" + session_id, session_dir = create_test_session(tmp_path) + + # First enable YOLO + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + + # Then disable it + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": False}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + + # Verify session state was saved + state_file = session_dir / "state.json" + state_data = json.loads(state_file.read_text(encoding="utf-8")) + assert state_data["approval"]["yolo"] is False + + def test_update_yolo_status_preserves_auto_approve_actions( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test that updating YOLO mode preserves auto_approve_actions.""" + session_id, session_dir = create_test_session(tmp_path) + + # Set initial state with auto_approve_actions + state = SessionState( + approval=ApprovalStateData( + yolo=False, + auto_approve_actions={"Shell", "WriteFile"}, + ), + ) + save_session_state(state, session_dir) + + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + assert set(data["auto_approve_actions"]) == {"Shell", "WriteFile"} + + @pytest.mark.anyio + async def test_update_yolo_status_persists_to_wire_jsonl( + self, tmp_path: Path, monkeypatch + ) -> None: + """Test that updating YOLO mode for stopped session persists to wire.jsonl.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + session_id, session_dir = create_test_session(tmp_path) + + app = create_app() + app.state.runner = create_mock_runner() + + with TestClient(app) as client: + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 200 + + # Verify wire.jsonl was created with StatusUpdate + wire_file = WireFile(session_dir / "wire.jsonl") + assert wire_file.path.exists() + + # Read and verify the wire file contents + records = [] + async for record in wire_file.iter_records(): + records.append(record) + + assert len(records) == 1 + msg = records[0].to_wire_message() + assert isinstance(msg, StatusUpdate) + assert msg.yolo_mode is True + + @pytest.mark.anyio + async def test_update_yolo_status_disable_persists_to_wire_jsonl( + self, tmp_path: Path, monkeypatch + ) -> None: + """Test that disabling YOLO mode for stopped session persists to wire.jsonl.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + session_id, session_dir = create_test_session(tmp_path) + + app = create_app() + app.state.runner = create_mock_runner() + + with TestClient(app) as client: + # First enable, then disable + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": False}, + ) + + assert response.status_code == 200 + + # Verify wire.jsonl contains both status updates + wire_file = WireFile(session_dir / "wire.jsonl") + records = [] + async for record in wire_file.iter_records(): + records.append(record) + + assert len(records) == 2 + # Check the second update (disable) + msg = records[1].to_wire_message() + assert isinstance(msg, StatusUpdate) + assert msg.yolo_mode is False + + +class TestUpdateYoloStatusRunningSession: + """Tests for updating YOLO status on running sessions.""" + + @pytest.mark.anyio + async def test_update_yolo_status_running_session_calls_set_yolo_mode( + self, + tmp_path: Path, + monkeypatch, + ) -> None: + """Test that updating YOLO on running session notifies the worker.""" + from kimi_cli.web.api.sessions import update_yolo_status + from kimi_cli.web.models import UpdateYoloRequest + + session_id, _ = create_test_session(tmp_path) + + # Create mock runner with running session + mock_runner = create_mock_runner_with_running_session() + + result = await update_yolo_status( + session_id=session_id, + request=UpdateYoloRequest(enabled=True), + runner=mock_runner, + ) + + assert result.enabled is True + + # Verify set_yolo_mode was called on the running session + mock_process = mock_runner.get_session.return_value + mock_process.set_yolo_mode.assert_awaited_once_with(True) + + @pytest.mark.anyio + async def test_update_yolo_status_running_session_does_not_save_to_disk( + self, + tmp_path: Path, + monkeypatch, + ) -> None: + """Test that updating YOLO on running session does not save to disk directly.""" + from kimi_cli.web.api.sessions import update_yolo_status + from kimi_cli.web.models import UpdateYoloRequest + + session_id, session_dir = create_test_session(tmp_path) + + # Create mock runner with running session + mock_runner = create_mock_runner_with_running_session() + + await update_yolo_status( + session_id=session_id, + request=UpdateYoloRequest(enabled=True), + runner=mock_runner, + ) + + # Verify state.json was NOT created (worker handles persistence) + state_file = session_dir / "state.json" + assert not state_file.exists() + + # Verify wire.jsonl was NOT created + wire_file = session_dir / "wire.jsonl" + assert not wire_file.exists() + + +class TestYoloStatusRoundTrip: + """Tests for round-trip YOLO status operations.""" + + def test_yolo_status_round_trip( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test that YOLO status can be set and retrieved correctly.""" + session_id, _ = create_test_session(tmp_path) + + # Initially false + response = client.get(f"/api/sessions/{session_id}/yolo") + assert response.json()["enabled"] is False + + # Enable YOLO + response = client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + assert response.json()["enabled"] is True + + # Verify it's now enabled + response = client.get(f"/api/sessions/{session_id}/yolo") + assert response.json()["enabled"] is True + + # Disable YOLO + response = client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": False}) + assert response.json()["enabled"] is False + + # Verify it's now disabled + response = client.get(f"/api/sessions/{session_id}/yolo") + assert response.json()["enabled"] is False + + +class TestYoloStatusWireReplay: + """Tests for YOLO status replay from wire.jsonl.""" + + @pytest.mark.anyio + async def test_wire_replay_includes_yolo_updates( + self, tmp_path: Path, monkeypatch + ) -> None: + """Test that wire.jsonl contains YOLO updates for replay.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + session_id, session_dir = create_test_session(tmp_path) + + app = create_app() + app.state.runner = create_mock_runner() + + with TestClient(app) as client: + # Enable YOLO + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + # Disable YOLO + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": False}) + # Enable again + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + + # Verify wire.jsonl contains all three status updates + wire_file = WireFile(session_dir / "wire.jsonl") + records = [] + async for record in wire_file.iter_records(): + records.append(record) + + assert len(records) == 3 + + # Check sequence: True, False, True + msgs = [r.to_wire_message() for r in records] + assert all(isinstance(m, StatusUpdate) for m in msgs) + assert msgs[0].yolo_mode is True + assert msgs[1].yolo_mode is False + assert msgs[2].yolo_mode is True From 203da08f52f4678c593593fa4b786b8d17a1c7e8 Mon Sep 17 00:00:00 2001 From: Lucas Pacheco Date: Thu, 9 Apr 2026 10:42:38 -0300 Subject: [PATCH 22/22] fix(web): guard against stale YOLO fetch results after session switches Capture the sessionId at the start of the refresh request and verify it hasn't changed before applying the fetch result. This prevents stale YOLO data from the previous session from being applied to the newly selected session when a session switch happens during the fetch. --- web/src/hooks/useYoloMode.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts index 1ca5de7f1..456c095d9 100644 --- a/web/src/hooks/useYoloMode.ts +++ b/web/src/hooks/useYoloMode.ts @@ -28,6 +28,9 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { return; } + // Capture sessionId at request start to detect stale responses + const requestSessionId = sessionId; + // Cancel any in-flight request if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -58,6 +61,15 @@ export function useYoloMode(sessionId: string | null): UseYoloModeReturn { } const data = await response.json(); + + // Guard against stale responses: only apply if session hasn't changed + if ( + requestSessionId !== sessionIdRef.current || + abortControllerRef.current !== controller + ) { + return; + } + setYoloStatus({ enabled: data.enabled, autoApproveActions: data.auto_approve_actions,