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,1491 @@
|
|
|
1
|
+
"""Textual TUI for ``monkeybot chat``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from textual import on, work
|
|
13
|
+
from textual.app import App, ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical
|
|
16
|
+
from textual.css.query import NoMatches
|
|
17
|
+
from textual.reactive import reactive
|
|
18
|
+
from textual.widgets import Button, OptionList, Static, TextArea
|
|
19
|
+
from textual.widgets.option_list import Option
|
|
20
|
+
|
|
21
|
+
from monkeybot_cli.chat_session import (
|
|
22
|
+
ChatSessionController,
|
|
23
|
+
ChatUiEvent,
|
|
24
|
+
HitlAnswer,
|
|
25
|
+
)
|
|
26
|
+
from monkeybot_cli.chat_status_bar import SessionUsageView, format_context_ring_markup, format_voice_status
|
|
27
|
+
from monkeybot_cli.chat_theme import MONKEYBOT_DARK, MONKEYBOT_LIGHT, resolve_theme_name
|
|
28
|
+
from monkeybot_cli.chat_tool_display import tool_collapsed_title
|
|
29
|
+
|
|
30
|
+
from monkeybot_cli.chat_tui_widgets import (
|
|
31
|
+
AssistantTurn,
|
|
32
|
+
Composer,
|
|
33
|
+
ComposerBusySpinner,
|
|
34
|
+
EarlierTurns,
|
|
35
|
+
EmptyHint,
|
|
36
|
+
GroundingBlock,
|
|
37
|
+
HitlCard,
|
|
38
|
+
SystemLine,
|
|
39
|
+
ThinkingLine,
|
|
40
|
+
ThinkingTrace,
|
|
41
|
+
ToolCallBlock,
|
|
42
|
+
TranscriptPane,
|
|
43
|
+
UserTurn,
|
|
44
|
+
_COMPOSER_PLACEHOLDER,
|
|
45
|
+
write_osc52_clipboard,
|
|
46
|
+
)
|
|
47
|
+
from monkeybot_cli.exit_commands import is_exit_command
|
|
48
|
+
from monkeybot_cli.chat_renderer import SessionController
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
_HISTORY_LIMIT = 500
|
|
53
|
+
_PENDING_LIMIT = 5
|
|
54
|
+
_MAX_MOUNTED_TURNS = 200
|
|
55
|
+
_SLASH_PREFIX_RE = re.compile(r"^/\S*$")
|
|
56
|
+
|
|
57
|
+
_SLASH_SPECS: tuple[tuple[str, str], ...] = (
|
|
58
|
+
("/help", "Show commands and key hints"),
|
|
59
|
+
("/new", "Start a fresh session"),
|
|
60
|
+
("/resume", "Resume a session by id"),
|
|
61
|
+
("/usage", "Toggle token/cost usage line (Ctrl+U)"),
|
|
62
|
+
("/timestamps", "Toggle turn timestamps"),
|
|
63
|
+
("/copy", "Copy last assistant reply"),
|
|
64
|
+
("/export", "Export transcript to a markdown file"),
|
|
65
|
+
("/bye", "Exit chat"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def gateway_host_label(base: str) -> str:
|
|
70
|
+
"""Short host:port label from a gateway base URL."""
|
|
71
|
+
parsed = urlparse(base)
|
|
72
|
+
host = parsed.hostname or parsed.netloc or base
|
|
73
|
+
if parsed.port:
|
|
74
|
+
return f"{host}:{parsed.port}"
|
|
75
|
+
return host or base
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_topbar(
|
|
79
|
+
*,
|
|
80
|
+
agent_name: str,
|
|
81
|
+
provider: str,
|
|
82
|
+
model: str,
|
|
83
|
+
session_id: str | None,
|
|
84
|
+
gateway: str,
|
|
85
|
+
width: int = 120,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Build top-bar text; prefer agent + short session id when truncating."""
|
|
88
|
+
short = (session_id or "")[:8]
|
|
89
|
+
parts = [agent_name, f"{provider}/{model}"]
|
|
90
|
+
if short:
|
|
91
|
+
parts.append(short)
|
|
92
|
+
parts.append(gateway)
|
|
93
|
+
line = " · ".join(parts)
|
|
94
|
+
if width > 20 and len(line) > width:
|
|
95
|
+
compact = [agent_name]
|
|
96
|
+
if short:
|
|
97
|
+
compact.append(short)
|
|
98
|
+
compact.append(gateway)
|
|
99
|
+
line = " · ".join(compact)
|
|
100
|
+
return line
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_slash_command(line: str) -> tuple[str, str] | None:
|
|
104
|
+
"""Return ``(name, arg)`` for a leading slash command, else ``None``."""
|
|
105
|
+
text = line.strip()
|
|
106
|
+
if not text.startswith("/"):
|
|
107
|
+
return None
|
|
108
|
+
parts = text.split(None, 1)
|
|
109
|
+
name = parts[0][1:].lower()
|
|
110
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
111
|
+
return name, arg
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def filter_slash_commands(prefix: str) -> list[tuple[str, str]]:
|
|
115
|
+
text = prefix.strip()
|
|
116
|
+
if not text.startswith("/"):
|
|
117
|
+
return []
|
|
118
|
+
lower = text.lower()
|
|
119
|
+
return [(cmd, desc) for cmd, desc in _SLASH_SPECS if cmd.startswith(lower)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ensure_history_file(agent_root: Path) -> Path:
|
|
123
|
+
path = agent_root / "data" / "chat_history"
|
|
124
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if not path.exists():
|
|
126
|
+
path.touch()
|
|
127
|
+
return path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_history_lines(agent_root: Path, *, limit: int = _HISTORY_LIMIT) -> list[str]:
|
|
131
|
+
path = ensure_history_file(agent_root)
|
|
132
|
+
try:
|
|
133
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
134
|
+
except OSError:
|
|
135
|
+
return []
|
|
136
|
+
return [line for line in lines if line.strip()][-limit:]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def append_history(agent_root: Path, line: str) -> None:
|
|
140
|
+
path = ensure_history_file(agent_root)
|
|
141
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
142
|
+
fh.write(line.rstrip("\n") + "\n")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ChatApp(App[int]):
|
|
146
|
+
"""Claude Code–style single-column chat TUI."""
|
|
147
|
+
|
|
148
|
+
CSS = """
|
|
149
|
+
Screen {
|
|
150
|
+
layout: vertical;
|
|
151
|
+
background: $background;
|
|
152
|
+
}
|
|
153
|
+
#topbar {
|
|
154
|
+
dock: top;
|
|
155
|
+
height: 1;
|
|
156
|
+
padding: 0 1;
|
|
157
|
+
background: $background;
|
|
158
|
+
color: $muted;
|
|
159
|
+
text-style: dim;
|
|
160
|
+
}
|
|
161
|
+
#transcript {
|
|
162
|
+
height: 1fr;
|
|
163
|
+
padding: 0 1 1 1;
|
|
164
|
+
background: $background;
|
|
165
|
+
scrollbar-background: $background;
|
|
166
|
+
scrollbar-color: $scrollbar;
|
|
167
|
+
}
|
|
168
|
+
#empty-hint {
|
|
169
|
+
width: 100%;
|
|
170
|
+
height: 100%;
|
|
171
|
+
content-align: center middle;
|
|
172
|
+
color: $disabled;
|
|
173
|
+
text-style: dim;
|
|
174
|
+
}
|
|
175
|
+
#jump-bottom {
|
|
176
|
+
height: 1;
|
|
177
|
+
min-height: 1;
|
|
178
|
+
width: 100%;
|
|
179
|
+
border: none;
|
|
180
|
+
background: $surface;
|
|
181
|
+
color: $secondary;
|
|
182
|
+
text-align: center;
|
|
183
|
+
display: none;
|
|
184
|
+
}
|
|
185
|
+
#jump-bottom:hover {
|
|
186
|
+
color: $foreground;
|
|
187
|
+
}
|
|
188
|
+
#jump-bottom:focus {
|
|
189
|
+
border: none;
|
|
190
|
+
text-style: bold;
|
|
191
|
+
}
|
|
192
|
+
#composer-wrap {
|
|
193
|
+
height: auto;
|
|
194
|
+
max-height: 16;
|
|
195
|
+
min-height: 1;
|
|
196
|
+
padding: 0 1;
|
|
197
|
+
border-top: solid $border;
|
|
198
|
+
background: $surface;
|
|
199
|
+
}
|
|
200
|
+
#composer-wrap.hitl {
|
|
201
|
+
border-top: solid $warning;
|
|
202
|
+
}
|
|
203
|
+
#composer-row {
|
|
204
|
+
height: auto;
|
|
205
|
+
min-height: 1;
|
|
206
|
+
width: 1fr;
|
|
207
|
+
align: left middle;
|
|
208
|
+
}
|
|
209
|
+
#composer-busy {
|
|
210
|
+
width: 2;
|
|
211
|
+
height: 1;
|
|
212
|
+
min-height: 1;
|
|
213
|
+
}
|
|
214
|
+
#slash-palette {
|
|
215
|
+
height: auto;
|
|
216
|
+
max-height: 6;
|
|
217
|
+
background: $surface;
|
|
218
|
+
color: $secondary;
|
|
219
|
+
border: none;
|
|
220
|
+
padding: 0;
|
|
221
|
+
display: none;
|
|
222
|
+
}
|
|
223
|
+
#prompt {
|
|
224
|
+
height: 1;
|
|
225
|
+
min-height: 1;
|
|
226
|
+
max-height: 8;
|
|
227
|
+
width: 1fr;
|
|
228
|
+
background: $surface;
|
|
229
|
+
color: $foreground;
|
|
230
|
+
border: none;
|
|
231
|
+
padding: 0 1;
|
|
232
|
+
}
|
|
233
|
+
#prompt:focus {
|
|
234
|
+
border: none;
|
|
235
|
+
}
|
|
236
|
+
#statusbar {
|
|
237
|
+
dock: bottom;
|
|
238
|
+
height: 1;
|
|
239
|
+
padding: 0 1;
|
|
240
|
+
background: $surface;
|
|
241
|
+
color: $disabled;
|
|
242
|
+
}
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
BINDINGS = [
|
|
246
|
+
Binding("ctrl+c", "ctrl_c", "Cancel/Exit", show=False, priority=True),
|
|
247
|
+
Binding("f1", "toggle_hints", "Hints", show=False),
|
|
248
|
+
Binding("ctrl+u", "toggle_usage", "Usage", show=False),
|
|
249
|
+
Binding("pageup", "transcript_page_up", "Scroll up", show=False),
|
|
250
|
+
Binding("pagedown", "transcript_page_down", "Scroll down", show=False),
|
|
251
|
+
Binding("y", "hitl_approve", "Approve", show=False, priority=True),
|
|
252
|
+
Binding("n", "hitl_deny", "Deny", show=False, priority=True),
|
|
253
|
+
Binding("space", "toggle_ptt", "PTT", show=False),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
show_hints: reactive[bool] = reactive(False)
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
base: str,
|
|
262
|
+
agent_root: Path,
|
|
263
|
+
provider: str,
|
|
264
|
+
model: str,
|
|
265
|
+
spawned_gateway: bool,
|
|
266
|
+
model_provider: str | None = None,
|
|
267
|
+
model_name: str | None = None,
|
|
268
|
+
show_thinking: bool = False,
|
|
269
|
+
verbose: bool = False,
|
|
270
|
+
show_usage: bool = False,
|
|
271
|
+
resume_session_id: str | None = None,
|
|
272
|
+
animations_enabled: bool = True,
|
|
273
|
+
theme_choice: str = "auto",
|
|
274
|
+
controller: SessionController | None = None,
|
|
275
|
+
) -> None:
|
|
276
|
+
super().__init__()
|
|
277
|
+
self.register_theme(MONKEYBOT_DARK)
|
|
278
|
+
self.register_theme(MONKEYBOT_LIGHT)
|
|
279
|
+
self.theme = resolve_theme_name(theme_choice)
|
|
280
|
+
self.base = base
|
|
281
|
+
self.agent_root = agent_root
|
|
282
|
+
self.provider = provider
|
|
283
|
+
self.model = model
|
|
284
|
+
self.spawned_gateway = spawned_gateway
|
|
285
|
+
self.show_usage = show_usage
|
|
286
|
+
self.animations_enabled = animations_enabled
|
|
287
|
+
self.theme_choice = theme_choice
|
|
288
|
+
self._resume_session_id = resume_session_id
|
|
289
|
+
if controller is not None:
|
|
290
|
+
self._controller = controller
|
|
291
|
+
self._controller.set_emit(self._on_controller_event)
|
|
292
|
+
else:
|
|
293
|
+
self._controller = ChatSessionController(
|
|
294
|
+
base=base,
|
|
295
|
+
model_provider=model_provider,
|
|
296
|
+
model_name=model_name,
|
|
297
|
+
show_thinking=show_thinking,
|
|
298
|
+
verbose=verbose,
|
|
299
|
+
show_usage=show_usage,
|
|
300
|
+
emit=self._on_controller_event,
|
|
301
|
+
resume_session_id=resume_session_id,
|
|
302
|
+
)
|
|
303
|
+
self._hitl_active = False
|
|
304
|
+
self._hitl_kind: str | None = None
|
|
305
|
+
self._turn_active = False
|
|
306
|
+
self._open_tools: dict[str, ToolCallBlock] = {}
|
|
307
|
+
self._anon_tools: list[ToolCallBlock] = []
|
|
308
|
+
self._exit_code = 0
|
|
309
|
+
self._assistant: AssistantTurn | None = None
|
|
310
|
+
self._thinking: ThinkingLine | None = None
|
|
311
|
+
self._thinking_trace: ThinkingTrace | None = None
|
|
312
|
+
self._hitl_card: HitlCard | None = None
|
|
313
|
+
self._hitl_status_flash: str | None = None
|
|
314
|
+
self._ring_text = "[dim]○ 0%[/]"
|
|
315
|
+
self._usage_text = ""
|
|
316
|
+
self._session_cost_text = ""
|
|
317
|
+
self._conn_text = ""
|
|
318
|
+
self._voice_text = ""
|
|
319
|
+
self._voice_level: float | None = None
|
|
320
|
+
self._tui_ptt_held = False
|
|
321
|
+
self._last_assistant_text = ""
|
|
322
|
+
self._pending: list[str] = []
|
|
323
|
+
self.show_timestamps = False
|
|
324
|
+
self._turn_count = 0
|
|
325
|
+
self._auto_scroll = True
|
|
326
|
+
self._session_id: str | None = resume_session_id
|
|
327
|
+
self.title = "monkeybot chat"
|
|
328
|
+
|
|
329
|
+
def compose(self) -> ComposeResult:
|
|
330
|
+
yield Static(
|
|
331
|
+
format_topbar(
|
|
332
|
+
agent_name=self.agent_root.name,
|
|
333
|
+
provider=self.provider,
|
|
334
|
+
model=self.model,
|
|
335
|
+
session_id=self._session_id,
|
|
336
|
+
gateway=gateway_host_label(self.base),
|
|
337
|
+
),
|
|
338
|
+
id="topbar",
|
|
339
|
+
)
|
|
340
|
+
with TranscriptPane(id="transcript"):
|
|
341
|
+
yield self._make_empty_hint()
|
|
342
|
+
yield Button("↓ Jump to latest", id="jump-bottom", compact=True, flat=True)
|
|
343
|
+
with Vertical(id="composer-wrap"):
|
|
344
|
+
yield OptionList(id="slash-palette", compact=True)
|
|
345
|
+
with Horizontal(id="composer-row"):
|
|
346
|
+
yield ComposerBusySpinner(id="composer-busy")
|
|
347
|
+
yield Composer(load_history_lines(self.agent_root))
|
|
348
|
+
yield Static(self._status_line(), id="statusbar", markup=True)
|
|
349
|
+
|
|
350
|
+
def _make_empty_hint(self) -> EmptyHint:
|
|
351
|
+
return EmptyHint()
|
|
352
|
+
|
|
353
|
+
def watch_show_hints(self, _value: bool) -> None:
|
|
354
|
+
with contextlib.suppress(NoMatches):
|
|
355
|
+
self.query_one("#statusbar", Static).update(self._status_line())
|
|
356
|
+
|
|
357
|
+
def _status_line(self) -> str:
|
|
358
|
+
parts = [self._ring_text]
|
|
359
|
+
if self._conn_text:
|
|
360
|
+
parts.append(self._conn_text)
|
|
361
|
+
if self._voice_text:
|
|
362
|
+
parts.append(self._voice_text)
|
|
363
|
+
if self._usage_text:
|
|
364
|
+
parts.append(self._usage_text)
|
|
365
|
+
elif self._session_cost_text:
|
|
366
|
+
parts.append(self._session_cost_text)
|
|
367
|
+
with contextlib.suppress(NoMatches):
|
|
368
|
+
composer = self.query_one("#prompt", Composer)
|
|
369
|
+
if composer.in_search:
|
|
370
|
+
parts.append(f"bck-i-search: {composer.search_query}")
|
|
371
|
+
return " · ".join(parts)
|
|
372
|
+
if self._pending:
|
|
373
|
+
parts.append(f"queued · {len(self._pending)} waiting")
|
|
374
|
+
if self._hitl_status_flash:
|
|
375
|
+
parts.append(self._hitl_status_flash)
|
|
376
|
+
if self._turn_active and not self._hitl_active:
|
|
377
|
+
parts.append("working · Ctrl-C interrupt")
|
|
378
|
+
elif self._hitl_active:
|
|
379
|
+
if self._hitl_kind == "elicit":
|
|
380
|
+
parts.append("Enter submit · Ctrl-C cancel")
|
|
381
|
+
else:
|
|
382
|
+
parts.append("y approve · n deny · Ctrl-C cancel")
|
|
383
|
+
elif self.show_hints:
|
|
384
|
+
parts.append(
|
|
385
|
+
"Enter send · Ctrl+J newline · ↑ history · / commands · Ctrl+U usage"
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
parts.append("F1 hints")
|
|
389
|
+
return " · ".join(parts)
|
|
390
|
+
|
|
391
|
+
def _set_composer_busy(self, busy: bool) -> None:
|
|
392
|
+
with contextlib.suppress(NoMatches):
|
|
393
|
+
self.query_one("#composer-busy", ComposerBusySpinner).set_busy(busy)
|
|
394
|
+
|
|
395
|
+
def _refresh_status(self) -> None:
|
|
396
|
+
with contextlib.suppress(NoMatches):
|
|
397
|
+
self.query_one("#statusbar", Static).update(self._status_line())
|
|
398
|
+
self._set_composer_busy(self._turn_active and not self._hitl_active)
|
|
399
|
+
|
|
400
|
+
def _set_ring(self, text: str) -> None:
|
|
401
|
+
self._ring_text = text
|
|
402
|
+
self._refresh_status()
|
|
403
|
+
|
|
404
|
+
def _refresh_topbar(self) -> None:
|
|
405
|
+
with contextlib.suppress(NoMatches):
|
|
406
|
+
width = self.size.width if self.size.width else 120
|
|
407
|
+
self.query_one("#topbar", Static).update(
|
|
408
|
+
format_topbar(
|
|
409
|
+
agent_name=self.agent_root.name,
|
|
410
|
+
provider=self.provider,
|
|
411
|
+
model=self.model,
|
|
412
|
+
session_id=self._session_id,
|
|
413
|
+
gateway=gateway_host_label(self.base),
|
|
414
|
+
width=width,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def _set_usage_line(self, usage: dict[str, object]) -> None:
|
|
419
|
+
try:
|
|
420
|
+
cost = float(usage.get("cost_usd") or 0)
|
|
421
|
+
ms = int(usage.get("duration_ms") or 0)
|
|
422
|
+
inp = usage.get("input_tokens")
|
|
423
|
+
out = usage.get("output_tokens")
|
|
424
|
+
self._usage_text = f"in={inp} out={out} ${cost:.4f} {ms}ms"
|
|
425
|
+
if cost > 0:
|
|
426
|
+
self._session_cost_text = f"${cost:.4f}"
|
|
427
|
+
except (TypeError, ValueError):
|
|
428
|
+
self._usage_text = ""
|
|
429
|
+
self._refresh_status()
|
|
430
|
+
|
|
431
|
+
def _update_session_cost_from_view(self, usage: SessionUsageView) -> None:
|
|
432
|
+
if usage.cost_usd > 0:
|
|
433
|
+
self._session_cost_text = f"${usage.cost_usd:.4f}"
|
|
434
|
+
if self.show_usage:
|
|
435
|
+
self._usage_text = (
|
|
436
|
+
f"in={usage.input_tokens} out={usage.output_tokens} "
|
|
437
|
+
f"${usage.cost_usd:.4f}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _hide_empty_hint(self) -> None:
|
|
441
|
+
with contextlib.suppress(NoMatches):
|
|
442
|
+
self.query_one("#empty-hint").remove()
|
|
443
|
+
|
|
444
|
+
def _transcript(self) -> TranscriptPane:
|
|
445
|
+
return self.query_one("#transcript", TranscriptPane)
|
|
446
|
+
|
|
447
|
+
def _should_follow(self) -> bool:
|
|
448
|
+
return self._auto_scroll
|
|
449
|
+
|
|
450
|
+
def _set_transcript_follow(self, enabled: bool) -> None:
|
|
451
|
+
"""Enable/disable sticky bottom follow via Textual scroll anchoring."""
|
|
452
|
+
self._auto_scroll = enabled
|
|
453
|
+
with contextlib.suppress(NoMatches):
|
|
454
|
+
transcript = self._transcript()
|
|
455
|
+
if enabled:
|
|
456
|
+
# anchor() scrolls to end with immediate=True (no deferred yank race).
|
|
457
|
+
transcript.anchor(True)
|
|
458
|
+
else:
|
|
459
|
+
transcript.release_anchor()
|
|
460
|
+
|
|
461
|
+
def _scroll_to_latest(self) -> None:
|
|
462
|
+
"""Stick to the bottom if follow is still enabled (re-checks at call time)."""
|
|
463
|
+
if not self._auto_scroll:
|
|
464
|
+
return
|
|
465
|
+
with contextlib.suppress(NoMatches):
|
|
466
|
+
transcript = self._transcript()
|
|
467
|
+
# If the user has released the anchor, never call scroll_end/anchor —
|
|
468
|
+
# Textual's scroll_end clears _anchor_released and yanks back to bottom.
|
|
469
|
+
if transcript._anchor_released:
|
|
470
|
+
self._auto_scroll = False
|
|
471
|
+
self._refresh_jump_button()
|
|
472
|
+
return
|
|
473
|
+
# immediate=True avoids nesting an unconditional call_after_refresh.
|
|
474
|
+
transcript.scroll_end(animate=False, immediate=True)
|
|
475
|
+
|
|
476
|
+
def _follow_scroll(self) -> None:
|
|
477
|
+
if not self._auto_scroll:
|
|
478
|
+
return
|
|
479
|
+
# Defer one frame so on_mount sizing / markdown layout are reflected in
|
|
480
|
+
# max_scroll_y; the callback re-checks _auto_scroll before scrolling.
|
|
481
|
+
self.call_after_refresh(self._scroll_to_latest)
|
|
482
|
+
|
|
483
|
+
def _on_transcript_scroll(self) -> None:
|
|
484
|
+
"""Enable auto-scroll at the bottom; pause it when the user scrolls up."""
|
|
485
|
+
transcript = self._transcript()
|
|
486
|
+
at_end = transcript.is_vertical_scroll_end
|
|
487
|
+
self._auto_scroll = at_end
|
|
488
|
+
if at_end:
|
|
489
|
+
# Re-engage compositor stickiness when the user returns to the bottom.
|
|
490
|
+
if transcript.is_anchored and transcript._anchor_released:
|
|
491
|
+
transcript._anchor_released = False
|
|
492
|
+
else:
|
|
493
|
+
# Ensure growth / follow cannot fight the user while reading history.
|
|
494
|
+
if transcript.is_anchored and not transcript._anchor_released:
|
|
495
|
+
transcript.release_anchor()
|
|
496
|
+
self._refresh_jump_button()
|
|
497
|
+
def _refresh_jump_button(self) -> None:
|
|
498
|
+
with contextlib.suppress(NoMatches):
|
|
499
|
+
btn = self.query_one("#jump-bottom", Button)
|
|
500
|
+
btn.display = (not self._auto_scroll) and self._transcript().max_scroll_y > 0
|
|
501
|
+
|
|
502
|
+
def action_jump_bottom(self) -> None:
|
|
503
|
+
"""Scroll to the latest turn and re-enable sticky auto-scroll."""
|
|
504
|
+
self._set_transcript_follow(True)
|
|
505
|
+
self._refresh_jump_button()
|
|
506
|
+
|
|
507
|
+
def action_transcript_page_up(self) -> None:
|
|
508
|
+
"""Page the transcript up (composer keeps focus)."""
|
|
509
|
+
with contextlib.suppress(NoMatches):
|
|
510
|
+
self._transcript().action_page_up()
|
|
511
|
+
|
|
512
|
+
def action_transcript_page_down(self) -> None:
|
|
513
|
+
"""Page the transcript down (composer keeps focus)."""
|
|
514
|
+
with contextlib.suppress(NoMatches):
|
|
515
|
+
self._transcript().action_page_down()
|
|
516
|
+
|
|
517
|
+
@on(Button.Pressed, "#jump-bottom")
|
|
518
|
+
def on_jump_bottom_pressed(self) -> None:
|
|
519
|
+
self.action_jump_bottom()
|
|
520
|
+
|
|
521
|
+
def _count_mounted_turns(self) -> int:
|
|
522
|
+
return self._turn_count
|
|
523
|
+
|
|
524
|
+
def _is_counted_turn(self, widget: object) -> bool:
|
|
525
|
+
return isinstance(
|
|
526
|
+
widget,
|
|
527
|
+
(
|
|
528
|
+
UserTurn,
|
|
529
|
+
AssistantTurn,
|
|
530
|
+
ToolCallBlock,
|
|
531
|
+
SystemLine,
|
|
532
|
+
HitlCard,
|
|
533
|
+
GroundingBlock,
|
|
534
|
+
ThinkingTrace,
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _turn_digest(self, widget: object) -> str:
|
|
539
|
+
if isinstance(widget, UserTurn):
|
|
540
|
+
preview = widget.body.strip().replace("\n", " ")[:60]
|
|
541
|
+
return f"you: {preview}"
|
|
542
|
+
if isinstance(widget, AssistantTurn):
|
|
543
|
+
preview = widget._raw.strip().replace("\n", " ")[:60]
|
|
544
|
+
return f"assistant: {preview}"
|
|
545
|
+
if isinstance(widget, ThinkingTrace):
|
|
546
|
+
preview = widget._raw.strip().replace("\n", " ")[:60]
|
|
547
|
+
return f"thinking: {preview}"
|
|
548
|
+
if isinstance(widget, ToolCallBlock):
|
|
549
|
+
return f"tool: {widget.display_label}"
|
|
550
|
+
if isinstance(widget, SystemLine):
|
|
551
|
+
return f"system: {widget.body.strip().replace(chr(10), ' ')[:60]}"
|
|
552
|
+
if isinstance(widget, GroundingBlock):
|
|
553
|
+
return "grounding"
|
|
554
|
+
if isinstance(widget, HitlCard):
|
|
555
|
+
return "hitl"
|
|
556
|
+
return "…"
|
|
557
|
+
|
|
558
|
+
def _trim_old_turns(self) -> None:
|
|
559
|
+
# Maintain a local counter — Textual remove() is deferred so DOM counts lag.
|
|
560
|
+
while self._turn_count >= _MAX_MOUNTED_TURNS:
|
|
561
|
+
transcript = self._transcript()
|
|
562
|
+
earlier: EarlierTurns | None = None
|
|
563
|
+
victim = None
|
|
564
|
+
for child in transcript.children:
|
|
565
|
+
if isinstance(child, EarlierTurns):
|
|
566
|
+
earlier = child
|
|
567
|
+
continue
|
|
568
|
+
if child is self._assistant or child is self._thinking or child is self._thinking_trace or child is self._hitl_card:
|
|
569
|
+
continue
|
|
570
|
+
if isinstance(child, ToolCallBlock) and (
|
|
571
|
+
child in self._open_tools.values() or child in self._anon_tools
|
|
572
|
+
):
|
|
573
|
+
continue
|
|
574
|
+
if self._is_counted_turn(child) and not getattr(child, "_trimmed", False):
|
|
575
|
+
victim = child
|
|
576
|
+
break
|
|
577
|
+
if victim is None:
|
|
578
|
+
break
|
|
579
|
+
victim._trimmed = True # type: ignore[attr-defined]
|
|
580
|
+
digest = self._turn_digest(victim)
|
|
581
|
+
victim.remove()
|
|
582
|
+
self._turn_count = max(0, self._turn_count - 1)
|
|
583
|
+
if earlier is None:
|
|
584
|
+
earlier = EarlierTurns()
|
|
585
|
+
transcript.mount(earlier, before=0)
|
|
586
|
+
earlier.absorb(digest)
|
|
587
|
+
|
|
588
|
+
def _mount(self, widget: object) -> None:
|
|
589
|
+
self._hide_empty_hint()
|
|
590
|
+
if self._is_counted_turn(widget):
|
|
591
|
+
self._trim_old_turns()
|
|
592
|
+
self._turn_count += 1
|
|
593
|
+
follow = self._should_follow()
|
|
594
|
+
self._transcript().mount(widget) # type: ignore[arg-type]
|
|
595
|
+
if follow:
|
|
596
|
+
self._follow_scroll()
|
|
597
|
+
else:
|
|
598
|
+
self._refresh_jump_button()
|
|
599
|
+
|
|
600
|
+
def _show_thinking(self, text: str = "thinking…") -> None:
|
|
601
|
+
if self._thinking is not None and self._thinking.is_attached:
|
|
602
|
+
self._thinking.set_text(text)
|
|
603
|
+
self._follow_scroll()
|
|
604
|
+
return
|
|
605
|
+
line = ThinkingLine(text)
|
|
606
|
+
self._thinking = line
|
|
607
|
+
self._mount(line)
|
|
608
|
+
|
|
609
|
+
def _clear_thinking(self) -> None:
|
|
610
|
+
if self._thinking is not None and self._thinking.is_attached:
|
|
611
|
+
self._thinking.remove()
|
|
612
|
+
self._thinking = None
|
|
613
|
+
|
|
614
|
+
def _finish_thinking_trace(self, *, clear: bool = False) -> None:
|
|
615
|
+
if self._thinking_trace is not None and self._thinking_trace.is_attached:
|
|
616
|
+
if not self._thinking_trace.has_class("-done"):
|
|
617
|
+
self._thinking_trace.finish()
|
|
618
|
+
if clear:
|
|
619
|
+
self._thinking_trace = None
|
|
620
|
+
|
|
621
|
+
def _seal_thinking_trace(self) -> None:
|
|
622
|
+
"""Finish the current thinking block and start fresh on the next delta."""
|
|
623
|
+
self._finish_thinking_trace(clear=True)
|
|
624
|
+
def _mount_user(self, body: str) -> None:
|
|
625
|
+
self._mount(UserTurn(body, show_timestamp=self.show_timestamps))
|
|
626
|
+
|
|
627
|
+
def _mount_system(self, body: str, *, error: bool = False) -> None:
|
|
628
|
+
self._mount(SystemLine(body, error=error))
|
|
629
|
+
|
|
630
|
+
def _ensure_assistant(self) -> AssistantTurn:
|
|
631
|
+
if self._assistant is None:
|
|
632
|
+
self._clear_thinking()
|
|
633
|
+
block = AssistantTurn(show_timestamp=self.show_timestamps)
|
|
634
|
+
self._assistant = block
|
|
635
|
+
self._mount(block)
|
|
636
|
+
return self._assistant
|
|
637
|
+
|
|
638
|
+
def _close_assistant(self) -> None:
|
|
639
|
+
if self._assistant is not None:
|
|
640
|
+
if self._assistant._raw:
|
|
641
|
+
self._last_assistant_text = self._assistant._raw
|
|
642
|
+
self._assistant.finish()
|
|
643
|
+
self._assistant = None
|
|
644
|
+
|
|
645
|
+
def _mount_tool(
|
|
646
|
+
self,
|
|
647
|
+
title: str,
|
|
648
|
+
*,
|
|
649
|
+
tool: str = "",
|
|
650
|
+
args: dict[str, object] | None = None,
|
|
651
|
+
call_id: str = "",
|
|
652
|
+
) -> ToolCallBlock:
|
|
653
|
+
block = ToolCallBlock(title, tool=tool, args=args, call_id=call_id)
|
|
654
|
+
self._mount(block)
|
|
655
|
+
return block
|
|
656
|
+
|
|
657
|
+
def _clear_open_tools(self) -> None:
|
|
658
|
+
self._open_tools.clear()
|
|
659
|
+
self._anon_tools.clear()
|
|
660
|
+
|
|
661
|
+
def _register_open_tool(self, block: ToolCallBlock) -> None:
|
|
662
|
+
if block.call_id:
|
|
663
|
+
self._open_tools[block.call_id] = block
|
|
664
|
+
else:
|
|
665
|
+
self._anon_tools.append(block)
|
|
666
|
+
|
|
667
|
+
def _pop_open_tool(self, call_id: str) -> ToolCallBlock | None:
|
|
668
|
+
if call_id and call_id in self._open_tools:
|
|
669
|
+
return self._open_tools.pop(call_id)
|
|
670
|
+
if self._anon_tools:
|
|
671
|
+
return self._anon_tools.pop(0)
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
def _apply_timestamps(self) -> None:
|
|
675
|
+
for child in self._transcript().children:
|
|
676
|
+
if isinstance(child, UserTurn):
|
|
677
|
+
child.show_timestamp = self.show_timestamps
|
|
678
|
+
child.refresh_label()
|
|
679
|
+
elif isinstance(child, AssistantTurn):
|
|
680
|
+
child.show_timestamp = self.show_timestamps
|
|
681
|
+
child.refresh_label()
|
|
682
|
+
|
|
683
|
+
def _clear_hitl_card(self) -> None:
|
|
684
|
+
if self._hitl_card is not None and self._hitl_card.is_attached:
|
|
685
|
+
self._hitl_card.remove()
|
|
686
|
+
self._hitl_card = None
|
|
687
|
+
|
|
688
|
+
def _palette(self) -> OptionList:
|
|
689
|
+
return self.query_one("#slash-palette", OptionList)
|
|
690
|
+
|
|
691
|
+
def _hide_slash_palette(self) -> None:
|
|
692
|
+
with contextlib.suppress(NoMatches):
|
|
693
|
+
palette = self._palette()
|
|
694
|
+
palette.display = False
|
|
695
|
+
palette.clear_options()
|
|
696
|
+
|
|
697
|
+
def _update_slash_palette(self, text: str) -> None:
|
|
698
|
+
palette = self._palette()
|
|
699
|
+
if self._hitl_active or not _SLASH_PREFIX_RE.match(text):
|
|
700
|
+
palette.display = False
|
|
701
|
+
palette.clear_options()
|
|
702
|
+
return
|
|
703
|
+
matches = filter_slash_commands(text)
|
|
704
|
+
if not matches:
|
|
705
|
+
palette.display = False
|
|
706
|
+
palette.clear_options()
|
|
707
|
+
return
|
|
708
|
+
palette.set_options(
|
|
709
|
+
[Option(f"{cmd} {desc}", id=cmd) for cmd, desc in matches]
|
|
710
|
+
)
|
|
711
|
+
palette.highlighted = 0
|
|
712
|
+
palette.display = True
|
|
713
|
+
|
|
714
|
+
def slash_submit_text(self, text: str) -> str | None:
|
|
715
|
+
"""If the slash palette is open, return the highlighted command."""
|
|
716
|
+
with contextlib.suppress(NoMatches):
|
|
717
|
+
palette = self._palette()
|
|
718
|
+
if not palette.display or palette.option_count == 0:
|
|
719
|
+
return None
|
|
720
|
+
idx = palette.highlighted if palette.highlighted is not None else 0
|
|
721
|
+
option = palette.get_option_at_index(idx)
|
|
722
|
+
if option.id:
|
|
723
|
+
return str(option.id)
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
def complete_slash_from_palette(self) -> str | None:
|
|
727
|
+
return self.slash_submit_text(self.query_one("#prompt", Composer).text)
|
|
728
|
+
|
|
729
|
+
def slash_palette_move(self, delta: int) -> bool:
|
|
730
|
+
with contextlib.suppress(NoMatches):
|
|
731
|
+
palette = self._palette()
|
|
732
|
+
if not palette.display or palette.option_count == 0:
|
|
733
|
+
return False
|
|
734
|
+
idx = palette.highlighted if palette.highlighted is not None else 0
|
|
735
|
+
idx = max(0, min(palette.option_count - 1, idx + delta))
|
|
736
|
+
palette.highlighted = idx
|
|
737
|
+
return True
|
|
738
|
+
return False
|
|
739
|
+
|
|
740
|
+
async def on_mount(self) -> None:
|
|
741
|
+
self.query_one("#prompt", Composer).focus()
|
|
742
|
+
self._hide_slash_palette()
|
|
743
|
+
# Native Textual anchoring keeps the transcript at the bottom as content
|
|
744
|
+
# grows, until the user scrolls away (see _on_transcript_scroll).
|
|
745
|
+
self._set_transcript_follow(True)
|
|
746
|
+
self._connect_session()
|
|
747
|
+
|
|
748
|
+
@on(TextArea.Changed, "#prompt")
|
|
749
|
+
def on_prompt_changed(self, event: TextArea.Changed) -> None:
|
|
750
|
+
if isinstance(event.text_area, Composer) and not event.text_area.in_search:
|
|
751
|
+
self._update_slash_palette(event.text_area.text)
|
|
752
|
+
|
|
753
|
+
@work(exclusive=True, group="session")
|
|
754
|
+
async def _connect_session(self) -> None:
|
|
755
|
+
try:
|
|
756
|
+
await self._controller.connect()
|
|
757
|
+
except RuntimeError as exc:
|
|
758
|
+
self._mount_system(str(exc), error=True)
|
|
759
|
+
self._exit_code = 1
|
|
760
|
+
self.exit(self._exit_code)
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
def _on_controller_event(self, event: ChatUiEvent) -> None:
|
|
764
|
+
self.call_later(self._handle_event, event)
|
|
765
|
+
|
|
766
|
+
def _handle_event(self, event: ChatUiEvent) -> None:
|
|
767
|
+
handler = _TUI_EVENT_HANDLERS.get(event.kind)
|
|
768
|
+
if handler is not None:
|
|
769
|
+
handler(self, event.payload)
|
|
770
|
+
|
|
771
|
+
def _ev_usage_updated(self, p: dict) -> None:
|
|
772
|
+
usage = p.get("usage")
|
|
773
|
+
if isinstance(usage, SessionUsageView):
|
|
774
|
+
self._set_ring(format_context_ring_markup(usage))
|
|
775
|
+
self._update_session_cost_from_view(usage)
|
|
776
|
+
self._refresh_status()
|
|
777
|
+
else:
|
|
778
|
+
view = self._controller.usage.usage
|
|
779
|
+
self._set_ring(format_context_ring_markup(view))
|
|
780
|
+
if view is not None:
|
|
781
|
+
self._update_session_cost_from_view(view)
|
|
782
|
+
self._refresh_status()
|
|
783
|
+
|
|
784
|
+
def _ev_session_ready(self, p: dict) -> None:
|
|
785
|
+
sid = str(p.get("session_id") or "") or None
|
|
786
|
+
self._session_id = sid
|
|
787
|
+
self._refresh_topbar()
|
|
788
|
+
|
|
789
|
+
def _ev_connection_state(self, p: dict) -> None:
|
|
790
|
+
state = str(p.get("state") or "")
|
|
791
|
+
if state == "reconnecting":
|
|
792
|
+
attempt = p.get("attempt")
|
|
793
|
+
self._conn_text = (
|
|
794
|
+
f"[yellow]reconnecting…[/] ({attempt})"
|
|
795
|
+
if attempt
|
|
796
|
+
else "[yellow]reconnecting…[/]"
|
|
797
|
+
)
|
|
798
|
+
elif state == "connected":
|
|
799
|
+
self._conn_text = ""
|
|
800
|
+
self._refresh_status()
|
|
801
|
+
|
|
802
|
+
def _ev_transcript_backfill(self, p: dict) -> None:
|
|
803
|
+
messages = p.get("messages") or []
|
|
804
|
+
if not isinstance(messages, list) or not messages:
|
|
805
|
+
return
|
|
806
|
+
self._hide_empty_hint()
|
|
807
|
+
for item in messages:
|
|
808
|
+
if not isinstance(item, dict):
|
|
809
|
+
continue
|
|
810
|
+
role = str(item.get("role") or "")
|
|
811
|
+
text = str(item.get("text") or "")
|
|
812
|
+
if not text:
|
|
813
|
+
continue
|
|
814
|
+
if role == "user":
|
|
815
|
+
self._mount(UserTurn(text, show_timestamp=self.show_timestamps))
|
|
816
|
+
elif role == "assistant":
|
|
817
|
+
turn = AssistantTurn(show_timestamp=self.show_timestamps)
|
|
818
|
+
self._mount(turn)
|
|
819
|
+
turn.append_delta(text)
|
|
820
|
+
turn.finish()
|
|
821
|
+
self._last_assistant_text = text
|
|
822
|
+
self._follow_scroll()
|
|
823
|
+
|
|
824
|
+
def _ev_thinking(self, p: dict) -> None:
|
|
825
|
+
self._show_thinking(str(p.get("text") or "thinking…"))
|
|
826
|
+
|
|
827
|
+
def _ev_thinking_clear(self, _p: dict) -> None:
|
|
828
|
+
self._clear_thinking()
|
|
829
|
+
|
|
830
|
+
def _ev_turn_started(self, _p: dict) -> None:
|
|
831
|
+
self._turn_active = True
|
|
832
|
+
self._assistant = None
|
|
833
|
+
self._seal_thinking_trace()
|
|
834
|
+
self._clear_open_tools()
|
|
835
|
+
self._show_thinking("thinking…")
|
|
836
|
+
self._refresh_status()
|
|
837
|
+
|
|
838
|
+
def _ev_assistant_start(self, _p: dict) -> None:
|
|
839
|
+
self._clear_thinking()
|
|
840
|
+
|
|
841
|
+
def _ev_assistant_delta(self, p: dict) -> None:
|
|
842
|
+
delta = str(p.get("delta", ""))
|
|
843
|
+
if not delta:
|
|
844
|
+
return
|
|
845
|
+
self._ensure_assistant().append_delta(delta)
|
|
846
|
+
self._follow_scroll()
|
|
847
|
+
|
|
848
|
+
def _ev_tool_started(self, p: dict) -> None:
|
|
849
|
+
self._close_assistant()
|
|
850
|
+
self._clear_thinking()
|
|
851
|
+
# Next thinking phase (e.g. after tools) should open a new block.
|
|
852
|
+
self._seal_thinking_trace()
|
|
853
|
+
raw_args = p.get("args")
|
|
854
|
+
args = dict(raw_args) if isinstance(raw_args, dict) else {}
|
|
855
|
+
tool = str(p.get("tool") or "tool")
|
|
856
|
+
label = str(p.get("label") or tool)
|
|
857
|
+
call_id = str(p.get("call_id") or "")
|
|
858
|
+
title = tool_collapsed_title(tool, label, args)
|
|
859
|
+
block = self._mount_tool(title, tool=tool, args=args, call_id=call_id)
|
|
860
|
+
self._register_open_tool(block)
|
|
861
|
+
|
|
862
|
+
def _ev_tool_finished(self, p: dict) -> None:
|
|
863
|
+
err = p.get("error")
|
|
864
|
+
result = str(p.get("result") or "")
|
|
865
|
+
call_id = str(p.get("call_id") or "")
|
|
866
|
+
block = self._pop_open_tool(call_id)
|
|
867
|
+
if block is None:
|
|
868
|
+
tool = str(p.get("tool") or "tool")
|
|
869
|
+
block = self._mount_tool(
|
|
870
|
+
tool_collapsed_title(tool, tool, {}),
|
|
871
|
+
tool=tool,
|
|
872
|
+
call_id=call_id,
|
|
873
|
+
)
|
|
874
|
+
block.mark_finished(error=err, result=result)
|
|
875
|
+
self._follow_scroll()
|
|
876
|
+
|
|
877
|
+
def _ev_summarizing(self, p: dict) -> None:
|
|
878
|
+
tokens = int(p.get("tokens") or 0)
|
|
879
|
+
self._show_thinking(f"summarizing context ({tokens:,} tokens)")
|
|
880
|
+
|
|
881
|
+
def _ev_summarized(self, p: dict) -> None:
|
|
882
|
+
turns = int(p.get("turns") or 0)
|
|
883
|
+
noun = "turn" if turns == 1 else "turns"
|
|
884
|
+
self._show_thinking(f"summarized {turns} {noun}")
|
|
885
|
+
|
|
886
|
+
def _ev_grounding(self, p: dict) -> None:
|
|
887
|
+
self._clear_thinking()
|
|
888
|
+
queries = p.get("search_queries") or []
|
|
889
|
+
lines = ["**grounded search**"]
|
|
890
|
+
if queries:
|
|
891
|
+
q = ", ".join(f'"{q}"' for q in queries)
|
|
892
|
+
lines[0] += f" — {q}"
|
|
893
|
+
for source in (p.get("sources") or [])[:5]:
|
|
894
|
+
if not isinstance(source, dict):
|
|
895
|
+
continue
|
|
896
|
+
title = str(source.get("title") or "").strip()
|
|
897
|
+
uri = str(source.get("uri") or "").strip()
|
|
898
|
+
if not uri:
|
|
899
|
+
continue
|
|
900
|
+
label = title or uri
|
|
901
|
+
lines.append(f"- [{label}]({uri})")
|
|
902
|
+
self._mount(GroundingBlock("\n".join(lines)))
|
|
903
|
+
|
|
904
|
+
def _ev_turn_error(self, p: dict) -> None:
|
|
905
|
+
self._close_assistant()
|
|
906
|
+
self._finish_thinking_trace(clear=True)
|
|
907
|
+
self._clear_thinking()
|
|
908
|
+
self._mount_system(f"Error: {p.get('error')}", error=True)
|
|
909
|
+
self._finish_turn_ui()
|
|
910
|
+
|
|
911
|
+
def _ev_turn_complete(self, p: dict) -> None:
|
|
912
|
+
self._close_assistant()
|
|
913
|
+
self._finish_thinking_trace(clear=True)
|
|
914
|
+
self._clear_thinking()
|
|
915
|
+
usage = p.get("usage")
|
|
916
|
+
if isinstance(usage, dict) and self.show_usage:
|
|
917
|
+
self._set_usage_line(usage)
|
|
918
|
+
self._finish_turn_ui()
|
|
919
|
+
|
|
920
|
+
def _ev_turn_aborted(self, p: dict) -> None:
|
|
921
|
+
self._close_assistant()
|
|
922
|
+
self._finish_thinking_trace(clear=True)
|
|
923
|
+
self._clear_thinking()
|
|
924
|
+
cancel_ok = p.get("cancel_ok")
|
|
925
|
+
if cancel_ok is True:
|
|
926
|
+
msg = "Turn aborted — cancel sent; server may still finish"
|
|
927
|
+
elif cancel_ok is False:
|
|
928
|
+
msg = "Turn aborted locally (cancel failed)"
|
|
929
|
+
else:
|
|
930
|
+
msg = "Turn aborted — cancel sent; server may still finish"
|
|
931
|
+
self._mount_system(msg)
|
|
932
|
+
self._hitl_active = False
|
|
933
|
+
self._hitl_kind = None
|
|
934
|
+
self._hitl_status_flash = None
|
|
935
|
+
self.query_one("#composer-wrap").remove_class("hitl")
|
|
936
|
+
self._clear_hitl_card()
|
|
937
|
+
self._finish_turn_ui()
|
|
938
|
+
|
|
939
|
+
def _ev_hitl_required(self, p: dict) -> None:
|
|
940
|
+
self._hitl_active = True
|
|
941
|
+
kind = str(p.get("hitl_kind") or "confirm")
|
|
942
|
+
self._hitl_kind = kind
|
|
943
|
+
self._hitl_status_flash = None
|
|
944
|
+
wrap = self.query_one("#composer-wrap")
|
|
945
|
+
wrap.add_class("hitl")
|
|
946
|
+
self._clear_hitl_card()
|
|
947
|
+
raw_schema = p.get("schema")
|
|
948
|
+
schema = dict(raw_schema) if isinstance(raw_schema, dict) else None
|
|
949
|
+
raw_args = p.get("arguments")
|
|
950
|
+
arguments = dict(raw_args) if isinstance(raw_args, dict) else {}
|
|
951
|
+
timeout_raw = p.get("timeout_sec")
|
|
952
|
+
timeout_sec: float | None
|
|
953
|
+
try:
|
|
954
|
+
timeout_sec = float(timeout_raw) if timeout_raw is not None else None
|
|
955
|
+
except (TypeError, ValueError):
|
|
956
|
+
timeout_sec = None
|
|
957
|
+
card = HitlCard(
|
|
958
|
+
str(p.get("prompt") or "Waiting for input…"),
|
|
959
|
+
hitl_kind=kind,
|
|
960
|
+
tool_name=str(p.get("tool_name") or ""),
|
|
961
|
+
arguments=arguments,
|
|
962
|
+
schema=schema,
|
|
963
|
+
timeout_sec=timeout_sec,
|
|
964
|
+
)
|
|
965
|
+
self._hitl_card = card
|
|
966
|
+
self._mount(card)
|
|
967
|
+
self._hide_slash_palette()
|
|
968
|
+
self._refresh_status()
|
|
969
|
+
self.query_one("#prompt", Composer).focus()
|
|
970
|
+
|
|
971
|
+
def _ev_hitl_failed(self, p: dict) -> None:
|
|
972
|
+
self._mount_system(str(p.get("message") or "HITL failed"), error=True)
|
|
973
|
+
self._clear_hitl_ui()
|
|
974
|
+
|
|
975
|
+
def _ev_hitl_frontend_unsupported(self, p: dict) -> None:
|
|
976
|
+
self._mount_system(
|
|
977
|
+
f"Frontend tool '{p.get('name')}' requires a UI — not supported here",
|
|
978
|
+
error=True,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
def _ev_session_busy(self, _p: dict) -> None:
|
|
982
|
+
self._mount_system("Session busy — wait for the current turn to finish")
|
|
983
|
+
self._finish_turn_ui()
|
|
984
|
+
|
|
985
|
+
def _ev_error(self, p: dict) -> None:
|
|
986
|
+
self._mount_system(str(p.get("message") or "Error"), error=True)
|
|
987
|
+
|
|
988
|
+
def _ev_stream_failed(self, p: dict) -> None:
|
|
989
|
+
self._mount_system(str(p.get("message") or "Stream failed"), error=True)
|
|
990
|
+
if p.get("fatal", True):
|
|
991
|
+
self._exit_code = 1
|
|
992
|
+
self.exit(self._exit_code)
|
|
993
|
+
|
|
994
|
+
def _ev_stream_ended(self, _p: dict) -> None:
|
|
995
|
+
self._exit_code = 1 if self._controller.stream_error else self._exit_code
|
|
996
|
+
|
|
997
|
+
def _ev_thinking_trace(self, p: dict) -> None:
|
|
998
|
+
# Legacy --show-thinking path; prefer thinking_block_* when present.
|
|
999
|
+
self._show_thinking(f"thinking {p.get('text')}")
|
|
1000
|
+
|
|
1001
|
+
def _ev_thinking_block_delta(self, p: dict) -> None:
|
|
1002
|
+
text = str(p.get("text") or "")
|
|
1003
|
+
if not text:
|
|
1004
|
+
return
|
|
1005
|
+
self._clear_thinking()
|
|
1006
|
+
if self._thinking_trace is not None and self._thinking_trace.is_attached:
|
|
1007
|
+
# Provider may reopen thinking after text started; append to the same
|
|
1008
|
+
# block above the reply instead of mounting a stray card below it.
|
|
1009
|
+
if self._thinking_trace.has_class("-done"):
|
|
1010
|
+
self._thinking_trace.reopen()
|
|
1011
|
+
else:
|
|
1012
|
+
block = ThinkingTrace()
|
|
1013
|
+
self._thinking_trace = block
|
|
1014
|
+
self._mount(block)
|
|
1015
|
+
self._thinking_trace.append_delta(text)
|
|
1016
|
+
self._follow_scroll()
|
|
1017
|
+
|
|
1018
|
+
def _ev_thinking_block_complete(self, _p: dict) -> None:
|
|
1019
|
+
# Keep the pointer so late re-entered deltas can reopen this block.
|
|
1020
|
+
self._finish_thinking_trace(clear=False)
|
|
1021
|
+
self._follow_scroll()
|
|
1022
|
+
|
|
1023
|
+
def _ev_voice_state(self, p: dict) -> None:
|
|
1024
|
+
state = str(p.get("state") or "")
|
|
1025
|
+
level = p.get("level_db")
|
|
1026
|
+
level_f = float(level) if isinstance(level, (int, float)) else None
|
|
1027
|
+
self._voice_level = level_f
|
|
1028
|
+
self._voice_text = format_voice_status(state, level_f) if state else ""
|
|
1029
|
+
self._refresh_status()
|
|
1030
|
+
|
|
1031
|
+
def _ev_audio_chunk(self, p: dict) -> None:
|
|
1032
|
+
level = p.get("level_db")
|
|
1033
|
+
if isinstance(level, (int, float)) and self._voice_text:
|
|
1034
|
+
state = "listening"
|
|
1035
|
+
if "PTT" in self._voice_text:
|
|
1036
|
+
state = "ptt_held"
|
|
1037
|
+
elif "speaking" in self._voice_text:
|
|
1038
|
+
state = "speaking"
|
|
1039
|
+
elif "muted" in self._voice_text:
|
|
1040
|
+
state = "muted"
|
|
1041
|
+
self._voice_text = format_voice_status(state, float(level))
|
|
1042
|
+
self._refresh_status()
|
|
1043
|
+
|
|
1044
|
+
def _ev_device_error(self, p: dict) -> None:
|
|
1045
|
+
msg = str(p.get("message") or "Audio device error")
|
|
1046
|
+
hint = p.get("hint")
|
|
1047
|
+
body = msg if not hint else f"{msg}\n{hint}"
|
|
1048
|
+
self._mount_system(body, error=True)
|
|
1049
|
+
|
|
1050
|
+
def _ev_user_transcript(self, p: dict) -> None:
|
|
1051
|
+
text = str(p.get("text") or "").strip()
|
|
1052
|
+
if not text:
|
|
1053
|
+
return
|
|
1054
|
+
if p.get("is_final", True):
|
|
1055
|
+
self._mount_user(text)
|
|
1056
|
+
self._clear_thinking()
|
|
1057
|
+
|
|
1058
|
+
def _toggle_tui_ptt(self) -> None:
|
|
1059
|
+
if getattr(self._controller, "turn_based", True):
|
|
1060
|
+
return
|
|
1061
|
+
self._tui_ptt_held = not self._tui_ptt_held
|
|
1062
|
+
setter = getattr(self._controller, "set_ptt_held", None)
|
|
1063
|
+
if callable(setter):
|
|
1064
|
+
setter(self._tui_ptt_held)
|
|
1065
|
+
self._refresh_status()
|
|
1066
|
+
|
|
1067
|
+
def _finish_turn_ui(self) -> None:
|
|
1068
|
+
self._turn_active = False
|
|
1069
|
+
self._assistant = None
|
|
1070
|
+
self._clear_open_tools()
|
|
1071
|
+
self._refresh_status()
|
|
1072
|
+
self.query_one("#prompt", Composer).focus()
|
|
1073
|
+
self.call_later(self._try_drain_pending)
|
|
1074
|
+
|
|
1075
|
+
def _try_drain_pending(self) -> None:
|
|
1076
|
+
if self._turn_active or self._hitl_active or not self._pending:
|
|
1077
|
+
return
|
|
1078
|
+
value = self._pending.pop(0)
|
|
1079
|
+
self._refresh_status()
|
|
1080
|
+
self._send_user_message(value)
|
|
1081
|
+
|
|
1082
|
+
def _clear_hitl_ui(self) -> None:
|
|
1083
|
+
self._hitl_active = False
|
|
1084
|
+
self._hitl_kind = None
|
|
1085
|
+
self._hitl_status_flash = None
|
|
1086
|
+
wrap = self.query_one("#composer-wrap")
|
|
1087
|
+
wrap.remove_class("hitl")
|
|
1088
|
+
self._clear_hitl_card()
|
|
1089
|
+
self._refresh_status()
|
|
1090
|
+
|
|
1091
|
+
def _flash_hitl_status(self, message: str) -> None:
|
|
1092
|
+
self._hitl_status_flash = message
|
|
1093
|
+
self._refresh_status()
|
|
1094
|
+
self.set_timer(1.5, self._clear_hitl_status_flash)
|
|
1095
|
+
|
|
1096
|
+
def _clear_hitl_status_flash(self) -> None:
|
|
1097
|
+
self._hitl_status_flash = None
|
|
1098
|
+
self._refresh_status()
|
|
1099
|
+
|
|
1100
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
1101
|
+
if action in ("hitl_approve", "hitl_deny"):
|
|
1102
|
+
return bool(self._hitl_active and self._hitl_kind == "confirm")
|
|
1103
|
+
return True
|
|
1104
|
+
|
|
1105
|
+
def action_hitl_approve(self) -> None:
|
|
1106
|
+
if self._hitl_active and self._hitl_kind == "confirm":
|
|
1107
|
+
self._finish_hitl_answer(HitlAnswer(approved=True, text="y"))
|
|
1108
|
+
|
|
1109
|
+
def action_hitl_deny(self) -> None:
|
|
1110
|
+
if self._hitl_active and self._hitl_kind == "confirm":
|
|
1111
|
+
self._finish_hitl_answer(HitlAnswer(approved=False, text="n"))
|
|
1112
|
+
|
|
1113
|
+
def _send_user_message(self, value: str) -> None:
|
|
1114
|
+
append_history(self.agent_root, value)
|
|
1115
|
+
composer = self.query_one("#prompt", Composer)
|
|
1116
|
+
composer.push_history(value)
|
|
1117
|
+
self._mount_user(value.strip())
|
|
1118
|
+
self._submit_message(value)
|
|
1119
|
+
|
|
1120
|
+
@on(Composer.Submitted)
|
|
1121
|
+
def on_composer_submitted(self, event: Composer.Submitted) -> None:
|
|
1122
|
+
value = event.text
|
|
1123
|
+
self._hide_slash_palette()
|
|
1124
|
+
if self._hitl_active:
|
|
1125
|
+
self._resolve_hitl(value)
|
|
1126
|
+
return
|
|
1127
|
+
if not value.strip():
|
|
1128
|
+
return
|
|
1129
|
+
parsed = parse_slash_command(value)
|
|
1130
|
+
if parsed is not None:
|
|
1131
|
+
self._dispatch_slash(parsed[0], parsed[1])
|
|
1132
|
+
return
|
|
1133
|
+
if self._turn_active:
|
|
1134
|
+
if not getattr(self._controller, "turn_based", True):
|
|
1135
|
+
# Realtime barge-in: interrupt then send immediately.
|
|
1136
|
+
self._controller.abort_turn()
|
|
1137
|
+
self._turn_active = False
|
|
1138
|
+
self._close_assistant()
|
|
1139
|
+
self._clear_thinking()
|
|
1140
|
+
else:
|
|
1141
|
+
if len(self._pending) >= _PENDING_LIMIT:
|
|
1142
|
+
self._mount_system(
|
|
1143
|
+
f"Queue full ({_PENDING_LIMIT}) — wait for the current turn",
|
|
1144
|
+
error=True,
|
|
1145
|
+
)
|
|
1146
|
+
return
|
|
1147
|
+
self._pending.append(value)
|
|
1148
|
+
self._refresh_status()
|
|
1149
|
+
return
|
|
1150
|
+
if self._controller.reconnecting or not self._controller.stream_alive:
|
|
1151
|
+
self._mount_system("Not connected — wait for reconnect", error=True)
|
|
1152
|
+
self._refresh_status()
|
|
1153
|
+
return
|
|
1154
|
+
self._send_user_message(value)
|
|
1155
|
+
|
|
1156
|
+
def _dispatch_slash(self, name: str, arg: str) -> None:
|
|
1157
|
+
if name == "bye":
|
|
1158
|
+
self._goodbye_and_exit()
|
|
1159
|
+
return
|
|
1160
|
+
if name == "help":
|
|
1161
|
+
self._cmd_help()
|
|
1162
|
+
return
|
|
1163
|
+
if name == "new":
|
|
1164
|
+
self._cmd_new()
|
|
1165
|
+
return
|
|
1166
|
+
if name == "resume":
|
|
1167
|
+
self._cmd_resume(arg)
|
|
1168
|
+
return
|
|
1169
|
+
if name == "usage":
|
|
1170
|
+
self._cmd_usage()
|
|
1171
|
+
return
|
|
1172
|
+
if name == "timestamps":
|
|
1173
|
+
self._cmd_timestamps()
|
|
1174
|
+
return
|
|
1175
|
+
if name == "copy":
|
|
1176
|
+
self._cmd_copy()
|
|
1177
|
+
return
|
|
1178
|
+
if name == "export":
|
|
1179
|
+
self._cmd_export()
|
|
1180
|
+
return
|
|
1181
|
+
self._mount_system("Unknown command — try /help", error=True)
|
|
1182
|
+
|
|
1183
|
+
def _cmd_help(self) -> None:
|
|
1184
|
+
lines = ["Commands:"]
|
|
1185
|
+
for cmd, desc in _SLASH_SPECS:
|
|
1186
|
+
lines.append(f" {cmd} — {desc}")
|
|
1187
|
+
lines.append(
|
|
1188
|
+
"Keys: Enter send · Ctrl+J / Alt+Enter / Shift+Enter newline · "
|
|
1189
|
+
"↑ history · Ctrl+R search · Ctrl+U usage · F1 hints"
|
|
1190
|
+
)
|
|
1191
|
+
self._mount_system("\n".join(lines))
|
|
1192
|
+
|
|
1193
|
+
def _cmd_new(self) -> None:
|
|
1194
|
+
if self._turn_active:
|
|
1195
|
+
self._mount_system("Wait for the current turn to finish before /new")
|
|
1196
|
+
return
|
|
1197
|
+
self._pending.clear()
|
|
1198
|
+
self._restart_session()
|
|
1199
|
+
|
|
1200
|
+
def _cmd_resume(self, arg: str) -> None:
|
|
1201
|
+
sid = arg.strip()
|
|
1202
|
+
if not sid:
|
|
1203
|
+
self._mount_system("Usage: /resume <session_id>", error=True)
|
|
1204
|
+
return
|
|
1205
|
+
if self._turn_active:
|
|
1206
|
+
self._mount_system("Wait for the current turn to finish before /resume")
|
|
1207
|
+
return
|
|
1208
|
+
self._pending.clear()
|
|
1209
|
+
self._resume_session(sid)
|
|
1210
|
+
|
|
1211
|
+
@work(exclusive=True, group="session")
|
|
1212
|
+
async def _restart_session(self) -> None:
|
|
1213
|
+
try:
|
|
1214
|
+
await self._controller.restart_session()
|
|
1215
|
+
except RuntimeError as exc:
|
|
1216
|
+
self._mount_system(str(exc), error=True)
|
|
1217
|
+
return
|
|
1218
|
+
self._reset_transcript_ui()
|
|
1219
|
+
self._mount_system("New session")
|
|
1220
|
+
self._refresh_status()
|
|
1221
|
+
self.query_one("#prompt", Composer).focus()
|
|
1222
|
+
|
|
1223
|
+
@work(exclusive=True, group="session")
|
|
1224
|
+
async def _resume_session(self, session_id: str) -> None:
|
|
1225
|
+
try:
|
|
1226
|
+
await self._controller.resume_session(session_id)
|
|
1227
|
+
except RuntimeError as exc:
|
|
1228
|
+
self._mount_system(str(exc), error=True)
|
|
1229
|
+
return
|
|
1230
|
+
self._reset_transcript_ui(keep_empty_hint=False)
|
|
1231
|
+
self._mount_system(f"Resumed session {session_id[:8]}")
|
|
1232
|
+
self._refresh_status()
|
|
1233
|
+
self.query_one("#prompt", Composer).focus()
|
|
1234
|
+
|
|
1235
|
+
def _reset_transcript_ui(self, *, keep_empty_hint: bool = True) -> None:
|
|
1236
|
+
transcript = self._transcript()
|
|
1237
|
+
for child in list(transcript.children):
|
|
1238
|
+
child.remove()
|
|
1239
|
+
self._assistant = None
|
|
1240
|
+
self._thinking = None
|
|
1241
|
+
self._thinking_trace = None
|
|
1242
|
+
self._hitl_card = None
|
|
1243
|
+
self._clear_open_tools()
|
|
1244
|
+
self._last_assistant_text = ""
|
|
1245
|
+
self._usage_text = ""
|
|
1246
|
+
self._session_cost_text = ""
|
|
1247
|
+
self._ring_text = "[dim]○ 0%[/]"
|
|
1248
|
+
self._turn_count = 0
|
|
1249
|
+
if keep_empty_hint:
|
|
1250
|
+
transcript.mount(self._make_empty_hint())
|
|
1251
|
+
|
|
1252
|
+
def _cmd_usage(self) -> None:
|
|
1253
|
+
self.show_usage = not self.show_usage
|
|
1254
|
+
self._controller.show_usage = self.show_usage
|
|
1255
|
+
if self.show_usage:
|
|
1256
|
+
self._mount_system("Usage display on")
|
|
1257
|
+
self._refresh_usage()
|
|
1258
|
+
else:
|
|
1259
|
+
self._usage_text = ""
|
|
1260
|
+
self._mount_system("Usage display off")
|
|
1261
|
+
self._refresh_status()
|
|
1262
|
+
|
|
1263
|
+
def _cmd_timestamps(self) -> None:
|
|
1264
|
+
self.show_timestamps = not self.show_timestamps
|
|
1265
|
+
self._apply_timestamps()
|
|
1266
|
+
state = "on" if self.show_timestamps else "off"
|
|
1267
|
+
self._mount_system(f"Timestamps {state}")
|
|
1268
|
+
|
|
1269
|
+
@work
|
|
1270
|
+
async def _refresh_usage(self) -> None:
|
|
1271
|
+
await self._controller.refresh_usage()
|
|
1272
|
+
|
|
1273
|
+
def _cmd_copy(self) -> None:
|
|
1274
|
+
text = self._last_assistant_text
|
|
1275
|
+
if not text.strip():
|
|
1276
|
+
self._mount_system("Nothing to copy — no assistant reply yet")
|
|
1277
|
+
return
|
|
1278
|
+
if write_osc52_clipboard(self, text):
|
|
1279
|
+
self._mount_system("Copied last assistant reply")
|
|
1280
|
+
else:
|
|
1281
|
+
self._mount_system("Clipboard write failed (OSC 52 unavailable)", error=True)
|
|
1282
|
+
|
|
1283
|
+
def _cmd_export(self) -> None:
|
|
1284
|
+
try:
|
|
1285
|
+
path = self._write_transcript_export()
|
|
1286
|
+
except OSError as exc:
|
|
1287
|
+
self._mount_system(f"Export failed: {exc}", error=True)
|
|
1288
|
+
return
|
|
1289
|
+
self._mount_system(f"Exported transcript to {path}")
|
|
1290
|
+
|
|
1291
|
+
def _write_transcript_export(self) -> Path:
|
|
1292
|
+
lines: list[str] = ["# monkeybot chat export", ""]
|
|
1293
|
+
for child in self._transcript().children:
|
|
1294
|
+
if isinstance(child, EarlierTurns):
|
|
1295
|
+
lines.extend(
|
|
1296
|
+
[
|
|
1297
|
+
f"## Earlier ({child.omitted} turns)",
|
|
1298
|
+
"",
|
|
1299
|
+
*child.digest_lines,
|
|
1300
|
+
"",
|
|
1301
|
+
]
|
|
1302
|
+
)
|
|
1303
|
+
elif isinstance(child, UserTurn):
|
|
1304
|
+
lines.extend(["## You", "", child.body, ""])
|
|
1305
|
+
elif isinstance(child, AssistantTurn):
|
|
1306
|
+
lines.extend(["## Assistant", "", child._raw, ""])
|
|
1307
|
+
elif isinstance(child, ThinkingTrace):
|
|
1308
|
+
lines.extend(["## Thinking", "", child._raw, ""])
|
|
1309
|
+
elif isinstance(child, SystemLine):
|
|
1310
|
+
lines.extend([f"*{child.body}*", ""])
|
|
1311
|
+
elif isinstance(child, GroundingBlock):
|
|
1312
|
+
lines.extend([child.markdown, ""])
|
|
1313
|
+
elif isinstance(child, ToolCallBlock):
|
|
1314
|
+
lines.extend([f"### Tool: {child.display_label}", ""])
|
|
1315
|
+
elif isinstance(child, HitlCard):
|
|
1316
|
+
lines.extend([f"*HITL: {child.prompt}*", ""])
|
|
1317
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1318
|
+
path = self.agent_root / "data" / f"chat_export_{ts}.md"
|
|
1319
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1320
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
1321
|
+
return path
|
|
1322
|
+
|
|
1323
|
+
def _finish_hitl_answer(self, answer: HitlAnswer) -> None:
|
|
1324
|
+
self._clear_hitl_ui()
|
|
1325
|
+
with contextlib.suppress(NoMatches):
|
|
1326
|
+
composer = self.query_one("#prompt", Composer)
|
|
1327
|
+
composer.clear()
|
|
1328
|
+
composer._sync_height()
|
|
1329
|
+
self._controller.provide_hitl_answer(answer)
|
|
1330
|
+
|
|
1331
|
+
def _resolve_hitl(self, value: str) -> None:
|
|
1332
|
+
text = value.strip()
|
|
1333
|
+
lower = text.lower()
|
|
1334
|
+
kind = self._hitl_kind or "confirm"
|
|
1335
|
+
if kind == "confirm":
|
|
1336
|
+
if not text:
|
|
1337
|
+
self._flash_hitl_status("press y or n")
|
|
1338
|
+
return
|
|
1339
|
+
if lower in ("y", "yes"):
|
|
1340
|
+
self._finish_hitl_answer(HitlAnswer(approved=True, text=text))
|
|
1341
|
+
elif lower in ("n", "no"):
|
|
1342
|
+
self._finish_hitl_answer(HitlAnswer(approved=False, text=text))
|
|
1343
|
+
else:
|
|
1344
|
+
self._finish_hitl_answer(HitlAnswer(approved=False, text=text))
|
|
1345
|
+
return
|
|
1346
|
+
if not text:
|
|
1347
|
+
return
|
|
1348
|
+
self._finish_hitl_answer(HitlAnswer(text=text))
|
|
1349
|
+
|
|
1350
|
+
def _resolve_hitl_timeout(self) -> None:
|
|
1351
|
+
if not self._hitl_active:
|
|
1352
|
+
return
|
|
1353
|
+
self._clear_hitl_ui()
|
|
1354
|
+
self._mount_system("confirmation timed out")
|
|
1355
|
+
self._controller.provide_hitl_answer(HitlAnswer(cancelled=True))
|
|
1356
|
+
|
|
1357
|
+
@work(exclusive=True, group="submit")
|
|
1358
|
+
async def _submit_message(self, message: str) -> None:
|
|
1359
|
+
await self._controller.submit(message)
|
|
1360
|
+
if not self._controller.stream_alive:
|
|
1361
|
+
self._exit_code = 1
|
|
1362
|
+
self.exit(self._exit_code)
|
|
1363
|
+
|
|
1364
|
+
def action_toggle_hints(self) -> None:
|
|
1365
|
+
self.show_hints = not self.show_hints
|
|
1366
|
+
|
|
1367
|
+
def action_toggle_usage(self) -> None:
|
|
1368
|
+
self._cmd_usage()
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def action_toggle_ptt(self) -> None:
|
|
1372
|
+
"""Space toggles in-TUI PTT when composer is empty (realtime / SSH)."""
|
|
1373
|
+
with contextlib.suppress(NoMatches):
|
|
1374
|
+
composer = self.query_one("#prompt", Composer)
|
|
1375
|
+
if composer.text.strip() or getattr(self._controller, "turn_based", True):
|
|
1376
|
+
# Don't steal Space from normal typing / turn-based chat.
|
|
1377
|
+
composer.insert(" ")
|
|
1378
|
+
return
|
|
1379
|
+
self._toggle_tui_ptt()
|
|
1380
|
+
|
|
1381
|
+
def action_ctrl_c(self) -> None:
|
|
1382
|
+
composer = self.query_one("#prompt", Composer)
|
|
1383
|
+
if composer.in_search:
|
|
1384
|
+
composer.action_cancel_search()
|
|
1385
|
+
return
|
|
1386
|
+
if self._hitl_active:
|
|
1387
|
+
self._finish_hitl_answer(HitlAnswer(cancelled=True))
|
|
1388
|
+
return
|
|
1389
|
+
if self._turn_active:
|
|
1390
|
+
self._controller.abort_turn()
|
|
1391
|
+
return
|
|
1392
|
+
if composer.text.strip():
|
|
1393
|
+
composer.clear()
|
|
1394
|
+
composer._sync_height()
|
|
1395
|
+
self._hide_slash_palette()
|
|
1396
|
+
return
|
|
1397
|
+
self._goodbye_and_exit()
|
|
1398
|
+
|
|
1399
|
+
def _goodbye_and_exit(self) -> None:
|
|
1400
|
+
msg = (
|
|
1401
|
+
"Goodbye — shutting down gateway…"
|
|
1402
|
+
if self.spawned_gateway
|
|
1403
|
+
else "Goodbye."
|
|
1404
|
+
)
|
|
1405
|
+
self._mount_system(msg)
|
|
1406
|
+
self._close_session_and_exit()
|
|
1407
|
+
|
|
1408
|
+
@work(exclusive=True)
|
|
1409
|
+
async def _close_session_and_exit(self) -> None:
|
|
1410
|
+
# Must not be named `_shutdown` — that shadows Textual.App._shutdown.
|
|
1411
|
+
await self._controller.close()
|
|
1412
|
+
self._exit_code = 1 if self._controller.stream_error else self._exit_code
|
|
1413
|
+
self.exit(self._exit_code)
|
|
1414
|
+
|
|
1415
|
+
async def on_unmount(self) -> None:
|
|
1416
|
+
try:
|
|
1417
|
+
await self._controller.close()
|
|
1418
|
+
except Exception:
|
|
1419
|
+
logger.debug("controller close failed during unmount", exc_info=True)
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
_TUI_EVENT_HANDLERS = {
|
|
1423
|
+
"usage_updated": ChatApp._ev_usage_updated,
|
|
1424
|
+
"session_ready": ChatApp._ev_session_ready,
|
|
1425
|
+
"connection_state": ChatApp._ev_connection_state,
|
|
1426
|
+
"transcript_backfill": ChatApp._ev_transcript_backfill,
|
|
1427
|
+
"thinking": ChatApp._ev_thinking,
|
|
1428
|
+
"thinking_clear": ChatApp._ev_thinking_clear,
|
|
1429
|
+
"turn_started": ChatApp._ev_turn_started,
|
|
1430
|
+
"assistant_start": ChatApp._ev_assistant_start,
|
|
1431
|
+
"assistant_delta": ChatApp._ev_assistant_delta,
|
|
1432
|
+
"tool_started": ChatApp._ev_tool_started,
|
|
1433
|
+
"tool_finished": ChatApp._ev_tool_finished,
|
|
1434
|
+
"summarizing": ChatApp._ev_summarizing,
|
|
1435
|
+
"summarized": ChatApp._ev_summarized,
|
|
1436
|
+
"grounding": ChatApp._ev_grounding,
|
|
1437
|
+
"turn_error": ChatApp._ev_turn_error,
|
|
1438
|
+
"turn_complete": ChatApp._ev_turn_complete,
|
|
1439
|
+
"turn_aborted": ChatApp._ev_turn_aborted,
|
|
1440
|
+
"hitl_required": ChatApp._ev_hitl_required,
|
|
1441
|
+
"hitl_failed": ChatApp._ev_hitl_failed,
|
|
1442
|
+
"hitl_frontend_unsupported": ChatApp._ev_hitl_frontend_unsupported,
|
|
1443
|
+
"session_busy": ChatApp._ev_session_busy,
|
|
1444
|
+
"error": ChatApp._ev_error,
|
|
1445
|
+
"stream_failed": ChatApp._ev_stream_failed,
|
|
1446
|
+
"stream_ended": ChatApp._ev_stream_ended,
|
|
1447
|
+
"thinking_trace": ChatApp._ev_thinking_trace,
|
|
1448
|
+
"thinking_block_delta": ChatApp._ev_thinking_block_delta,
|
|
1449
|
+
"thinking_block_complete": ChatApp._ev_thinking_block_complete,
|
|
1450
|
+
"voice_state": ChatApp._ev_voice_state,
|
|
1451
|
+
"audio_chunk": ChatApp._ev_audio_chunk,
|
|
1452
|
+
"device_error": ChatApp._ev_device_error,
|
|
1453
|
+
"user_transcript": ChatApp._ev_user_transcript,
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def run_chat_tui(
|
|
1458
|
+
*,
|
|
1459
|
+
base: str,
|
|
1460
|
+
agent_root: Path,
|
|
1461
|
+
provider: str,
|
|
1462
|
+
model: str,
|
|
1463
|
+
spawned_gateway: bool,
|
|
1464
|
+
model_provider: str | None = None,
|
|
1465
|
+
model_name: str | None = None,
|
|
1466
|
+
show_thinking: bool = False,
|
|
1467
|
+
verbose: bool = False,
|
|
1468
|
+
show_usage: bool = False,
|
|
1469
|
+
resume_session_id: str | None = None,
|
|
1470
|
+
animations_enabled: bool = True,
|
|
1471
|
+
theme_choice: str = "auto",
|
|
1472
|
+
controller: SessionController | None = None,
|
|
1473
|
+
) -> int:
|
|
1474
|
+
app = ChatApp(
|
|
1475
|
+
base=base,
|
|
1476
|
+
agent_root=agent_root,
|
|
1477
|
+
provider=provider,
|
|
1478
|
+
model=model,
|
|
1479
|
+
spawned_gateway=spawned_gateway,
|
|
1480
|
+
model_provider=model_provider,
|
|
1481
|
+
model_name=model_name,
|
|
1482
|
+
show_thinking=show_thinking,
|
|
1483
|
+
verbose=verbose,
|
|
1484
|
+
show_usage=show_usage,
|
|
1485
|
+
resume_session_id=resume_session_id,
|
|
1486
|
+
animations_enabled=animations_enabled,
|
|
1487
|
+
theme_choice=theme_choice,
|
|
1488
|
+
controller=controller,
|
|
1489
|
+
)
|
|
1490
|
+
result = app.run()
|
|
1491
|
+
return int(result) if isinstance(result, int) else 0
|