Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CHANGELOG references --sessions but actual CLI flag is --pick-session

The CHANGELOG entry at line 106 says --sessions but the actual Typer option is --pick-session (src/kimi_cli/cli/__init__.py:131). The same incorrect name propagates to both the English and Chinese changelog docs (docs/en/release-notes/changelog.md:99, docs/zh/release-notes/changelog.md:99).

Suggested change
- CLI: Add `--sessions` option to interactively select a session to resume
- CLI: Add `--pick-session` option to interactively select a session to resume
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

- 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)

Expand Down
4 changes: 3 additions & 1 deletion docs/en/reference/kimi-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +59 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Documentation references --sessions but the actual CLI flag is --pick-session

The English reference docs document the flag as --sessions (line 59) and list it as mutually exclusive under that name (line 62), but the actual Typer option registered in the code is --pick-session (src/kimi_cli/cli/__init__.py:131). A user following the documentation would type kimi --sessions and receive an unrecognized option error. The tests confirm the real flag is --pick-session (e.g., tests/e2e/test_cli_error_output.py:116). The same mismatch appears in the Chinese docs (docs/zh/reference/kimi-command.md:59,62), the English changelog (docs/en/release-notes/changelog.md:99), the Chinese changelog (docs/zh/release-notes/changelog.md:99), and CHANGELOG.md:106.

Suggested change
| `--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.
| `--pick-session` | | Interactively select a session from the current working directory |
| `--list-sessions` | | List all sessions in the current working directory and exit |
`--continue`, `--session`, `--pick-session`, and `--list-sessions` are mutually exclusive.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


## Input and commands

Expand Down
3 changes: 3 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion docs/zh/reference/kimi-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ kimi [OPTIONS] COMMAND [ARGS]
|------|------|------|
| `--continue` | `-C` | 继续当前工作目录的上一个会话 |
| `--session ID` | `-S` | 恢复指定 ID 的会话,若不存在则创建新会话 |
| `--sessions` | | 交互式选择当前工作目录的会话 |
| `--list-sessions` | | 列出当前工作目录的所有会话并退出 |

`--continue` 和 `--session` 互斥。
`--continue`、`--session`、`--sessions` 和 `--list-sessions` 互斥。
Comment on lines +59 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Chinese docs reference --sessions but actual CLI flag is --pick-session

Same documentation/code mismatch as the English docs: the Chinese reference docs use --sessions (line 59) and the mutual-exclusion note (line 62), but the implemented flag is --pick-session (src/kimi_cli/cli/__init__.py:131).

Suggested change
| `--sessions` | | 交互式选择当前工作目录的会话 |
| `--list-sessions` | | 列出当前工作目录的所有会话并退出 |
`--continue``--session` 互斥。
`--continue``--session``--sessions``--list-sessions` 互斥。
| `--pick-session` | | 交互式选择当前工作目录的会话 |
| `--list-sessions` | | 列出当前工作目录的所有会话并退出 |
`--continue``--session``--pick-session``--list-sessions` 互斥。
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


## 输入与命令

Expand Down
3 changes: 3 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
90 changes: 90 additions & 0 deletions src/kimi_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Comment on lines +521 to +523

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suffix check and split use different strings: endswith(f"({s.id})") vs rsplit(f" ({s.id})", 1). Using the same suffix value for both (e.g., suffix = f" ({s.id})" then endswith(suffix)) avoids subtle mismatches if the title format ever changes.

Copilot uses AI. Check for mistakes.

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.
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
import uuid
from dataclasses import dataclass
from pathlib import Path
from textwrap import shorten

from kaos.path import KaosPath
from kosong.message import Message

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

Expand Down
6 changes: 3 additions & 3 deletions src/kimi_cli/utils/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 ""

Expand Down
19 changes: 19 additions & 0 deletions src/kimi_cli/utils/string.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +10 to +26

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten() claims to return at most width characters, but when width <= len(placeholder) (or if a longer placeholder is passed), cut becomes 0/negative and the returned string can exceed width (e.g., slicing with a negative index + placeholder). Consider guarding this case explicitly (e.g., return placeholder[:width] or raise) to preserve the function contract.

Copilot uses AI. Check for mistakes.


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:
Expand Down
7 changes: 3 additions & 4 deletions src/kimi_cli/web/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/kimi_cli/web/store/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
55 changes: 55 additions & 0 deletions tests/e2e/test_cli_error_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading