kimi-cli 0.44__py3-none-any.whl → 0.78__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.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
@@ -0,0 +1,179 @@
1
+ from dataclasses import dataclass
2
+ from typing import Protocol
3
+
4
+ import rich
5
+ from kosong.message import Message
6
+
7
+ from kimi_cli.cli import OutputFormat
8
+ from kimi_cli.soul.message import tool_result_to_message
9
+ from kimi_cli.utils.aioqueue import QueueShutDown
10
+ from kimi_cli.wire import Wire
11
+ from kimi_cli.wire.types import (
12
+ ContentPart,
13
+ StepBegin,
14
+ StepInterrupted,
15
+ ToolCall,
16
+ ToolCallPart,
17
+ ToolResult,
18
+ WireMessage,
19
+ )
20
+
21
+
22
+ class Printer(Protocol):
23
+ def feed(self, msg: WireMessage) -> None: ...
24
+ def flush(self) -> None: ...
25
+
26
+
27
+ def _merge_content(buffer: list[ContentPart], part: ContentPart) -> None:
28
+ if not buffer or not buffer[-1].merge_in_place(part):
29
+ buffer.append(part)
30
+
31
+
32
+ class TextPrinter(Printer):
33
+ def feed(self, msg: WireMessage) -> None:
34
+ rich.print(msg)
35
+
36
+ def flush(self) -> None:
37
+ pass
38
+
39
+
40
+ class JsonPrinter(Printer):
41
+ @dataclass(slots=True)
42
+ class _ToolCallState:
43
+ tool_call: ToolCall
44
+ tool_result: ToolResult | None
45
+
46
+ def __init__(self) -> None:
47
+ self._content_buffer: list[ContentPart] = []
48
+ """The buffer to merge content parts."""
49
+ self._tool_call_buffer: dict[str, JsonPrinter._ToolCallState] = {}
50
+ """The buffer to store tool calls and their results."""
51
+ self._last_tool_call: ToolCall | None = None
52
+
53
+ def feed(self, msg: WireMessage) -> None:
54
+ match msg:
55
+ case StepBegin() | StepInterrupted():
56
+ self.flush()
57
+ case ContentPart() as part:
58
+ # merge with previous parts as much as possible
59
+ _merge_content(self._content_buffer, part)
60
+ case ToolCall() as call:
61
+ self._tool_call_buffer[call.id] = JsonPrinter._ToolCallState(
62
+ tool_call=call, tool_result=None
63
+ )
64
+ self._last_tool_call = call
65
+ case ToolCallPart() as part:
66
+ if self._last_tool_call is None:
67
+ return
68
+ assert self._last_tool_call.merge_in_place(part)
69
+ case ToolResult() as result:
70
+ state = self._tool_call_buffer.get(result.tool_call_id)
71
+ if state is None:
72
+ return
73
+ state.tool_result = result
74
+ case _:
75
+ # ignore other messages
76
+ pass
77
+
78
+ def flush(self) -> None:
79
+ if not self._content_buffer and not self._tool_call_buffer:
80
+ return
81
+
82
+ tool_calls: list[ToolCall] = []
83
+ tool_results: list[ToolResult] = []
84
+ for state in self._tool_call_buffer.values():
85
+ if state.tool_result is None:
86
+ # this should only happen when interrupted
87
+ continue
88
+ tool_calls.append(state.tool_call)
89
+ tool_results.append(state.tool_result)
90
+
91
+ message = Message(
92
+ role="assistant",
93
+ content=self._content_buffer,
94
+ tool_calls=tool_calls or None,
95
+ )
96
+ print(message.model_dump_json(exclude_none=True), flush=True)
97
+
98
+ for result in tool_results:
99
+ # FIXME: this assumes the way how the soul convert `ToolResult` to `Message`
100
+ message = tool_result_to_message(result)
101
+ print(message.model_dump_json(exclude_none=True), flush=True)
102
+
103
+ self._content_buffer.clear()
104
+ self._tool_call_buffer.clear()
105
+
106
+
107
+ class FinalOnlyTextPrinter(Printer):
108
+ def __init__(self) -> None:
109
+ self._content_buffer: list[ContentPart] = []
110
+
111
+ def feed(self, msg: WireMessage) -> None:
112
+ match msg:
113
+ case StepBegin() | StepInterrupted():
114
+ self._content_buffer.clear()
115
+ case ContentPart() as part:
116
+ _merge_content(self._content_buffer, part)
117
+ case _:
118
+ pass
119
+
120
+ def flush(self) -> None:
121
+ if not self._content_buffer:
122
+ return
123
+ message = Message(role="assistant", content=self._content_buffer)
124
+ text = message.extract_text()
125
+ if text:
126
+ print(text, flush=True)
127
+ self._content_buffer.clear()
128
+
129
+
130
+ class FinalOnlyJsonPrinter(Printer):
131
+ def __init__(self) -> None:
132
+ self._content_buffer: list[ContentPart] = []
133
+
134
+ def feed(self, msg: WireMessage) -> None:
135
+ match msg:
136
+ case StepBegin() | StepInterrupted():
137
+ self._content_buffer.clear()
138
+ case ContentPart() as part:
139
+ _merge_content(self._content_buffer, part)
140
+ case _:
141
+ pass
142
+
143
+ def flush(self) -> None:
144
+ if not self._content_buffer:
145
+ return
146
+ message = Message(role="assistant", content=self._content_buffer)
147
+ text = message.extract_text()
148
+ if text:
149
+ final_message = Message(role="assistant", content=text)
150
+ print(final_message.model_dump_json(exclude_none=True), flush=True)
151
+ self._content_buffer.clear()
152
+
153
+
154
+ async def visualize(output_format: OutputFormat, final_only: bool, wire: Wire) -> None:
155
+ if final_only:
156
+ match output_format:
157
+ case "text":
158
+ handler = FinalOnlyTextPrinter()
159
+ case "stream-json":
160
+ handler = FinalOnlyJsonPrinter()
161
+ else:
162
+ match output_format:
163
+ case "text":
164
+ handler = TextPrinter()
165
+ case "stream-json":
166
+ handler = JsonPrinter()
167
+
168
+ wire_ui = wire.ui_side(merge=True)
169
+ while True:
170
+ try:
171
+ msg = await wire_ui.receive()
172
+ except QueueShutDown:
173
+ handler.flush()
174
+ break
175
+
176
+ handler.feed(msg)
177
+
178
+ if isinstance(msg, StepInterrupted):
179
+ break
@@ -1,11 +1,14 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
4
+ import shlex
2
5
  from collections.abc import Awaitable, Coroutine
