kimi-cli 0.44__py3-none-any.whl → 0.78__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
@@ -1,46 +1,58 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
4
+ from collections import deque
5
+ from collections.abc import Callable
2
6
  from contextlib import asynccontextmanager, suppress
7
+ from typing import NamedTuple
3
8
 
4
- from kosong.base.message import ContentPart, TextPart, ToolCall, ToolCallPart
5
- from kosong.tooling import ToolResult
9
+ import streamingjson # type: ignore[reportMissingTypeStubs]
10
+ from kosong.message import Message
11
+ from kosong.tooling import ToolError, ToolOk
12
+ from rich.console import Group, RenderableType
13
+ from rich.live import Live
14
+ from rich.markup import escape
15
+ from rich.panel import Panel
16
+ from rich.spinner import Spinner
17
+ from rich.text import Text
6
18
 
7
- from kimi_cli.soul import StatusSnapshot
19
+ from kimi_cli.tools import extract_key_argument
8
20
  from kimi_cli.ui.shell.console import console
9
- from kimi_cli.ui.shell.keyboard import listen_for_keyboard
10
- from kimi_cli.ui.shell.liveview import StepLiveView, StepLiveViewWithMarkdown
21
+ from kimi_cli.ui.shell.keyboard import KeyEvent, listen_for_keyboard
22
+ from kimi_cli.utils.aioqueue import QueueShutDown
23
+ from kimi_cli.utils.message import message_stringify
24
+ from kimi_cli.utils.rich.columns import BulletColumns
25
+ from kimi_cli.utils.rich.markdown import Markdown
11
26
  from kimi_cli.wire import WireUISide
