kimi-cli 0.45__py3-none-any.whl → 0.47__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

kimi_cli/CHANGELOG.md CHANGED
@@ -9,6 +9,24 @@ Internal builds may append content to the Unreleased section.
9
9
  Only write entries that are worth mentioning to users.
10
10
  -->
11
11
 
12
+ ## [0.47] - 2025-11-05
13
+
14
+ ### Fixed
15
+
16
+ - Fix Ctrl-W not working in some environments
17
+ - Do not load SearchWeb tool when the search service is not configured
18
+
19
+ ## [0.46] - 2025-11-03
20
+
21
+ ### Added
22
+
23
+ - Introduce Wire over stdio for local IPC (experimental, subject to change)
24
+ - Support Anthropic provider type
25
+
26
+ ### Fixed
27
+
28
+ - Fix binary packed by PyInstaller not working due to wrong entrypoint
29
+
12
30
  ## [0.45] - 2025-10-31
13
31
 
14
32
  ### Added
kimi_cli/app.py CHANGED
@@ -193,3 +193,10 @@ class KimiCLI:
193
193
  with self._app_env():
194
194
  app = ACPServer(self._soul)
195
195
  return await app.run()
196
+
197
+ async def run_wire_server(self) -> bool:
198
+ from kimi_cli.ui.wire import WireServer
199
+
200
+ with self._app_env():
201
+ server = WireServer(self._soul)
202
+ return await server.run()
kimi_cli/cli.py CHANGED
@@ -16,7 +16,7 @@ class Reload(Exception):
16
16
  pass
17
17
 
18
18
 
19
- UIMode = Literal["shell", "print", "acp"]
19
+ UIMode = Literal["shell", "print", "acp", "wire"]
20
20
  InputFormat = Literal["text", "stream-json"]
21
21
  OutputFormat = Literal["text", "stream-json"]
22
22
 
