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.
Files changed (51) hide show
  1. monkeybot_cli/__init__.py +3 -0
  2. monkeybot_cli/chat_renderer.py +87 -0
  3. monkeybot_cli/chat_session.py +911 -0
  4. monkeybot_cli/chat_status_bar.py +205 -0
  5. monkeybot_cli/chat_theme.py +91 -0
  6. monkeybot_cli/chat_tool_display.py +334 -0
  7. monkeybot_cli/chat_tui.py +1491 -0
  8. monkeybot_cli/chat_tui_widgets.py +996 -0
  9. monkeybot_cli/commands/__init__.py +1 -0
  10. monkeybot_cli/commands/chat.py +817 -0
  11. monkeybot_cli/commands/doctor.py +293 -0
  12. monkeybot_cli/commands/loop.py +207 -0
  13. monkeybot_cli/commands/new.py +207 -0
  14. monkeybot_cli/commands/run_cmd.py +41 -0
  15. monkeybot_cli/commands/talk.py +102 -0
  16. monkeybot_cli/commands/validate.py +385 -0
  17. monkeybot_cli/compat.py +7 -0
  18. monkeybot_cli/config_resolve.py +55 -0
  19. monkeybot_cli/exit_commands.py +13 -0
  20. monkeybot_cli/extras_catalog.py +95 -0
  21. monkeybot_cli/gateway_health.py +34 -0
  22. monkeybot_cli/main.py +38 -0
  23. monkeybot_cli/opensandbox_lifecycle.py +314 -0
  24. monkeybot_cli/output.py +110 -0
  25. monkeybot_cli/providers.py +112 -0
  26. monkeybot_cli/realtime/__init__.py +13 -0
  27. monkeybot_cli/realtime/audio_io.py +147 -0
  28. monkeybot_cli/realtime/client.py +17 -0
  29. monkeybot_cli/realtime/gateway_manager.py +142 -0
  30. monkeybot_cli/realtime/push_to_talk.py +128 -0
  31. monkeybot_cli/realtime/session.py +256 -0
  32. monkeybot_cli/realtime/session_controller.py +501 -0
  33. monkeybot_cli/realtime/talk_ui.py +243 -0
  34. monkeybot_cli/realtime/wire_encode.py +39 -0
  35. monkeybot_cli/runtime_python.py +91 -0
  36. monkeybot_cli/scaffold.py +287 -0
  37. monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
  38. monkeybot_cli/scaffold_defaults/__init__.py +1 -0
  39. monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
  40. monkeybot_cli/scaffold_defaults/env.example +35 -0
  41. monkeybot_cli/scaffold_defaults/mcp.json +49 -0
  42. monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
  43. monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
  44. monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
  45. monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
  46. monkeybot_cli/session_controller.py +7 -0
  47. monkeybot_cli/terminal_markdown.py +48 -0
  48. monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
  49. monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
  50. monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
  51. 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