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

@@ -1,15 +1,23 @@
1
+ from collections import deque
2
+
1
3
  import streamingjson
2
4
  from kosong.base.message import ToolCall, ToolCallPart
3
5
  from kosong.tooling import ToolError, ToolOk, ToolResult, ToolReturnType
4
- from rich.console import Group, RenderableType
6
+ from rich import box
7
+ from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
5
8
  from rich.live import Live
9
+ from rich.markdown import Heading, Markdown
6
10
  from rich.markup import escape
11
+ from rich.panel import Panel
7
12
  from rich.spinner import Spinner
13
+ from rich.status import Status
8
14
  from rich.text import Text
9
15
 
10
16
  from kimi_cli.soul import StatusSnapshot
17
+ from kimi_cli.soul.wire import ApprovalRequest, ApprovalResponse
11
18
  from kimi_cli.tools import extract_subtitle
12
19
  from kimi_cli.ui.shell.console import console
20
+ from kimi_cli.ui.shell.keyboard import KeyEvent
13
21
 
14
22
 
15
23
  class _ToolCallDisplay:
@@ -67,20 +75,80 @@ class _ToolCallDisplay:
67
75
  self.renderable = Group(*lines)
68
76
 
69
77
 
78
+ class _ApprovalRequestDisplay:
79
+ def __init__(self, request: ApprovalRequest):
80
+ self.request = request
81
+ self.options = [
82
+ ("Approve", ApprovalResponse.APPROVE),
83
+ ("Approve for this session", ApprovalResponse.APPROVE_FOR_SESSION),
84
+ ("Reject, tell Kimi CLI what to do instead", ApprovalResponse.REJECT),
85
+ ]
86
+ self.selected_index = 0
87
+
88
+ def render(self) -> RenderableType:
89
+ """Render the approval menu as a panel."""
90
+ lines = []
91
+
92
+ # Add request details
93
+ lines.append(
94
+ Text(f'{self.request.sender} is requesting approval to "{self.request.description}".')
95
+ )
96
+
97
+ lines.append(Text("")) # Empty line
98
+
99
+ # Add menu options
100
+ for i, (option_text, _) in enumerate(self.options):
101
+ if i == self.selected_index:
102
+ lines.append(Text(f"→ {option_text}", style="cyan"))
103
+ else:
104
+ lines.append(Text(f" {option_text}", style="grey50"))
105
+
106
+ content = Group(*lines)
107
+ return Panel.fit(
108
+ content,
109
+ title="[yellow]⚠ Approval Requested[/yellow]",
110
+ border_style="yellow",
111
+ padding=(1, 2),
112
+ )
113
+
114
+ def move_up(self):
115
+ """Move selection up."""
116
+ self.selected_index = (self.selected_index - 1) % len(self.options)
117
+
118
+ def move_down(self):
119
+ """Move selection down."""
120
+ self.selected_index = (self.selected_index + 1) % len(self.options)
121
+
122
+ def get_selected_response(self) -> ApprovalResponse:
123
+ """Get the approval response based on selected option."""
124
+ return self.options[self.selected_index][1]
125
+
126
+
70
127
  class StepLiveView:
71
128
  def __init__(self, status: StatusSnapshot):
129
+ # message content
72
130
  self._line_buffer = Text("")
131
+
132
+ # tool call
73
133
  self._tool_calls: dict[str, _ToolCallDisplay] = {}
74
134
  self._last_tool_call: _ToolCallDisplay | None = None
135
+
136
+ # approval request
137
+ self._approval_queue = deque[ApprovalRequest]()
138
+ self._current_approval: _ApprovalRequestDisplay | None = None
139
+ self._reject_all_following = False
140
+
141
+ # status
75
142
  self._status_text: Text | None = Text(
76
143
  self._format_status(status), style="grey50", justify="right"
77
144
  )
145
+ self._buffer_status: RenderableType | None = None
78
146
 
79
147
  def __enter__(self):
80
148
  self._live = Live(
81
149
  self._compose(),
82
150
  console=console,
83
- refresh_per_second=4,
151
+ refresh_per_second=10,
84
152
  transient=False, # leave the last frame on the screen
85
153
  vertical_overflow="visible",
86
154
  )
@@ -94,18 +162,26 @@ class StepLiveView:
94
162
  sections = []
95
163
  if self._line_buffer:
96
164
  sections.append(self._line_buffer)
165
+ if self._buffer_status:
166
+ sections.append(self._buffer_status)
97
167
  for view in self._tool_calls.values():
98
168
  sections.append(view.renderable)
169
+ if self._current_approval:
170
+ sections.append(self._current_approval.render())
171
+ if not sections:
172
+ # if there's nothing to display, do not show status bar
173
+ return Group()
174
+ # TODO: pin status bar at the bottom
99
175
  if self._status_text:
100
176
  sections.append(self._status_text)
101
177
  return Group(*sections)
102
178
 
103
- def _push_out(self, text: Text | str):
179
+ def _push_out(self, renderable: RenderableType):
104
180
  """
105
- Push the text out of the live view to the console.
106
- After this, the printed line will not be changed further.
181
+ Push the renderable out of the live view to the console.
182
+ After this, the renderable will not be changed further.
107
183
  """
108
- console.print(text)
184
+ console.print(renderable)
109
185
 
110
186
  def append_text(self, text: str):
111
187
  lines = text.split("\n")
@@ -134,12 +210,72 @@ class StepLiveView:
134
210
  view.finish(tool_result.result)
135
211
  self._live.update(self._compose())
136
212
 
213
+ def request_approval(self, approval_request: ApprovalRequest) -> None:
214
+ # If we're rejecting all following requests, reject immediately
215
+ if self._reject_all_following:
216
+ approval_request.resolve(ApprovalResponse.REJECT)
217
+ return
218
+
219
+ # Add to queue
220
+ self._approval_queue.append(approval_request)
221
+
222
+ # If no approval is currently being displayed, show the next one
223
+ if self._current_approval is None:
224
+ self._show_next_approval_request()
225
+ self._live.update(self._compose())
226
+
227
+ def _show_next_approval_request(self) -> None:
228
+ """Show the next approval request from the queue."""
229
+ if not self._approval_queue:
230
+ return
231
+
232
+ while self._approval_queue:
233
+ request = self._approval_queue.popleft()
234
+ if request.resolved:
235
+ # skip resolved requests
236
+ continue
237
+ self._current_approval = _ApprovalRequestDisplay(request)
238
+ break
239
+
137
240
  def update_status(self, status: StatusSnapshot):
138
241
  if self._status_text is None:
139
242
  return
140
243
  self._status_text.plain = self._format_status(status)
141
244
 
245
+ def handle_keyboard_event(self, event: KeyEvent):
246
+ if not self._current_approval:
247
+ # just ignore any keyboard event when there's no approval request
248
+ return
249
+
250
+ match event:
251
+ case KeyEvent.UP:
252
+ self._current_approval.move_up()
253
+ self._live.update(self._compose())
254
+ case KeyEvent.DOWN:
255
+ self._current_approval.move_down()
256
+ self._live.update(self._compose())
257
+ case KeyEvent.ENTER:
258
+ resp = self._current_approval.get_selected_response()
259
+ self._current_approval.request.resolve(resp)
260
+ if resp == ApprovalResponse.APPROVE_FOR_SESSION:
261
+ for request in self._approval_queue:
262
+ # approve all queued requests with the same action
263
+ if request.action == self._current_approval.request.action:
264
+ request.resolve(ApprovalResponse.APPROVE_FOR_SESSION)
265
+ elif resp == ApprovalResponse.REJECT:
266
+ # one rejection should stop the step immediately
267
+ while self._approval_queue:
268
+ self._approval_queue.popleft().resolve(ApprovalResponse.REJECT)
269
+ self._reject_all_following = True
270
+ self._current_approval = None
271
+ self._show_next_approval_request()
272
+ self._live.update(self._compose())
273
+ case _:
274
+ # just ignore any other keyboard event
275
+ return
276
+
142
277
  def finish(self):
278
+ self._current_approval = None
143
279
  for view in self._tool_calls.values():
144
280
  if not view.finished:
145
281
  # this should not happen, but just in case
@@ -147,6 +283,7 @@ class StepLiveView:
147
283
  self._live.update(self._compose())
148
284
 
149
285
  def interrupt(self):
286
+ self._current_approval = None
150
287
  for view in self._tool_calls.values():
151
288
  if not view.finished:
152
289
  view.finish(ToolError(message="", brief="Interrupted"))
@@ -156,3 +293,85 @@ class StepLiveView:
156
293
  def _format_status(status: StatusSnapshot) -> str:
157
294
  bounded = max(0.0, min(status.context_usage, 1.0))
158
295
  return f"context: {bounded:.1%}"
296
+
297
+
298
+ class StepLiveViewWithMarkdown(StepLiveView):
299
+ # TODO: figure out a streaming implementation for this
300
+
301
+ def __init__(self, status: StatusSnapshot):
302
+ super().__init__(status)
303
+ self._pending_markdown_parts: list[str] = []
304
+ self._buffer_status_active = False
305
+ self._buffer_status_obj: Status | None = None
306
+
307
+ def append_text(self, text: str):
308
+ if not self._pending_markdown_parts:
309
+ self._show_thinking_status()
310
+ self._pending_markdown_parts.append(text)
311
+
312
+ def append_tool_call(self, tool_call: ToolCall):
313
+ self._flush_markdown()
314
+ super().append_tool_call(tool_call)
315
+
316
+ def finish(self):
317
+ self._flush_markdown()
318
+ super().finish()
319
+
320
+ def interrupt(self):
321
+ self._flush_markdown()
322
+ super().interrupt()
323
+
324
+ def __exit__(self, exc_type, exc_value, traceback):
325
+ self._flush_markdown()
326
+ return super().__exit__(exc_type, exc_value, traceback)
327
+
328
+ def _flush_markdown(self):
329
+ self._hide_thinking_status()
330
+ if not self._pending_markdown_parts:
331
+ return
332
+ markdown_text = "".join(self._pending_markdown_parts)
333
+ self._pending_markdown_parts.clear()
334
+ if markdown_text.strip():
335
+ self._push_out(_LeftAlignedMarkdown(markdown_text, justify="left"))
336
+
337
+ def _show_thinking_status(self):
338
+ if self._buffer_status_active:
339
+ return
340
+ self._buffer_status_active = True
341
+ self._line_buffer.plain = ""
342
+ self._buffer_status_obj = Status("Thinking...", console=console, spinner="dots")
343
+ self._buffer_status = self._buffer_status_obj.renderable
344
+ self._live.update(self._compose())
345
+
346
+ def _hide_thinking_status(self):
347
+ if not self._buffer_status_active:
348
+ return
349
+ self._buffer_status_active = False
350
+ if self._buffer_status_obj is not None:
351
+ self._buffer_status_obj.stop()
352
+ self._buffer_status = None
353
+ self._buffer_status_obj = None
354
+ self._live.update(self._compose())
355
+
356
+
357
+ class _LeftAlignedHeading(Heading):
358
+ """Heading element with left-aligned content."""
359
+
360
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
361
+ text = self.text
362
+ text.justify = "left"
363
+ if self.tag == "h2":
364
+ text.stylize("bold")
365
+ if self.tag == "h1":
366
+ yield Panel(text, box=box.HEAVY, style="markdown.h1.border")
367
+ else:
368
+ if self.tag == "h2":
369
+ yield Text("")
370
+ yield text
371
+
372
+
373
+ class _LeftAlignedMarkdown(Markdown):
374
+ """Markdown renderer that left-aligns headings."""
375
+
376
+ elements = dict(Markdown.elements)
377
+ elements["heading_open"] = _LeftAlignedHeading
@@ -2,16 +2,13 @@ import tempfile
2
2
  import webbrowser
3
3
  from collections.abc import Awaitable, Callable, Sequence
4
4
  from pathlib import Path
5
- from string import Template
6
5
  from typing import TYPE_CHECKING, NamedTuple, overload
7
6
 
