kimi-cli 0.35__py3-none-any.whl → 0.52__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.
- kimi_cli/CHANGELOG.md +165 -0
- kimi_cli/__init__.py +0 -374
- kimi_cli/agents/{koder → default}/agent.yaml +1 -1
- kimi_cli/agents/{koder → default}/system.md +1 -1
- kimi_cli/agentspec.py +115 -0
- kimi_cli/app.py +208 -0
- kimi_cli/cli.py +321 -0
- kimi_cli/config.py +33 -16
- kimi_cli/constant.py +4 -0
- kimi_cli/exception.py +16 -0
- kimi_cli/llm.py +144 -3
- kimi_cli/metadata.py +6 -69
- kimi_cli/prompts/__init__.py +4 -0
- kimi_cli/session.py +103 -0
- kimi_cli/soul/__init__.py +130 -9
- kimi_cli/soul/agent.py +159 -0
- kimi_cli/soul/approval.py +5 -6
- kimi_cli/soul/compaction.py +106 -0
- kimi_cli/soul/context.py +1 -1
- kimi_cli/soul/kimisoul.py +180 -80
- kimi_cli/soul/message.py +6 -6
- kimi_cli/soul/runtime.py +96 -0
- kimi_cli/soul/toolset.py +3 -2
- kimi_cli/tools/__init__.py +35 -31
- kimi_cli/tools/bash/__init__.py +25 -9
- kimi_cli/tools/bash/cmd.md +31 -0
- kimi_cli/tools/dmail/__init__.py +5 -4
- kimi_cli/tools/file/__init__.py +8 -0
- kimi_cli/tools/file/glob.md +1 -1
- kimi_cli/tools/file/glob.py +4 -4
- kimi_cli/tools/file/grep.py +36 -19
- kimi_cli/tools/file/patch.py +52 -10
- kimi_cli/tools/file/read.py +6 -5
- kimi_cli/tools/file/replace.py +16 -4
- kimi_cli/tools/file/write.py +16 -4
- kimi_cli/tools/mcp.py +7 -4
- kimi_cli/tools/task/__init__.py +60 -41
- kimi_cli/tools/task/task.md +1 -1
- kimi_cli/tools/todo/__init__.py +4 -2
- kimi_cli/tools/utils.py +1 -1
- kimi_cli/tools/web/fetch.py +2 -1
- kimi_cli/tools/web/search.py +13 -12
- kimi_cli/ui/__init__.py +0 -68
- kimi_cli/ui/acp/__init__.py +67 -38
- kimi_cli/ui/print/__init__.py +46 -69
- kimi_cli/ui/shell/__init__.py +145 -154
- kimi_cli/ui/shell/console.py +27 -1
- kimi_cli/ui/shell/debug.py +187 -0
- kimi_cli/ui/shell/keyboard.py +183 -0
- kimi_cli/ui/shell/metacmd.py +34 -81
- kimi_cli/ui/shell/prompt.py +245 -28
- kimi_cli/ui/shell/replay.py +104 -0
- kimi_cli/ui/shell/setup.py +19 -19
- kimi_cli/ui/shell/update.py +11 -5
- kimi_cli/ui/shell/visualize.py +576 -0
- kimi_cli/ui/wire/README.md +109 -0
- kimi_cli/ui/wire/__init__.py +340 -0
- kimi_cli/ui/wire/jsonrpc.py +48 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +10 -0
- kimi_cli/utils/changelog.py +6 -2
- kimi_cli/utils/clipboard.py +10 -0
- kimi_cli/utils/message.py +15 -1
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/markdown.py +959 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +41 -0
- kimi_cli/utils/string.py +8 -0
- kimi_cli/utils/term.py +114 -0
- kimi_cli/wire/__init__.py +73 -0
- kimi_cli/wire/message.py +191 -0
- kimi_cli-0.52.dist-info/METADATA +186 -0
- kimi_cli-0.52.dist-info/RECORD +99 -0
- kimi_cli-0.52.dist-info/entry_points.txt +3 -0
- kimi_cli/agent.py +0 -261
- kimi_cli/agents/koder/README.md +0 -3
- kimi_cli/prompts/metacmds/__init__.py +0 -4
- kimi_cli/soul/wire.py +0 -101
- kimi_cli/ui/shell/liveview.py +0 -158
- kimi_cli/utils/provider.py +0 -64
- kimi_cli-0.35.dist-info/METADATA +0 -24
- kimi_cli-0.35.dist-info/RECORD +0 -76
- kimi_cli-0.35.dist-info/entry_points.txt +0 -3
- /kimi_cli/agents/{koder → default}/sub.yaml +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.52.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections import deque
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from contextlib import asynccontextmanager, suppress
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
import streamingjson # pyright: ignore[reportMissingTypeStubs]
|
|
8
|
+
from kosong.message import ContentPart, TextPart, ThinkPart, ToolCall, ToolCallPart
|
|
9
|
+
from kosong.tooling import ToolError, ToolOk, ToolResult, ToolReturnType
|
|
10
|
+
from rich.console import Group, RenderableType
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.markup import escape
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.spinner import Spinner
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from kimi_cli.soul import StatusSnapshot
|
|
19
|
+
from kimi_cli.tools import extract_key_argument
|
|
20
|
+
from kimi_cli.ui.shell.console import console
|
|
21
|
+
from kimi_cli.ui.shell.keyboard import KeyEvent, listen_for_keyboard
|
|
22
|
+
from kimi_cli.utils.rich.markdown import Markdown
|
|
23
|
+
from kimi_cli.wire import WireUISide
|
|
24
|
+
from kimi_cli.wire.message import (
|
|
25
|
+
ApprovalRequest,
|
|
26
|
+
ApprovalResponse,
|
|
27
|
+
CompactionBegin,
|
|
28
|
+
CompactionEnd,
|
|
29
|
+
StatusUpdate,
|
|
30
|
+
StepBegin,
|
|
31
|
+
StepInterrupted,
|
|
32
|
+
SubagentEvent,
|
|
33
|
+
WireMessage,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def visualize(
|
|
40
|
+
wire: WireUISide,
|
|
41
|
+
*,
|
|
42
|
+
initial_status: StatusSnapshot,
|
|
43
|
+
cancel_event: asyncio.Event | None = None,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
A loop to consume agent events and visualize the agent behavior.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
wire: Communication channel with the agent
|
|
50
|
+
initial_status: Initial status snapshot
|
|
51
|
+
cancel_event: Event that can be set (e.g., by ESC key) to cancel the run
|
|
52
|
+
"""
|
|
53
|
+
view = _LiveView(initial_status, cancel_event)
|
|
54
|
+
await view.visualize_loop(wire)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _ContentBlock:
|
|
58
|
+
def __init__(self, is_think: bool):
|
|
59
|
+
self.is_think = is_think
|
|
60
|
+
self._spinner = Spinner("dots", "Thinking..." if is_think else "Composing...")
|
|
61
|
+
self.raw_text = ""
|
|
62
|
+
|
|
63
|
+
def compose(self) -> RenderableType:
|
|
64
|
+
return self._spinner
|
|
65
|
+
|
|
66
|
+
def compose_final(self) -> RenderableType:
|
|
67
|
+
return _with_bullet(
|
|
68
|
+
Markdown(
|
|
69
|
+
self.raw_text,
|
|
70
|
+
style="grey50 italic" if self.is_think else "",
|
|
71
|
+
),
|
|
72
|
+
bullet_style="grey50",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def append(self, content: str) -> None:
|
|
76
|
+
self.raw_text += content
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _ToolCallBlock:
|
|
80
|
+
class FinishedSubCall(NamedTuple):
|
|
81
|
+
call: ToolCall
|
|
82
|
+
result: ToolReturnType
|
|
83
|
+
|
|
84
|
+
def __init__(self, tool_call: ToolCall):
|
|
85
|
+
self._tool_name = tool_call.function.name
|
|
86
|
+
self._lexer = streamingjson.Lexer()
|
|
87
|
+
if tool_call.function.arguments is not None:
|
|
88
|
+
self._lexer.append_string(tool_call.function.arguments)
|
|
89
|
+
|
|
90
|
+
self._argument = extract_key_argument(self._lexer, self._tool_name)
|
|
91
|
+
self._result: ToolReturnType | None = None
|
|
92
|
+
|
|
93
|
+
self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {}
|
|
94
|
+
self._last_subagent_tool_call: ToolCall | None = None
|
|
95
|
+
self._n_finished_subagent_tool_calls = 0
|
|
96
|
+
self._finished_subagent_tool_calls = deque[_ToolCallBlock.FinishedSubCall](
|
|
97
|
+
maxlen=MAX_SUBAGENT_TOOL_CALLS_TO_SHOW
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self._spinning_dots = Spinner("dots", text="")
|
|
101
|
+
self._renderable: RenderableType = self._compose()
|
|
102
|
+
|
|
103
|
+
def compose(self) -> RenderableType:
|
|
104
|
+
return self._renderable
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def finished(self) -> bool:
|
|
108
|
+
return self._result is not None
|
|
109
|
+
|
|
110
|
+
def append_args_part(self, args_part: str):
|
|
111
|
+
if self.finished:
|
|
112
|
+
return
|
|
113
|
+
self._lexer.append_string(args_part)
|
|
114
|
+
# TODO: maybe don't extract detail if it's already stable
|
|
115
|
+
argument = extract_key_argument(self._lexer, self._tool_name)
|
|
116
|
+
if argument and argument != self._argument:
|
|
117
|
+
self._argument = argument
|
|
118
|
+
self._renderable: RenderableType = _with_bullet(
|
|
119
|
+
Text.from_markup(self._get_headline_markup()),
|
|
120
|
+
bullet=self._spinning_dots,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def finish(self, result: ToolReturnType):
|
|
124
|
+
self._result = result
|
|
125
|
+
self._renderable = self._compose()
|
|
126
|
+
|
|
127
|
+
def append_sub_tool_call(self, tool_call: ToolCall):
|
|
128
|
+
self._ongoing_subagent_tool_calls[tool_call.id] = tool_call
|
|
129
|
+
self._last_subagent_tool_call = tool_call
|
|
130
|
+
|
|
131
|
+
def append_sub_tool_call_part(self, tool_call_part: ToolCallPart):
|
|
132
|
+
if self._last_subagent_tool_call is None:
|
|
133
|
+
return
|
|
134
|
+
if not tool_call_part.arguments_part:
|
|
135
|
+
return
|
|
136
|
+
if self._last_subagent_tool_call.function.arguments is None:
|
|
137
|
+
self._last_subagent_tool_call.function.arguments = tool_call_part.arguments_part
|
|
138
|
+
else:
|
|
139
|
+
self._last_subagent_tool_call.function.arguments += tool_call_part.arguments_part
|
|
140
|
+
|
|
141
|
+
def finish_sub_tool_call(self, tool_result: ToolResult):
|
|
142
|
+
self._last_subagent_tool_call = None
|
|
143
|
+
sub_tool_call = self._ongoing_subagent_tool_calls.pop(tool_result.tool_call_id, None)
|
|
144
|
+
if sub_tool_call is None:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
self._finished_subagent_tool_calls.append(
|
|
148
|
+
_ToolCallBlock.FinishedSubCall(
|
|
149
|
+
call=sub_tool_call,
|
|
150
|
+
result=tool_result.result,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
self._n_finished_subagent_tool_calls += 1
|
|
154
|
+
self._renderable = self._compose()
|
|
155
|
+
|
|
156
|
+
def _compose(self) -> RenderableType:
|
|
157
|
+
lines: list[RenderableType] = [
|
|
158
|
+
Text.from_markup(self._get_headline_markup()),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
if self._n_finished_subagent_tool_calls > MAX_SUBAGENT_TOOL_CALLS_TO_SHOW:
|
|
162
|
+
n_hidden = self._n_finished_subagent_tool_calls - MAX_SUBAGENT_TOOL_CALLS_TO_SHOW
|
|
163
|
+
lines.append(
|
|
164
|
+
_with_bullet(
|
|
165
|
+
Text(
|
|
166
|
+
f"{n_hidden} more tool call{'s' if n_hidden > 1 else ''} ...",
|
|
167
|
+
style="grey50 italic",
|
|
168
|
+
),
|
|
169
|
+
bullet_style="grey50",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
for sub_call, sub_result in self._finished_subagent_tool_calls:
|
|
173
|
+
argument = extract_key_argument(
|
|
174
|
+
sub_call.function.arguments or "", sub_call.function.name
|
|
175
|
+
)
|
|
176
|
+
lines.append(
|
|
177
|
+
_with_bullet(
|
|
178
|
+
Text.from_markup(
|
|
179
|
+
f"Used [blue]{sub_call.function.name}[/blue]"
|
|
180
|
+
+ (f" [grey50]({argument})[/grey50]" if argument else "")
|
|
181
|
+
),
|
|
182
|
+
bullet_style="green" if isinstance(sub_result, ToolOk) else "red",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if self._result is not None and self._result.brief:
|
|
187
|
+
lines.append(
|
|
188
|
+
Markdown(
|
|
189
|
+
self._result.brief,
|
|
190
|
+
style="grey50" if isinstance(self._result, ToolOk) else "red",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if self.finished:
|
|
195
|
+
return _with_bullet(
|
|
196
|
+
Group(*lines),
|
|
197
|
+
bullet_style="green" if isinstance(self._result, ToolOk) else "red",
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
return _with_bullet(
|
|
201
|
+
Group(*lines),
|
|
202
|
+
bullet=self._spinning_dots,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _get_headline_markup(self) -> str:
|
|
206
|
+
return f"{'Used' if self.finished else 'Using'} [blue]{self._tool_name}[/blue]" + (
|
|
207
|
+
f" [grey50]({escape(self._argument)})[/grey50]" if self._argument else ""
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _ApprovalRequestPanel:
|
|
212
|
+
def __init__(self, request: ApprovalRequest):
|
|
213
|
+
self.request = request
|
|
214
|
+
self.options = [
|
|
215
|
+
("Approve", ApprovalResponse.APPROVE),
|
|
216
|
+
("Approve for this session", ApprovalResponse.APPROVE_FOR_SESSION),
|
|
217
|
+
("Reject, tell Kimi CLI what to do instead", ApprovalResponse.REJECT),
|
|
218
|
+
]
|
|
219
|
+
self.selected_index = 0
|
|
220
|
+
|
|
221
|
+
def render(self) -> RenderableType:
|
|
222
|
+
"""Render the approval menu as a panel."""
|
|
223
|
+
lines: list[RenderableType] = []
|
|
224
|
+
|
|
225
|
+
# Add request details
|
|
226
|
+
lines.append(
|
|
227
|
+
Text(f'{self.request.sender} is requesting approval to "{self.request.description}".')
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
lines.append(Text("")) # Empty line
|
|
231
|
+
|
|
232
|
+
# Add menu options
|
|
233
|
+
for i, (option_text, _) in enumerate(self.options):
|
|
234
|
+
if i == self.selected_index:
|
|
235
|
+
lines.append(Text(f"→ {option_text}", style="cyan"))
|
|
236
|
+
else:
|
|
237
|
+
lines.append(Text(f" {option_text}", style="grey50"))
|
|
238
|
+
|
|
239
|
+
content = Group(*lines)
|
|
240
|
+
return Panel.fit(
|
|
241
|
+
content,
|
|
242
|
+
title="[yellow]⚠ Approval Requested[/yellow]",
|
|
243
|
+
border_style="yellow",
|
|
244
|
+
padding=(1, 2),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def move_up(self):
|
|
248
|
+
"""Move selection up."""
|
|
249
|
+
self.selected_index = (self.selected_index - 1) % len(self.options)
|
|
250
|
+
|
|
251
|
+
def move_down(self):
|
|
252
|
+
"""Move selection down."""
|
|
253
|
+
self.selected_index = (self.selected_index + 1) % len(self.options)
|
|
254
|
+
|
|
255
|
+
def get_selected_response(self) -> ApprovalResponse:
|
|
256
|
+
"""Get the approval response based on selected option."""
|
|
257
|
+
return self.options[self.selected_index][1]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _StatusBlock:
|
|
261
|
+
def __init__(self, initial: StatusSnapshot) -> None:
|
|
262
|
+
self.text = Text("", justify="right", style="grey50")
|
|
263
|
+
self.update(initial)
|
|
264
|
+
|
|
265
|
+
def render(self) -> RenderableType:
|
|
266
|
+
return self.text
|
|
267
|
+
|
|
268
|
+
def update(self, status: StatusSnapshot) -> None:
|
|
269
|
+
self.text.plain = f"context: {status.context_usage:.1%}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@asynccontextmanager
|
|
273
|
+
async def _keyboard_listener(handler: Callable[[KeyEvent], None]):
|
|
274
|
+
async def _keyboard():
|
|
275
|
+
async for event in listen_for_keyboard():
|
|
276
|
+
handler(event)
|
|
277
|
+
|
|
278
|
+
task = asyncio.create_task(_keyboard())
|
|
279
|
+
try:
|
|
280
|
+
yield
|
|
281
|
+
finally:
|
|
282
|
+
task.cancel()
|
|
283
|
+
with suppress(asyncio.CancelledError):
|
|
284
|
+
await task
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class _LiveView:
|
|
288
|
+
def __init__(self, initial_status: StatusSnapshot, cancel_event: asyncio.Event | None = None):
|
|
289
|
+
self._cancel_event = cancel_event
|
|
290
|
+
|
|
291
|
+
self._mooning_spinner: Spinner | None = None
|
|
292
|
+
self._compacting_spinner: Spinner | None = None
|
|
293
|
+
|
|
294
|
+
self._current_content_block: _ContentBlock | None = None
|
|
295
|
+
self._tool_call_blocks: dict[str, _ToolCallBlock] = {}
|
|
296
|
+
self._last_tool_call_block: _ToolCallBlock | None = None
|
|
297
|
+
self._approval_request_queue = deque[ApprovalRequest]()
|
|
298
|
+
self._current_approval_request_panel: _ApprovalRequestPanel | None = None
|
|
299
|
+
self._reject_all_following = False
|
|
300
|
+
self._status_block = _StatusBlock(initial_status)
|
|
301
|
+
|
|
302
|
+
self._need_recompose = False
|
|
303
|
+
|
|
304
|
+
async def visualize_loop(self, wire: WireUISide):
|
|
305
|
+
with Live(
|
|
306
|
+
self.compose(),
|
|
307
|
+
console=console,
|
|
308
|
+
refresh_per_second=10,
|
|
309
|
+
transient=True,
|
|
310
|
+
vertical_overflow="visible",
|
|
311
|
+
) as live:
|
|
312
|
+
|
|
313
|
+
def keyboard_handler(event: KeyEvent) -> None:
|
|
314
|
+
self.dispatch_keyboard_event(event)
|
|
315
|
+
if self._need_recompose:
|
|
316
|
+
live.update(self.compose())
|
|
317
|
+
self._need_recompose = False
|
|
318
|
+
|
|
319
|
+
async with _keyboard_listener(keyboard_handler):
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
msg = await wire.receive()
|
|
323
|
+
except asyncio.QueueShutDown:
|
|
324
|
+
self.cleanup(is_interrupt=False)
|
|
325
|
+
live.update(self.compose())
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
if isinstance(msg, StepInterrupted):
|
|
329
|
+
self.cleanup(is_interrupt=True)
|
|
330
|
+
live.update(self.compose())
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
self.dispatch_wire_message(msg)
|
|
334
|
+
if self._need_recompose:
|
|
335
|
+
live.update(self.compose())
|
|
336
|
+
self._need_recompose = False
|
|
337
|
+
|
|
338
|
+
def refresh_soon(self) -> None:
|
|
339
|
+
self._need_recompose = True
|
|
340
|
+
|
|
341
|
+
def compose(self) -> RenderableType:
|
|
342
|
+
"""Compose the live view display content."""
|
|
343
|
+
blocks: list[RenderableType] = []
|
|
344
|
+
if self._mooning_spinner is not None:
|
|
345
|
+
blocks.append(self._mooning_spinner)
|
|
346
|
+
elif self._compacting_spinner is not None:
|
|
347
|
+
blocks.append(self._compacting_spinner)
|
|
348
|
+
else:
|
|
349
|
+
if self._current_content_block is not None:
|
|
350
|
+
blocks.append(self._current_content_block.compose())
|
|
351
|
+
for tool_call in self._tool_call_blocks.values():
|
|
352
|
+
blocks.append(tool_call.compose())
|
|
353
|
+
if self._current_approval_request_panel:
|
|
354
|
+
blocks.append(self._current_approval_request_panel.render())
|
|
355
|
+
blocks.append(self._status_block.render())
|
|
356
|
+
return Group(*blocks)
|
|
357
|
+
|
|
358
|
+
def dispatch_wire_message(self, msg: WireMessage) -> None:
|
|
359
|
+
"""Dispatch the Wire message to UI components."""
|
|
360
|
+
assert not isinstance(msg, StepInterrupted) # handled in visualize_loop
|
|
361
|
+
|
|
362
|
+
if isinstance(msg, StepBegin):
|
|
363
|
+
self.cleanup(is_interrupt=False)
|
|
364
|
+
self._mooning_spinner = Spinner("moon", "")
|
|
365
|
+
self.refresh_soon()
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
if self._mooning_spinner is not None:
|
|
369
|
+
self._mooning_spinner = None
|
|
370
|
+
self.refresh_soon()
|
|
371
|
+
|
|
372
|
+
match msg:
|
|
373
|
+
case CompactionBegin():
|
|
374
|
+
self._compacting_spinner = Spinner("balloon", "Compacting...")
|
|
375
|
+
self.refresh_soon()
|
|
376
|
+
case CompactionEnd():
|
|
377
|
+
self._compacting_spinner = None
|
|
378
|
+
self.refresh_soon()
|
|
379
|
+
case StatusUpdate(status=status):
|
|
380
|
+
self._status_block.update(status)
|
|
381
|
+
case ContentPart():
|
|
382
|
+
self.append_content(msg)
|
|
383
|
+
case ToolCall():
|
|
384
|
+
self.append_tool_call(msg)
|
|
385
|
+
case ToolCallPart():
|
|
386
|
+
self.append_tool_call_part(msg)
|
|
387
|
+
case ToolResult():
|
|
388
|
+
self.append_tool_result(msg)
|
|
389
|
+
case ApprovalRequest():
|
|
390
|
+
self.request_approval(msg)
|
|
391
|
+
case SubagentEvent():
|
|
392
|
+
self.handle_subagent_event(msg)
|
|
393
|
+
|
|
394
|
+
def dispatch_keyboard_event(self, event: KeyEvent) -> None:
|
|
395
|
+
# handle ESC key to cancel the run
|
|
396
|
+
if event == KeyEvent.ESCAPE and self._cancel_event is not None:
|
|
397
|
+
self._cancel_event.set()
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
if not self._current_approval_request_panel:
|
|
401
|
+
# just ignore any keyboard event when there's no approval request
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
match event:
|
|
405
|
+
case KeyEvent.UP:
|
|
406
|
+
self._current_approval_request_panel.move_up()
|
|
407
|
+
self.refresh_soon()
|
|
408
|
+
case KeyEvent.DOWN:
|
|
409
|
+
self._current_approval_request_panel.move_down()
|
|
410
|
+
self.refresh_soon()
|
|
411
|
+
case KeyEvent.ENTER:
|
|
412
|
+
resp = self._current_approval_request_panel.get_selected_response()
|
|
413
|
+
self._current_approval_request_panel.request.resolve(resp)
|
|
414
|
+
if resp == ApprovalResponse.APPROVE_FOR_SESSION:
|
|
415
|
+
to_remove_from_queue: list[ApprovalRequest] = []
|
|
416
|
+
for request in self._approval_request_queue:
|
|
417
|
+
# approve all queued requests with the same action
|
|
418
|
+
if request.action == self._current_approval_request_panel.request.action:
|
|
419
|
+
request.resolve(ApprovalResponse.APPROVE_FOR_SESSION)
|
|
420
|
+
to_remove_from_queue.append(request)
|
|
421
|
+
for request in to_remove_from_queue:
|
|
422
|
+
self._approval_request_queue.remove(request)
|
|
423
|
+
elif resp == ApprovalResponse.REJECT:
|
|
424
|
+
# one rejection should stop the step immediately
|
|
425
|
+
while self._approval_request_queue:
|
|
426
|
+
self._approval_request_queue.popleft().resolve(ApprovalResponse.REJECT)
|
|
427
|
+
self._reject_all_following = True
|
|
428
|
+
self.show_next_approval_request()
|
|
429
|
+
case _:
|
|
430
|
+
# just ignore any other keyboard event
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
def cleanup(self, is_interrupt: bool) -> None:
|
|
434
|
+
"""Cleanup the live view on step end or interruption."""
|
|
435
|
+
self.flush_content()
|
|
436
|
+
|
|
437
|
+
for block in self._tool_call_blocks.values():
|
|
438
|
+
if not block.finished:
|
|
439
|
+
# this should not happen, but just in case
|
|
440
|
+
block.finish(
|
|
441
|
+
ToolError(message="", brief="Interrupted")
|
|
442
|
+
if is_interrupt
|
|
443
|
+
else ToolOk(output="")
|
|
444
|
+
)
|
|
445
|
+
self._last_tool_call_block = None
|
|
446
|
+
self.flush_finished_tool_calls()
|
|
447
|
+
|
|
448
|
+
while self._approval_request_queue:
|
|
449
|
+
# should not happen, but just in case
|
|
450
|
+
self._approval_request_queue.popleft().resolve(ApprovalResponse.REJECT)
|
|
451
|
+
self._current_approval_request_panel = None
|
|
452
|
+
self._reject_all_following = False
|
|
453
|
+
|
|
454
|
+
def flush_content(self) -> None:
|
|
455
|
+
"""Flush the current content block."""
|
|
456
|
+
if self._current_content_block is not None:
|
|
457
|
+
console.print(self._current_content_block.compose_final())
|
|
458
|
+
self._current_content_block = None
|
|
459
|
+
self.refresh_soon()
|
|
460
|
+
|
|
461
|
+
def flush_finished_tool_calls(self) -> None:
|
|
462
|
+
"""Flush all leading finished tool call blocks."""
|
|
463
|
+
tool_call_ids = list(self._tool_call_blocks.keys())
|
|
464
|
+
for tool_call_id in tool_call_ids:
|
|
465
|
+
block = self._tool_call_blocks[tool_call_id]
|
|
466
|
+
if not block.finished:
|
|
467
|
+
break
|
|
468
|
+
|
|
469
|
+
self._tool_call_blocks.pop(tool_call_id)
|
|
470
|
+
console.print(block.compose())
|
|
471
|
+
if self._last_tool_call_block == block:
|
|
472
|
+
self._last_tool_call_block = None
|
|
473
|
+
self.refresh_soon()
|
|
474
|
+
|
|
475
|
+
def append_content(self, part: ContentPart) -> None:
|
|
476
|
+
match part:
|
|
477
|
+
case ThinkPart(think=text) | TextPart(text=text):
|
|
478
|
+
if not text:
|
|
479
|
+
return
|
|
480
|
+
is_think = isinstance(part, ThinkPart)
|
|
481
|
+
if self._current_content_block is None:
|
|
482
|
+
self._current_content_block = _ContentBlock(is_think)
|
|
483
|
+
self.refresh_soon()
|
|
484
|
+
elif self._current_content_block.is_think != is_think:
|
|
485
|
+
self.flush_content()
|
|
486
|
+
self._current_content_block = _ContentBlock(is_think)
|
|
487
|
+
self.refresh_soon()
|
|
488
|
+
self._current_content_block.append(text)
|
|
489
|
+
case _:
|
|
490
|
+
# TODO: support more content part types
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
def append_tool_call(self, tool_call: ToolCall) -> None:
|
|
494
|
+
self.flush_content()
|
|
495
|
+
self._tool_call_blocks[tool_call.id] = _ToolCallBlock(tool_call)
|
|
496
|
+
self._last_tool_call_block = self._tool_call_blocks[tool_call.id]
|
|
497
|
+
self.refresh_soon()
|
|
498
|
+
|
|
499
|
+
def append_tool_call_part(self, part: ToolCallPart) -> None:
|
|
500
|
+
if not part.arguments_part:
|
|
501
|
+
return
|
|
502
|
+
if self._last_tool_call_block is None:
|
|
503
|
+
return
|
|
504
|
+
self._last_tool_call_block.append_args_part(part.arguments_part)
|
|
505
|
+
self.refresh_soon()
|
|
506
|
+
|
|
507
|
+
def append_tool_result(self, result: ToolResult) -> None:
|
|
508
|
+
if block := self._tool_call_blocks.get(result.tool_call_id):
|
|
509
|
+
block.finish(result.result)
|
|
510
|
+
self.flush_finished_tool_calls()
|
|
511
|
+
self.refresh_soon()
|
|
512
|
+
|
|
513
|
+
def request_approval(self, request: ApprovalRequest) -> None:
|
|
514
|
+
# If we're rejecting all following requests, reject immediately
|
|
515
|
+
if self._reject_all_following:
|
|
516
|
+
request.resolve(ApprovalResponse.REJECT)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
self._approval_request_queue.append(request)
|
|
520
|
+
|
|
521
|
+
if self._current_approval_request_panel is None:
|
|
522
|
+
self.show_next_approval_request()
|
|
523
|
+
|
|
524
|
+
def show_next_approval_request(self) -> None:
|
|
525
|
+
"""
|
|
526
|
+
Show the next approval request from the queue.
|
|
527
|
+
If there are no pending requests, clear the current approval panel.
|
|
528
|
+
"""
|
|
529
|
+
if not self._approval_request_queue:
|
|
530
|
+
if self._current_approval_request_panel is not None:
|
|
531
|
+
self._current_approval_request_panel = None
|
|
532
|
+
self.refresh_soon()
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
while self._approval_request_queue:
|
|
536
|
+
request = self._approval_request_queue.popleft()
|
|
537
|
+
if request.resolved:
|
|
538
|
+
# skip resolved requests
|
|
539
|
+
continue
|
|
540
|
+
self._current_approval_request_panel = _ApprovalRequestPanel(request)
|
|
541
|
+
self.refresh_soon()
|
|
542
|
+
break
|
|
543
|
+
|
|
544
|
+
def handle_subagent_event(self, event: SubagentEvent) -> None:
|
|
545
|
+
block = self._tool_call_blocks.get(event.task_tool_call_id)
|
|
546
|
+
if block is None:
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
match event.event:
|
|
550
|
+
case ToolCall() as tool_call:
|
|
551
|
+
block.append_sub_tool_call(tool_call)
|
|
552
|
+
case ToolCallPart() as tool_call_part:
|
|
553
|
+
block.append_sub_tool_call_part(tool_call_part)
|
|
554
|
+
case ToolResult() as tool_result:
|
|
555
|
+
block.finish_sub_tool_call(tool_result)
|
|
556
|
+
self.refresh_soon()
|
|
557
|
+
case _:
|
|
558
|
+
# ignore other events for now
|
|
559
|
+
# TODO: may need to handle multi-level nested subagents
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _with_bullet(
|
|
564
|
+
renderable: RenderableType,
|
|
565
|
+
*,
|
|
566
|
+
bullet_style: str | None = None,
|
|
567
|
+
bullet: RenderableType | None = None,
|
|
568
|
+
) -> RenderableType:
|
|
569
|
+
table = Table.grid(padding=(0, 0))
|
|
570
|
+
table.expand = True
|
|
571
|
+
table.add_column(width=2, justify="left", style=bullet_style)
|
|
572
|
+
table.add_column(ratio=1)
|
|
573
|
+
if bullet is None:
|
|
574
|
+
bullet = Text("•")
|
|
575
|
+
table.add_row(bullet, renderable)
|
|
576
|
+
return table
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Wire over STDIO
|
|
2
|
+
|
|
3
|
+
Learn how `WireServer` (src/kimi_cli/ui/wire/__init__.py) exposes the Soul runtime over stdio.
|
|
4
|
+
Use this reference when building clients or SDKs.
|
|
5
|
+
|
|
6
|
+
## Transport
|
|
7
|
+
- The server acquires stdio streams via `acp.stdio_streams()` and stays alive until stdin closes.
|
|
8
|
+
- Messages use newline-delimited JSON. Each object must include `"jsonrpc": "2.0"`.
|
|
9
|
+
- Outbound JSON is UTF-8 encoded with compact separators `(",", ":")`.
|
|
10
|
+
|
|
11
|
+
## Lifecycle
|
|
12
|
+
1. A client launches `kimi` (or another entry point) with the wire UI enabled.
|
|
13
|
+
2. `WireServer.run()` spawns a reader loop on stdin and a writer loop draining an internal queue.
|
|
14
|
+
3. Incoming payloads are validated by `JSONRPC_MESSAGE_ADAPTER`; invalid objects only log warnings.
|
|
15
|
+
4. The Soul uses `Wire` (src/kimi_cli/wire/__init__.py); the UI forwards every message as JSON-RPC.
|
|
16
|
+
5. EOF on stdin or a fatal error cancels the Soul, rejects approvals, and closes stdout.
|
|
17
|
+
|
|
18
|
+
## Client → Server calls
|
|
19
|
+
|
|
20
|
+
### `run`
|
|
21
|
+
- Request:
|
|
22
|
+
```json
|
|
23
|
+
{"jsonrpc": "2.0", "id": "<request-id>", "method": "run", "params": {"input": "<prompt>"}}
|
|
24
|
+
```
|
|
25
|
+
`params.prompt` is accepted as an alias for `params.input`.
|
|
26
|
+
- Success results:
|
|
27
|
+
- `{"status": "finished"}` when the run completes.
|
|
28
|
+
- `{"status": "cancelled"}` when either side interrupts.
|
|
29
|
+
- `{"status": "max_steps_reached", "steps": <int>}` when the step limit triggers.
|
|
30
|
+
- Error codes:
|
|
31
|
+
- `-32000`: A run is already in progress.
|
|
32
|
+
- `-32602`: The `input` or `prompt` parameter is missing or not a string.
|
|
33
|
+
- `-32001`: LLM is not configured.
|
|
34
|
+
- `-32002`: The chat provider reported an error.
|
|
35
|
+
- `-32003`: The requested LLM is unsupported.
|
|
36
|
+
- `-32099`: An unhandled exception occurred during the run.
|
|
37
|
+
|
|
38
|
+
### `interrupt`
|
|
39
|
+
- Request:
|
|
40
|
+
```json
|
|
41
|
+
{"jsonrpc": "2.0", "id": "<request-id>", "method": "interrupt", "params": {}}
|
|
42
|
+
```
|
|
43
|
+
The `id` field is optional; omitting it turns the request into a notification.
|
|
44
|
+
- Success results:
|
|
45
|
+
- `{"status": "ok"}` when a running Soul acknowledges the interrupt.
|
|
46
|
+
- `{"status": "idle"}` when no run is active.
|
|
47
|
+
- Interrupt requests never raise protocol errors.
|
|
48
|
+
|
|
49
|
+
## Server → Client traffic
|
|
50
|
+
|
|
51
|
+
### Event notifications
|
|
52
|
+
Events are JSON-RPC notifications with method `event` and no `id`.
|
|
53
|
+
Payloads come from `serialize_event` (src/kimi_cli/wire/message.py):
|
|
54
|
+
- `step_begin`: payload `{"n": <int>}` with the 1-based step counter.
|
|
55
|
+
- `step_interrupted`: no payload; the Soul paused mid-step.
|
|
56
|
+
- `compaction_begin`: no payload; a compaction pass started.
|
|
57
|
+
- `compaction_end`: no payload; always follows `compaction_begin`.
|
|
58
|
+
- `status_update`: payload `{"context_usage": <int>}` from `StatusSnapshot`.
|
|
59
|
+
- `content_part`: JSON object produced by `ContentPart.model_dump(mode="json", exclude_none=True)`.
|
|
60
|
+
- `tool_call`: JSON object produced by `ToolCall.model_dump(mode="json", exclude_none=True)`.
|
|
61
|
+
- `tool_call_part`: JSON object from `ToolCallPart.model_dump(mode="json", exclude_none=True)`.
|
|
62
|
+
- `tool_result`: object with `tool_call_id`, `ok`, and `result` (`output`, `message`, `brief`).
|
|
63
|
+
When `ok` is true the `output` may be text, a JSON object, or an array of JSON objects for
|
|
64
|
+
multi-part content.
|
|
65
|
+
|
|
66
|
+
Event order mirrors Soul execution because the server uses an `asyncio.Queue` for FIFO delivery.
|
|
67
|
+
|
|
68
|
+
### Approval requests
|
|
69
|
+
- Approval prompts use method `request`; their `id` equals the UUID in `ApprovalRequest.id`:
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"jsonrpc": "2.0",
|
|
73
|
+
"id": "<approval-id>",
|
|
74
|
+
"method": "request",
|
|
75
|
+
"params": {
|
|
76
|
+
"type": "approval",
|
|
77
|
+
"payload": {
|
|
78
|
+
"id": "<approval-id>",
|
|
79
|
+
"tool_call_id": "<tool-call-id>",
|
|
80
|
+
"sender": "<agent>",
|
|
81
|
+
"action": "<action>",
|
|
82
|
+
"description": "<human readable context>"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
- Clients reply with JSON-RPC success.
|
|
88
|
+
`result.response` must be `approve`, `approve_for_session`, or `reject`:
|
|
89
|
+
```json
|
|
90
|
+
{"jsonrpc": "2.0", "id": "<approval-id>", "result": {"response": "approve"}}
|
|
91
|
+
```
|
|
92
|
+
- Error responses or unknown values are interpreted as rejection.
|
|
93
|
+
- Unanswered approvals are auto-rejected during server shutdown.
|
|
94
|
+
|
|
95
|
+
## Error responses from the server
|
|
96
|
+
Errors follow JSON-RPC semantics.
|
|
97
|
+
The error object includes `code` and `message`.
|
|
98
|
+
Custom codes live in the `-320xx` range.
|
|
99
|
+
Clients should allow an optional `data` field even though the server omits it today.
|
|
100
|
+
|
|
101
|
+
## Shutdown semantics
|
|
102
|
+
- Shutdown cancels runs, stops the writer queue, rejects pending approvals, and closes stdout.
|
|
103
|
+
- EOF on stdout signals process exit; clients can treat it as terminal.
|
|
104
|
+
|
|
105
|
+
## Implementation notes for SDK authors
|
|
106
|
+
- Only one `run` call may execute at a time; queue additional runs client side.
|
|
107
|
+
- The payloads for `content_part`, `tool_call`, and `tool_call_part` already contain JSON objects.
|
|
108
|
+
- Approval handling is synchronous; always send a response even if the user cancels.
|
|
109
|
+
- Logging is verbose for non-stream messages; unknown methods are ignored for forward compatibility.
|