monkeybot-cli 0.2.1__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.
- monkeybot_cli/__init__.py +3 -0
- monkeybot_cli/chat_renderer.py +87 -0
- monkeybot_cli/chat_session.py +911 -0
- monkeybot_cli/chat_status_bar.py +205 -0
- monkeybot_cli/chat_theme.py +91 -0
- monkeybot_cli/chat_tool_display.py +334 -0
- monkeybot_cli/chat_tui.py +1491 -0
- monkeybot_cli/chat_tui_widgets.py +996 -0
- monkeybot_cli/commands/__init__.py +1 -0
- monkeybot_cli/commands/chat.py +817 -0
- monkeybot_cli/commands/doctor.py +293 -0
- monkeybot_cli/commands/loop.py +207 -0
- monkeybot_cli/commands/new.py +207 -0
- monkeybot_cli/commands/run_cmd.py +41 -0
- monkeybot_cli/commands/talk.py +102 -0
- monkeybot_cli/commands/validate.py +385 -0
- monkeybot_cli/compat.py +7 -0
- monkeybot_cli/config_resolve.py +55 -0
- monkeybot_cli/exit_commands.py +13 -0
- monkeybot_cli/extras_catalog.py +95 -0
- monkeybot_cli/gateway_health.py +34 -0
- monkeybot_cli/main.py +38 -0
- monkeybot_cli/opensandbox_lifecycle.py +314 -0
- monkeybot_cli/output.py +110 -0
- monkeybot_cli/providers.py +112 -0
- monkeybot_cli/realtime/__init__.py +13 -0
- monkeybot_cli/realtime/audio_io.py +147 -0
- monkeybot_cli/realtime/client.py +17 -0
- monkeybot_cli/realtime/gateway_manager.py +142 -0
- monkeybot_cli/realtime/push_to_talk.py +128 -0
- monkeybot_cli/realtime/session.py +256 -0
- monkeybot_cli/realtime/session_controller.py +501 -0
- monkeybot_cli/realtime/talk_ui.py +243 -0
- monkeybot_cli/realtime/wire_encode.py +39 -0
- monkeybot_cli/runtime_python.py +91 -0
- monkeybot_cli/scaffold.py +287 -0
- monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
- monkeybot_cli/scaffold_defaults/__init__.py +1 -0
- monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
- monkeybot_cli/scaffold_defaults/env.example +35 -0
- monkeybot_cli/scaffold_defaults/mcp.json +49 -0
- monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
- monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
- monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
- monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
- monkeybot_cli/session_controller.py +7 -0
- monkeybot_cli/terminal_markdown.py +48 -0
- monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
- monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
- monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
- monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"""monkeybot chat — gateway client (Textual TUI or plain fallback)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Coroutine
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, NamedTuple, TextIO
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from monkeybot_cli.chat_session import (
|
|
21
|
+
ChatSessionController,
|
|
22
|
+
ChatUiEvent,
|
|
23
|
+
HitlAnswer,
|
|
24
|
+
format_hitl_plain_prompt,
|
|
25
|
+
)
|
|
26
|
+
from monkeybot_cli.chat_status_bar import (
|
|
27
|
+
SessionUsageView,
|
|
28
|
+
format_context_ring,
|
|
29
|
+
format_context_ring_plain,
|
|
30
|
+
)
|
|
31
|
+
from monkeybot_cli.chat_tool_display import (
|
|
32
|
+
tool_display,
|
|
33
|
+
tool_spinner_prefix,
|
|
34
|
+
)
|
|
35
|
+
from monkeybot_cli.chat_tui import is_exit_command, run_chat_tui
|
|
36
|
+
from monkeybot_cli.config_resolve import (
|
|
37
|
+
load_agent_dotenv,
|
|
38
|
+
load_config_doc,
|
|
39
|
+
resolve_agent_root,
|
|
40
|
+
resolve_config,
|
|
41
|
+
)
|
|
42
|
+
from monkeybot_cli.gateway_health import wait_for_health as _wait_for_health
|
|
43
|
+
from monkeybot_cli.opensandbox_lifecycle import (
|
|
44
|
+
ensure_opensandbox_for_agent,
|
|
45
|
+
is_sandbox_enabled,
|
|
46
|
+
server_url_from_config,
|
|
47
|
+
)
|
|
48
|
+
from monkeybot_cli.runtime_python import DEFAULT_PORT, gateway_argv, resolve_runtime_python
|
|
49
|
+
from monkeybot_cli.terminal_markdown import MarkdownPlainStream
|
|
50
|
+
|
|
51
|
+
_DIM = "\x1b[2m"
|
|
52
|
+
_BOLD = "\x1b[1m"
|
|
53
|
+
_GREEN = "\x1b[32m"
|
|
54
|
+
_RED = "\x1b[31m"
|
|
55
|
+
_RESET = "\x1b[0m"
|
|
56
|
+
_USER_PROMPT = "🧑"
|
|
57
|
+
_ASSISTANT_PREFIX = "🐵 "
|
|
58
|
+
_CYAN = "\x1b[36m"
|
|
59
|
+
_UNDERLINE = "\x1b[4m"
|
|
60
|
+
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
61
|
+
_GROUNDING_SOURCES_MAX = 5
|
|
62
|
+
|
|
63
|
+
def _hyperlink(url: str, label: str) -> str:
|
|
64
|
+
"""OSC 8 terminal hyperlink; falls back to a plain URL on non-TTY streams."""
|
|
65
|
+
if not url:
|
|
66
|
+
return label
|
|
67
|
+
if not sys.stdout.isatty():
|
|
68
|
+
return f"{label} ({url})" if label and label != url else url
|
|
69
|
+
return f"\x1b]8;;{url}\x1b\\{label or url}\x1b]8;;\x1b\\"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _print_grounding(evt: Any) -> None:
|
|
73
|
+
if not getattr(evt, "sources", None) and not getattr(evt, "search_queries", None):
|
|
74
|
+
return
|
|
75
|
+
header = "grounded search"
|
|
76
|
+
queries = getattr(evt, "search_queries", None) or []
|
|
77
|
+
if queries:
|
|
78
|
+
header += " — " + ", ".join(f'"{q}"' for q in queries)
|
|
79
|
+
print(f"{_DIM} 🔎 {header}{_RESET}", flush=True)
|
|
80
|
+
sources = list(getattr(evt, "sources", None) or [])
|
|
81
|
+
for source in sources[:_GROUNDING_SOURCES_MAX]:
|
|
82
|
+
title = source.get("title", "").strip()
|
|
83
|
+
uri = source.get("uri", "").strip()
|
|
84
|
+
if not uri:
|
|
85
|
+
continue
|
|
86
|
+
link = _hyperlink(uri, title or uri)
|
|
87
|
+
print(f"{_DIM} {_CYAN}{_UNDERLINE}{link}{_RESET}", flush=True)
|
|
88
|
+
remaining = len(sources) - _GROUNDING_SOURCES_MAX
|
|
89
|
+
if remaining > 0:
|
|
90
|
+
print(f"{_DIM} … and {remaining} more{_RESET}", flush=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def use_textual_tui() -> bool:
|
|
94
|
+
if os.environ.get("MONKEYBOT_CHAT_PLAIN", "").strip().lower() in ("1", "true", "yes"):
|
|
95
|
+
return False
|
|
96
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _SpinnerLine:
|
|
100
|
+
def __init__(self, prefix: str) -> None:
|
|
101
|
+
self._prefix = prefix
|
|
102
|
+
self._stop = asyncio.Event()
|
|
103
|
+
self._task: asyncio.Task[None] | None = None
|
|
104
|
+
|
|
105
|
+
def start(self) -> None:
|
|
106
|
+
self._task = asyncio.create_task(self._run())
|
|
107
|
+
|
|
108
|
+
async def clear(self) -> None:
|
|
109
|
+
self._stop.set()
|
|
110
|
+
if self._task is not None:
|
|
111
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
112
|
+
await self._task
|
|
113
|
+
sys.stdout.write("\r\x1b[2K")
|
|
114
|
+
sys.stdout.flush()
|
|
115
|
+
|
|
116
|
+
async def finish(self, line: str) -> None:
|
|
117
|
+
await self.clear()
|
|
118
|
+
sys.stdout.write(f"{line}\n")
|
|
119
|
+
sys.stdout.flush()
|
|
120
|
+
|
|
121
|
+
async def _run(self) -> None:
|
|
122
|
+
i = 0
|
|
123
|
+
while not self._stop.is_set():
|
|
124
|
+
frame = _SPINNER_FRAMES[i % len(_SPINNER_FRAMES)]
|
|
125
|
+
sys.stdout.write(f"\r{_DIM} {frame} {self._prefix}{_RESET}")
|
|
126
|
+
sys.stdout.flush()
|
|
127
|
+
i += 1
|
|
128
|
+
try:
|
|
129
|
+
await asyncio.wait_for(self._stop.wait(), timeout=0.08)
|
|
130
|
+
except TimeoutError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _TurnActivity:
|
|
135
|
+
"""Animated status lines for tools (plain path + unit tests)."""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
self._line: _SpinnerLine | None = None
|
|
139
|
+
self._active_display = ""
|
|
140
|
+
|
|
141
|
+
async def _start(self, prefix: str) -> None:
|
|
142
|
+
if self._line is not None:
|
|
143
|
+
await self._line.clear()
|
|
144
|
+
self._line = _SpinnerLine(prefix)
|
|
145
|
+
self._line.start()
|
|
146
|
+
|
|
147
|
+
async def tool_started(
|
|
148
|
+
self,
|
|
149
|
+
tool: str,
|
|
150
|
+
label: str | None = None,
|
|
151
|
+
args: dict[str, object] | None = None,
|
|
152
|
+
*,
|
|
153
|
+
display: str | None = None,
|
|
154
|
+
prefix: str | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
# Compat: tests call tool_started(tool, label, args)
|
|
157
|
+
if display is None and label is not None and args is not None:
|
|
158
|
+
display = tool_display(tool, label, args)
|
|
159
|
+
prefix = tool_spinner_prefix(tool, label, args)
|
|
160
|
+
self._active_display = display or tool
|
|
161
|
+
await self._start(prefix or display or tool)
|
|
162
|
+
|
|
163
|
+
async def tool_finished(
|
|
164
|
+
self,
|
|
165
|
+
tool: str | None = None,
|
|
166
|
+
*,
|
|
167
|
+
error: str | None = None,
|
|
168
|
+
verbose: bool = False,
|
|
169
|
+
result: str = "",
|
|
170
|
+
) -> None:
|
|
171
|
+
display = self._active_display or tool or "tool"
|
|
172
|
+
if error:
|
|
173
|
+
err = " ".join(error.split())
|
|
174
|
+
line = f"{_DIM} {_RED}✗{_RESET}{_DIM} {display} — {err}{_RESET}"
|
|
175
|
+
else:
|
|
176
|
+
line = f"{_DIM} {_GREEN}✓{_RESET}{_DIM} {display}{_RESET}"
|
|
177
|
+
self._active_display = ""
|
|
178
|
+
if self._line is not None:
|
|
179
|
+
await self._line.finish(line)
|
|
180
|
+
self._line = None
|
|
181
|
+
else:
|
|
182
|
+
sys.stdout.write(f"{line}\n")
|
|
183
|
+
sys.stdout.flush()
|
|
184
|
+
if verbose and (error or result):
|
|
185
|
+
print(f"{_DIM} {error or result}{_RESET}")
|
|
186
|
+
|
|
187
|
+
async def summarizing(self, tokens: int) -> None:
|
|
188
|
+
await self._start(f"summarizing context ({tokens:,} tokens)")
|
|
189
|
+
|
|
190
|
+
async def summarized(self, turns: int) -> None:
|
|
191
|
+
noun = "turn" if turns == 1 else "turns"
|
|
192
|
+
line = f"{_DIM} {_GREEN}✓{_RESET}{_DIM} summarized {turns} {noun}{_RESET}"
|
|
193
|
+
if self._line is not None:
|
|
194
|
+
await self._line.finish(line)
|
|
195
|
+
self._line = None
|
|
196
|
+
else:
|
|
197
|
+
sys.stdout.write(f"{line}\n")
|
|
198
|
+
sys.stdout.flush()
|
|
199
|
+
|
|
200
|
+
async def cancel(self) -> None:
|
|
201
|
+
if self._line is not None:
|
|
202
|
+
await self._line.clear()
|
|
203
|
+
self._line = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _finish_assistant_turn(
|
|
207
|
+
*,
|
|
208
|
+
md_stream: MarkdownPlainStream,
|
|
209
|
+
assistant_label_shown: bool,
|
|
210
|
+
show_usage: bool,
|
|
211
|
+
usage: Any | None = None,
|
|
212
|
+
error: str | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
if assistant_label_shown:
|
|
215
|
+
tail = md_stream.flush()
|
|
216
|
+
if tail:
|
|
217
|
+
print(tail, end="", flush=True)
|
|
218
|
+
if error:
|
|
219
|
+
print(f"\n{_RED}Error: {error}{_RESET}", flush=True)
|
|
220
|
+
elif assistant_label_shown or error:
|
|
221
|
+
print()
|
|
222
|
+
if show_usage and usage is not None:
|
|
223
|
+
print(
|
|
224
|
+
f"{_DIM}[usage] in={usage.get('input_tokens')} out={usage.get('output_tokens')} "
|
|
225
|
+
f"cost=${usage.get('cost_usd'):.4f} {usage.get('duration_ms')}ms{_RESET}"
|
|
226
|
+
)
|
|
227
|
+
print()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _read_line(prompt: str, interrupt: asyncio.Event) -> str | None:
|
|
231
|
+
if interrupt.is_set():
|
|
232
|
+
return None
|
|
233
|
+
loop = asyncio.get_running_loop()
|
|
234
|
+
future: asyncio.Future[str] = loop.create_future()
|
|
235
|
+
|
|
236
|
+
def _read() -> None:
|
|
237
|
+
try:
|
|
238
|
+
line = input(prompt)
|
|
239
|
+
loop.call_soon_threadsafe(future.set_result, line)
|
|
240
|
+
except EOFError:
|
|
241
|
+
loop.call_soon_threadsafe(future.set_result, "")
|
|
242
|
+
|
|
243
|
+
threading.Thread(target=_read, daemon=True).start()
|
|
244
|
+
interrupt_waiter = asyncio.create_task(interrupt.wait())
|
|
245
|
+
try:
|
|
246
|
+
done, pending = await asyncio.wait(
|
|
247
|
+
[future, interrupt_waiter],
|
|
248
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
249
|
+
)
|
|
250
|
+
for task in pending:
|
|
251
|
+
task.cancel()
|
|
252
|
+
if interrupt_waiter in done:
|
|
253
|
+
return None
|
|
254
|
+
return future.result()
|
|
255
|
+
except asyncio.CancelledError:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _port_from_config(config_path: Path | None) -> int:
|
|
260
|
+
if config_path is None:
|
|
261
|
+
return DEFAULT_PORT
|
|
262
|
+
_, doc = load_config_doc(str(config_path))
|
|
263
|
+
runtime = doc.get("runtime") if isinstance(doc.get("runtime"), dict) else {}
|
|
264
|
+
try:
|
|
265
|
+
return int(runtime.get("port", DEFAULT_PORT))
|
|
266
|
+
except (TypeError, ValueError):
|
|
267
|
+
return DEFAULT_PORT
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _resolve_base_url(args: argparse.Namespace, config_path: Path | None) -> str:
|
|
271
|
+
if args.url:
|
|
272
|
+
return args.url.rstrip("/")
|
|
273
|
+
port = args.port if args.port else _port_from_config(config_path)
|
|
274
|
+
return f"http://127.0.0.1:{port}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _model_banner_fields(
|
|
278
|
+
args: argparse.Namespace, config_path: Path | None
|
|
279
|
+
) -> tuple[str, str]:
|
|
280
|
+
provider = (args.model_provider or "").strip()
|
|
281
|
+
model = (args.model_name or "").strip()
|
|
282
|
+
if config_path is not None and (not provider or not model):
|
|
283
|
+
_, doc = load_config_doc(str(config_path))
|
|
284
|
+
model_cfg = doc.get("model") if isinstance(doc.get("model"), dict) else {}
|
|
285
|
+
if not provider:
|
|
286
|
+
provider = str(model_cfg.get("provider") or "").strip()
|
|
287
|
+
if not model:
|
|
288
|
+
model = str(model_cfg.get("name") or "").strip()
|
|
289
|
+
return provider or "?", model or "?"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _print_context_ring(usage: SessionUsageView) -> None:
|
|
293
|
+
"""Print a context-window ring line for the plain path."""
|
|
294
|
+
if sys.stdout.isatty():
|
|
295
|
+
ring = format_context_ring(
|
|
296
|
+
estimated_prompt_tokens=usage.estimated_prompt_tokens,
|
|
297
|
+
last_prompt_tokens=usage.last_prompt_tokens,
|
|
298
|
+
context_window_tokens=usage.context_window_tokens,
|
|
299
|
+
summarization_threshold_tokens=usage.summarization_threshold_tokens,
|
|
300
|
+
)
|
|
301
|
+
print(ring, flush=True)
|
|
302
|
+
else:
|
|
303
|
+
print(format_context_ring_plain(usage), flush=True)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _PlainRenderer:
|
|
307
|
+
"""Print-based sink for the non-TTY / CI path."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, *, animations_enabled: bool = True) -> None:
|
|
310
|
+
self.animations_enabled = animations_enabled
|
|
311
|
+
self.spinner = _SpinnerLine("thinking…")
|
|
312
|
+
self.activity = _TurnActivity()
|
|
313
|
+
self.md = MarkdownPlainStream()
|
|
314
|
+
self.assistant_open = False
|
|
315
|
+
self.hitl_prompt: str | None = None
|
|
316
|
+
self._pending_hitl: asyncio.Future[HitlAnswer] | None = None
|
|
317
|
+
self._last_usage: SessionUsageView | None = None
|
|
318
|
+
self._thinking_open = False
|
|
319
|
+
self._io_queue: asyncio.Queue[Coroutine[Any, Any, None] | None] = asyncio.Queue()
|
|
320
|
+
self._io_worker: asyncio.Task[None] | None = None
|
|
321
|
+
|
|
322
|
+
def start_io_worker(self) -> None:
|
|
323
|
+
self._io_worker = asyncio.create_task(self._drain_io())
|
|
324
|
+
|
|
325
|
+
async def stop_io_worker(self) -> None:
|
|
326
|
+
await self._io_queue.put(None)
|
|
327
|
+
if self._io_worker is not None:
|
|
328
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
329
|
+
await self._io_worker
|
|
330
|
+
self._io_worker = None
|
|
331
|
+
|
|
332
|
+
def _schedule(self, coro: Coroutine[Any, Any, None]) -> None:
|
|
333
|
+
self._io_queue.put_nowait(coro)
|
|
334
|
+
|
|
335
|
+
async def _drain_io(self) -> None:
|
|
336
|
+
while True:
|
|
337
|
+
item = await self._io_queue.get()
|
|
338
|
+
if item is None:
|
|
339
|
+
return
|
|
340
|
+
await item
|
|
341
|
+
|
|
342
|
+
def on_event(self, event: ChatUiEvent, controller: Any) -> None:
|
|
343
|
+
handler = getattr(self, f"_on_{event.kind}", None)
|
|
344
|
+
if handler is not None:
|
|
345
|
+
handler(event.payload, controller)
|
|
346
|
+
|
|
347
|
+
def _on_turn_started(self, _p: dict, _controller: Any) -> None:
|
|
348
|
+
self.assistant_open = False
|
|
349
|
+
self._thinking_open = False
|
|
350
|
+
self.md = MarkdownPlainStream()
|
|
351
|
+
print()
|
|
352
|
+
self.spinner = _SpinnerLine("thinking…")
|
|
353
|
+
if self.animations_enabled:
|
|
354
|
+
self.spinner.start()
|
|
355
|
+
else:
|
|
356
|
+
print(f"{_DIM} thinking…{_RESET}", flush=True)
|
|
357
|
+
self.activity = _TurnActivity()
|
|
358
|
+
|
|
359
|
+
def _on_thinking_clear(self, _p: dict, _controller: Any) -> None:
|
|
360
|
+
self._schedule(self.spinner.clear())
|
|
361
|
+
self._schedule(self.activity.cancel())
|
|
362
|
+
|
|
363
|
+
def _on_assistant_start(self, _p: dict, _controller: Any) -> None:
|
|
364
|
+
if self._thinking_open:
|
|
365
|
+
self._on_thinking_block_complete({}, _controller)
|
|
366
|
+
print(_ASSISTANT_PREFIX, end="", flush=True)
|
|
367
|
+
self.assistant_open = True
|
|
368
|
+
|
|
369
|
+
def _on_assistant_delta(self, p: dict, _controller: Any) -> None:
|
|
370
|
+
rendered = self.md.feed(str(p.get("delta", "")))
|
|
371
|
+
if rendered:
|
|
372
|
+
print(rendered, end="", flush=True)
|
|
373
|
+
|
|
374
|
+
def _on_tool_started(self, p: dict, _controller: Any) -> None:
|
|
375
|
+
tool = str(p.get("tool") or "tool")
|
|
376
|
+
label = str(p.get("label") or tool)
|
|
377
|
+
raw_args = p.get("args")
|
|
378
|
+
args = dict(raw_args) if isinstance(raw_args, dict) else {}
|
|
379
|
+
self._schedule(
|
|
380
|
+
self.activity.tool_started(
|
|
381
|
+
tool,
|
|
382
|
+
display=tool_display(tool, label, args),
|
|
383
|
+
prefix=tool_spinner_prefix(tool, label, args),
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _on_tool_finished(self, p: dict, _controller: Any) -> None:
|
|
388
|
+
self._schedule(
|
|
389
|
+
self.activity.tool_finished(
|
|
390
|
+
str(p.get("tool") or "tool"),
|
|
391
|
+
error=p.get("error"),
|
|
392
|
+
verbose=bool(p.get("verbose")),
|
|
393
|
+
result=str(p.get("result") or ""),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _on_summarizing(self, p: dict, _controller: Any) -> None:
|
|
398
|
+
self._schedule(self.activity.summarizing(int(p.get("tokens") or 0)))
|
|
399
|
+
|
|
400
|
+
def _on_summarized(self, p: dict, _controller: Any) -> None:
|
|
401
|
+
self._schedule(self.activity.summarized(int(p.get("turns") or 0)))
|
|
402
|
+
|
|
403
|
+
def _on_grounding(self, p: dict, _controller: Any) -> None:
|
|
404
|
+
queries = p.get("search_queries") or []
|
|
405
|
+
header = "grounded search"
|
|
406
|
+
if queries:
|
|
407
|
+
header += " — " + ", ".join(f'"{q}"' for q in queries)
|
|
408
|
+
print(f"{_DIM} 🔎 {header}{_RESET}", flush=True)
|
|
409
|
+
for source in (p.get("sources") or [])[:5]:
|
|
410
|
+
if isinstance(source, dict) and source.get("uri"):
|
|
411
|
+
title = str(source.get("title") or "").strip()
|
|
412
|
+
uri = str(source.get("uri") or "").strip()
|
|
413
|
+
print(f"{_DIM} {_CYAN}{_UNDERLINE}{title or uri} ({uri}){_RESET}", flush=True)
|
|
414
|
+
|
|
415
|
+
def _on_turn_error(self, p: dict, _controller: Any) -> None:
|
|
416
|
+
_finish_assistant_turn(
|
|
417
|
+
md_stream=self.md,
|
|
418
|
+
assistant_label_shown=self.assistant_open,
|
|
419
|
+
show_usage=False,
|
|
420
|
+
error=str(p.get("error") or ""),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _on_turn_complete(self, p: dict, _controller: Any) -> None:
|
|
424
|
+
_finish_assistant_turn(
|
|
425
|
+
md_stream=self.md,
|
|
426
|
+
assistant_label_shown=self.assistant_open,
|
|
427
|
+
show_usage=bool(p.get("usage")),
|
|
428
|
+
usage=p.get("usage"),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def _on_turn_aborted(self, p: dict, _controller: Any) -> None:
|
|
432
|
+
cancel_ok = p.get("cancel_ok")
|
|
433
|
+
if cancel_ok is False:
|
|
434
|
+
print(f"{_DIM}Turn aborted locally (cancel failed){_RESET}\n", flush=True)
|
|
435
|
+
else:
|
|
436
|
+
print(
|
|
437
|
+
f"{_DIM}Turn aborted — cancel sent; server may still finish{_RESET}\n",
|
|
438
|
+
flush=True,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _on_hitl_required(self, p: dict, _controller: Any) -> None:
|
|
442
|
+
from monkeybot_cli.chat_session import HitlRequest
|
|
443
|
+
|
|
444
|
+
kind = str(p.get("hitl_kind") or "confirm")
|
|
445
|
+
if kind not in ("confirm", "elicit", "frontend_unsupported"):
|
|
446
|
+
kind = "confirm"
|
|
447
|
+
raw_schema = p.get("schema")
|
|
448
|
+
schema = dict(raw_schema) if isinstance(raw_schema, dict) else None
|
|
449
|
+
raw_args = p.get("arguments")
|
|
450
|
+
arguments = dict(raw_args) if isinstance(raw_args, dict) else {}
|
|
451
|
+
timeout_raw = p.get("timeout_sec")
|
|
452
|
+
try:
|
|
453
|
+
timeout_sec = float(timeout_raw) if timeout_raw is not None else None
|
|
454
|
+
except (TypeError, ValueError):
|
|
455
|
+
timeout_sec = None
|
|
456
|
+
req = HitlRequest(
|
|
457
|
+
kind=kind, # type: ignore[arg-type]
|
|
458
|
+
prompt=str(p.get("prompt") or ""),
|
|
459
|
+
tool_call_id=str(p.get("tool_call_id") or ""),
|
|
460
|
+
elicitation_id=str(p.get("elicitation_id") or ""),
|
|
461
|
+
tool_name=str(p.get("tool_name") or ""),
|
|
462
|
+
schema=schema,
|
|
463
|
+
arguments=arguments,
|
|
464
|
+
timeout_sec=timeout_sec,
|
|
465
|
+
)
|
|
466
|
+
self.hitl_prompt = format_hitl_plain_prompt(req)
|
|
467
|
+
print(f"{_DIM}{self.hitl_prompt}{_RESET}", flush=True)
|
|
468
|
+
|
|
469
|
+
def _on_hitl_failed(self, p: dict, _controller: Any) -> None:
|
|
470
|
+
print(f"{_RED}{p.get('message')}{_RESET}", flush=True)
|
|
471
|
+
|
|
472
|
+
def _on_hitl_frontend_unsupported(self, p: dict, _controller: Any) -> None:
|
|
473
|
+
print(
|
|
474
|
+
f"\x1b[33mFrontend tool '{p.get('name')}' requires a UI — "
|
|
475
|
+
f"not supported in terminal chat.\x1b[0m"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _on_session_busy(self, _p: dict, _controller: Any) -> None:
|
|
479
|
+
print("Session busy — wait for the current turn to finish.", file=sys.stderr)
|
|
480
|
+
|
|
481
|
+
def _on_error(self, p: dict, _controller: Any) -> None:
|
|
482
|
+
print(str(p.get("message") or ""), file=sys.stderr)
|
|
483
|
+
|
|
484
|
+
def _on_stream_failed(self, p: dict, _controller: Any) -> None:
|
|
485
|
+
print(str(p.get("message") or ""), file=sys.stderr)
|
|
486
|
+
|
|
487
|
+
def _on_thinking_trace(self, p: dict, _controller: Any) -> None:
|
|
488
|
+
print(f"{_DIM}[thinking] {p.get('text')}{_RESET}", flush=True)
|
|
489
|
+
|
|
490
|
+
def _on_thinking_block_delta(self, p: dict, _controller: Any) -> None:
|
|
491
|
+
text = str(p.get("text") or "")
|
|
492
|
+
if not text:
|
|
493
|
+
return
|
|
494
|
+
if not self._thinking_open:
|
|
495
|
+
self.spinner.stop()
|
|
496
|
+
print(f"{_DIM}{_BOLD}Thinking...{_RESET}", flush=True)
|
|
497
|
+
self._thinking_open = True
|
|
498
|
+
print(f"{_DIM}{text}{_RESET}", end="", flush=True)
|
|
499
|
+
|
|
500
|
+
def _on_thinking_block_complete(self, _p: dict, _controller: Any) -> None:
|
|
501
|
+
if self._thinking_open:
|
|
502
|
+
print(flush=True)
|
|
503
|
+
print(f"{_DIM}{_BOLD}...done thinking.{_RESET}", flush=True)
|
|
504
|
+
self._thinking_open = False
|
|
505
|
+
|
|
506
|
+
def _on_usage_updated(self, p: dict, _controller: Any) -> None:
|
|
507
|
+
usage = p.get("usage")
|
|
508
|
+
if isinstance(usage, SessionUsageView):
|
|
509
|
+
self._last_usage = usage
|
|
510
|
+
_print_context_ring(usage)
|
|
511
|
+
|
|
512
|
+
# Intentional no-ops for TUI-only / optional plain events (parity with EVENT_KINDS).
|
|
513
|
+
def _on_session_ready(self, _p: dict, _controller: Any) -> None:
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
def _on_connection_state(self, _p: dict, _controller: Any) -> None:
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
def _on_transcript_backfill(self, _p: dict, _controller: Any) -> None:
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
def _on_thinking(self, _p: dict, _controller: Any) -> None:
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
def _on_stream_ended(self, _p: dict, _controller: Any) -> None:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
def _on_voice_state(self, _p: dict, _controller: Any) -> None:
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
def _on_audio_chunk(self, _p: dict, _controller: Any) -> None:
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
def _on_device_error(self, p: dict, _controller: Any) -> None:
|
|
535
|
+
msg = str(p.get("message") or "Audio device error")
|
|
536
|
+
hint = p.get("hint")
|
|
537
|
+
print(f"{_DIM}{msg}{_RESET}", flush=True)
|
|
538
|
+
if hint:
|
|
539
|
+
print(f"{_DIM}{hint}{_RESET}", flush=True)
|
|
540
|
+
|
|
541
|
+
def _on_user_transcript(self, p: dict, _controller: Any) -> None:
|
|
542
|
+
text = str(p.get("text") or "").strip()
|
|
543
|
+
if text and p.get("is_final", True):
|
|
544
|
+
print(f"{_USER_PROMPT} {text}", flush=True)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def _plain_chat_session(
|
|
548
|
+
args: argparse.Namespace,
|
|
549
|
+
base: str,
|
|
550
|
+
*,
|
|
551
|
+
spawned_gateway: bool,
|
|
552
|
+
) -> int:
|
|
553
|
+
interrupt = asyncio.Event()
|
|
554
|
+
loop = asyncio.get_running_loop()
|
|
555
|
+
with contextlib.suppress(NotImplementedError):
|
|
556
|
+
loop.add_signal_handler(signal.SIGINT, interrupt.set)
|
|
557
|
+
|
|
558
|
+
animations_enabled = not (
|
|
559
|
+
bool(getattr(args, "no_animations", False))
|
|
560
|
+
or os.environ.get("MONKEYBOT_CHAT_NO_ANIMATIONS", "").strip().lower()
|
|
561
|
+
in ("1", "true", "yes")
|
|
562
|
+
)
|
|
563
|
+
renderer = _PlainRenderer(animations_enabled=animations_enabled)
|
|
564
|
+
renderer.start_io_worker()
|
|
565
|
+
|
|
566
|
+
async def hitl_reader(req: Any) -> HitlAnswer:
|
|
567
|
+
from monkeybot_cli.chat_session import HitlRequest
|
|
568
|
+
|
|
569
|
+
assert isinstance(req, HitlRequest)
|
|
570
|
+
# Prompt already printed via _on_hitl_required; short line for input.
|
|
571
|
+
label = "y/n" if req.kind == "confirm" else "input"
|
|
572
|
+
ans = await _read_line(f"{label}: ", interrupt)
|
|
573
|
+
if ans is None or interrupt.is_set():
|
|
574
|
+
return HitlAnswer(cancelled=True)
|
|
575
|
+
lower = ans.strip().lower()
|
|
576
|
+
if req.kind == "confirm":
|
|
577
|
+
if lower in ("y", "yes"):
|
|
578
|
+
return HitlAnswer(approved=True, text=ans)
|
|
579
|
+
return HitlAnswer(approved=False, text=ans)
|
|
580
|
+
return HitlAnswer(text=ans)
|
|
581
|
+
|
|
582
|
+
controller = ChatSessionController(
|
|
583
|
+
base=base,
|
|
584
|
+
model_provider=args.model_provider,
|
|
585
|
+
model_name=args.model_name,
|
|
586
|
+
show_thinking=args.show_thinking,
|
|
587
|
+
verbose=args.verbose,
|
|
588
|
+
show_usage=args.usage,
|
|
589
|
+
emit=lambda e: renderer.on_event(e, controller),
|
|
590
|
+
hitl_reader=hitl_reader,
|
|
591
|
+
resume_session_id=getattr(args, "session", None),
|
|
592
|
+
)
|
|
593
|
+
try:
|
|
594
|
+
await controller.connect()
|
|
595
|
+
except RuntimeError as exc:
|
|
596
|
+
print(str(exc), file=sys.stderr)
|
|
597
|
+
await renderer.stop_io_worker()
|
|
598
|
+
return 1
|
|
599
|
+
|
|
600
|
+
hint = "Type /bye to exit"
|
|
601
|
+
if spawned_gateway:
|
|
602
|
+
hint += " (stops the gateway)"
|
|
603
|
+
print(f"{_DIM}{hint}. Ctrl-C also exits.{_RESET}\n")
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
while not interrupt.is_set() and controller.stream_alive:
|
|
607
|
+
user_line = await _read_line(_USER_PROMPT, interrupt)
|
|
608
|
+
if user_line is None or interrupt.is_set():
|
|
609
|
+
break
|
|
610
|
+
if not user_line.strip():
|
|
611
|
+
continue
|
|
612
|
+
if is_exit_command(user_line):
|
|
613
|
+
if spawned_gateway:
|
|
614
|
+
print(f"\n{_DIM}Goodbye — shutting down gateway…{_RESET}")
|
|
615
|
+
else:
|
|
616
|
+
print(f"\n{_DIM}Goodbye.{_RESET}")
|
|
617
|
+
break
|
|
618
|
+
await controller.submit(user_line)
|
|
619
|
+
finally:
|
|
620
|
+
await controller.close()
|
|
621
|
+
await renderer.stop_io_worker()
|
|
622
|
+
return 1 if controller.stream_error else 0
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class _SpawnedGateway(NamedTuple):
|
|
626
|
+
proc: subprocess.Popen[str]
|
|
627
|
+
log_path: Path
|
|
628
|
+
log_file: TextIO
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _format_gateway_log_tail(log_path: Path, *, max_lines: int = 40) -> str:
|
|
632
|
+
if not log_path.is_file():
|
|
633
|
+
return ""
|
|
634
|
+
text = log_path.read_text(encoding="utf-8", errors="replace").strip()
|
|
635
|
+
if not text:
|
|
636
|
+
return ""
|
|
637
|
+
return "\n".join(text.splitlines()[-max_lines:])
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _tail_gateway_log(gateway: _SpawnedGateway, *, max_lines: int = 40) -> str:
|
|
641
|
+
if gateway.proc.poll() is None:
|
|
642
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
643
|
+
gateway.proc.wait(timeout=1)
|
|
644
|
+
else:
|
|
645
|
+
gateway.proc.wait()
|
|
646
|
+
gateway.log_file.flush()
|
|
647
|
+
with contextlib.suppress(OSError):
|
|
648
|
+
gateway.log_file.close()
|
|
649
|
+
return _format_gateway_log_tail(gateway.log_path, max_lines=max_lines)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _cleanup_gateway_log(gateway: _SpawnedGateway | None) -> None:
|
|
653
|
+
if gateway is None:
|
|
654
|
+
return
|
|
655
|
+
with contextlib.suppress(OSError):
|
|
656
|
+
if not gateway.log_file.closed:
|
|
657
|
+
gateway.log_file.close()
|
|
658
|
+
with contextlib.suppress(OSError):
|
|
659
|
+
gateway.log_path.unlink(missing_ok=True)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _spawn_gateway(config_path: Path | None, agent_root: Path, port: int) -> _SpawnedGateway:
|
|
663
|
+
env = os.environ.copy()
|
|
664
|
+
if config_path is not None:
|
|
665
|
+
env["MONKEYBOT_CONFIG"] = str(config_path)
|
|
666
|
+
env["PORT"] = str(port)
|
|
667
|
+
env.setdefault("LOG_LEVEL", "error")
|
|
668
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
669
|
+
mode="w+",
|
|
670
|
+
prefix="monkeybot-gateway-",
|
|
671
|
+
suffix=".log",
|
|
672
|
+
delete=False,
|
|
673
|
+
encoding="utf-8",
|
|
674
|
+
errors="replace",
|
|
675
|
+
)
|
|
676
|
+
proc = subprocess.Popen(
|
|
677
|
+
gateway_argv(resolve_runtime_python(agent_root)),
|
|
678
|
+
env=env,
|
|
679
|
+
cwd=agent_root,
|
|
680
|
+
stdout=subprocess.DEVNULL,
|
|
681
|
+
stderr=log_file,
|
|
682
|
+
)
|
|
683
|
+
return _SpawnedGateway(proc=proc, log_path=Path(log_file.name), log_file=log_file)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def run_chat(args: argparse.Namespace) -> int:
|
|
687
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
688
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
689
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
690
|
+
agent_root = resolve_agent_root(cwd=cwd, config_path=config_path)
|
|
691
|
+
|
|
692
|
+
base = _resolve_base_url(args, config_path)
|
|
693
|
+
attach = args.attach or bool(args.url)
|
|
694
|
+
|
|
695
|
+
proc: subprocess.Popen[str] | None = None
|
|
696
|
+
spawned: _SpawnedGateway | None = None
|
|
697
|
+
if not attach:
|
|
698
|
+
if config_path is None:
|
|
699
|
+
print(
|
|
700
|
+
"No monkeybot.yaml found in this directory. cd into an agent root "
|
|
701
|
+
"(containing monkeybot_config/monkeybot.yaml) or pass --config / --attach.",
|
|
702
|
+
file=sys.stderr,
|
|
703
|
+
)
|
|
704
|
+
return 1
|
|
705
|
+
_, cfg_doc = load_config_doc(config_path)
|
|
706
|
+
if is_sandbox_enabled(cfg_doc):
|
|
707
|
+
if not ensure_opensandbox_for_agent(
|
|
708
|
+
agent_root,
|
|
709
|
+
server_url=server_url_from_config(cfg_doc),
|
|
710
|
+
):
|
|
711
|
+
print(
|
|
712
|
+
f"{_DIM}Continuing without a healthy OpenSandbox — run_command may fail.{_RESET}",
|
|
713
|
+
file=sys.stderr,
|
|
714
|
+
)
|
|
715
|
+
port = args.port if args.port else _port_from_config(config_path)
|
|
716
|
+
spawned = _spawn_gateway(config_path, agent_root, port)
|
|
717
|
+
proc = spawned.proc
|
|
718
|
+
if not _wait_for_health(base, proc):
|
|
719
|
+
print("Gateway failed to start.", file=sys.stderr)
|
|
720
|
+
log_tail = _tail_gateway_log(spawned)
|
|
721
|
+
if log_tail:
|
|
722
|
+
print(f"{_DIM}--- gateway log ---{_RESET}", file=sys.stderr)
|
|
723
|
+
print(log_tail, file=sys.stderr)
|
|
724
|
+
print(f"{_DIM}--- end log ---{_RESET}", file=sys.stderr)
|
|
725
|
+
else:
|
|
726
|
+
print("Run `monkeybot run` to see logs.", file=sys.stderr)
|
|
727
|
+
if proc.poll() is None:
|
|
728
|
+
proc.terminate()
|
|
729
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
730
|
+
proc.wait(timeout=5)
|
|
731
|
+
_cleanup_gateway_log(spawned)
|
|
732
|
+
return 1
|
|
733
|
+
|
|
734
|
+
provider, model = _model_banner_fields(args, config_path)
|
|
735
|
+
animations_enabled = not (
|
|
736
|
+
bool(getattr(args, "no_animations", False))
|
|
737
|
+
or os.environ.get("MONKEYBOT_CHAT_NO_ANIMATIONS", "").strip().lower()
|
|
738
|
+
in ("1", "true", "yes")
|
|
739
|
+
)
|
|
740
|
+
theme_choice = str(getattr(args, "theme", "auto") or "auto")
|
|
741
|
+
try:
|
|
742
|
+
if use_textual_tui():
|
|
743
|
+
return run_chat_tui(
|
|
744
|
+
base=base,
|
|
745
|
+
agent_root=agent_root,
|
|
746
|
+
provider=provider,
|
|
747
|
+
model=model,
|
|
748
|
+
spawned_gateway=not attach,
|
|
749
|
+
model_provider=args.model_provider,
|
|
750
|
+
model_name=args.model_name,
|
|
751
|
+
show_thinking=args.show_thinking,
|
|
752
|
+
verbose=args.verbose,
|
|
753
|
+
show_usage=args.usage,
|
|
754
|
+
resume_session_id=getattr(args, "session", None),
|
|
755
|
+
animations_enabled=animations_enabled,
|
|
756
|
+
theme_choice=theme_choice,
|
|
757
|
+
)
|
|
758
|
+
return asyncio.run(_plain_chat_session(args, base, spawned_gateway=not attach))
|
|
759
|
+
except KeyboardInterrupt:
|
|
760
|
+
sys.stdout.write("\n")
|
|
761
|
+
sys.stdout.flush()
|
|
762
|
+
if not attach:
|
|
763
|
+
print(f"{_DIM}Shutting down gateway…{_RESET}", file=sys.stderr)
|
|
764
|
+
return 130
|
|
765
|
+
finally:
|
|
766
|
+
if proc and proc.poll() is None:
|
|
767
|
+
proc.kill()
|
|
768
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
769
|
+
proc.wait(timeout=1)
|
|
770
|
+
if spawned is not None:
|
|
771
|
+
_cleanup_gateway_log(spawned)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
775
|
+
p = subparsers.add_parser(
|
|
776
|
+
"chat",
|
|
777
|
+
help="Talk to the agent (starts the gateway automatically from the current dir)",
|
|
778
|
+
)
|
|
779
|
+
p.add_argument("--cwd", help="Agent root (defaults to the current directory)")
|
|
780
|
+
p.add_argument(
|
|
781
|
+
"--config", help="Path to monkeybot.yaml (defaults to ./monkeybot_config/monkeybot.yaml)"
|
|
782
|
+
)
|
|
783
|
+
p.add_argument(
|
|
784
|
+
"--attach",
|
|
785
|
+
action="store_true",
|
|
786
|
+
help="Connect to an already-running gateway instead of spawning one",
|
|
787
|
+
)
|
|
788
|
+
p.add_argument(
|
|
789
|
+
"--url", help="Gateway base URL (implies --attach; overrides config-derived port)"
|
|
790
|
+
)
|
|
791
|
+
p.add_argument("--port", type=int, help="Gateway port (overrides runtime.port from config)")
|
|
792
|
+
p.add_argument("--show-thinking", action="store_true", help="Show thinking events")
|
|
793
|
+
p.add_argument(
|
|
794
|
+
"--verbose", action="store_true", help="Show tool result payloads after each tool"
|
|
795
|
+
)
|
|
796
|
+
p.add_argument("--usage", action="store_true", help="Show token usage after each turn")
|
|
797
|
+
p.add_argument(
|
|
798
|
+
"--session",
|
|
799
|
+
dest="session",
|
|
800
|
+
help="Resume an existing gateway session id (with transcript backfill when available)",
|
|
801
|
+
)
|
|
802
|
+
p.add_argument(
|
|
803
|
+
"--model-provider", dest="model_provider", help="Override model provider for session"
|
|
804
|
+
)
|
|
805
|
+
p.add_argument("--model-name", dest="model_name", help="Override model name for session")
|
|
806
|
+
p.add_argument(
|
|
807
|
+
"--no-animations",
|
|
808
|
+
action="store_true",
|
|
809
|
+
help="Disable decorative spinners / batched markdown flush (also: MONKEYBOT_CHAT_NO_ANIMATIONS=1)",
|
|
810
|
+
)
|
|
811
|
+
p.add_argument(
|
|
812
|
+
"--theme",
|
|
813
|
+
choices=("auto", "dark", "light"),
|
|
814
|
+
default="auto",
|
|
815
|
+
help="Chat TUI theme (auto uses COLORFGBG when set; default dark)",
|
|
816
|
+
)
|
|
817
|
+
p.set_defaults(func=run_chat)
|