12
- from kimi_cli.wire.message import (
27
+ from kimi_cli.wire.types import (
13
28
  ApprovalRequest,
29
+ ApprovalRequestResolved,
30
+ BriefDisplayBlock,
14
31
  CompactionBegin,
15
32
  CompactionEnd,
33
+ ContentPart,
16
34
  StatusUpdate,
17
35
  StepBegin,
18
36
  StepInterrupted,
37
+ SubagentEvent,
38
+ TextPart,
39
+ ThinkPart,
40
+ TodoDisplayBlock,
41
+ ToolCall,
42
+ ToolCallPart,
43
+ ToolResult,
44
+ ToolReturnValue,
45
+ TurnBegin,
46
+ WireMessage,
19
47
  )
20
48
 
21
-
22
- @asynccontextmanager
23
- async def _keyboard_listener(step: StepLiveView):
24
- async def _keyboard():
25
- try:
26
- async for event in listen_for_keyboard():
27
- step.handle_keyboard_event(event)
28
- except asyncio.CancelledError:
29
- return
30
-
31
- task = asyncio.create_task(_keyboard())
32
- try:
33
- yield
34
- finally:
35
- task.cancel()
36
- with suppress(asyncio.CancelledError):
37
- await task
49
+ MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4
38
50
 
39
51
 
40
52
  async def visualize(
41
53
  wire: WireUISide,
42
54
  *,
43
- initial_status: StatusSnapshot,
55
+ initial_status: StatusUpdate,
44
56
  cancel_event: asyncio.Event | None = None,
45
57
  ):
46
58
  """
@@ -51,61 +63,551 @@ async def visualize(
51
63
  initial_status: Initial status snapshot
52
64
  cancel_event: Event that can be set (e.g., by ESC key) to cancel the run
53
65
  """
66
+ view = _LiveView(initial_status, cancel_event)
67
+ await view.visualize_loop(wire)
68
+
54
69
 
55
- latest_status = initial_status
70
+ class _ContentBlock:
71
+ def __init__(self, is_think: bool):
72
+ self.is_think = is_think
73
+ self._spinner = Spinner("dots", "Thinking..." if is_think else "Composing...")
74
+ self.raw_text = ""
56
75
 
57
- # expect a StepBegin
58
- assert isinstance(await wire.receive(), StepBegin)
76
+ def compose(self) -> RenderableType:
77
+ return self._spinner
59
78
 
60
- while True:
61
- # TODO: Maybe we can always have a StepLiveView here.
62
- # No need to recreate for each step.
63
- with StepLiveViewWithMarkdown(latest_status, cancel_event) as step:
64
- async with _keyboard_listener(step):
65
- # spin the moon at the beginning of each step
66
- with console.status("", spinner="moon"):
67
- msg = await wire.receive()
79
+ def compose_final(self) -> RenderableType:
80
+ return BulletColumns(
81
+ Markdown(
82
+ self.raw_text,
83
+ style="grey50 italic" if self.is_think else "",
84
+ ),
85
+ bullet_style="grey50" if self.is_think else None,
86
+ )
68
87
 
69
- if isinstance(msg, CompactionBegin):
70
- with console.status("[cyan]Compacting...[/cyan]"):
88
+ def append(self, content: str) -> None:
89
+ self.raw_text += content
90
+
91
+
92
+ class _ToolCallBlock:
93
+ class FinishedSubCall(NamedTuple):
94
+ call: ToolCall
95
+ result: ToolReturnValue
96
+
97
+ def __init__(self, tool_call: ToolCall):
98
+ self._tool_name = tool_call.function.name
99
+ self._lexer = streamingjson.Lexer()
100
+ if tool_call.function.arguments is not None:
101
+ self._lexer.append_string(tool_call.function.arguments)
102
+
103
+ self._argument = extract_key_argument(self._lexer, self._tool_name)
104
+ self._result: ToolReturnValue | None = None
105
+
106
+ self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {}
107
+ self._last_subagent_tool_call: ToolCall | None = None
108
+ self._n_finished_subagent_tool_calls = 0
109
+ self._finished_subagent_tool_calls = deque[_ToolCallBlock.FinishedSubCall](
110
+ maxlen=MAX_SUBAGENT_TOOL_CALLS_TO_SHOW
111
+ )
112
+
113
+ self._spinning_dots = Spinner("dots", text="")
114
+ self._renderable: RenderableType = self._compose()
115
+
116
+ def compose(self) -> RenderableType:
117
+ return self._renderable
118
+
119
+ @property
120
+ def finished(self) -> bool:
121
+ return self._result is not None
122
+
123
+ def append_args_part(self, args_part: str):
124
+ if self.finished:
125
+ return
126
+ self._lexer.append_string(args_part)
127
+ # TODO: maybe don't extract detail if it's already stable
128
+ argument = extract_key_argument(self._lexer, self._tool_name)
129
+ if argument and argument != self._argument:
130
+ self._argument = argument
131
+ self._renderable = BulletColumns(
132
+ Text.from_markup(self._get_headline_markup()),
133
+ bullet=self._spinning_dots,
134
+ )
135
+
136
+ def finish(self, result: ToolReturnValue):
137
+ self._result = result
138
+ self._renderable = self._compose()
139
+
140
+ def append_sub_tool_call(self, tool_call: ToolCall):
141
+ self._ongoing_subagent_tool_calls[tool_call.id] = tool_call
142
+ self._last_subagent_tool_call = tool_call
143
+
144
+ def append_sub_tool_call_part(self, tool_call_part: ToolCallPart):
145
+ if self._last_subagent_tool_call is None:
146
+ return
147
+ if not tool_call_part.arguments_part:
148
+ return
149
+ if self._last_subagent_tool_call.function.arguments is None:
150
+ self._last_subagent_tool_call.function.arguments = tool_call_part.arguments_part
151
+ else:
152
+ self._last_subagent_tool_call.function.arguments += tool_call_part.arguments_part
153
+
154
+ def finish_sub_tool_call(self, tool_result: ToolResult):
155
+ self._last_subagent_tool_call = None
156
+ sub_tool_call = self._ongoing_subagent_tool_calls.pop(tool_result.tool_call_id, None)
157
+ if sub_tool_call is None:
158
+ return
159
+
160
+ self._finished_subagent_tool_calls.append(
161
+ _ToolCallBlock.FinishedSubCall(
162
+ call=sub_tool_call,
163
+ result=tool_result.return_value,
164
+ )
165
+ )
166
+ self._n_finished_subagent_tool_calls += 1
167
+ self._renderable = self._compose()
168
+
169
+ def _compose(self) -> RenderableType:
170
+ lines: list[RenderableType] = [
171
+ Text.from_markup(self._get_headline_markup()),
172
+ ]
173
+
174
+ if self._n_finished_subagent_tool_calls > MAX_SUBAGENT_TOOL_CALLS_TO_SHOW:
175
+ n_hidden = self._n_finished_subagent_tool_calls - MAX_SUBAGENT_TOOL_CALLS_TO_SHOW
176
+ lines.append(
177
+ BulletColumns(
178
+ Text(
179
+ f"{n_hidden} more tool call{'s' if n_hidden > 1 else ''} ...",
180
+ style="grey50 italic",
181
+ ),
182
+ bullet_style="grey50",
183
+ )
184
+ )
185
+ for sub_call, sub_result in self._finished_subagent_tool_calls:
186
+ argument = extract_key_argument(
187
+ sub_call.function.arguments or "", sub_call.function.name
188
+ )
189
+ lines.append(
190
+ BulletColumns(
191
+ Text.from_markup(
192
+ f"Used [blue]{sub_call.function.name}[/blue]"
193
+ + (f" [grey50]({argument})[/grey50]" if argument else "")
194
+ ),
195
+ bullet_style="green" if not sub_result.is_error else "red",
196
+ )
197
+ )
198
+
199
+ if self._result is not None:
200
+ for block in self._result.display:
201
+ if isinstance(block, BriefDisplayBlock):
202
+ style = "grey50" if not self._result.is_error else "red"
203
+ if block.text:
204
+ lines.append(Markdown(block.text, style=style))
205
+ elif isinstance(block, TodoDisplayBlock):
206
+ markdown = self._render_todo_markdown(block)
207
+ if markdown:
208
+ lines.append(Markdown(markdown, style="grey50"))
209
+
210
+ if self.finished:
211
+ assert self._result is not None
212
+ return BulletColumns(
213
+ Group(*lines),
214
+ bullet_style="green" if not self._result.is_error else "red",
215
+ )
216
+ else:
217
+ return BulletColumns(
218
+ Group(*lines),
219
+ bullet=self._spinning_dots,
220
+ )
221
+
222
+ def _get_headline_markup(self) -> str:
223
+ return f"{'Used' if self.finished else 'Using'} [blue]{self._tool_name}[/blue]" + (
224
+ f" [grey50]({escape(self._argument)})[/grey50]" if self._argument else ""
225
+ )
226
+
227
+ def _render_todo_markdown(self, block: TodoDisplayBlock) -> str:
228
+ lines: list[str] = []
229
+ for todo in block.items:
230
+ normalized = todo.status.replace("_", " ").lower()
231
+ match normalized:
232
+ case "pending":
233
+ lines.append(f"- {todo.title}")
234
+ case "in progress":
235
+ lines.append(f"- {todo.title} ←")
236
+ case "done":
237
+ lines.append(f"- ~~{todo.title}~~")
238
+ case _:
239
+ lines.append(f"- {todo.title}")
240
+ return "\n".join(lines)
241
+
242
+
243
+ class _ApprovalRequestPanel:
244
+ def __init__(self, request: ApprovalRequest):
245
+ self.request = request
246
+ self.options: list[tuple[str, ApprovalRequest.Response]] = [
247
+ ("Approve once", "approve"),
248
+ ("Approve for this session", "approve_for_session"),
249
+ ("Reject, tell Kimi CLI what to do instead", "reject"),
250
+ ]
251
+ self.selected_index = 0
252
+
253
+ def render(self) -> RenderableType:
254
+ """Render the approval menu as a panel."""
255
+ lines: list[RenderableType] = []
256
+
257
+ # Add request details
258
+ lines.append(
259
+ Text.assemble(
260
+ Text.from_markup(f"[blue]{self.request.sender}[/blue]"),
261
+ Text(f' is requesting approval to "{self.request.description}".'),
262
+ )
263
+ )
264
+
265
+ lines.append(Text("")) # Empty line
266
+
267
+ # Add menu options
268
+ for i, (option_text, _) in enumerate(self.options):
269
+ if i == self.selected_index:
270
+ lines.append(Text(f"→ {option_text}", style="cyan"))
271
+ else:
272
+ lines.append(Text(f" {option_text}", style="grey50"))
273
+
274
+ content = Group(*lines)
275
+ return Panel.fit(
276
+ content,
277
+ title="[yellow]⚠ Approval Requested[/yellow]",
278
+ border_style="yellow",
279
+ padding=(1, 2),
280
+ )
281
+
282
+ def move_up(self):
283
+ """Move selection up."""
284
+ self.selected_index = (self.selected_index - 1) % len(self.options)
285
+
286
+ def move_down(self):
287
+ """Move selection down."""
288
+ self.selected_index = (self.selected_index + 1) % len(self.options)
289
+
290
+ def get_selected_response(self) -> ApprovalRequest.Response:
291
+ """Get the approval response based on selected option."""
292
+ return self.options[self.selected_index][1]
293
+
294
+
295
+ class _StatusBlock:
296
+ def __init__(self, initial: StatusUpdate) -> None:
297
+ self.text = Text("", justify="right")
298
+ self.update(initial)
299
+
300
+ def render(self) -> RenderableType:
301
+ return self.text
302
+
303
+ def update(self, status: StatusUpdate) -> None:
304
+ if status.context_usage is not None:
305
+ self.text.plain = f"context: {status.context_usage:.1%}"
306
+
307
+
308
+ @asynccontextmanager
309
+ async def _keyboard_listener(handler: Callable[[KeyEvent], None]):
310
+ async def _keyboard():
311
+ async for event in listen_for_keyboard():
312
+ handler(event)
313
+
314
+ task = asyncio.create_task(_keyboard())
315
+ try:
316
+ yield
317
+ finally:
318
+ task.cancel()
319
+ with suppress(asyncio.CancelledError):
320
+ await task
321
+
322
+
323
+ class _LiveView:
324
+ def __init__(self, initial_status: StatusUpdate, cancel_event: asyncio.Event | None = None):
325
+ self._cancel_event = cancel_event
326
+
327
+ self._mooning_spinner: Spinner | None = None
328
+ self._compacting_spinner: Spinner | None = None
329
+
330
+ self._current_content_block: _ContentBlock | None = None
331
+ self._tool_call_blocks: dict[str, _ToolCallBlock] = {}
332
+ self._last_tool_call_block: _ToolCallBlock | None = None
333
+ self._approval_request_queue = deque[ApprovalRequest]()
334
+ """
335
+ It is possible that multiple subagents request approvals at the same time,
336
+ in which case we will have to queue them up and show them one by one.
337
+ """
338
+ self._current_approval_request_panel: _ApprovalRequestPanel | None = None
339
+ self._reject_all_following = False
340
+ self._status_block = _StatusBlock(initial_status)
341
+
342
+ self._need_recompose = False
343
+
344
+ async def visualize_loop(self, wire: WireUISide):
345
+ with Live(
346
+ self.compose(),
347
+ console=console,
348
+ refresh_per_second=10,
349
+ transient=True,
350
+ vertical_overflow="visible",
351
+ ) as live:
352
+
353
+ def keyboard_handler(event: KeyEvent) -> None:
354
+ self.dispatch_keyboard_event(event)
355
+ if self._need_recompose:
356
+ live.update(self.compose())
357
+ self._need_recompose = False
358
+
359
+ async with _keyboard_listener(keyboard_handler):
360
+ while True:
361
+ try:
71
362
  msg = await wire.receive()
363
+ except QueueShutDown:
364
+ self.cleanup(is_interrupt=False)
365
+ live.update(self.compose())
366
+ break
367
+
72
368
  if isinstance(msg, StepInterrupted):
369
+ self.cleanup(is_interrupt=True)
370
+ live.update(self.compose())
73
371
  break
74
- assert isinstance(msg, CompactionEnd)
75
- continue
76
372
 
77
- # visualization loop for one step
78
- while True:
79
- match msg:
80
- case TextPart(text=text):
81
- step.append_text(text)
82
- case ContentPart():
83
- # TODO: support more content parts
84
- step.append_text(f"[{msg.__class__.__name__}]")
85
- case ToolCall():
86
- step.append_tool_call(msg)
87
- case ToolCallPart():
88
- step.append_tool_call_part(msg)
89
- case ToolResult():
90
- step.append_tool_result(msg)
91
- case ApprovalRequest():
92
- step.request_approval(msg)
93
- case StatusUpdate(status=status):
94
- latest_status = status
95
- step.update_status(latest_status)
96
- case _:
97
- break # break the step loop
98
- msg = await wire.receive()
99
-
100
- # cleanup the step live view
101
- if isinstance(msg, StepInterrupted):
102
- step.interrupt()
103
- else:
104
- step.finish()
105
-
106
- if isinstance(msg, StepInterrupted):
107
- # for StepInterrupted, the visualization loop should end immediately
373
+ self.dispatch_wire_message(msg)
374
+ if self._need_recompose:
375
+ live.update(self.compose())
376
+ self._need_recompose = False
377
+
378
+ def refresh_soon(self) -> None:
379
+ self._need_recompose = True
380
+
381
+ def compose(self) -> RenderableType:
382
+ """Compose the live view display content."""
383
+ blocks: list[RenderableType] = []
384
+ if self._mooning_spinner is not None:
385
+ blocks.append(self._mooning_spinner)
386
+ elif self._compacting_spinner is not None:
387
+ blocks.append(self._compacting_spinner)
388
+ else:
389
+ if self._current_content_block is not None:
390
+ blocks.append(self._current_content_block.compose())
391
+ for tool_call in self._tool_call_blocks.values():
392
+ blocks.append(tool_call.compose())
393
+ if self._current_approval_request_panel:
394
+ blocks.append(self._current_approval_request_panel.render())
395
+ blocks.append(self._status_block.render())
396
+ return Group(*blocks)
397
+
398
+ def dispatch_wire_message(self, msg: WireMessage) -> None:
399
+ """Dispatch the Wire message to UI components."""
400
+ assert not isinstance(msg, StepInterrupted) # handled in visualize_loop
401
+
402
+ if isinstance(msg, StepBegin):
403
+ self.cleanup(is_interrupt=False)
404
+ self._mooning_spinner = Spinner("moon", "")
405
+ self.refresh_soon()
406
+ return
407
+
408
+ if self._mooning_spinner is not None:
409
+ # any message other than StepBegin should end the mooning state
410
+ self._mooning_spinner = None
411
+ self.refresh_soon()
412
+
413
+ match msg:
414
+ case TurnBegin():
415
+ self.flush_content()
416
+ console.print(
417
+ Panel(
418
+ Text(message_stringify(Message(role="user", content=msg.user_input))),
419
+ padding=(0, 1),
420
+ )
421
+ )
422
+ case CompactionBegin():
423
+ self._compacting_spinner = Spinner("balloon", "Compacting...")
424
+ self.refresh_soon()
425
+ case CompactionEnd():
426
+ self._compacting_spinner = None
427
+ self.refresh_soon()
428
+ case StatusUpdate():
429
+ self._status_block.update(msg)
430
+ case ContentPart():
431
+ self.append_content(msg)
432
+ case ToolCall():
433
+ self.append_tool_call(msg)
434
+ case ToolCallPart():
435
+ self.append_tool_call_part(msg)
436
+ case ToolResult():
437
+ self.append_tool_result(msg)
438
+ case SubagentEvent():
439
+ self.handle_subagent_event(msg)
440
+ case ApprovalRequestResolved():
441
+ # we don't need to handle this because the request is resolved on UI
442
+ pass
443
+ case ApprovalRequest():
444
+ self.request_approval(msg)
445
+
446
+ def dispatch_keyboard_event(self, event: KeyEvent) -> None:
447
+ # handle ESC key to cancel the run
448
+ if event == KeyEvent.ESCAPE and self._cancel_event is not None:
449
+ self._cancel_event.set()
450
+ return
451
+
452
+ if not self._current_approval_request_panel:
453
+ # just ignore any keyboard event when there's no approval request
454
+ return
455
+
456
+ match event:
457
+ case KeyEvent.UP:
458
+ self._current_approval_request_panel.move_up()
459
+ self.refresh_soon()
460
+ case KeyEvent.DOWN:
461
+ self._current_approval_request_panel.move_down()
462
+ self.refresh_soon()
463
+ case KeyEvent.ENTER:
464
+ resp = self._current_approval_request_panel.get_selected_response()
465
+ self._current_approval_request_panel.request.resolve(resp)
466
+ if resp == "approve_for_session":
467
+ to_remove_from_queue: list[ApprovalRequest] = []
468
+ for request in self._approval_request_queue:
469
+ # approve all queued requests with the same action
470
+ if request.action == self._current_approval_request_panel.request.action:
471
+ request.resolve("approve_for_session")
472
+ to_remove_from_queue.append(request)
473
+ for request in to_remove_from_queue:
474
+ self._approval_request_queue.remove(request)
475
+ elif resp == "reject":
476
+ # one rejection should stop the step immediately
477
+ while self._approval_request_queue:
478
+ self._approval_request_queue.popleft().resolve("reject")
479
+ self._reject_all_following = True
480
+ self.show_next_approval_request()
481
+ case _:
482
+ # just ignore any other keyboard event
483
+ return
484
+
485
+ def cleanup(self, is_interrupt: bool) -> None:
486
+ """Cleanup the live view on step end or interruption."""
487
+ self.flush_content()
488
+
489
+ for block in self._tool_call_blocks.values():
490
+ if not block.finished:
491
+ # this should not happen, but just in case
492
+ block.finish(
493
+ ToolError(message="", brief="Interrupted")
494
+ if is_interrupt
495
+ else ToolOk(output="")
496
+ )
497
+ self._last_tool_call_block = None
498
+ self.flush_finished_tool_calls()
499
+
500
+ while self._approval_request_queue:
501
+ # should not happen, but just in case
502
+ self._approval_request_queue.popleft().resolve("reject")
503
+ self._current_approval_request_panel = None
504
+ self._reject_all_following = False
505
+
506
+ def flush_content(self) -> None:
507
+ """Flush the current content block."""
508
+ if self._current_content_block is not None:
509
+ console.print(self._current_content_block.compose_final())
510
+ self._current_content_block = None
511
+ self.refresh_soon()
512
+
513
+ def flush_finished_tool_calls(self) -> None:
514
+ """Flush all leading finished tool call blocks."""
515
+ tool_call_ids = list(self._tool_call_blocks.keys())
516
+ for tool_call_id in tool_call_ids:
517
+ block = self._tool_call_blocks[tool_call_id]
518
+ if not block.finished:
519
+ break
520
+
521
+ self._tool_call_blocks.pop(tool_call_id)
522
+ console.print(block.compose())
523
+ if self._last_tool_call_block == block:
524
+ self._last_tool_call_block = None
525
+ self.refresh_soon()
526
+
527
+ def append_content(self, part: ContentPart) -> None:
528
+ match part:
529
+ case ThinkPart(think=text) | TextPart(text=text):
530
+ if not text:
531
+ return
532
+ is_think = isinstance(part, ThinkPart)
533
+ if self._current_content_block is None:
534
+ self._current_content_block = _ContentBlock(is_think)
535
+ self.refresh_soon()
536
+ elif self._current_content_block.is_think != is_think:
537
+ self.flush_content()
538
+ self._current_content_block = _ContentBlock(is_think)
539
+ self.refresh_soon()
540
+ self._current_content_block.append(text)
541
+ case _:
542
+ # TODO: support more content part types
543
+ pass
544
+
545
+ def append_tool_call(self, tool_call: ToolCall) -> None:
546
+ self.flush_content()
547
+ self._tool_call_blocks[tool_call.id] = _ToolCallBlock(tool_call)
548
+ self._last_tool_call_block = self._tool_call_blocks[tool_call.id]
549
+ self.refresh_soon()
550
+
551
+ def append_tool_call_part(self, part: ToolCallPart) -> None:
552
+ if not part.arguments_part:
553
+ return
554
+ if self._last_tool_call_block is None:
555
+ return
556
+ self._last_tool_call_block.append_args_part(part.arguments_part)
557
+ self.refresh_soon()
558
+
559
+ def append_tool_result(self, result: ToolResult) -> None:
560
+ if block := self._tool_call_blocks.get(result.tool_call_id):
561
+ block.finish(result.return_value)
562
+ self.flush_finished_tool_calls()
563
+ self.refresh_soon()
564
+
565
+ def request_approval(self, request: ApprovalRequest) -> None:
566
+ # If we're rejecting all following requests, reject immediately
567
+ if self._reject_all_following:
568
+ request.resolve("reject")
569
+ return
570
+
571
+ self._approval_request_queue.append(request)
572
+
573
+ if self._current_approval_request_panel is None:
574
+ console.bell()
575
+ self.show_next_approval_request()
576
+
577
+ def show_next_approval_request(self) -> None:
578
+ """
579
+ Show the next approval request from the queue.
580
+ If there are no pending requests, clear the current approval panel.
581
+ """
582
+ if not self._approval_request_queue:
583
+ if self._current_approval_request_panel is not None:
584
+ self._current_approval_request_panel = None
585
+ self.refresh_soon()
586
+ return
587
+
588
+ while self._approval_request_queue:
589
+ request = self._approval_request_queue.popleft()
590
+ if request.resolved:
591
+ # skip resolved requests
592
+ continue
593
+ self._current_approval_request_panel = _ApprovalRequestPanel(request)
594
+ self.refresh_soon()
108
595
  break
109
596
 
110
- assert isinstance(msg, StepBegin), "expect a StepBegin"
111
- # start a new step
597
+ def handle_subagent_event(self, event: SubagentEvent) -> None:
598
+ block = self._tool_call_blocks.get(event.task_tool_call_id)
599
+ if block is None:
600
+ return
601
+
602
+ match event.event:
603
+ case ToolCall() as tool_call:
604
+ block.append_sub_tool_call(tool_call)
605
+ case ToolCallPart() as tool_call_part:
606
+ block.append_sub_tool_call_part(tool_call_part)
607
+ case ToolResult() as tool_result:
608
+ block.finish_sub_tool_call(tool_result)
609
+ self.refresh_soon()
610
+ case _:
611
+ # ignore other events for now
612
+ # TODO: may need to handle multi-level nested subagents
613
+ pass