@@ -170,9 +170,12 @@ def kimi(
170
170
 
171
171
  echo: Callable[..., None] = click.echo if verbose else _noop_echo
172
172
 
173
+ if debug:
174
+ logger.enable("kosong")
173
175
  logger.add(
174
176
  get_share_dir() / "logs" / "kimi.log",
175
- level="DEBUG" if debug else "INFO",
177
+ # FIXME: configure level for different modules
178
+ level="TRACE" if debug else "INFO",
176
179
  rotation="06:00",
177
180
  retention="10 days",
178
181
  )
@@ -238,6 +241,10 @@ def kimi(
238
241
  if command is not None:
239
242
  logger.warning("ACP server ignores command argument")
240
243
  return await instance.run_acp_server()
244
+ case "wire":
245
+ if command is not None:
246
+ logger.warning("Wire server ignores command argument")
247
+ return await instance.run_wire_server()
241
248
 
242
249
  while True:
243
250
  try:
kimi_cli/config.py CHANGED
@@ -12,7 +12,7 @@ from kimi_cli.utils.logging import logger
12
12
  class LLMProvider(BaseModel):
13
13
  """LLM provider configuration."""
14
14
 
15
- type: Literal["kimi", "openai_legacy", "openai_responses", "_chaos"]
15
+ type: Literal["kimi", "openai_legacy", "openai_responses", "anthropic", "_chaos"]
16
16
  """Provider type"""
17
17
  base_url: str
18
18
  """API base URL"""
kimi_cli/llm.py CHANGED
@@ -105,6 +105,20 @@ def create_llm(
105
105
  api_key=provider.api_key.get_secret_value(),
106
106
  stream=stream,
107
107
  )
108
+ case "anthropic":
109
+ from kosong.chat_provider.anthropic import Anthropic
110
+
111
+ chat_provider = Anthropic(
112
+ model=model.model,
113
+ base_url=provider.base_url,
114
+ api_key=provider.api_key.get_secret_value(),
115
+ stream=stream,
116
+ default_max_tokens=50000,
117
+ ).with_generation_kwargs(
118
+ # TODO: support configurable values
119
+ thinking={"type": "enabled", "budget_tokens": 1024},
120
+ beta_features=["interleaved-thinking-2025-05-14"],
121
+ )
108
122
  case "_chaos":
109
123
  from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig
110
124
 
kimi_cli/soul/agent.py CHANGED
@@ -13,6 +13,7 @@ from kimi_cli.soul.approval import Approval
13
13
  from kimi_cli.soul.denwarenji import DenwaRenji
14
14
  from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime
15
15
  from kimi_cli.soul.toolset import CustomToolset
16
+ from kimi_cli.tools import SkipThisTool
16
17
  from kimi_cli.utils.logging import logger
17
18
 
18
19
 
@@ -99,7 +100,11 @@ def _load_tools(
99
100
  ) -> list[str]:
100
101
  bad_tools: list[str] = []
101
102
  for tool_path in tool_paths:
102
- tool = _load_tool(tool_path, dependencies)
103
+ try:
104
+ tool = _load_tool(tool_path, dependencies)
105
+ except SkipThisTool:
106
+ logger.info("Skipping tool: {tool_path}", tool_path=tool_path)
107
+ continue
103
108
  if tool:
104
109
  toolset += tool
105
110
  else:
kimi_cli/soul/toolset.py CHANGED
@@ -2,7 +2,8 @@ from contextvars import ContextVar
2
2
  from typing import override
3
3
 
4
4
  from kosong.base.message import ToolCall
5
- from kosong.tooling import HandleResult, SimpleToolset
5
+ from kosong.tooling import HandleResult
6
+ from kosong.tooling.simple import SimpleToolset
6
7
 
7
8
  current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
8
9
 
@@ -1,12 +1,19 @@
1
1
  import json
2
2
  from pathlib import Path
3
+ from typing import cast
3
4
 
4
- import streamingjson
5
+ import streamingjson # pyright: ignore[reportMissingTypeStubs]
5
6
  from kosong.utils.typing import JsonType
6
7
 
7
8
  from kimi_cli.utils.string import shorten_middle
8
9
 
9
10
 
11
+ class SkipThisTool(Exception):
12
+ """Raised when a tool decides to skip itself from the loading process."""
13
+
14
+ pass
15
+
16
+
10
17
  def extract_subtitle(lexer: streamingjson.Lexer, tool_name: str) -> str | None:
11
18
  try:
12
19
  curr_args: JsonType = json.loads(lexer.complete_json())
@@ -29,15 +36,15 @@ def extract_subtitle(lexer: streamingjson.Lexer, tool_name: str) -> str | None:
29
36
  case "SetTodoList":
30
37
  if not isinstance(curr_args, dict) or not curr_args.get("todos"):
31
38
  return None
32
- if not isinstance(curr_args["todos"], list):
39
+
40
+ from kimi_cli.tools.todo import Params
41
+
42
+ try:
43
+ todo_params = Params.model_validate(curr_args)
44
+ for todo in todo_params.todos:
45
+ subtitle += f"• {todo.title} [{todo.status}]\n"
46
+ except Exception:
33
47
  return None
34
- for todo in curr_args["todos"]:
35
- if not isinstance(todo, dict) or not todo.get("title"):
36
- continue
37
- subtitle += f"• {todo['title']}"
38
- if todo.get("status"):
39
- subtitle += f" [{todo['status']}]"
40
- subtitle += "\n"
41
48
  return "\n" + subtitle.strip()
42
49
  case "Bash":
43
50
  if not isinstance(curr_args, dict) or not curr_args.get("command"):
@@ -72,7 +79,9 @@ def extract_subtitle(lexer: streamingjson.Lexer, tool_name: str) -> str | None:
72
79
  return None
73
80
  subtitle = str(curr_args["url"])
74
81
  case _:
75
- subtitle = "".join(lexer.json_content)
82
+ # lexer.json_content is list[str] based on streamingjson source code
83
+ content: list[str] = cast(list[str], lexer.json_content) # pyright: ignore[reportUnknownMemberType]
84
+ subtitle = "".join(content)
76
85
  if tool_name not in ["SetTodoList"]:
77
86
  subtitle = shorten_middle(subtitle, width=50)
78
87
  return subtitle
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
+ from collections.abc import Callable
2
3
  from pathlib import Path
3
- from typing import override
4
+ from typing import Any, override
4
5
 
5
6
  from kosong.tooling import CallableTool2, ToolReturnType
6
7
  from pydantic import BaseModel, Field
@@ -29,7 +30,7 @@ class Bash(CallableTool2[Params]):
29
30
  description: str = load_desc(Path(__file__).parent / "bash.md", {})
30
31
  params: type[Params] = Params
31
32
 
32
- def __init__(self, approval: Approval, **kwargs):
33
+ def __init__(self, approval: Approval, **kwargs: Any):
33
34
  super().__init__(**kwargs)
34
35
  self._approval = approval
35
36
 
@@ -71,8 +72,13 @@ class Bash(CallableTool2[Params]):
71
72
  )
72
73
 
73
74
 
74
- async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -> int:
75
- async def _read_stream(stream, cb):
75
+ async def _stream_subprocess(
76
+ command: str,
77
+ stdout_cb: Callable[[bytes], None],
78
+ stderr_cb: Callable[[bytes], None],
79
+ timeout: int,
80
+ ) -> int:
81
+ async def _read_stream(stream: asyncio.StreamReader, cb: Callable[[bytes], None]):
76
82
  while True:
77
83
  line = await stream.readline()
78
84
  if line:
@@ -85,6 +91,9 @@ async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -
85
91
  command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
86
92
  )
