diff --git a/CHANGELOG.md b/CHANGELOG.md index 745066e7d..d1604b41c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,9 @@ Only write entries that are worth mentioning to users. - Shell: Enhance `Ctrl-V` clipboard paste to support video files in addition to images — video file paths are inserted as text, and a crash when clipboard data is `None` is fixed - Core: Pass session ID as `user_id` metadata to Anthropic API - Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization +- CLI: Add `--sessions` option to interactively select a session to resume +- CLI: Add `--list-sessions` option to list all sessions for the working directory +- Core: Add custom `shorten` function for better CJK text support in session titles and exports ## 1.17.0 (2026-03-03) diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 4e98912fe..5a2d05705 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -56,8 +56,10 @@ The working directory determines the root directory for file operations. Relativ |--------|-------|-------------| | `--continue` | `-C` | Continue the previous session in the current working directory | | `--session ID` | `-S` | Resume session with specified ID, creates new session if not exists | +| `--sessions` | | Interactively select a session from the current working directory | +| `--list-sessions` | | List all sessions in the current working directory and exit | -`--continue` and `--session` are mutually exclusive. +`--continue`, `--session`, `--sessions`, and `--list-sessions` are mutually exclusive. ## Input and commands diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index d56f8f666..74cc54377 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -96,6 +96,9 @@ This page documents the changes in each Kimi Code CLI release. - Shell: Enhance `Ctrl-V` clipboard paste to support video files in addition to images — video file paths are inserted as text, and a crash when clipboard data is `None` is fixed - Core: Pass session ID as `user_id` metadata to Anthropic API - Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization +- CLI: Add `--sessions` option to interactively select a session to resume +- CLI: Add `--list-sessions` option to list all sessions for the working directory +- Core: Add custom `shorten` function for better CJK text support in session titles and exports ## 1.17.0 (2026-03-03) diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index a15bb443a..97d78dcca 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -56,8 +56,10 @@ kimi [OPTIONS] COMMAND [ARGS] |------|------|------| | `--continue` | `-C` | 继续当前工作目录的上一个会话 | | `--session ID` | `-S` | 恢复指定 ID 的会话,若不存在则创建新会话 | +| `--sessions` | | 交互式选择当前工作目录的会话 | +| `--list-sessions` | | 列出当前工作目录的所有会话并退出 | -`--continue` 和 `--session` 互斥。 +`--continue`、`--session`、`--sessions` 和 `--list-sessions` 互斥。 ## 输入与命令 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 68942c798..7bdd32fd2 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -96,6 +96,9 @@ - Shell:增强 `Ctrl-V` 剪贴板粘贴功能,支持粘贴视频文件——视频文件路径以文本形式插入输入框,同时修复剪贴板数据为 `None` 时的崩溃问题 - Core:将会话 ID 作为 `user_id` 元数据传递给 Anthropic API - Web:修复 WebSocket 重连时斜杠命令丢失的问题,并为会话初始化添加自动重试逻辑 +- CLI:新增 `--sessions` 选项,支持交互式选择要恢复的会话 +- CLI:新增 `--list-sessions` 选项,支持列出工作目录下的所有会话 +- Core:新增自定义 `shorten` 函数,优化中日韩文本在会话标题和导出内容中的截断显示效果 ## 1.17.0 (2026-03-03) diff --git a/src/kimi_cli/cli/__init__.py b/src/kimi_cli/cli/__init__.py index 9fbf83cb5..866420ff8 100644 --- a/src/kimi_cli/cli/__init__.py +++ b/src/kimi_cli/cli/__init__.py @@ -39,6 +39,12 @@ def __init__(self, session_id: str | None = None): OutputFormat = Literal["text", "stream-json"] +def _strip_session_id_suffix(title: str, session_id: str) -> str: + """Remove the trailing `` (session_id)`` that `Session.refresh` appends.""" + suffix = f" ({session_id})" + return title.rsplit(suffix, 1)[0] if title.endswith(suffix) else title + + def _version_callback(value: bool) -> None: if value: from kimi_cli.constant import get_version @@ -119,6 +125,20 @@ def kimi( help="Continue the previous session for the working directory. Default: no.", ), ] = False, + sessions: Annotated[ + bool, + typer.Option( + "--pick-session", + help="Interactively select a session to resume for the working directory.", + ), + ] = False, + list_sessions: Annotated[ + bool, + typer.Option( + "--list-sessions", + help="List all sessions for the working directory and exit.", + ), + ] = False, config_string: Annotated[ str | None, typer.Option( @@ -385,6 +405,8 @@ def _emit_fatal_error(message: str) -> None: { "--continue": continue_, "--session": session_id is not None, + "--pick-session": sessions, + "--list-sessions": list_sessions, }, { "--config": config_string is not None, @@ -434,6 +456,11 @@ def _emit_fatal_error(message: str) -> None: "Final-message-only output is only supported for print UI", param_hint="--final-message-only", ) + if sessions and ui != "shell": + raise typer.BadParameter( + "--pick-session is only supported for shell UI", + param_hint="--pick-session", + ) config: Config | Path | None = None if config_string is not None: @@ -472,6 +499,32 @@ def _emit_fatal_error(message: str) -> None: work_dir = KaosPath.unsafe_from_local_path(local_work_dir) if local_work_dir else KaosPath.cwd() + if list_sessions: + from rich.console import Console + from rich.table import Table + + from kimi_cli.utils.datetime import format_relative_time + + async def _list(): + return await Session.list(work_dir) + + all_sessions = asyncio.run(_list()) + console = Console() + if not all_sessions: + console.print("[yellow]No sessions found for the working directory.[/yellow]") + raise typer.Exit(0) + + table = Table(show_header=True, show_edge=False) + table.add_column("ID") + table.add_column("Title") + table.add_column("Updated") + for s in all_sessions: + name = _strip_session_id_suffix(s.title, s.id) + table.add_row(s.id, name, format_relative_time(s.updated_at)) + + console.print(table) + raise typer.Exit(0) + async def _run(session_id: str | None) -> tuple[Session, bool]: """ Create/load session and run the CLI instance. @@ -634,6 +687,43 @@ async def _reload_loop(session_id: str | None) -> bool: await _post_run(last_session, succeeded) return False + if sessions: + from prompt_toolkit.shortcuts.choice_input import ChoiceInput + from rich.console import Console + + from kimi_cli.utils.datetime import format_relative_time + + async def _pick_session() -> str: + all_sessions = await Session.list(work_dir) + if not all_sessions: + Console().print("[yellow]No sessions found for the working directory.[/yellow]") + raise typer.Exit(0) + + choices: list[tuple[str, str]] = [] + for s in all_sessions: + time_str = format_relative_time(s.updated_at) + short_id = s.id[:8] + name = _strip_session_id_suffix(s.title, s.id) + label = f"{name} ({short_id}), {time_str}" + choices.append((s.id, label)) + + try: + selection = await ChoiceInput( + message="Select a session to resume" + " (↑↓ navigate, Enter select, Ctrl+C cancel):", + options=choices, + default=choices[0][0], + ).prompt_async() + except (EOFError, KeyboardInterrupt): + raise typer.Exit(0) from None + + if not selection: + raise typer.Exit(0) + + return selection + + session_id = asyncio.run(_pick_session()) + try: switch_to_web = asyncio.run(_reload_loop(session_id)) except (typer.BadParameter, typer.Exit): diff --git a/src/kimi_cli/session.py b/src/kimi_cli/session.py index e1f50df0f..5aa8e038f 100644 --- a/src/kimi_cli/session.py +++ b/src/kimi_cli/session.py @@ -7,7 +7,6 @@ import uuid from dataclasses import dataclass from pathlib import Path -from textwrap import shorten from kaos.path import KaosPath from kosong.message import Message @@ -15,6 +14,7 @@ from kimi_cli.metadata import WorkDirMeta, load_metadata, save_metadata from kimi_cli.session_state import SessionState, load_session_state, save_session_state from kimi_cli.utils.logging import logger +from kimi_cli.utils.string import shorten from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import TurnBegin diff --git a/src/kimi_cli/utils/export.py b/src/kimi_cli/utils/export.py index 933a7c7b4..50b5c3306 100644 --- a/src/kimi_cli/utils/export.py +++ b/src/kimi_cli/utils/export.py @@ -4,7 +4,6 @@ from collections.abc import Sequence from datetime import datetime from pathlib import Path -from textwrap import shorten from typing import TYPE_CHECKING, cast import aiofiles @@ -14,6 +13,7 @@ from kimi_cli.soul.message import is_system_reminder_message, system from kimi_cli.utils.message import message_stringify from kimi_cli.utils.path import sanitize_cli_path +from kimi_cli.utils.string import shorten from kimi_cli.wire.types import ( AudioURLPart, ContentPart, @@ -66,12 +66,12 @@ def _extract_tool_call_hint(args_json: str) -> str: for key in _HINT_KEYS: val = args.get(key) if isinstance(val, str) and val.strip(): - return shorten(val, width=60, placeholder="…") + return shorten(val, width=60) # Fallback: first short string value for val in args.values(): if isinstance(val, str) and 0 < len(val) <= 80: - return shorten(val, width=60, placeholder="…") + return shorten(val, width=60) return "" diff --git a/src/kimi_cli/utils/string.py b/src/kimi_cli/utils/string.py index bd4379bba..e6fff6f7a 100644 --- a/src/kimi_cli/utils/string.py +++ b/src/kimi_cli/utils/string.py @@ -7,6 +7,25 @@ _NEWLINE_RE = re.compile(r"[\r\n]+") +def shorten(text: str, *, width: int, placeholder: str = "…") -> str: + """Shorten text to at most *width* characters. + + Normalises whitespace, then truncates — preferring a word boundary + when one exists near the cut point, but falling back to a hard cut + so that CJK text without spaces won't collapse to just the placeholder. + """ + text = " ".join(text.split()) + if len(text) <= width: + return text + cut = width - len(placeholder) + if cut <= 0: + return text[:width] + space = text.rfind(" ", 0, cut + 1) + if space > 0: + cut = space + return text[:cut].rstrip() + placeholder + + def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str: """Shorten the text by inserting ellipsis in the middle.""" if len(text) <= width: diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 36cd42507..bf12a638e 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -951,12 +951,11 @@ async def generate_session_title( if not user_message: return GenerateTitleResponse(title="Untitled") - # Fallback title from user message (used if AI generation fails) - from textwrap import shorten + from kimi_cli.utils.string import shorten user_text = user_message.strip() user_text = " ".join(user_text.split()) - fallback_title = shorten(user_text, width=50, placeholder="...") or "Untitled" + fallback_title = shorten(user_text, width=50) or "Untitled" # If AI generation failed too many times, use fallback and mark as generated if metadata.title_generate_attempts >= 3: @@ -1018,7 +1017,7 @@ async def generate_session_title( title = generated_title ai_generated = True elif generated_title: - title = shorten(generated_title, width=50, placeholder="...") + title = shorten(generated_title, width=50) ai_generated = True except Exception as e: diff --git a/src/kimi_cli/web/store/sessions.py b/src/kimi_cli/web/store/sessions.py index 357c749f6..b3a20b34b 100644 --- a/src/kimi_cli/web/store/sessions.py +++ b/src/kimi_cli/web/store/sessions.py @@ -129,10 +129,11 @@ def _derive_title_from_wire(session_dir: Path) -> str: try: import json - from textwrap import shorten from kosong.message import Message + from kimi_cli.utils.string import shorten + with open(wire_file, encoding="utf-8") as f: for line in f: line = line.strip() diff --git a/tests/e2e/test_cli_error_output.py b/tests/e2e/test_cli_error_output.py index 857400f32..e8e78108e 100644 --- a/tests/e2e/test_cli_error_output.py +++ b/tests/e2e/test_cli_error_output.py @@ -111,6 +111,61 @@ def test_invalid_config_toml_is_reported(tmp_path: Path) -> None: ) +def test_sessions_and_continue_conflict_is_reported(tmp_path: Path) -> None: + share_dir = tmp_path / "share" + result = _run_kimi(["--pick-session", "--continue"], share_dir=share_dir) + assert result.returncode == snapshot(2) + assert result.stdout == snapshot("") + assert _normalize_cli_error_output(result.stderr) == snapshot( + """\ +Usage: python -m kimi_cli.cli [OPTIONS] COMMAND [ARGS]... +Try 'python -m kimi_cli.cli -h' for help. +Error: +Invalid value for --continue: Cannot combine --continue, --pick-session. +""" + ) + + +def test_list_sessions_and_session_conflict_is_reported(tmp_path: Path) -> None: + share_dir = tmp_path / "share" + result = _run_kimi(["--list-sessions", "--session", "abc"], share_dir=share_dir) + assert result.returncode == snapshot(2) + assert result.stdout == snapshot("") + assert _normalize_cli_error_output(result.stderr) == snapshot( + """\ +Usage: python -m kimi_cli.cli [OPTIONS] COMMAND [ARGS]... +Try 'python -m kimi_cli.cli -h' for help. +Error: +Invalid value for --session: Cannot combine --session, --list-sessions. +""" + ) + + +def test_sessions_with_print_mode_is_reported(tmp_path: Path) -> None: + share_dir = tmp_path / "share" + result = _run_kimi(["--pick-session", "--print", "--prompt", "hi"], share_dir=share_dir) + assert result.returncode == snapshot(2) + assert result.stdout == snapshot("") + assert _normalize_cli_error_output(result.stderr) == snapshot( + """\ +Usage: python -m kimi_cli.cli [OPTIONS] COMMAND [ARGS]... +Try 'python -m kimi_cli.cli -h' for help. +Error: +Invalid value for --pick-session: --pick-session is only supported for shell UI +""" + ) + + +def test_list_sessions_with_no_sessions(tmp_path: Path) -> None: + share_dir = tmp_path / "share" + work_dir = tmp_path / "work" + work_dir.mkdir(parents=True, exist_ok=True) + result = _run_kimi(["--list-sessions", "--work-dir", str(work_dir)], share_dir=share_dir) + assert result.returncode == snapshot(0) + assert "No sessions found" in result.stdout + assert result.stderr == snapshot("") + + def test_continue_without_previous_session_is_reported(tmp_path: Path) -> None: share_dir = tmp_path / "share" work_dir = tmp_path / "work"