3
6
  from dataclasses import dataclass
4
7
  from enum import Enum
5
8
  from typing import Any
6
9
 
7
- from kosong.base.message import ContentPart
8
10
  from kosong.chat_provider import APIStatusError, ChatProviderError
11
+ from loguru import logger
9
12
  from rich.console import Group, RenderableType
10
13
  from rich.panel import Panel
11
14
  from rich.table import Table
@@ -14,20 +17,34 @@ from rich.text import Text
14
17
  from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul
15
18
  from kimi_cli.soul.kimisoul import KimiSoul
16
19
  from kimi_cli.ui.shell.console import console
17
- from kimi_cli.ui.shell.metacmd import get_meta_command
18
- from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, ensure_new_line, toast
20
+ from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, toast
19
21
  from kimi_cli.ui.shell.replay import replay_recent_history
22
+ from kimi_cli.ui.shell.slash import registry as shell_slash_registry
23
+ from kimi_cli.ui.shell.slash import shell_mode_registry
20
24
  from kimi_cli.ui.shell.update import LATEST_VERSION_FILE, UpdateResult, do_update, semver_tuple
21
25
  from kimi_cli.ui.shell.visualize import visualize
22
- from kimi_cli.utils.logging import logger
26
+ from kimi_cli.utils.envvar import get_env_bool
23
27
  from kimi_cli.utils.signals import install_sigint_handler
28
+ from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call
29
+ from kimi_cli.utils.term import ensure_new_line, ensure_tty_sane
30
+ from kimi_cli.wire.types import ContentPart, StatusUpdate
24
31
 
25
32
 
26
- class ShellApp:
27
- def __init__(self, soul: Soul, welcome_info: list["WelcomeInfoItem"] | None = None):
33
+ class Shell:
34
+ def __init__(self, soul: Soul, welcome_info: list[WelcomeInfoItem] | None = None):
28
35
  self.soul = soul
29
36
  self._welcome_info = list(welcome_info or [])
30
37
  self._background_tasks: set[asyncio.Task[Any]] = set()
