From e1f62616f60e1e8f4846744193230e2e9fac26ee Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 27 Feb 2026 14:40:02 +0800 Subject: [PATCH 1/2] feat(shell): add /new command to start a new session --- src/kimi_cli/ui/shell/slash.py | 18 ++ .../ui_and_conv/test_shell_slash_commands.py | 266 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 tests/ui_and_conv/test_shell_slash_commands.py diff --git a/src/kimi_cli/ui/shell/slash.py b/src/kimi_cli/ui/shell/slash.py index 5147c3554..28518374c 100644 --- a/src/kimi_cli/ui/shell/slash.py +++ b/src/kimi_cli/ui/shell/slash.py @@ -310,6 +310,24 @@ async def clear(app: Shell, args: str): raise Reload() +@registry.command +async def new(app: Shell, args: str): + """Start a new session""" + soul = _ensure_kimi_soul(app) + if soul is None: + return + current_session = soul.runtime.session + work_dir = current_session.work_dir + # Clean up the current session if it has no content, so that chaining + # /new commands (or switching away before the first message) does not + # leave orphan empty session directories on disk. + if current_session.is_empty(): + await current_session.delete() + session = await Session.create(work_dir) + console.print("[green]New session created. Switching...[/green]") + raise Reload(session_id=session.id) + + @registry.command(name="sessions", aliases=["resume"]) async def list_sessions(app: Shell, args: str): """List sessions and resume optionally""" diff --git a/tests/ui_and_conv/test_shell_slash_commands.py b/tests/ui_and_conv/test_shell_slash_commands.py new file mode 100644 index 000000000..deea208ec --- /dev/null +++ b/tests/ui_and_conv/test_shell_slash_commands.py @@ -0,0 +1,266 @@ +"""Tests for shell-level slash commands.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest +from kaos.path import KaosPath +from kosong.message import Message + +from kimi_cli.cli import Reload +from kimi_cli.session import Session +from kimi_cli.ui.shell.slash import ShellSlashCmdFunc, shell_mode_registry +from kimi_cli.ui.shell.slash import registry as shell_slash_registry +from kimi_cli.utils.slashcmd import SlashCommand +from kimi_cli.wire.types import TextPart + + +async def _invoke_slash_command(command: SlashCommand[ShellSlashCmdFunc], shell: Any) -> None: + ret = command.func(shell, "") + if isinstance(ret, Awaitable): + await ret + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def isolated_share_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Provide an isolated share directory for metadata operations.""" + share_dir = tmp_path / "share" + share_dir.mkdir() + + def _get_share_dir() -> Path: + share_dir.mkdir(parents=True, exist_ok=True) + return share_dir + + monkeypatch.setattr("kimi_cli.share.get_share_dir", _get_share_dir) + monkeypatch.setattr("kimi_cli.metadata.get_share_dir", _get_share_dir) + return share_dir + + +@pytest.fixture +def work_dir(tmp_path: Path) -> KaosPath: + path = tmp_path / "work" + path.mkdir() + return KaosPath.unsafe_from_local_path(path) + + +@pytest.fixture +def mock_shell(work_dir: KaosPath) -> Mock: + """Create a mock Shell whose soul passes the KimiSoul isinstance check. + + The mock session is treated as non-empty so that /new does not attempt + to delete it (delete would fail on a plain Mock because it is not awaitable). + """ + from kimi_cli.soul.kimisoul import KimiSoul + + mock_soul = Mock(spec=KimiSoul) + mock_soul.runtime.session.work_dir = work_dir + mock_soul.runtime.session.id = "current-session-id" + mock_soul.runtime.session.is_empty.return_value = False + + shell = Mock() + shell.soul = mock_soul + return shell + + +# --------------------------------------------------------------------------- +# /new — registration +# --------------------------------------------------------------------------- + + +class TestNewCommandRegistration: + """Verify /new is registered in the correct registries.""" + + def test_registered_in_shell_registry(self) -> None: + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + assert cmd.name == "new" + assert cmd.description == "Start a new session" + + def test_not_in_shell_mode_registry(self) -> None: + """/new should NOT be available in shell mode (Ctrl-X toggle).""" + assert shell_mode_registry.find_command("new") is None + + def test_not_in_soul_registry(self) -> None: + """/new should NOT appear in soul-level commands (Web UI visibility).""" + from kimi_cli.soul.slash import registry as soul_slash_registry + + assert soul_slash_registry.find_command("new") is None + + +# --------------------------------------------------------------------------- +# /new — behaviour +# --------------------------------------------------------------------------- + + +class TestNewCommandBehavior: + """Verify /new creates a new session and raises Reload.""" + + async def test_raises_reload_with_new_session_id( + self, isolated_share_dir: Path, mock_shell: Mock + ) -> None: + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + + with pytest.raises(Reload) as exc_info: + await _invoke_slash_command(cmd, mock_shell) + + session_id = exc_info.value.session_id + assert session_id is not None + assert session_id != "current-session-id" + + async def test_new_session_persisted_on_disk( + self, isolated_share_dir: Path, work_dir: KaosPath, mock_shell: Mock + ) -> None: + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + + with pytest.raises(Reload) as exc_info: + await _invoke_slash_command(cmd, mock_shell) + + session_id = exc_info.value.session_id + assert session_id is not None + new_session = await Session.find(work_dir, session_id) + assert new_session is not None + assert new_session.context_file.exists() + assert new_session.context_file.stat().st_size == 0 # empty context + + async def test_consecutive_calls_produce_unique_ids( + self, isolated_share_dir: Path, mock_shell: Mock + ) -> None: + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + + ids: list[str] = [] + for _ in range(3): + with pytest.raises(Reload) as exc_info: + await _invoke_slash_command(cmd, mock_shell) + session_id = exc_info.value.session_id + assert session_id is not None + ids.append(session_id) + + assert len(set(ids)) == 3 + + async def test_returns_early_without_kimi_soul(self) -> None: + """When soul is not a KimiSoul, the command should silently return.""" + shell = Mock() + shell.soul = Mock() # plain Mock, not spec=KimiSoul + + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + + # Should return without raising Reload + await _invoke_slash_command(cmd, shell) + + +# --------------------------------------------------------------------------- +# /new — empty-session cleanup +# --------------------------------------------------------------------------- + + +def _write_context_message(context_file: Path, text: str) -> None: + """Write a user message to a context file to make the session non-empty.""" + context_file.parent.mkdir(parents=True, exist_ok=True) + message = Message(role="user", content=[TextPart(text=text)]) + context_file.write_text(message.model_dump_json(exclude_none=True) + "\n", encoding="utf-8") + + +class TestNewCommandSessionCleanup: + """Verify /new cleans up the current session when it is empty.""" + + async def test_deletes_empty_current_session( + self, isolated_share_dir: Path, work_dir: KaosPath + ) -> None: + """An empty current session should be removed to avoid orphan directories.""" + from kimi_cli.soul.kimisoul import KimiSoul + + empty_session = await Session.create(work_dir) + assert empty_session.is_empty() + session_dir = empty_session.work_dir_meta.sessions_dir / empty_session.id + assert session_dir.exists() + + mock_soul = Mock(spec=KimiSoul) + mock_soul.runtime.session = empty_session + shell = Mock() + shell.soul = mock_soul + + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + with pytest.raises(Reload): + await _invoke_slash_command(cmd, shell) + + # The empty session directory should have been cleaned up + assert not session_dir.exists() + + async def test_preserves_non_empty_current_session( + self, isolated_share_dir: Path, work_dir: KaosPath + ) -> None: + """A session that already has content must NOT be deleted.""" + from kimi_cli.soul.kimisoul import KimiSoul + + session_with_content = await Session.create(work_dir) + _write_context_message(session_with_content.context_file, "hello world") + assert not session_with_content.is_empty() + session_dir = session_with_content.work_dir_meta.sessions_dir / session_with_content.id + + mock_soul = Mock(spec=KimiSoul) + mock_soul.runtime.session = session_with_content + shell = Mock() + shell.soul = mock_soul + + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + with pytest.raises(Reload): + await _invoke_slash_command(cmd, shell) + + # The non-empty session directory must still exist + assert session_dir.exists() + + async def test_chained_new_does_not_accumulate_empty_sessions( + self, isolated_share_dir: Path, work_dir: KaosPath + ) -> None: + """Calling /new repeatedly should not leave orphan empty sessions.""" + from kimi_cli.soul.kimisoul import KimiSoul + + cmd = shell_slash_registry.find_command("new") + assert cmd is not None + + # Simulate: session A (empty) → /new → session B (empty) → /new → session C + session_a = await Session.create(work_dir) + dir_a = session_a.work_dir_meta.sessions_dir / session_a.id + + mock_soul = Mock(spec=KimiSoul) + mock_soul.runtime.session = session_a + shell = Mock() + shell.soul = mock_soul + + # First /new: A is empty → cleaned up, B created + with pytest.raises(Reload) as exc_info: + await _invoke_slash_command(cmd, shell) + session_b_id = exc_info.value.session_id + assert session_b_id is not None + session_b = await Session.find(work_dir, session_b_id) + assert session_b is not None + dir_b = session_b.work_dir_meta.sessions_dir / session_b.id + + assert not dir_a.exists() # A cleaned up + assert dir_b.exists() # B exists + + # Second /new: B is empty → cleaned up, C created + mock_soul.runtime.session = session_b + with pytest.raises(Reload) as exc_info: + await _invoke_slash_command(cmd, shell) + session_c_id = exc_info.value.session_id + assert session_c_id is not None + + assert not dir_b.exists() # B cleaned up + session_c = await Session.find(work_dir, session_c_id) + assert session_c is not None From cfa95038cf07db65947acdf43e0a18a07395bb14 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 27 Feb 2026 14:52:46 +0800 Subject: [PATCH 2/2] feat(shell): add `/new` command to create and switch to a new session without restarting Kimi Code CLI --- CHANGELOG.md | 1 + docs/en/guides/sessions.md | 4 +++- docs/en/reference/slash-commands.md | 4 ++++ docs/en/release-notes/changelog.md | 1 + docs/zh/guides/sessions.md | 4 +++- docs/zh/reference/slash-commands.md | 4 ++++ docs/zh/release-notes/changelog.md | 1 + 7 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a504c08..5d22450ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI - Wire: Auto-hide `AskUserQuestion` tool when the client does not support the `supports_question` capability, preventing the LLM from invoking unsupported interactions ## 1.15.0 (2026-02-27) diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index 605a14d9a..6fd8c46bb 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -4,7 +4,9 @@ Kimi Code CLI automatically saves your conversation history, allowing you to con ## Session resuming -Each time you start Kimi Code CLI, a new session is created. If you want to continue a previous conversation, there are several ways: +Each time you start Kimi Code CLI, a new session is created. While running, you can also enter the `/new` command to create and switch to a new session at any time, without exiting the program. + +If you want to continue a previous conversation, there are several ways: **Continue the most recent session** diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 24fab3d89..8b674621d 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -94,6 +94,10 @@ Output includes: ## Session management +### `/new` + +Create a new session and switch to it immediately, without exiting Kimi Code CLI. If the current session has no content, the empty session directory is automatically cleaned up. + ### `/sessions` List all sessions in the current working directory, allowing switching to other sessions. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 4cf77e9ff..22195dc8d 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 +- Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI - Wire: Auto-hide `AskUserQuestion` tool when the client does not support the `supports_question` capability, preventing the LLM from invoking unsupported interactions ## 1.15.0 (2026-02-27) diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index b53e2318f..2a72514de 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -4,7 +4,9 @@ Kimi Code CLI 会自动保存你的对话历史,方便你随时继续之前的 ## 会话续接 -每次启动 Kimi Code CLI 时,都会创建一个新的会话。如果你想继续之前的对话,有几种方式: +每次启动 Kimi Code CLI 时,都会创建一个新的会话。在运行过程中,你也可以输入 `/new` 命令随时创建并切换到一个新会话,无需退出程序。 + +如果你想继续之前的对话,有几种方式: **继续最近的会话** diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 09223ebba..95b09072a 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -94,6 +94,10 @@ ## 会话管理 +### `/new` + +创建一个新会话并立即切换过去,无需退出 Kimi Code CLI。如果当前会话没有任何内容,会自动清理空会话目录。 + ### `/sessions` 列出当前工作目录下的所有会话,可切换到其他会话。 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 61af50dc5..b28f5695c 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- Shell:新增 `/new` 斜杠命令,无需重启 Kimi Code CLI 即可创建并切换到新会话 - Wire:当客户端不支持 `supports_question` 能力时,自动隐藏 `AskUserQuestion` 工具,避免 LLM 调用不受支持的交互 ## 1.15.0 (2026-02-27)