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.
- kimi_cli/CHANGELOG.md +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -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 +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/ui/shell/visualize.py
CHANGED
|
@@ -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
|
-
|
|
5
|
-
from kosong.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
76
|
+
def compose(self) -> RenderableType:
|
|
77
|
+
return self._spinner
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if isinstance(msg,
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|