38
+ self._available_slash_commands: dict[str, SlashCommand[Any]] = {
39
+ **{cmd.name: cmd for cmd in soul.available_slash_commands},
40
+ **{cmd.name: cmd for cmd in shell_slash_registry.list_commands()},
41
+ }
42
+ """Shell-level slash commands + soul-level slash commands. Name to command mapping."""
43
+
44
+ @property
45
+ def available_slash_commands(self) -> dict[str, SlashCommand[Any]]:
46
+ """Get all available slash commands, including shell-level and soul-level commands."""
47
+ return self._available_slash_commands
31
48
 
32
49
  async def run(self, command: str | None = None) -> bool:
33
50
  if command is not None:
@@ -35,48 +52,64 @@ class ShellApp:
35
52
  logger.info("Running agent with command: {command}", command=command)
36
53
  return await self._run_soul_command(command)
37
54
 
38
- self._start_background_task(self._auto_update())
55
+ # Start auto-update background task if not disabled
56
+ if get_env_bool("KIMI_CLI_NO_AUTO_UPDATE"):
57
+ logger.info("Auto-update disabled by KIMI_CLI_NO_AUTO_UPDATE environment variable")
58
+ else:
59
+ self._start_background_task(self._auto_update())
39
60
 
40
61
  _print_welcome_info(self.soul.name or "Kimi CLI", self._welcome_info)
41
62
 
42
63
  if isinstance(self.soul, KimiSoul):
43
- await replay_recent_history(self.soul.context.history)
64
+ await replay_recent_history(
65
+ self.soul.context.history,
66
+ wire_file=self.soul.wire_file,
67
+ )
44
68
 
45
- with CustomPromptSession(lambda: self.soul.status) as prompt_session:
46
- while True:
47
- try:
48
- ensure_new_line()
49
- user_input = await prompt_session.prompt()
50
- except KeyboardInterrupt:
51
- logger.debug("Exiting by KeyboardInterrupt")
52
- console.print("[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]")
53
- continue
54
- except EOFError:
55
- logger.debug("Exiting by EOF")
56
- console.print("Bye!")
57
- break
58
-
59
- if not user_input:
60
- logger.debug("Got empty input, skipping")
61
- continue
62
- logger.debug("Got user input: {user_input}", user_input=user_input)
63
-
64
- if user_input.command in ["exit", "quit", "/exit", "/quit"]:
65
- logger.debug("Exiting by meta command")
66
- console.print("Bye!")
67
- break
68
-
69
- if user_input.mode == PromptMode.SHELL:
70
- await self._run_shell_command(user_input.command)
71
- continue
72
-
73
- if user_input.command.startswith("/"):
74
- logger.debug("Running meta command: {command}", command=user_input.command)
75
- await self._run_meta_command(user_input.command[1:])
76
- continue
77
-
78
- logger.info("Running agent command: {command}", command=user_input.content)
79
- await self._run_soul_command(user_input.content)
69
+ with CustomPromptSession(
70
+ status_provider=lambda: self.soul.status,
71
+ model_capabilities=self.soul.model_capabilities or set(),
72
+ model_name=self.soul.model_name,
73
+ thinking=self.soul.thinking or False,
74
+ agent_mode_slash_commands=list(self._available_slash_commands.values()),
75
+ shell_mode_slash_commands=shell_mode_registry.list_commands(),
76
+ ) as prompt_session:
77
+ try:
78
+ while True:
79
+ ensure_tty_sane()
80
+ try:
81
+ ensure_new_line()
82
+ user_input = await prompt_session.prompt()
83
+ except KeyboardInterrupt:
84
+ logger.debug("Exiting by KeyboardInterrupt")
85
+ console.print("[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]")
86
+ continue
87
+ except EOFError:
88
+ logger.debug("Exiting by EOF")
89
+ console.print("Bye!")
90
+ break
91
+
92
+ if not user_input:
93
+ logger.debug("Got empty input, skipping")
94
+ continue
95
+ logger.debug("Got user input: {user_input}", user_input=user_input)
96
+
97
+ if user_input.command in ["exit", "quit", "/exit", "/quit"]:
98
+ logger.debug("Exiting by slash command")
99
+ console.print("Bye!")
100
+ break
101
+
102
+ if user_input.mode == PromptMode.SHELL:
103
+ await self._run_shell_command(user_input.command)
104
+ continue
105
+
106
+ if slash_cmd_call := parse_slash_command_call(user_input.command):
107
+ await self._run_slash_command(slash_cmd_call)
108
+ continue
109
+
110
+ await self._run_soul_command(user_input.content)
111
+ finally:
112
+ ensure_tty_sane()
80
113
 
81
114
  return True
82
115
 
@@ -85,6 +118,28 @@ class ShellApp:
85
118
  if not command.strip():
