kimi-cli 0.35__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 +304 -0
- kimi_cli/__init__.py +374 -0
- kimi_cli/agent.py +261 -0
- kimi_cli/agents/koder/README.md +3 -0
- kimi_cli/agents/koder/agent.yaml +24 -0
- kimi_cli/agents/koder/sub.yaml +11 -0
- kimi_cli/agents/koder/system.md +72 -0
- kimi_cli/config.py +138 -0
- kimi_cli/llm.py +8 -0
- kimi_cli/metadata.py +117 -0
- kimi_cli/prompts/metacmds/__init__.py +4 -0
- kimi_cli/prompts/metacmds/compact.md +74 -0
- kimi_cli/prompts/metacmds/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/share.py +8 -0
- kimi_cli/soul/__init__.py +59 -0
- kimi_cli/soul/approval.py +69 -0
- kimi_cli/soul/context.py +142 -0
- kimi_cli/soul/denwarenji.py +37 -0
- kimi_cli/soul/kimisoul.py +248 -0
- kimi_cli/soul/message.py +76 -0
- kimi_cli/soul/toolset.py +25 -0
- kimi_cli/soul/wire.py +101 -0
- kimi_cli/tools/__init__.py +85 -0
- kimi_cli/tools/bash/__init__.py +97 -0
- kimi_cli/tools/bash/bash.md +31 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +15 -0
- kimi_cli/tools/file/__init__.py +21 -0
- kimi_cli/tools/file/glob.md +17 -0
- kimi_cli/tools/file/glob.py +149 -0
- kimi_cli/tools/file/grep.md +5 -0
- kimi_cli/tools/file/grep.py +285 -0
- kimi_cli/tools/file/patch.md +8 -0
- kimi_cli/tools/file/patch.py +131 -0
- kimi_cli/tools/file/read.md +14 -0
- kimi_cli/tools/file/read.py +139 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +132 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +107 -0
- kimi_cli/tools/mcp.py +85 -0
- kimi_cli/tools/task/__init__.py +156 -0
- kimi_cli/tools/task/task.md +26 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +27 -0
- kimi_cli/tools/todo/set_todo_list.md +15 -0
- kimi_cli/tools/utils.py +150 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +94 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +126 -0
- kimi_cli/ui/__init__.py +68 -0
- kimi_cli/ui/acp/__init__.py +441 -0
- kimi_cli/ui/print/__init__.py +176 -0
- kimi_cli/ui/shell/__init__.py +326 -0
- kimi_cli/ui/shell/console.py +3 -0
- kimi_cli/ui/shell/liveview.py +158 -0
- kimi_cli/ui/shell/metacmd.py +309 -0
- kimi_cli/ui/shell/prompt.py +574 -0
- kimi_cli/ui/shell/setup.py +192 -0
- kimi_cli/ui/shell/update.py +204 -0
- kimi_cli/utils/changelog.py +101 -0
- kimi_cli/utils/logging.py +18 -0
- kimi_cli/utils/message.py +8 -0
- kimi_cli/utils/path.py +23 -0
- kimi_cli/utils/provider.py +64 -0
- kimi_cli/utils/pyinstaller.py +24 -0
- kimi_cli/utils/string.py +12 -0
- kimi_cli-0.35.dist-info/METADATA +24 -0
- kimi_cli-0.35.dist-info/RECORD +76 -0
- kimi_cli-0.35.dist-info/WHEEL +4 -0
- kimi_cli-0.35.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import signal
|
|
3
|
+
from collections.abc import Awaitable, Coroutine
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from kosong.base.message import ContentPart, TextPart, ToolCall, ToolCallPart
|
|
7
|
+
from kosong.chat_provider import APIStatusError, ChatProviderError
|
|
8
|
+
from kosong.tooling import ToolResult
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul
|
|
15
|
+
from kimi_cli.soul.kimisoul import KimiSoul
|
|
16
|
+
from kimi_cli.soul.wire import (
|
|
17
|
+
ApprovalRequest,
|
|
18
|
+
ApprovalResponse,
|
|
19
|
+
StatusUpdate,
|
|
20
|
+
StepBegin,
|
|
21
|
+
StepInterrupted,
|
|
22
|
+
Wire,
|
|
23
|
+
)
|
|
24
|
+
from kimi_cli.ui import RunCancelled, run_soul
|
|
25
|
+
from kimi_cli.ui.shell.console import console
|
|
26
|
+
from kimi_cli.ui.shell.liveview import StepLiveView
|
|
27
|
+
from kimi_cli.ui.shell.metacmd import get_meta_command
|
|
28
|
+
from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, toast
|
|
29
|
+
from kimi_cli.ui.shell.update import UpdateResult, do_update
|
|
30
|
+
from kimi_cli.utils.logging import logger
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Reload(Exception):
|
|
34
|
+
"""Reload configuration."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ShellApp:
|
|
40
|
+
def __init__(self, soul: Soul, welcome_info: dict[str, str] | None = None):
|
|
41
|
+
self.soul = soul
|
|
42
|
+
self.welcome_info = welcome_info or {}
|
|
43
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
44
|
+
|
|
45
|
+
async def run(self, command: str | None = None) -> bool:
|
|
46
|
+
if command is not None:
|
|
47
|
+
# run single command and exit
|
|
48
|
+
logger.info("Running agent with command: {command}", command=command)
|
|
49
|
+
return await self._run(command)
|
|
50
|
+
|
|
51
|
+
self._start_auto_update_task()
|
|
52
|
+
|
|
53
|
+
_print_welcome_info(self.soul.name or "Kimi CLI", self.soul.model, self.welcome_info)
|
|
54
|
+
|
|
55
|
+
with CustomPromptSession(lambda: self.soul.status) as prompt_session:
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
user_input = await prompt_session.prompt()
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
logger.debug("Exiting by KeyboardInterrupt")
|
|
61
|
+
console.print("[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]")
|
|
62
|
+
continue
|
|
63
|
+
except EOFError:
|
|
64
|
+
logger.debug("Exiting by EOF")
|
|
65
|
+
console.print("Bye!")
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
if not user_input:
|
|
69
|
+
logger.debug("Got empty input, skipping")
|
|
70
|
+
continue
|
|
71
|
+
logger.debug("Got user input: {user_input}", user_input=user_input)
|
|
72
|
+
|
|
73
|
+
if user_input.command in ["exit", "quit", "/exit", "/quit"]:
|
|
74
|
+
logger.debug("Exiting by meta command")
|
|
75
|
+
console.print("Bye!")
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
if user_input.mode == PromptMode.SHELL:
|
|
79
|
+
await self._run_shell_command(user_input.command)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
command = user_input.command
|
|
83
|
+
if command.startswith("/"):
|
|
84
|
+
logger.debug("Running meta command: {command}", command=command)
|
|
85
|
+
await self._run_meta_command(command[1:])
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
logger.info("Running agent command: {command}", command=command)
|
|
89
|
+
await self._run(command)
|
|
90
|
+
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
async def _run_shell_command(self, command: str) -> None:
|
|
94
|
+
"""Run a shell command in foreground."""
|
|
95
|
+
if not command.strip():
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
logger.info("Running shell command: {cmd}", cmd=command)
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
try:
|
|
101
|
+
# TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
|
|
102
|
+
# Later we should consider making this behave like a real shell.
|
|
103
|
+
proc = await asyncio.create_subprocess_shell(command)
|
|
104
|
+
|
|
105
|
+
def _handler():
|
|
106
|
+
logger.debug("SIGINT received.")
|
|
107
|
+
proc.terminate()
|
|
108
|
+
|
|
109
|
+
loop.add_signal_handler(signal.SIGINT, _handler)
|
|
110
|
+
|
|
111
|
+
await proc.wait()
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.exception("Failed to run shell command:")
|
|
114
|
+
console.print(f"[red]Failed to run shell command: {e}[/red]")
|
|
115
|
+
finally:
|
|
116
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
117
|
+
|
|
118
|
+
async def _run(self, command: str) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Run the soul and handle any known exceptions.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
bool: Whether the run is successful.
|
|
124
|
+
"""
|
|
125
|
+
cancel_event = asyncio.Event()
|
|
126
|
+
|
|
127
|
+
def _handler():
|
|
128
|
+
logger.debug("SIGINT received.")
|
|
129
|
+
cancel_event.set()
|
|
130
|
+
|
|
131
|
+
loop = asyncio.get_running_loop()
|
|
132
|
+
loop.add_signal_handler(signal.SIGINT, _handler)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
await run_soul(self.soul, command, self._visualize, cancel_event)
|
|
136
|
+
return True
|
|
137
|
+
except LLMNotSet:
|
|
138
|
+
logger.error("LLM not set")
|
|
139
|
+
console.print("[red]LLM not set, send /setup to configure[/red]")
|
|
140
|
+
except ChatProviderError as e:
|
|
141
|
+
logger.exception("LLM provider error:")
|
|
142
|
+
if isinstance(e, APIStatusError) and e.status_code == 401:
|
|
143
|
+
console.print("[red]Authorization failed, please check your API key[/red]")
|
|
144
|
+
elif isinstance(e, APIStatusError) and e.status_code == 402:
|
|
145
|
+
console.print("[red]Membership expired, please renew your plan[/red]")
|
|
146
|
+
elif isinstance(e, APIStatusError) and e.status_code == 403:
|
|
147
|
+
console.print("[red]Quota exceeded, please upgrade your plan or retry later[/red]")
|
|
148
|
+
else:
|
|
149
|
+
console.print(f"[red]LLM provider error: {e}[/red]")
|
|
150
|
+
except MaxStepsReached as e:
|
|
151
|
+
logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
|
|
152
|
+
console.print(f"[yellow]Max steps reached: {e.n_steps}[/yellow]")
|
|
153
|
+
except RunCancelled:
|
|
154
|
+
logger.info("Cancelled by user")
|
|
155
|
+
console.print("[red]Interrupted by user[/red]")
|
|
156
|
+
except Reload:
|
|
157
|
+
# just propagate
|
|
158
|
+
raise
|
|
159
|
+
except BaseException as e:
|
|
160
|
+
logger.exception("Unknown error:")
|
|
161
|
+
console.print(f"[red]Unknown error: {e}[/red]")
|
|
162
|
+
raise # re-raise unknown error
|
|
163
|
+
finally:
|
|
164
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
def _start_auto_update_task(self) -> None:
|
|
168
|
+
self._add_background_task(self._auto_update_background())
|
|
169
|
+
|
|
170
|
+
async def _auto_update_background(self) -> None:
|
|
171
|
+
toast("checking for updates...", duration=2.0)
|
|
172
|
+
result = await do_update(print=False, check_only=True)
|
|
173
|
+
if result == UpdateResult.UPDATE_AVAILABLE:
|
|
174
|
+
while True:
|
|
175
|
+
toast("new version found, run `uv tool upgrade ikimi` to upgrade", duration=30.0)
|
|
176
|
+
await asyncio.sleep(60.0)
|
|
177
|
+
elif result == UpdateResult.UPDATED:
|
|
178
|
+
toast("auto updated, restart to use the new version", duration=5.0)
|
|
179
|
+
|
|
180
|
+
def _add_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
|
|
181
|
+
task = asyncio.create_task(coro)
|
|
182
|
+
self._background_tasks.add(task)
|
|
183
|
+
|
|
184
|
+
def _cleanup(t: asyncio.Task[Any]) -> None:
|
|
185
|
+
self._background_tasks.discard(t)
|
|
186
|
+
try:
|
|
187
|
+
t.result()
|
|
188
|
+
except asyncio.CancelledError:
|
|
189
|
+
pass
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.exception("Background task failed:")
|
|
192
|
+
|
|
193
|
+
task.add_done_callback(_cleanup)
|
|
194
|
+
return task
|
|
195
|
+
|
|
196
|
+
async def _visualize(self, wire: Wire):
|
|
197
|
+
"""
|
|
198
|
+
A loop to consume agent events and visualize the agent behavior.
|
|
199
|
+
This loop never raise any exception except asyncio.CancelledError.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
# expect a StepBegin
|
|
203
|
+
assert isinstance(await wire.receive(), StepBegin)
|
|
204
|
+
|
|
205
|
+
while True:
|
|
206
|
+
# spin the moon at the beginning of each step
|
|
207
|
+
with console.status("", spinner="moon"):
|
|
208
|
+
msg = await wire.receive()
|
|
209
|
+
|
|
210
|
+
with StepLiveView(self.soul.status) as step:
|
|
211
|
+
# visualization loop for one step
|
|
212
|
+
while True:
|
|
213
|
+
match msg:
|
|
214
|
+
case TextPart(text=text):
|
|
215
|
+
step.append_text(text)
|
|
216
|
+
case ContentPart():
|
|
217
|
+
# TODO: support more content parts
|
|
218
|
+
step.append_text(f"[{msg.__class__.__name__}]")
|
|
219
|
+
case ToolCall():
|
|
220
|
+
step.append_tool_call(msg)
|
|
221
|
+
case ToolCallPart():
|
|
222
|
+
step.append_tool_call_part(msg)
|
|
223
|
+
case ToolResult():
|
|
224
|
+
step.append_tool_result(msg)
|
|
225
|
+
case ApprovalRequest():
|
|
226
|
+
msg.resolve(ApprovalResponse.APPROVE)
|
|
227
|
+
# TODO(approval): handle approval request
|
|
228
|
+
case StatusUpdate(status=status):
|
|
229
|
+
step.update_status(status)
|
|
230
|
+
case _:
|
|
231
|
+
break # break the step loop
|
|
232
|
+
msg = await wire.receive()
|
|
233
|
+
|
|
234
|
+
# cleanup the step live view
|
|
235
|
+
if isinstance(msg, StepInterrupted):
|
|
236
|
+
step.interrupt()
|
|
237
|
+
else:
|
|
238
|
+
step.finish()
|
|
239
|
+
|
|
240
|
+
if isinstance(msg, StepInterrupted):
|
|
241
|
+
# for StepInterrupted, the visualization loop should end immediately
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
assert isinstance(msg, StepBegin), "expect a StepBegin"
|
|
245
|
+
# start a new step
|
|
246
|
+
except asyncio.QueueShutDown:
|
|
247
|
+
logger.debug("Visualization loop shutting down")
|
|
248
|
+
|
|
249
|
+
async def _run_meta_command(self, command_str: str):
|
|
250
|
+
parts = command_str.split(" ")
|
|
251
|
+
command_name = parts[0]
|
|
252
|
+
command_args = parts[1:]
|
|
253
|
+
command = get_meta_command(command_name)
|
|
254
|
+
if command is None:
|
|
255
|
+
console.print(f"Meta command /{command_name} not found")
|
|
256
|
+
return
|
|
257
|
+
if command.kimi_soul_only and not isinstance(self.soul, KimiSoul):
|
|
258
|
+
console.print(f"Meta command /{command_name} not supported")
|
|
259
|
+
return
|
|
260
|
+
logger.debug(
|
|
261
|
+
"Running meta command: {command_name} with args: {command_args}",
|
|
262
|
+
command_name=command_name,
|
|
263
|
+
command_args=command_args,
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
ret = command.func(self, command_args)
|
|
267
|
+
if isinstance(ret, Awaitable):
|
|
268
|
+
await ret
|
|
269
|
+
except LLMNotSet:
|
|
270
|
+
logger.error("LLM not set")
|
|
271
|
+
console.print("[red]LLM not set, send /setup to configure[/red]")
|
|
272
|
+
except ChatProviderError as e:
|
|
273
|
+
logger.exception("LLM provider error:")
|
|
274
|
+
console.print(f"[red]LLM provider error: {e}[/red]")
|
|
275
|
+
except Reload:
|
|
276
|
+
# just propagate
|
|
277
|
+
raise
|
|
278
|
+
except BaseException as e:
|
|
279
|
+
logger.exception("Unknown error:")
|
|
280
|
+
console.print(f"[red]Unknown error: {e}[/red]")
|
|
281
|
+
raise # re-raise unknown error
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
_KIMI_BLUE = "dodger_blue1"
|
|
285
|
+
_LOGO = f"""\
|
|
286
|
+
[{_KIMI_BLUE}]\
|
|
287
|
+
▐█▛█▛█▌
|
|
288
|
+
▐█████▌\
|
|
289
|
+
[{_KIMI_BLUE}]\
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _print_welcome_info(name: str, model: str, info_items: dict[str, str]) -> None:
|
|
294
|
+
head = Text.from_markup(f"[bold]Welcome to {name}![/bold]")
|
|
295
|
+
help_text = Text.from_markup("[grey50]Send /help for help information.[/grey50]")
|
|
296
|
+
|
|
297
|
+
# Use Table for precise width control
|
|
298
|
+
logo = Text.from_markup(_LOGO)
|
|
299
|
+
table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False)
|
|
300
|
+
table.add_column(justify="left")
|
|
301
|
+
table.add_column(justify="left")
|
|
302
|
+
table.add_row(logo, Group(head, help_text))
|
|
303
|
+
|
|
304
|
+
rows: list[RenderableType] = [table]
|
|
305
|
+
|
|
306
|
+
rows.append(Text("")) # Empty line
|
|
307
|
+
rows.extend(
|
|
308
|
+
Text.from_markup(f"[grey50]{key}: {value}[/grey50]") for key, value in info_items.items()
|
|
309
|
+
)
|
|
310
|
+
if model:
|
|
311
|
+
rows.append(Text.from_markup(f"[grey50]Model: {model}[/grey50]"))
|
|
312
|
+
else:
|
|
313
|
+
rows.append(
|
|
314
|
+
Text.from_markup(
|
|
315
|
+
"[grey50]Model:[/grey50] [yellow]not set, send /setup to configure[/yellow]"
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
console.print(
|
|
320
|
+
Panel(
|
|
321
|
+
Group(*rows),
|
|
322
|
+
border_style=_KIMI_BLUE,
|
|
323
|
+
expand=False,
|
|
324
|
+
padding=(1, 2),
|
|
325
|
+
)
|
|
326
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import streamingjson
|
|
2
|
+
from kosong.base.message import ToolCall, ToolCallPart
|
|
3
|
+
from kosong.tooling import ToolError, ToolOk, ToolResult, ToolReturnType
|
|
4
|
+
from rich.console import Group, RenderableType
|
|
5
|
+
from rich.live import Live
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
from rich.spinner import Spinner
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from kimi_cli.soul import StatusSnapshot
|
|
11
|
+
from kimi_cli.tools import extract_subtitle
|
|
12
|
+
from kimi_cli.ui.shell.console import console
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _ToolCallDisplay:
|
|
16
|
+
def __init__(self, tool_call: ToolCall):
|
|
17
|
+
self._tool_name = tool_call.function.name
|
|
18
|
+
self._lexer = streamingjson.Lexer()
|
|
19
|
+
if tool_call.function.arguments is not None:
|
|
20
|
+
self._lexer.append_string(tool_call.function.arguments)
|
|
21
|
+
|
|
22
|
+
self._title_markup = f"Using [blue]{self._tool_name}[/blue]"
|
|
23
|
+
self._subtitle = extract_subtitle(self._lexer, self._tool_name)
|
|
24
|
+
self._finished = False
|
|
25
|
+
self._spinner = Spinner("dots", text=self._spinner_markup)
|
|
26
|
+
self.renderable: RenderableType = Group(self._spinner)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def finished(self) -> bool:
|
|
30
|
+
return self._finished
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def _spinner_markup(self) -> str:
|
|
34
|
+
return self._title_markup + self._subtitle_markup
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def _subtitle_markup(self) -> str:
|
|
38
|
+
subtitle = self._subtitle
|
|
39
|
+
return f"[grey50]: {escape(subtitle)}[/grey50]" if subtitle else ""
|
|
40
|
+
|
|
41
|
+
def append_args_part(self, args_part: str):
|
|
42
|
+
if self.finished:
|
|
43
|
+
return
|
|
44
|
+
self._lexer.append_string(args_part)
|
|
45
|
+
# TODO: don't extract detail if it's already stable
|
|
46
|
+
new_subtitle = extract_subtitle(self._lexer, self._tool_name)
|
|
47
|
+
if new_subtitle and new_subtitle != self._subtitle:
|
|
48
|
+
self._subtitle = new_subtitle
|
|
49
|
+
self._spinner.update(text=self._spinner_markup)
|
|
50
|
+
|
|
51
|
+
def finish(self, result: ToolReturnType):
|
|
52
|
+
"""
|
|
53
|
+
Finish the live display of a tool call.
|
|
54
|
+
After calling this, the `renderable` property should be re-rendered.
|
|
55
|
+
"""
|
|
56
|
+
self._finished = True
|
|
57
|
+
sign = "[red]✗[/red]" if isinstance(result, ToolError) else "[green]✓[/green]"
|
|
58
|
+
lines = [
|
|
59
|
+
Text.from_markup(f"{sign} Used [blue]{self._tool_name}[/blue]" + self._subtitle_markup)
|
|
60
|
+
]
|
|
61
|
+
if result.brief:
|
|
62
|
+
lines.append(
|
|
63
|
+
Text.from_markup(
|
|
64
|
+
f" {result.brief}", style="grey50" if isinstance(result, ToolOk) else "red"
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
self.renderable = Group(*lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StepLiveView:
|
|
71
|
+
def __init__(self, status: StatusSnapshot):
|
|
72
|
+
self._line_buffer = Text("")
|
|
73
|
+
self._tool_calls: dict[str, _ToolCallDisplay] = {}
|
|
74
|
+
self._last_tool_call: _ToolCallDisplay | None = None
|
|
75
|
+
self._status_text: Text | None = Text(
|
|
76
|
+
self._format_status(status), style="grey50", justify="right"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def __enter__(self):
|
|
80
|
+
self._live = Live(
|
|
81
|
+
self._compose(),
|
|
82
|
+
console=console,
|
|
83
|
+
refresh_per_second=4,
|
|
84
|
+
transient=False, # leave the last frame on the screen
|
|
85
|
+
vertical_overflow="visible",
|
|
86
|
+
)
|
|
87
|
+
self._live.__enter__()
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
91
|
+
self._live.__exit__(exc_type, exc_value, traceback)
|
|
92
|
+
|
|
93
|
+
def _compose(self) -> RenderableType:
|
|
94
|
+
sections = []
|
|
95
|
+
if self._line_buffer:
|
|
96
|
+
sections.append(self._line_buffer)
|
|
97
|
+
for view in self._tool_calls.values():
|
|
98
|
+
sections.append(view.renderable)
|
|
99
|
+
if self._status_text:
|
|
100
|
+
sections.append(self._status_text)
|
|
101
|
+
return Group(*sections)
|
|
102
|
+
|
|
103
|
+
def _push_out(self, text: Text | str):
|
|
104
|
+
"""
|
|
105
|
+
Push the text out of the live view to the console.
|
|
106
|
+
After this, the printed line will not be changed further.
|
|
107
|
+
"""
|
|
108
|
+
console.print(text)
|
|
109
|
+
|
|
110
|
+
def append_text(self, text: str):
|
|
111
|
+
lines = text.split("\n")
|
|
112
|
+
prev_is_empty = not self._line_buffer
|
|
113
|
+
for line in lines[:-1]:
|
|
114
|
+
self._push_out(self._line_buffer + line)
|
|
115
|
+
self._line_buffer.plain = ""
|
|
116
|
+
self._line_buffer.append(lines[-1])
|
|
117
|
+
if (prev_is_empty and self._line_buffer) or (not prev_is_empty and not self._line_buffer):
|
|
118
|
+
self._live.update(self._compose())
|
|
119
|
+
|
|
120
|
+
def append_tool_call(self, tool_call: ToolCall):
|
|
121
|
+
self._tool_calls[tool_call.id] = _ToolCallDisplay(tool_call)
|
|
122
|
+
self._last_tool_call = self._tool_calls[tool_call.id]
|
|
123
|
+
self._live.update(self._compose())
|
|
124
|
+
|
|
125
|
+
def append_tool_call_part(self, tool_call_part: ToolCallPart):
|
|
126
|
+
if not tool_call_part.arguments_part:
|
|
127
|
+
return
|
|
128
|
+
if self._last_tool_call is None:
|
|
129
|
+
return
|
|
130
|
+
self._last_tool_call.append_args_part(tool_call_part.arguments_part)
|
|
131
|
+
|
|
132
|
+
def append_tool_result(self, tool_result: ToolResult):
|
|
133
|
+
if view := self._tool_calls.get(tool_result.tool_call_id):
|
|
134
|
+
view.finish(tool_result.result)
|
|
135
|
+
self._live.update(self._compose())
|
|
136
|
+
|
|
137
|
+
def update_status(self, status: StatusSnapshot):
|
|
138
|
+
if self._status_text is None:
|
|
139
|
+
return
|
|
140
|
+
self._status_text.plain = self._format_status(status)
|
|
141
|
+
|
|
142
|
+
def finish(self):
|
|
143
|
+
for view in self._tool_calls.values():
|
|
144
|
+
if not view.finished:
|
|
145
|
+
# this should not happen, but just in case
|
|
146
|
+
view.finish(ToolOk(output=""))
|
|
147
|
+
self._live.update(self._compose())
|
|
148
|
+
|
|
149
|
+
def interrupt(self):
|
|
150
|
+
for view in self._tool_calls.values():
|
|
151
|
+
if not view.finished:
|
|
152
|
+
view.finish(ToolError(message="", brief="Interrupted"))
|
|
153
|
+
self._live.update(self._compose())
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _format_status(status: StatusSnapshot) -> str:
|
|
157
|
+
bounded = max(0.0, min(status.context_usage, 1.0))
|
|
158
|
+
return f"context: {bounded:.1%}"
|