87
93
 
94
+ assert process.stdout is not None, "stdout is None"
95
+ assert process.stderr is not None, "stderr is None"
96
+
88
97
  try:
89
98
  await asyncio.wait_for(
90
99
  asyncio.gather(
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, override
3
3
 
4
4
  from kosong.tooling import CallableTool2, ToolError, ToolReturnType
5
5
 
@@ -8,12 +8,12 @@ from kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail
8
8
  NAME = "SendDMail"
9
9
 
10
10
 
11
- class SendDMail(CallableTool2):
11
+ class SendDMail(CallableTool2[DMail]):
12
12
  name: str = NAME
13
13
  description: str = (Path(__file__).parent / "dmail.md").read_text(encoding="utf-8")
14
14
  params: type[DMail] = DMail
15
15
 
16
- def __init__(self, denwa_renji: DenwaRenji, **kwargs):
16
+ def __init__(self, denwa_renji: DenwaRenji, **kwargs: Any) -> None:
17
17
  super().__init__(**kwargs)
18
18
  self._denwa_renji = denwa_renji
19
19
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  from pathlib import Path
5
- from typing import override
5
+ from typing import Any, override
6
6
 
7
7
  import aiofiles.os
8
8
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
@@ -38,7 +38,7 @@ class Glob(CallableTool2[Params]):
38
38
  )
39
39
  params: type[Params] = Params
40
40
 
41
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
41
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs: Any) -> None:
42
42
  super().__init__(**kwargs)
43
43
  self._work_dir = builtin_args.KIMI_WORK_DIR
44
44
 
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import override
10
10
 
11
11
  import aiohttp
12
- import ripgrepy
12
+ import ripgrepy # pyright: ignore[reportMissingTypeStubs]
13
13
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
14
14
  from pydantic import BaseModel, Field
15
15
 
@@ -1,8 +1,8 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, Literal, override
3
3
 
4
4
  import aiofiles
5
- import patch_ng
5
+ import patch_ng # pyright: ignore[reportMissingTypeStubs]
6
6
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
@@ -12,6 +12,36 @@ from kimi_cli.tools.file import FileActions
12
12
  from kimi_cli.tools.utils import ToolRejectedError
13
13
 
14
14
 
15
+ def _parse_patch(diff_bytes: bytes) -> patch_ng.PatchSet | None:
16
+ """Parse patch from bytes, returning PatchSet or None on error.
17
+
18
+ This wrapper provides type hints for the untyped patch_ng.fromstring function.
19
+ """
20
+ result: patch_ng.PatchSet | Literal[False] = patch_ng.fromstring(diff_bytes) # pyright: ignore[reportUnknownMemberType]
21
+ return result if result is not False else None
22
+
23
+
24
+ def _count_hunks(patch_set: patch_ng.PatchSet) -> int:
25
+ """Count total hunks across all items in a PatchSet.
26
+
27
+ This wrapper provides type hints for the untyped patch_ng library.
28
+ From source code inspection: PatchSet.items is list[Patch], Patch.hunks is list[Hunk].
29
+ Type ignore needed because patch_ng lacks type annotations.
30
+ """
31
+ items: list[patch_ng.Patch] = patch_set.items # pyright: ignore[reportUnknownMemberType]
32
+ # Each Patch has a hunks attribute (list[Hunk])
33
+ return sum(len(item.hunks) for item in items) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType]
34
+
35
+
36
+ def _apply_patch(patch_set: patch_ng.PatchSet, root: str) -> bool:
37
+ """Apply a patch to files under the given root directory.
38
+
39
+ This wrapper provides type hints for the untyped patch_ng.apply method.
40
+ """
41
+ success: Any = patch_set.apply(root=root) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
42
+ return bool(success) # pyright: ignore[reportUnknownArgumentType]
43
+
44
+
15
45
  class Params(BaseModel):