86
119
  return
87
120
 
121
+ # Check if it's an allowed slash command in shell mode
122
+ if slash_cmd_call := parse_slash_command_call(command):
123
+ if shell_mode_registry.find_command(slash_cmd_call.name):
124
+ await self._run_slash_command(slash_cmd_call)
125
+ return
126
+ else:
127
+ console.print(
128
+ f'[yellow]"/{slash_cmd_call.name}" is not available in shell mode. '
129
+ "Press Ctrl-X to switch to agent mode.[/yellow]"
130
+ )
131
+ return
132
+
133
+ # Check if user is trying to use 'cd' command
134
+ stripped_cmd = command.strip()
135
+ split_cmd = shlex.split(stripped_cmd)
136
+ if len(split_cmd) == 2 and split_cmd[0] == "cd":
137
+ console.print(
138
+ "[yellow]Warning: Directory changes are not preserved across command executions."
139
+ "[/yellow]"
140
+ )
141
+ return
142
+
88
143
  logger.info("Running shell command: {cmd}", cmd=command)
89
144
 
90
145
  proc: asyncio.subprocess.Process | None = None
@@ -107,41 +162,37 @@ class ShellApp:
107
162
  finally:
108
163
  remove_sigint()
109
164
 
110
- async def _run_meta_command(self, command_str: str):
165
+ async def _run_slash_command(self, command_call: SlashCommandCall) -> None:
111
166
  from kimi_cli.cli import Reload
112
167
 
113
- parts = command_str.split(" ")
114
- command_name = parts[0]
115
- command_args = parts[1:]
116
- command = get_meta_command(command_name)
117
- if command is None:
118
- console.print(f"Meta command /{command_name} not found")
168
+ if command_call.name not in self._available_slash_commands:
169
+ logger.info("Unknown slash command /{command}", command=command_call.name)
170
+ console.print(
171
+ f'[red]Unknown slash command "/{command_call.name}", '
172
+ 'type "/" for all available commands[/red]'
173
+ )
119
174
  return
120
- if command.kimi_soul_only and not isinstance(self.soul, KimiSoul):
121
- console.print(f"Meta command /{command_name} not supported")
175
+
176
+ command = shell_slash_registry.find_command(command_call.name)
177
+ if command is None:
178
+ # the input is a soul-level slash command call
179
+ await self._run_soul_command(command_call.raw_input)
122
180
  return
181
+
123
182
  logger.debug(
124
- "Running meta command: {command_name} with args: {command_args}",
125
- command_name=command_name,
126
- command_args=command_args,
183
+ "Running shell-level slash command: /{command} with args: {args}",
184
+ command=command_call.name,
185
+ args=command_call.args,
127
186
  )
187
+
128
188
  try:
129
- ret = command.func(self, command_args)
189
+ ret = command.func(self, command_call.args)
130
190
  if isinstance(ret, Awaitable):
131
191
  await ret
132
- except LLMNotSet:
133
- logger.error("LLM not set")
134
- console.print("[red]LLM not set, send /setup to configure[/red]")
135
- except ChatProviderError as e:
136
- logger.exception("LLM provider error:")
137
- console.print(f"[red]LLM provider error: {e}[/red]")
138
- except asyncio.CancelledError:
139
- logger.info("Interrupted by user")
140
- console.print("[red]Interrupted by user[/red]")
141
192
  except Reload:
142
193
  # just propagate
143
194
  raise
144
- except BaseException as e:
195
+ except Exception as e:
145
196
  logger.exception("Unknown error:")
146
197
  console.print(f"[red]Unknown error: {e}[/red]")
147
198
  raise # re-raise unknown error
@@ -153,6 +204,8 @@ class ShellApp:
153
204
  Returns:
154
205
  bool: Whether the run is successful.
