diff --git a/CHANGELOG.md b/CHANGELOG.md index c3da592f6..d4e41add3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Fix crash when working directory is removed during session — instead of flooding the terminal with OSError tracebacks, the shell now exits cleanly with a crash report showing the session ID and working directory path, allowing users to restart in a valid directory + ## 1.27.0 (2026-03-28) - Shell: Add `/feedback` command — submit feedback directly from the CLI session; the command falls back to opening GitHub Issues on network errors or timeouts diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index b71cac5a8..03faaea63 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Shell: Fix crash when working directory is removed during session — instead of flooding the terminal with OSError tracebacks, the shell now exits cleanly with a crash report showing the session ID and working directory path, allowing users to restart in a valid directory + ## 1.27.0 (2026-03-28) - Shell: Add `/feedback` command — submit feedback directly from the CLI session; the command falls back to opening GitHub Issues on network errors or timeouts diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 1f8de646c..7f6377821 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Shell:修复会话期间工作目录被移除时崩溃的问题——不再向终端输出大量 OSError 堆栈跟踪,而是以崩溃报告形式优雅退出,显示会话 ID 和工作目录路径,用户可在有效目录下重启继续 + ## 1.27.0 (2026-03-28) - Shell:新增 `/feedback` 命令——可直接在 CLI 会话中提交反馈,网络错误或超时时自动回退到打开 GitHub Issues 页面 diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 1f1905b0e..a88cbaff0 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -27,6 +27,7 @@ from kimi_cli.ui.shell.mcp_status import render_mcp_prompt from kimi_cli.ui.shell.prompt import ( CustomPromptSession, + CwdLostError, PromptMode, UserInput, toast, @@ -160,6 +161,37 @@ def available_slash_commands(self) -> dict[str, SlashCommand[Any]]: """Get all available slash commands, including shell-level and soul-level commands.""" return self._available_slash_commands + def _print_cwd_lost_crash(self) -> None: + """Print a crash report when the working directory is no longer accessible.""" + runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None + session_id = runtime.session.id if runtime else "unknown" + work_dir = str(runtime.session.work_dir) if runtime else "unknown" + + info = Table.grid(padding=(0, 1)) + info.add_row("Session:", session_id) + info.add_row("Working directory:", work_dir) + + panel = Panel( + Group( + Text( + "The working directory is no longer accessible " + "(external drive unplugged, directory deleted, or filesystem unmounted).", + ), + Text(""), + info, + Text(""), + Text( + "Your conversation history has been saved. " + "Restart kimi in a valid directory to continue.", + style="dim", + ), + ), + title="[bold red]Session crashed[/bold red]", + border_style="red", + ) + console.print() + console.print(panel) + @staticmethod def _should_exit_input(user_input: UserInput) -> bool: return user_input.command.strip() in {"exit", "quit", "/exit", "/quit"} @@ -240,6 +272,11 @@ async def _route_prompt_events( resume_prompt.clear() await idle_events.put(_PromptEvent(kind="eof")) return + except CwdLostError: + logger.error("Working directory no longer exists") + resume_prompt.clear() + await idle_events.put(_PromptEvent(kind="cwd_lost")) + return except Exception: logger.exception("Prompt router crashed") resume_prompt.clear() @@ -422,6 +459,11 @@ async def _invalidate_after_mcp_loading() -> None: console.print("Bye!") break + if event.kind == "cwd_lost": + self._print_cwd_lost_crash() + shell_ok = False + break + if event.kind == "error": shell_ok = False break diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index ede338e05..8fc648a27 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -80,6 +80,10 @@ PROMPT_SYMBOL_PLAN = "📋" +class CwdLostError(OSError): + """Raised when the working directory no longer exists (e.g. external drive unplugged).""" + + class SlashCommandCompleter(Completer): """ A completer that: @@ -2042,7 +2046,15 @@ def _render_bottom_toolbar(self) -> FormattedText: # CWD (truncated from left) + git branch with status badge # Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip - cwd = _truncate_left(_shorten_cwd(str(KaosPath.cwd())), _MAX_CWD_COLS) + try: + cwd = _truncate_left(_shorten_cwd(str(KaosPath.cwd())), _MAX_CWD_COLS) + except OSError: + # CWD no longer exists (e.g. external drive unplugged). Ask + # prompt_toolkit to exit; the raised exception will propagate out + # of prompt_async() into the Shell's event router which prints a + # crash report with session info and exits cleanly. + app.exit(exception=CwdLostError()) + return FormattedText([]) branch = _get_git_branch() if branch: dirty, ahead, behind = _get_git_status() diff --git a/tests/ui_and_conv/test_shell_prompt_router.py b/tests/ui_and_conv/test_shell_prompt_router.py index 91e7b4de5..593e3e452 100644 --- a/tests/ui_and_conv/test_shell_prompt_router.py +++ b/tests/ui_and_conv/test_shell_prompt_router.py @@ -9,7 +9,7 @@ import kimi_cli.ui.shell as shell_module from kimi_cli.soul import Soul -from kimi_cli.ui.shell.prompt import PromptMode, UserInput +from kimi_cli.ui.shell.prompt import CwdLostError, PromptMode, UserInput from kimi_cli.wire.types import TextPart @@ -245,3 +245,22 @@ def running_prompt_accepts_submission(self) -> bool: event = idle_events.get_nowait() assert event.kind == "eof" assert shell._exit_after_run is False + + +@pytest.mark.asyncio +async def test_route_prompt_events_cwd_lost_posts_cwd_lost_event( + _patched_prompt_router, +) -> None: + """When prompt_next raises CwdLostError the router should post a 'cwd_lost' + event and stop, so the main loop can print a crash report and exit.""" + shell = shell_module.Shell(cast(Soul, _make_fake_soul())) + prompt_session = _FakePromptSession([(False, CwdLostError())]) + idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue() + resume_prompt = asyncio.Event() + resume_prompt.set() + + await shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt) + + event = idle_events.get_nowait() + assert event.kind == "cwd_lost" + assert not resume_prompt.is_set()