8
- from kosong.base import generate
9
- from kosong.base.message import ContentPart, Message, TextPart
7
+ from kosong.base.message import Message
10
8
  from rich.panel import Panel
11
9
 
12
- import kimi_cli.prompts.metacmds as prompts
10
+ import kimi_cli.prompts as prompts
13
11
  from kimi_cli.agent import load_agents_md
14
- from kimi_cli.soul import LLMNotSet
15
12
  from kimi_cli.soul.context import Context
16
13
  from kimi_cli.soul.kimisoul import KimiSoul
17
14
  from kimi_cli.soul.message import system
@@ -23,6 +20,17 @@ if TYPE_CHECKING:
23
20
  from kimi_cli.ui.shell import ShellApp
24
21
 
25
22
  type MetaCmdFunc = Callable[["ShellApp", list[str]], None | Awaitable[None]]
23
+ """
24
+ A function that runs as a meta command.
25
+
26
+ Raises:
27
+ LLMNotSet: When the LLM is not set.
28
+ ChatProviderError: When the LLM provider returns an error.
29
+ Reload: When the configuration should be reloaded.
30
+ asyncio.CancelledError: When the command is interrupted by user.
31
+
32
+ This is quite similar to the `Soul.run` method.
33
+ """
26
34
 
27
35
 
28
36
  class MetaCommand(NamedTuple):
@@ -188,14 +196,12 @@ def feedback(app: "ShellApp", args: list[str]):
188
196
  console.print(f"Please submit feedback at [underline]{ISSUE_URL}[/underline].")
189
197
 
190
198
 
191
- @meta_command
199
+ @meta_command(kimi_soul_only=True)
192
200
  async def init(app: "ShellApp", args: list[str]):
193
201
  """Analyze the codebase and generate an `AGENTS.md` file"""
194
- soul_bak = app.soul
195
- if not isinstance(soul_bak, KimiSoul):
196
- console.print("[red]Failed to analyze the codebase.[/red]")
197
- return
202
+ assert isinstance(app.soul, KimiSoul)
198
203
 
204
+ soul_bak = app.soul
199
205
  with tempfile.TemporaryDirectory() as temp_dir:
200
206
  logger.info("Running `/init`")
201
207
  console.print("Analyzing the codebase...")
@@ -206,7 +212,7 @@ async def init(app: "ShellApp", args: list[str]):
206
212
  context=tmp_context,
207
213
  loop_control=soul_bak._loop_control,
208
214
  )
209
- ok = await app._run(prompts.INIT)
215
+ ok = await app._run_soul_command(prompts.INIT)
210
216
 
211
217
  if ok:
212
218
  console.print(
@@ -239,71 +245,23 @@ async def clear(app: "ShellApp", args: list[str]):
239
245
  console.print("[green]✓[/green] Context has been cleared.")
240
246
 
241
247
 
242
- @meta_command
248
+ @meta_command(kimi_soul_only=True)
243
249
  async def compact(app: "ShellApp", args: list[str]):
244
250
  """Compact the context"""
245
251
  assert isinstance(app.soul, KimiSoul)
246
252
 
247
- logger.info("Running `/compact`")
248
-
249
- if app.soul._agent_globals.llm is None:
250
- raise LLMNotSet()
251
-
252
- # Get current context history
253
- current_history = list(app.soul._context.history)
254
- if len(current_history) <= 1:
255
- console.print("[yellow]Context is too short to compact.[/yellow]")
253
+ if app.soul._context.n_checkpoints == 0:
254
+ console.print("[yellow]Context is empty.[/yellow]")
256
255
  return
257
256
 
258
- # Convert history to string for the compact prompt
259
- history_text = "\n\n".join(
260
- f"## Message {i + 1}\nRole: {msg.role}\nContent: {msg.content}"
261
- for i, msg in enumerate(current_history)
262
- )
263
-
264
- # Build the compact prompt using string template
265
- compact_template = Template(prompts.COMPACT)
266
- compact_prompt = compact_template.substitute(CONTEXT=history_text)
267
-
268
- # Create input message for compaction
269
- compact_message = Message(role="user", content=compact_prompt)
270
-
271
- # Call generate to get the compacted context
272
- try:
273
- with console.status("[cyan]Compacting...[/cyan]"):
274
- compacted_msg, usage = await generate(
275
- chat_provider=app.soul._agent_globals.llm.chat_provider,
276
- system_prompt="You are a helpful assistant that compacts conversation context.",
277
- tools=[],
278
- history=[compact_message],
279
- )
280
-
281
- # Clear the context and add the compacted message as the first message
282
- await app.soul._context.revert_to(0)
283
- content: list[ContentPart] = (
284
- [TextPart(text=compacted_msg.content)]
285
- if isinstance(compacted_msg.content, str)
286
- else compacted_msg.content
287
- )
288
- content.insert(
289
- 0, system("Previous context has been compacted. Here is the compaction output:")
290
- )
291
- await app.soul._context.append_message(Message(role="assistant", content=content))
292
-
293
- console.print("[green]✓[/green] Context has been compacted.")
294
- if usage:
295
- logger.info(
296
- "Compaction used {input} input tokens and {output} output tokens",
297
- input=usage.input,
298
- output=usage.output,
299
- )
300
- except Exception as e:
301
- logger.error("Failed to compact context: {error}", error=e)
302
- console.print(f"[red]Failed to compact the context: {e}[/red]")
303
- return
257
+ logger.info("Running `/compact`")
258
+ with console.status("[cyan]Compacting...[/cyan]"):
259
+ await app.soul.compact_context()
260
+ console.print("[green]✓[/green] Context has been compacted.")
304
261
 
305
262
 
306
263
  from . import ( # noqa: E402
264
+ debug, # noqa: F401
307
265
  setup, # noqa: F401
308
266
  update, # noqa: F401
309
267
  )
@@ -7,7 +7,6 @@ from prompt_toolkit.shortcuts.choice_input import ChoiceInput
7
7
  from pydantic import SecretStr
8
8
 
9
9
  from kimi_cli.config import LLMModel, LLMProvider, MoonshotSearchConfig, load_config, save_config
10
- from kimi_cli.soul.kimisoul import KimiSoul
11
10
  from kimi_cli.ui.shell.console import console
12
11
  from kimi_cli.ui.shell.metacmd import meta_command
13
12
 
@@ -25,8 +24,8 @@ class _Platform(NamedTuple):
25
24
 
26
25
  _PLATFORMS = [
27
26
  _Platform(
28
- id="kimi-coding",
29
- name="Kimi Coding Plan",
27
+ id="kimi-for-coding",
28
+ name="Kimi For Coding",
30
29
  base_url="https://api.kimi.com/coding/v1",
31
30
  search_url="https://api.kimi.com/coding/v1/search",
32
31
  ),
@@ -45,11 +44,9 @@ _PLATFORMS = [
45
44
  ]
46
45
 
47
46
 
48
- @meta_command(kimi_soul_only=True)
47
+ @meta_command
49
48
  async def setup(app: "ShellApp", args: list[str]):
50
49
  """Setup Kimi CLI"""
51
- assert isinstance(app.soul, KimiSoul)
52
-
53
50
  result = await _setup()
54
51
  if not result:
55
52
  # error message already printed
@@ -0,0 +1,105 @@
1
+ import asyncio
2
+ from contextlib import asynccontextmanager, suppress
3
+
4
+ from kosong.base.message import ContentPart, TextPart, ToolCall, ToolCallPart
5
+ from kosong.tooling import ToolResult
6
+
7
+ from kimi_cli.soul import StatusSnapshot
8
+ from kimi_cli.soul.wire import (
9
+ ApprovalRequest,
10
+ CompactionBegin,
11
+ CompactionEnd,
12
+ StatusUpdate,
13
+ StepBegin,
14
+ StepInterrupted,
15
+ Wire,
16
+ )
17
+ from kimi_cli.ui.shell.console import console
18
+ from kimi_cli.ui.shell.keyboard import listen_for_keyboard
19
+ from kimi_cli.ui.shell.liveview import StepLiveView, StepLiveViewWithMarkdown
20
+ from kimi_cli.utils.logging import logger
21
+
22
+
23
+ @asynccontextmanager
24
+ async def _keyboard_listener(step: StepLiveView):
25
+ async def _keyboard():
26
+ try:
27
+ async for event in listen_for_keyboard():
28
+ # TODO: ESCAPE to interrupt
29
+ step.handle_keyboard_event(event)
30
+ except asyncio.CancelledError:
31
+ return
32
+
33
+ task = asyncio.create_task(_keyboard())
34
+ try:
35
+ yield
36
+ finally:
37
+ task.cancel()
38
+ with suppress(asyncio.CancelledError):
39
+ await task
40
+
41
+
42
+ async def visualize(wire: Wire, *, initial_status: StatusSnapshot):
43
+ """
44
+ A loop to consume agent events and visualize the agent behavior.
45
+ This loop never raise any exception except asyncio.CancelledError.
46
+ """
47
+ latest_status = initial_status
48
+ try:
49
+ # expect a StepBegin
50
+ assert isinstance(await wire.receive(), StepBegin)
51
+
52
+ while True:
53
+ # TODO: Maybe we can always have a StepLiveView here.
54
+ # No need to recreate for each step.
55
+ with StepLiveViewWithMarkdown(latest_status) as step:
56
+ async with _keyboard_listener(step):
57
+ # spin the moon at the beginning of each step
58
+ with console.status("", spinner="moon"):
59
+ msg = await wire.receive()
60
+
61
+ if isinstance(msg, CompactionBegin):
62
+ with console.status("[cyan]Compacting...[/cyan]"):
63
+ msg = await wire.receive()
64
+ if isinstance(msg, StepInterrupted):
65
+ break
66
+ assert isinstance(msg, CompactionEnd)
67
+ continue
68
+
69
+ # visualization loop for one step
70
+ while True:
71
+ match msg:
72
+ case TextPart(text=text):
73
+ step.append_text(text)
74
+ case ContentPart():
75
+ # TODO: support more content parts
76
+ step.append_text(f"[{msg.__class__.__name__}]")
77
+ case ToolCall():
78
+ step.append_tool_call(msg)
79
+ case ToolCallPart():
80
+ step.append_tool_call_part(msg)
81
+ case ToolResult():
82
+ step.append_tool_result(msg)
83
+ case ApprovalRequest():
84
+ step.request_approval(msg)
85
+ case StatusUpdate(status=status):
86
+ latest_status = status
87
+ step.update_status(latest_status)
88
+ case _:
89
+ break # break the step loop
90
+ msg = await wire.receive()
91
+
92
+ # cleanup the step live view
93
+ if isinstance(msg, StepInterrupted):
94
+ step.interrupt()
95
+ else:
96
+ step.finish()
97
+
98
+ if isinstance(msg, StepInterrupted):
99
+ # for StepInterrupted, the visualization loop should end immediately
100
+ break
101
+
102
+ assert isinstance(msg, StepBegin), "expect a StepBegin"
103
+ # start a new step
104
+ except asyncio.QueueShutDown:
105
+ logger.debug("Visualization loop shutting down")
@@ -29,7 +29,13 @@ def augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel):
29
29
  pass
30
30
 
31
31
 
32
- def create_llm(provider: LLMProvider, model: LLMModel, stream: bool = True) -> LLM:
32
+ def create_llm(
33
+ provider: LLMProvider,
34
+ model: LLMModel,
35
+ *,
36
+ stream: bool = True,
37
+ session_id: str | None = None,
38
+ ) -> LLM:
33
39
  match provider.type:
34
40
  case "kimi":
35
41
  chat_provider = Kimi(
@@ -41,6 +47,8 @@ def create_llm(provider: LLMProvider, model: LLMModel, stream: bool = True) -> L
41
47
  "User-Agent": kimi_cli.USER_AGENT,
42
48
  },
43
49
  )
50
+ if session_id:
51
+ chat_provider = chat_provider.with_generation_kwargs(prompt_cache_key=session_id)
44
52
  case "openai_legacy":
45
53
  chat_provider = OpenAILegacy(
46
54
  model=model.model,