155
206
  """
207
+ logger.info("Running soul with user input: {user_input}", user_input=user_input)
208
+
156
209
  cancel_event = asyncio.Event()
157
210
 
158
211
  def _handler():
@@ -163,25 +216,24 @@ class ShellApp:
163
216
  remove_sigint = install_sigint_handler(loop, _handler)
164
217
 
165
218
  try:
166
- # Use lambda to pass cancel_event via closure
167
219
  await run_soul(
168
220
  self.soul,
169
221
  user_input,
170
222
  lambda wire: visualize(
171
- wire, initial_status=self.soul.status, cancel_event=cancel_event
223
+ wire.ui_side(merge=False), # shell UI maintain its own merge buffer
224
+ initial_status=StatusUpdate(context_usage=self.soul.status.context_usage),
225
+ cancel_event=cancel_event,
172
226
  ),
173
227
  cancel_event,
228
+ self.soul.wire_file if isinstance(self.soul, KimiSoul) else None,
174
229
  )
175
230
  return True
176
231
  except LLMNotSet:
177
- logger.error("LLM not set")
178
- console.print("[red]LLM not set, send /setup to configure[/red]")
232
+ logger.exception("LLM not set:")
233
+ console.print('[red]LLM not set, send "/setup" to configure[/red]')
179
234
  except LLMNotSupported as e:
180
- logger.error(
181
- "LLM model '{model_name}' does not support required capabilities: {capabilities}",
182
- model_name=e.llm.model_name,
183
- capabilities=", ".join(e.capabilities),
184
- )
235
+ # actually unsupported input/mode should already be blocked by prompt session
236
+ logger.exception("LLM not supported:")
185
237
  console.print(f"[red]{e}[/red]")
186
238
  except ChatProviderError as e:
187
239
  logger.exception("LLM provider error:")
@@ -195,27 +247,31 @@ class ShellApp:
195
247
  console.print(f"[red]LLM provider error: {e}[/red]")
196
248
  except MaxStepsReached as e:
197
249
  logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
198
- console.print(f"[yellow]Max steps reached: {e.n_steps}[/yellow]")
250
+ console.print(f"[yellow]{e}[/yellow]")
199
251
  except RunCancelled:
200
252
  logger.info("Cancelled by user")
201
253
  console.print("[red]Interrupted by user[/red]")
202
- except BaseException as e:
203
- logger.exception("Unknown error:")
204
- console.print(f"[red]Unknown error: {e}[/red]")
254
+ except Exception as e:
255
+ logger.exception("Unexpected error:")
256
+ console.print(f"[red]Unexpected error: {e}[/red]")
205
257
  raise # re-raise unknown error
206
258
  finally:
207
259
  remove_sigint()
208
260
  return False
209
261
 
210
262
  async def _auto_update(self) -> None:
211
- toast("checking for updates...", duration=2.0)
263
+ toast("checking for updates...", topic="update", duration=2.0)
212
264
  result = await do_update(print=False, check_only=True)
213
265
  if result == UpdateResult.UPDATE_AVAILABLE:
214
266
  while True:
215
- toast("new version found, run `uv tool upgrade kimi-cli` to upgrade", duration=30.0)
267
+ toast(
268
+ "new version found, run `uv tool upgrade kimi-cli` to upgrade",
269
+ topic="update",
270
+ duration=30.0,
271
+ )
216
272
  await asyncio.sleep(60.0)
217
273
  elif result == UpdateResult.UPDATED:
218
- toast("auto updated, restart to use the new version", duration=5.0)
274
+ toast("auto updated, restart to use the new version", topic="update", duration=5.0)
219
275
 
220
276
  def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
221
277
  task = asyncio.create_task(coro)
@@ -256,7 +312,7 @@ class WelcomeInfoItem:
256
312
 
257
313
 
258
314
  def _print_welcome_info(name: str, info_items: list[WelcomeInfoItem]) -> None:
259
- head = Text.from_markup(f"[bold]Welcome to {name}![/bold]")
315
+ head = Text.from_markup("Welcome to 2026, happy new year!")
260
316
  help_text = Text.from_markup("[grey50]Send /help for help information.[/grey50]")
261
317
 
262
318
  # Use Table for precise width control
@@ -268,7 +324,8 @@ def _print_welcome_info(name: str, info_items: list[WelcomeInfoItem]) -> None:
268
324
 
269
325
  rows: list[RenderableType] = [table]
270
326
 
271
- rows.append(Text("")) # Empty line
327
+ if info_items:
328
+ rows.append(Text("")) # empty line
272
329
  for item in info_items:
273
330
  rows.append(Text(f"{item.name}: {item.value}", style=item.level.value))
274
331
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from rich.console import Console
2
4
  from rich.theme import Theme
3
5
 
@@ -1,16 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  from typing import TYPE_CHECKING
3
5
 
4
- from kosong.base.message import (
5
- AudioURLPart,
6
- ContentPart,
7
- ImageURLPart,
8
- Message,
9
- TextPart,
10
- ThinkPart,
11
- ToolCall,
12
- )
13
- from rich.console import Group
6
+ from kosong.message import Message
7
+ from rich.console import Group, RenderableType
14
8
  from rich.panel import Panel
15
9
  from rich.rule import Rule
16
10
  from rich.syntax import Syntax
@@ -18,10 +12,19 @@ from rich.text import Text
18
12
 
19
13
  from kimi_cli.soul.kimisoul import KimiSoul
20
14
  from kimi_cli.ui.shell.console import console
21
- from kimi_cli.ui.shell.metacmd import meta_command
15
+ from kimi_cli.ui.shell.slash import registry
16
+ from kimi_cli.wire.types import (
17
+ AudioURLPart,
18
+ ContentPart,
19
+ ImageURLPart,
20
+ TextPart,
21
+ ThinkPart,
22
+ ToolCall,
23
+ VideoURLPart,
24
+ )
22
25
 
23
26
  if TYPE_CHECKING:
24
- from kimi_cli.ui.shell import ShellApp
27
+ from kimi_cli.ui.shell import Shell
25
28
 
26
29
 
27
30
  def _format_content_part(part: ContentPart) -> Text | Panel | Group:
@@ -56,6 +59,11 @@ def _format_content_part(part: ContentPart) -> Text | Panel | Group:
56
59
  id_text = f" (id: {audio.id})" if audio.id else ""
57
60
  return Text(f"[Audio{id_text}] {url_display}", style="blue")
58
61
 
62
+ case VideoURLPart(video_url=video):
63
+ url_display = video.url[:80] + "..." if len(video.url) > 80 else video.url
64
+ id_text = f" (id: {video.id})" if video.id else ""
65
+ return Text(f"[Video{id_text}] {url_display}", style="blue")
66
+
59
67
  case _:
60
68
  return Text(f"[Unknown content type: {type(part).__name__}]", style="red")
61
69
 
@@ -106,14 +114,11 @@ def _format_message(msg: Message, index: int) -> Panel:
106
114
  role_text += f" [dim]→ {msg.tool_call_id}[/dim]"
107
115
 
108
116
  # Format content
109
- content_items: list = []
117
+ content_items: list[RenderableType] = []
110
118
 
111
- if isinstance(msg.content, str):
112
- content_items.append(Text(msg.content, style="white"))
113
- else:
114
- for part in msg.content:
115
- formatted = _format_content_part(part)
116
- content_items.append(formatted)
119
+ for part in msg.content:
120
+ formatted = _format_content_part(part)
121
+ content_items.append(formatted)
117
122
 
118
123
  # Add tool calls if present
119
124
  if msg.tool_calls:
@@ -141,12 +146,12 @@ def _format_message(msg: Message, index: int) -> Panel:
141
146
  )
142
147
 
143
148
 
144
- @meta_command(kimi_soul_only=True)
145
- def debug(app: "ShellApp", args: list[str]):
149
+ @registry.command
150
+ def debug(app: Shell, args: str):
146
151
  """Debug the context"""
147
152
  assert isinstance(app.soul, KimiSoul)
148
153
 
149
- context = app.soul._context
154
+ context = app.soul.context
150
155
  history = context.history
151
156
 
152
157
  if not history:
@@ -166,7 +171,7 @@ def debug(app: "ShellApp", args: list[str]):
166
171
  Text(f"Total messages: {len(history)}", style="bold"),
167
172
  Text(f"Token count: {context.token_count:,}", style="bold"),
168
173
  Text(f"Checkpoints: {context.n_checkpoints}", style="bold"),
169
- Text(f"Trajectory: {context._file_backend}", style="dim"),
174
+ Text(f"Trajectory: {context.file_backend}", style="dim"),
170
175
  ),
171
176
  title="[bold]Context Info[/bold]",
172
177
  border_style="cyan",
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import sys
3
5
  import threading
@@ -5,6 +7,8 @@ import time
5
7
  from collections.abc import AsyncGenerator, Callable
6
8
  from enum import Enum, auto
7
9
 
10
+ from kimi_cli.utils.aioqueue import Queue
11
+
8
12
 
9
13
  class KeyEvent(Enum):
10
14
  UP = auto()
@@ -18,7 +22,7 @@ class KeyEvent(Enum):
18
22
 
19
23
  async def listen_for_keyboard() -> AsyncGenerator[KeyEvent]:
20
24
  loop = asyncio.get_running_loop()
21
- queue = asyncio.Queue[KeyEvent]()
25
+ queue = Queue[KeyEvent]()
22
26
  cancel_event = threading.Event()
23
27
 
24
28
  def emit(event: KeyEvent) -> None: