kimi-cli 0.35__py3-none-any.whl → 0.36__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 +14 -0
- kimi_cli/__init__.py +15 -2
- kimi_cli/soul/__init__.py +1 -1
- kimi_cli/soul/approval.py +3 -2
- kimi_cli/soul/compaction.py +105 -0
- kimi_cli/soul/kimisoul.py +94 -38
- kimi_cli/soul/wire.py +29 -4
- kimi_cli/tools/bash/__init__.py +3 -1
- kimi_cli/tools/file/__init__.py +8 -0
- kimi_cli/tools/file/patch.py +13 -1
- kimi_cli/tools/file/replace.py +13 -1
- kimi_cli/tools/file/write.py +13 -1
- kimi_cli/ui/__init__.py +1 -0
- kimi_cli/ui/print/__init__.py +1 -0
- kimi_cli/ui/shell/__init__.py +48 -102
- kimi_cli/ui/shell/console.py +27 -1
- kimi_cli/ui/shell/debug.py +187 -0
- kimi_cli/ui/shell/keyboard.py +115 -0
- kimi_cli/ui/shell/liveview.py +225 -6
- kimi_cli/ui/shell/metacmd.py +25 -67
- kimi_cli/ui/shell/setup.py +3 -6
- kimi_cli/ui/shell/visualize.py +105 -0
- kimi_cli/utils/provider.py +9 -1
- kimi_cli-0.36.dist-info/METADATA +150 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.36.dist-info}/RECORD +30 -26
- kimi_cli-0.35.dist-info/METADATA +0 -24
- /kimi_cli/prompts/{metacmds/__init__.py → __init__.py} +0 -0
- /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
- /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.36.dist-info}/WHEEL +0 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.36.dist-info}/entry_points.txt +0 -0
kimi_cli/ui/shell/liveview.py
CHANGED
|
@@ -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
|
|
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=
|
|
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,
|
|
179
|
+
def _push_out(self, renderable: RenderableType):
|
|
104
180
|
"""
|
|
105
|
-
Push the
|
|
106
|
-
After this, the
|
|
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(
|
|
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
|
kimi_cli/ui/shell/metacmd.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
)
|
kimi_cli/ui/shell/setup.py
CHANGED
|
@@ -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
|
|
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
|
|
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")
|
kimi_cli/utils/provider.py
CHANGED
|
@@ -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(
|
|
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,
|