16
46
  path: str = Field(description="The absolute path to the file to apply the patch to.")
17
47
  diff: str = Field(description="The diff content in unified format to apply.")
@@ -22,7 +52,7 @@ class PatchFile(CallableTool2[Params]):
22
52
  description: str = (Path(__file__).parent / "patch.md").read_text(encoding="utf-8")
23
53
  params: type[Params] = Params
24
54
 
25
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
55
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
26
56
  super().__init__(**kwargs)
27
57
  self._work_dir = builtin_args.KIMI_WORK_DIR
28
58
  self._approval = approval
@@ -87,10 +117,10 @@ class PatchFile(CallableTool2[Params]):
87
117
  original_content = await f.read()
88
118
 
89
119
  # Create patch object directly from string (no temporary file needed!)
90
- patch_set = patch_ng.fromstring(params.diff.encode("utf-8"))
120
+ patch_set = _parse_patch(params.diff.encode("utf-8"))
91
121
 
92
- # Handle case where patch_ng.fromstring returns False on parse errors
93
- if not patch_set or patch_set is True:
122
+ # Handle case where parsing failed
123
+ if patch_set is None:
94
124
  return ToolError(
95
125
  message=(
96
126
  "Failed to parse diff content: invalid patch format or no valid hunks found"
@@ -99,7 +129,7 @@ class PatchFile(CallableTool2[Params]):
99
129
  )
100
130
 
101
131
  # Count total hunks across all items
102
- total_hunks = sum(len(item.hunks) for item in patch_set.items)
132
+ total_hunks = _count_hunks(patch_set)
103
133
 
104
134
  if total_hunks == 0:
105
135
  return ToolError(
@@ -108,7 +138,7 @@ class PatchFile(CallableTool2[Params]):
108
138
  )
109
139
 
110
140
  # Apply the patch
111
- success = patch_set.apply(root=str(p.parent))
141
+ success = _apply_patch(patch_set, str(p.parent))
112
142
 
113
143
  if not success:
114
144
  return ToolError(
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, override
3
3
 
4
4
  import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
@@ -47,8 +47,9 @@ class ReadFile(CallableTool2[Params]):
47
47
  )
48
48
  params: type[Params] = Params
49
49
 
50
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
50
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs: Any) -> None:
51
51
  super().__init__(**kwargs)
52
+
52
53
  self._work_dir = builtin_args.KIMI_WORK_DIR
53
54
 
54
55
  @override
@@ -84,7 +85,7 @@ class ReadFile(CallableTool2[Params]):
84
85
 
85
86
  lines: list[str] = []
86
87
  n_bytes = 0
87
- truncated_line_numbers = []
88
+ truncated_line_numbers: list[int] = []
88
89
  max_lines_reached = False
89
90
  max_bytes_reached = False
90
91
  async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
@@ -108,7 +109,7 @@ class ReadFile(CallableTool2[Params]):
108
109
  break
109
110
 
110
111
  # Format output with line numbers like `cat -n`
111
- lines_with_no = []
112
+ lines_with_no: list[str] = []
112
113
  for line_num, line in zip(
113
114
  range(params.line_offset, params.line_offset + len(lines)), lines, strict=True
114
115
  ):
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, override
3
3
 
4
4
  import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
@@ -32,7 +32,7 @@ class StrReplaceFile(CallableTool2[Params]):
32
32
  description: str = (Path(__file__).parent / "replace.md").read_text(encoding="utf-8")
