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.
Files changed (88) hide show
  1. kimi_cli/CHANGELOG.md +165 -0
  2. kimi_cli/__init__.py +0 -374
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agents/{koder → default}/system.md +1 -1
  5. kimi_cli/agentspec.py +115 -0
  6. kimi_cli/app.py +208 -0
  7. kimi_cli/cli.py +321 -0
  8. kimi_cli/config.py +33 -16
  9. kimi_cli/constant.py +4 -0
  10. kimi_cli/exception.py +16 -0
  11. kimi_cli/llm.py +144 -3
  12. kimi_cli/metadata.py +6 -69
  13. kimi_cli/prompts/__init__.py +4 -0
  14. kimi_cli/session.py +103 -0
  15. kimi_cli/soul/__init__.py +130 -9
  16. kimi_cli/soul/agent.py +159 -0
  17. kimi_cli/soul/approval.py +5 -6
  18. kimi_cli/soul/compaction.py +106 -0
  19. kimi_cli/soul/context.py +1 -1
  20. kimi_cli/soul/kimisoul.py +180 -80
  21. kimi_cli/soul/message.py +6 -6
  22. kimi_cli/soul/runtime.py +96 -0
  23. kimi_cli/soul/toolset.py +3 -2
  24. kimi_cli/tools/__init__.py +35 -31
  25. kimi_cli/tools/bash/__init__.py +25 -9
  26. kimi_cli/tools/bash/cmd.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +5 -4
  28. kimi_cli/tools/file/__init__.py +8 -0
  29. kimi_cli/tools/file/glob.md +1 -1
  30. kimi_cli/tools/file/glob.py +4 -4
  31. kimi_cli/tools/file/grep.py +36 -19
  32. kimi_cli/tools/file/patch.py +52 -10
  33. kimi_cli/tools/file/read.py +6 -5
  34. kimi_cli/tools/file/replace.py +16 -4
  35. kimi_cli/tools/file/write.py +16 -4
  36. kimi_cli/tools/mcp.py +7 -4
  37. kimi_cli/tools/task/__init__.py +60 -41
  38. kimi_cli/tools/task/task.md +1 -1
  39. kimi_cli/tools/todo/__init__.py +4 -2
  40. kimi_cli/tools/utils.py +1 -1
  41. kimi_cli/tools/web/fetch.py +2 -1
  42. kimi_cli/tools/web/search.py +13 -12
  43. kimi_cli/ui/__init__.py +0 -68
  44. kimi_cli/ui/acp/__init__.py +67 -38
  45. kimi_cli/ui/print/__init__.py +46 -69
  46. kimi_cli/ui/shell/__init__.py +145 -154
  47. kimi_cli/ui/shell/console.py +27 -1
  48. kimi_cli/ui/shell/debug.py +187 -0
  49. kimi_cli/ui/shell/keyboard.py +183 -0
  50. kimi_cli/ui/shell/metacmd.py +34 -81
  51. kimi_cli/ui/shell/prompt.py +245 -28
  52. kimi_cli/ui/shell/replay.py +104 -0
  53. kimi_cli/ui/shell/setup.py +19 -19
  54. kimi_cli/ui/shell/update.py +11 -5
  55. kimi_cli/ui/shell/visualize.py +576 -0
  56. kimi_cli/ui/wire/README.md +109 -0
  57. kimi_cli/ui/wire/__init__.py +340 -0
  58. kimi_cli/ui/wire/jsonrpc.py +48 -0
  59. kimi_cli/utils/__init__.py +0 -0
  60. kimi_cli/utils/aiohttp.py +10 -0
  61. kimi_cli/utils/changelog.py +6 -2
  62. kimi_cli/utils/clipboard.py +10 -0
  63. kimi_cli/utils/message.py +15 -1
  64. kimi_cli/utils/rich/__init__.py +33 -0
  65. kimi_cli/utils/rich/markdown.py +959 -0
  66. kimi_cli/utils/rich/markdown_sample.md +108 -0
  67. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  68. kimi_cli/utils/signals.py +41 -0
  69. kimi_cli/utils/string.py +8 -0
  70. kimi_cli/utils/term.py +114 -0
  71. kimi_cli/wire/__init__.py +73 -0
  72. kimi_cli/wire/message.py +191 -0
  73. kimi_cli-0.52.dist-info/METADATA +186 -0
  74. kimi_cli-0.52.dist-info/RECORD +99 -0
  75. kimi_cli-0.52.dist-info/entry_points.txt +3 -0
  76. kimi_cli/agent.py +0 -261
  77. kimi_cli/agents/koder/README.md +0 -3
  78. kimi_cli/prompts/metacmds/__init__.py +0 -4
  79. kimi_cli/soul/wire.py +0 -101
  80. kimi_cli/ui/shell/liveview.py +0 -158
  81. kimi_cli/utils/provider.py +0 -64
  82. kimi_cli-0.35.dist-info/METADATA +0 -24
  83. kimi_cli-0.35.dist-info/RECORD +0 -76
  84. kimi_cli-0.35.dist-info/entry_points.txt +0 -3
  85. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  86. /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
  87. /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
  88. {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.