33
33
  params: type[Params] = Params
34
34
 
35
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
35
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
36
36
  super().__init__(**kwargs)
37
37
  self._work_dir = builtin_args.KIMI_WORK_DIR
38
38
  self._approval = approval
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import Literal, override
2
+ from typing import Any, Literal, override
3
3
 
4
4
  import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
@@ -29,7 +29,7 @@ class WriteFile(CallableTool2[Params]):
29
29
  description: str = (Path(__file__).parent / "write.md").read_text(encoding="utf-8")
30
30
  params: type[Params] = Params
31
31
 
32
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
32
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
33
33
  super().__init__(**kwargs)
34
34
  self._work_dir = builtin_args.KIMI_WORK_DIR
35
35
  self._approval = approval
kimi_cli/tools/mcp.py CHANGED
@@ -1,12 +1,15 @@
1
+ from typing import Any
2
+
1
3
  import fastmcp
2
4
  import mcp
3
5
  from fastmcp.client.client import CallToolResult
6
+ from fastmcp.client.transports import ClientTransport
4
7
  from kosong.base.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
5
8
  from kosong.tooling import CallableTool, ToolOk, ToolReturnType
6
9
 
7
10
 
8
- class MCPTool(CallableTool):
9
- def __init__(self, mcp_tool: mcp.Tool, client: fastmcp.Client, **kwargs):
11
+ class MCPTool[T: ClientTransport](CallableTool):
12
+ def __init__(self, mcp_tool: mcp.Tool, client: fastmcp.Client[T], **kwargs: Any):
10
13
  super().__init__(
11
14
  name=mcp_tool.name,
12
15
  description=mcp_tool.description or "",
@@ -16,7 +19,7 @@ class MCPTool(CallableTool):
16
19
  self._mcp_tool = mcp_tool
17
20
  self._client = client
18
21
 
19
- async def __call__(self, *args, **kwargs) -> ToolReturnType:
22
+ async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnType:
20
23
  async with self._client as client:
21
24
  result = await client.call_tool(self._mcp_tool.name, kwargs, timeout=20)
22
25
  return convert_tool_result(result)
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  from pathlib import Path
3
- from typing import override
3
+ from typing import Any, override
4
4
 
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
@@ -49,7 +49,7 @@ class Task(CallableTool2[Params]):
49
49
  name: str = "Task"
50
50
  params: type[Params] = Params
51
51
 
52
- def __init__(self, agent_spec: ResolvedAgentSpec, runtime: Runtime, **kwargs):
52
+ def __init__(self, agent_spec: ResolvedAgentSpec, runtime: Runtime, **kwargs: Any):
53
53
  super().__init__(
54
54
  description=load_desc(
55
55
  Path(__file__).parent / "task.md",
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, override
3
3
 
4
4
  from kosong.tooling import CallableTool2, ToolReturnType
5
5
  from pydantic import BaseModel, Field, ValidationError
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, ValidationError
7
7
  from kimi_cli.config import Config
8
8
  from kimi_cli.constant import USER_AGENT
9
9
  from kimi_cli.soul.toolset import get_current_tool_call_or_none
10
+ from kimi_cli.tools import SkipThisTool
10
11
  from kimi_cli.tools.utils import ToolResultBuilder, load_desc
11
12
  from kimi_cli.utils.aiohttp import new_client_session
12
13
 
@@ -39,16 +40,13 @@ class SearchWeb(CallableTool2[Params]):
39
40
  description: str = load_desc(Path(__file__).parent / "search.md", {})
40
41
  params: type[Params] = Params
41
42
 
42
- def __init__(self, config: Config, **kwargs):
43
+ def __init__(self, config: Config, **kwargs: Any):
43
44
  super().__init__(**kwargs)
44
- if config.services.moonshot_search is not None:
45
- self._base_url = config.services.moonshot_search.base_url
46
- self._api_key = config.services.moonshot_search.api_key.get_secret_value()
47
- self._custom_headers = config.services.moonshot_search.custom_headers or {}
48
- else:
49
- self._base_url = ""
50
- self._api_key = ""
51
- self._custom_headers = {}
45
+ if config.services.moonshot_search is None:
46
+ raise SkipThisTool()
47
+ self._base_url = config.services.moonshot_search.base_url
48
+ self._api_key = config.services.moonshot_search.api_key.get_secret_value()
49
+ self._custom_headers = config.services.moonshot_search.custom_headers or {}
52
50
 
53
51
  @override
54
52
  async def __call__(self, params: Params) -> ToolReturnType:
@@ -39,6 +39,7 @@ from pydantic import BaseModel, ValidationError
39
39
  from kimi_cli.share import get_share_dir
40
40
  from kimi_cli.soul import StatusSnapshot
41
41
  from kimi_cli.ui.shell.metacmd import get_meta_commands
42
+ from kimi_cli.utils.clipboard import is_clipboard_available
42
43
  from kimi_cli.utils.logging import logger
43
44
  from kimi_cli.utils.string import random_string
44
45
 
@@ -426,6 +427,7 @@ class CustomPromptSession:
426
427
 
427
428
  # Build key bindings
428
429
  _kb = KeyBindings()
430
+ shortcut_hints: list[str] = []
429
431
 
430
432
  @_kb.add("enter", filter=has_completions)
431
433
  def _accept_completion(event: KeyPressEvent) -> None:
@@ -438,12 +440,6 @@ class CustomPromptSession:
438
440
  completion = buff.complete_state.completions[0]
439
441
  buff.apply_completion(completion)
440
442
 
441
- @_kb.add("escape", "enter", eager=True)
442
- @_kb.add("c-j", eager=True)
443
- def _insert_newline(event: KeyPressEvent) -> None:
444
- """Insert a newline when Alt-Enter or Ctrl-J is pressed."""
445
- event.current_buffer.insert_text("\n")
446
-
447
443
  @_kb.add("c-x", eager=True)
448
444
  def _switch_mode(event: KeyPressEvent) -> None:
449
445
  self._mode = self._mode.toggle()
@@ -452,20 +448,38 @@ class CustomPromptSession:
452
448
  # Redraw UI
453
449
  event.app.invalidate()
454
450
 
455
- @_kb.add("c-v", eager=True)
456
- def _paste(event: KeyPressEvent) -> None:
457
- if self._try_paste_image(event):
458
- return
459
- clipboard_data = event.app.clipboard.get_data()
460
- event.current_buffer.paste_clipboard_data(clipboard_data)
451
+ shortcut_hints.append("ctrl-x: switch mode")
452
+
453
+ @_kb.add("escape", "enter", eager=True)
454
+ @_kb.add("c-j", eager=True)
455
+ def _insert_newline(event: KeyPressEvent) -> None:
456
+ """Insert a newline when Alt-Enter or Ctrl-J is pressed."""
457
+ event.current_buffer.insert_text("\n")
458
+
459
+ shortcut_hints.append("ctrl-j: newline")
460
+
461
+ if is_clipboard_available():
462
+
463
+ @_kb.add("c-v", eager=True)
464
+ def _paste(event: KeyPressEvent) -> None:
465
+ if self._try_paste_image(event):
466
+ return
467
+ clipboard_data = event.app.clipboard.get_data()
468
+ event.current_buffer.paste_clipboard_data(clipboard_data)
469
+
470
+ shortcut_hints.append("ctrl-v: paste")
471
+ clipboard = PyperclipClipboard()
472
+ else:
473
+ clipboard = None
461
474
 
475
+ self._shortcut_hints = shortcut_hints
462
476
  self._session = PromptSession(
463
477
  message=self._render_message,
464
478
  # prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
465
479
  completer=self._agent_mode_completer,
466
480
  complete_while_typing=True,
467
481
  key_bindings=_kb,
468
- clipboard=PyperclipClipboard(),
482
+ clipboard=clipboard,
469
483
  history=history,
470
484
  bottom_toolbar=self._render_bottom_toolbar,
471
485
  )
@@ -646,9 +660,7 @@ class CustomPromptSession:
646
660
  self._current_toast = None
647
661
  else:
648
662
  shortcuts = [
649
- "ctrl-x: switch mode",
650
- "ctrl-j: newline",
651
- "ctrl-v: paste",
663
+ *self._shortcut_hints,
652
664
  "ctrl-d: exit",
653
665
  ]
654
666
  for shortcut in shortcuts: