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,996 @@
1
+ """Textual widgets for the ``monkeybot chat`` TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import logging
8
+ import time
9
+ from datetime import datetime
10
+
11
+ from rich.text import Text
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Vertical, VerticalScroll
15
+ from textual.css.query import NoMatches
16
+ from textual.events import Key, Paste
17
+ from textual.message import Message
18
+ from textual.timer import Timer
19
+ from textual.widgets import Collapsible, Markdown, Static, TextArea
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ from monkeybot_cli.chat_session import format_args_preview, format_schema_field_lines
24
+ from monkeybot_cli.chat_tool_display import format_tool_expand_body
25
+
26
+ _SPIN = "⠋⠙⠹⠸⠴⠦⠧⠇⠏"
27
+ _HISTORY_LIMIT = 500
28
+ _COMPOSER_PLACEHOLDER = "Message the agent — / for commands"
29
+
30
+
31
+ def write_osc52_clipboard(app: App[object], text: str) -> bool:
32
+ """Best-effort OSC 52 clipboard write via the Textual driver."""
33
+ try:
34
+ payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
35
+ seq = f"\x1b]52;c;{payload}\x07"
36
+ driver = getattr(app, "_driver", None)
37
+ if driver is None or not hasattr(driver, "write"):
38
+ logger.debug("OSC 52 clipboard skipped: no Textual driver.write")
39
+ return False
40
+ driver.write(seq)
41
+ return True
42
+ except Exception:
43
+ logger.exception("OSC 52 clipboard write failed")
44
+ return False
45
+
46
+
47
+ class ThinkingLine(Static):
48
+ """Transient dim status row in the transcript (thinking / summarizing)."""
49
+
50
+ DEFAULT_CSS = """
51
+ ThinkingLine {
52
+ width: 1fr;
53
+ height: auto;
54
+ margin-top: 1;
55
+ padding: 0 1;
56
+ color: $muted;
57
+ text-style: dim italic;
58
+ }
59
+ """
60
+
61
+ def __init__(self, text: str = "thinking…", **kwargs: object) -> None:
62
+ super().__init__(Text(text, style="dim italic"), **kwargs) # type: ignore[arg-type]
63
+ self._label = text
64
+ self._spin_i = 0
65
+ self._started = time.monotonic()
66
+ self._timer: Timer | None = None
67
+
68
+ def _animations_enabled(self) -> bool:
69
+ app = self.app
70
+ return bool(getattr(app, "animations_enabled", True))
71
+
72
+ def on_mount(self) -> None:
73
+ if self._animations_enabled():
74
+ self._timer = self.set_interval(0.25, self._tick)
75
+ else:
76
+ # Elapsed seconds without spinner glyph.
77
+ self._timer = self.set_interval(1.0, self._tick)
78
+ self._tick()
79
+
80
+ def on_unmount(self) -> None:
81
+ if self._timer is not None:
82
+ self._timer.stop()
83
+ self._timer = None
84
+
85
+ def set_text(self, text: str) -> None:
86
+ self._label = text
87
+ self._render_line()
88
+
89
+ def _tick(self) -> None:
90
+ self._spin_i += 1
91
+ self._render_line()
92
+
93
+ def _render_line(self) -> None:
94
+ elapsed = max(0, int(time.monotonic() - self._started))
95
+ if self._animations_enabled():
96
+ glyph = _SPIN[self._spin_i % len(_SPIN)]
97
+ self.update(Text(f"{glyph} {self._label} {elapsed}s", style="dim italic"))
98
+ else:
99
+ self.update(Text(f"{self._label} {elapsed}s", style="dim italic"))
100
+
101
+
102
+ class ThinkingTrace(Vertical):
103
+ """OpenCode-style thinking block: ``Thinking...`` / body / ``...done thinking.``"""
104
+
105
+ DEFAULT_CSS = """
106
+ ThinkingTrace {
107
+ width: 1fr;
108
+ height: auto;
109
+ margin-top: 1;
110
+ padding: 0 1;
111
+ }
112
+ ThinkingTrace > .header {
113
+ height: 1;
114
+ color: $muted;
115
+ text-style: bold dim;
116
+ }
117
+ ThinkingTrace > .body {
118
+ height: auto;
119
+ color: $muted;
120
+ text-style: dim;
121
+ }
122
+ ThinkingTrace > .footer {
123
+ height: 1;
124
+ color: $muted;
125
+ text-style: bold dim;
126
+ display: none;
127
+ }
128
+ ThinkingTrace.-done > .footer {
129
+ display: block;
130
+ }
131
+ """
132
+
133
+ def __init__(self, **kwargs: object) -> None:
134
+ super().__init__(**kwargs) # type: ignore[arg-type]
135
+ self._raw = ""
136
+
137
+ def compose(self) -> ComposeResult:
138
+ yield Static("Thinking...", classes="header")
139
+ yield Static("", classes="body")
140
+ yield Static("...done thinking.", classes="footer")
141
+
142
+ def append_delta(self, chunk: str) -> None:
143
+ if not chunk:
144
+ return
145
+ self._raw += chunk
146
+ with contextlib.suppress(NoMatches):
147
+ self.query_one(".body", Static).update(Text(self._raw, style="dim"))
148
+
149
+ def finish(self) -> None:
150
+ self.add_class("-done")
151
+
152
+ def reopen(self) -> None:
153
+ """Allow late thinking deltas to continue this block after a premature close."""
154
+ self.remove_class("-done")
155
+
156
+
157
+ class SystemLine(Static):
158
+ """Dim full-width system / error line."""
159
+
160
+ DEFAULT_CSS = """
161
+ SystemLine {
162
+ width: 1fr;
163
+ height: auto;
164
+ margin-top: 1;
165
+ padding: 0 1;
166
+ color: $muted;
167
+ }
168
+ SystemLine.error {
169
+ color: $error;
170
+ }
171
+ """
172
+
173
+ def __init__(self, body: str, *, error: bool = False, **kwargs: object) -> None:
174
+ super().__init__(**kwargs) # type: ignore[arg-type]
175
+ self.body = body
176
+ self.mounted_at = datetime.now()
177
+ if error:
178
+ self.add_class("error")
179
+
180
+ def on_mount(self) -> None:
181
+ style = "red" if self.has_class("error") else "dim"
182
+ self.update(Text(self.body, style=style))
183
+
184
+
185
+ class GroundingBlock(Vertical):
186
+ """Grounding sources with clickable markdown links."""
187
+
188
+ DEFAULT_CSS = """
189
+ GroundingBlock {
190
+ width: 1fr;
191
+ height: auto;
192
+ margin-top: 1;
193
+ padding: 0 1;
194
+ }
195
+ GroundingBlock > Markdown {
196
+ height: auto;
197
+ margin: 0;
198
+ padding: 0;
199
+ background: transparent;
200
+ color: $muted;
201
+ }
202
+ """
203
+
204
+ def __init__(self, markdown: str, **kwargs: object) -> None:
205
+ super().__init__(**kwargs) # type: ignore[arg-type]
206
+ self.markdown = markdown
207
+ self.mounted_at = datetime.now()
208
+
209
+ def compose(self) -> ComposeResult:
210
+ yield Markdown(self.markdown, open_links=True)
211
+
212
+
213
+ class EarlierTurns(Collapsible):
214
+ """Summary of trimmed older transcript widgets."""
215
+
216
+ DEFAULT_CSS = """
217
+ EarlierTurns {
218
+ width: 1fr;
219
+ height: auto;
220
+ padding: 0 1;
221
+ margin-top: 1;
222
+ color: $muted;
223
+ }
224
+ EarlierTurns > Contents {
225
+ height: auto;
226
+ padding: 0 1 1 3;
227
+ color: $disabled;
228
+ }
229
+ """
230
+
231
+ def __init__(self, **kwargs: object) -> None:
232
+ self.omitted = 0
233
+ self.digest_lines: list[str] = []
234
+ self._body = Static("", classes="earlier-body")
235
+ super().__init__(
236
+ self._body,
237
+ title=" 0 earlier turns",
238
+ collapsed=True,
239
+ collapsed_symbol="▶",
240
+ expanded_symbol="▼",
241
+ **kwargs, # type: ignore[arg-type]
242
+ )
243
+
244
+ def absorb(self, label: str) -> None:
245
+ self.omitted += 1
246
+ self.digest_lines.append(label)
247
+ if len(self.digest_lines) > 40:
248
+ self.digest_lines = self.digest_lines[-40:]
249
+ noun = "turn" if self.omitted == 1 else "turns"
250
+ self.title = f" {self.omitted} earlier {noun}"
251
+ preview = "\n".join(self.digest_lines[-20:])
252
+ self._body.update(Text(preview, style="dim"))
253
+
254
+
255
+ class UserTurn(Static):
256
+ """Single-column user message."""
257
+
258
+ DEFAULT_CSS = """
259
+ UserTurn {
260
+ width: 1fr;
261
+ height: auto;
262
+ margin-top: 1;
263
+ padding: 0 1;
264
+ color: $foreground;
265
+ }
266
+ """
267
+
268
+ def __init__(self, body: str, *, show_timestamp: bool = False, **kwargs: object) -> None:
269
+ super().__init__(**kwargs) # type: ignore[arg-type]
270
+ self.body = body
271
+ self.mounted_at = datetime.now()
272
+ self.show_timestamp = show_timestamp
273
+
274
+ def on_mount(self) -> None:
275
+ self.refresh_label()
276
+
277
+ def refresh_label(self) -> None:
278
+ content = Text()
279
+ if self.show_timestamp:
280
+ content.append(self.mounted_at.strftime("%H:%M:%S "), style="dim")
281
+ content.append("you\n", style="bold")
282
+ content.append(self.body)
283
+ self.update(content)
284
+
285
+
286
+ class AssistantTurn(Vertical):
287
+ """Single-column assistant reply with live markdown + streaming cursor."""
288
+
289
+ DEFAULT_CSS = """
290
+ AssistantTurn {
291
+ width: 1fr;
292
+ height: auto;
293
+ margin-top: 1;
294
+ padding: 0 1;
295
+ }
296
+ AssistantTurn > .role {
297
+ height: 1;
298
+ color: $muted;
299
+ text-style: dim;
300
+ }
301
+ AssistantTurn > Markdown {
302
+ height: auto;
303
+ margin: 0;
304
+ padding: 0;
305
+ background: transparent;
306
+ color: $assistant;
307
+ }
308
+ AssistantTurn > .cursor {
309
+ height: 1;
310
+ color: $accent;
311
+ }
312
+ AssistantTurn.-done > .cursor {
313
+ display: none;
314
+ }
315
+ """
316
+
317
+ def __init__(self, *, show_timestamp: bool = False, **kwargs: object) -> None:
318
+ super().__init__(**kwargs) # type: ignore[arg-type]
319
+ self._raw = ""
320
+ self._pending = ""
321
+ self._streaming = True
322
+ self._flush_timer: Timer | None = None
323
+ self.mounted_at = datetime.now()
324
+ self.show_timestamp = show_timestamp
325
+
326
+ def compose(self) -> ComposeResult:
327
+ yield Static(self._role_label(), classes="role")
328
+ yield Markdown("", open_links=False, classes="body")
329
+ yield Static("▍", classes="cursor")
330
+
331
+ def _role_label(self) -> str:
332
+ if self.show_timestamp:
333
+ return f"{self.mounted_at.strftime('%H:%M:%S')} assistant"
334
+ return "assistant"
335
+
336
+ def refresh_label(self) -> None:
337
+ with contextlib.suppress(NoMatches):
338
+ self.query_one(".role", Static).update(self._role_label())
339
+
340
+ def on_mount(self) -> None:
341
+ # Flush timer is started lazily on first delta (and paused when drained).
342
+ return
343
+
344
+ def append_delta(self, chunk: str) -> None:
345
+ if not chunk:
346
+ return
347
+ self._raw += chunk
348
+ self._pending += chunk
349
+ app = self.app
350
+ if not bool(getattr(app, "animations_enabled", True)):
351
+ self._flush_markdown()
352
+ return
353
+ if self._flush_timer is None and self.is_attached:
354
+ self._flush_timer = self.set_interval(0.05, self._flush_markdown)
355
+
356
+ def finish(self) -> None:
357
+ self._streaming = False
358
+ if self.is_attached:
359
+ self._flush_markdown()
360
+ self.add_class("-done")
361
+ if self._flush_timer is not None:
362
+ self._flush_timer.stop()
363
+ self._flush_timer = None
364
+
365
+ def _flush_markdown(self) -> None:
366
+ if not self._pending:
367
+ if self._flush_timer is not None:
368
+ self._flush_timer.stop()
369
+ self._flush_timer = None
370
+ return
371
+ chunk = self._pending
372
+ self._pending = ""
373
+ nodes = self.query(Markdown)
374
+ if nodes:
375
+ # Append-only: O(chunk) vs full reparse via update(_raw).
376
+ nodes.first().append(chunk)
377
+ if not self._pending and self._flush_timer is not None:
378
+ self._flush_timer.stop()
379
+ self._flush_timer = None
380
+
381
+
382
+ class ToolCallBlock(Collapsible):
383
+ """Collapsed grey tool row; expand for Command/Result sections."""
384
+
385
+ DEFAULT_CSS = """
386
+ ToolCallBlock {
387
+ width: 1fr;
388
+ height: auto;
389
+ background: transparent;
390
+ border-top: none;
391
+ padding: 0 1;
392
+ margin-top: 0;
393
+ color: $muted;
394
+ }
395
+ ToolCallBlock > Contents {
396
+ width: 1fr;
397
+ height: auto;
398
+ padding: 0 1 1 3;
399
+ color: $disabled;
400
+ }
401
+ ToolCallBlock.running {
402
+ color: $muted;
403
+ }
404
+ ToolCallBlock.ok {
405
+ color: $muted;
406
+ }
407
+ ToolCallBlock.error {
408
+ color: $tool-error;
409
+ }
410
+ ToolCallBlock .tool-detail {
411
+ width: 1fr;
412
+ height: auto;
413
+ margin: 0;
414
+ padding: 0;
415
+ background: transparent;
416
+ color: $disabled;
417
+ }
418
+ """
419
+
420
+ def __init__(
421
+ self,
422
+ title: str,
423
+ *,
424
+ tool: str = "",
425
+ args: dict[str, object] | None = None,
426
+ call_id: str = "",
427
+ **kwargs: object,
428
+ ) -> None:
429
+ self.display_label = title
430
+ self.tool_name = tool
431
+ self.tool_args = dict(args or {})
432
+ self.call_id = call_id
433
+ self.status = "running"
434
+ self.mounted_at = datetime.now()
435
+ self._spin_i = 0
436
+ self._spin_timer: Timer | None = None
437
+ self._detail = Markdown(
438
+ format_tool_expand_body(tool, self.tool_args),
439
+ open_links=False,
440
+ classes="tool-detail",
441
+ )
442
+ super().__init__(
443
+ self._detail,
444
+ title=f" {title}",
445
+ collapsed=True,
446
+ collapsed_symbol="▶",
447
+ expanded_symbol="▼",
448
+ classes="running",
449
+ **kwargs, # type: ignore[arg-type]
450
+ )
451
+
452
+ def on_mount(self) -> None:
453
+ app = self.app
454
+ if bool(getattr(app, "animations_enabled", True)):
455
+ self._spin_timer = self.set_interval(0.08, self._tick_spinner)
456
+ self._tick_spinner()
457
+ else:
458
+ self.title = f" {self.display_label}"
459
+
460
+ def _tick_spinner(self) -> None:
461
+ if self.status != "running":
462
+ return
463
+ glyph = _SPIN[self._spin_i % len(_SPIN)]
464
+ self._spin_i += 1
465
+ self.title = f" {glyph} {self.display_label}"
466
+
467
+ def mark_finished(self, *, error: object = None, result: str = "") -> None:
468
+ self.status = "error" if error else "ok"
469
+ self.remove_class("running")
470
+ self.add_class(self.status)
471
+ if self._spin_timer is not None:
472
+ self._spin_timer.stop()
473
+ self._spin_timer = None
474
+ mark = "✗" if error else "✓"
475
+ self.title = f" {mark} {self.display_label}"
476
+ self._detail.update(
477
+ format_tool_expand_body(
478
+ self.tool_name,
479
+ self.tool_args,
480
+ result=result,
481
+ error=error,
482
+ )
483
+ )
484
+
485
+
486
+ class EmptyHint(Static):
487
+ """First-run empty state."""
488
+
489
+ DEFAULT_CSS = """
490
+ EmptyHint {
491
+ width: 1fr;
492
+ height: 100%;
493
+ padding: 0 2;
494
+ color: $muted;
495
+ content-align: center middle;
496
+ text-align: center;
497
+ }
498
+ """
499
+
500
+ def __init__(self, **kwargs: object) -> None:
501
+ super().__init__(id="empty-hint", **kwargs) # type: ignore[arg-type]
502
+
503
+ def on_mount(self) -> None:
504
+ self.update(Text("Welcome to monkeybot", style="dim"))
505
+
506
+
507
+ class HitlCard(Static):
508
+ """Inline HITL prompt in the transcript (confirm or elicit)."""
509
+
510
+ DEFAULT_CSS = """
511
+ HitlCard {
512
+ width: 1fr;
513
+ height: auto;
514
+ margin-top: 1;
515
+ padding: 1 2;
516
+ border-left: tall $warning;
517
+ background: $hitl-surface;
518
+ color: $hitl-text;
519
+ }
520
+ """
521
+
522
+ def __init__(
523
+ self,
524
+ prompt: str,
525
+ *,
526
+ hitl_kind: str = "confirm",
527
+ tool_name: str = "",
528
+ arguments: dict | None = None,
529
+ schema: dict | None = None,
530
+ timeout_sec: float | None = None,
531
+ **kwargs: object,
532
+ ) -> None:
533
+ super().__init__(**kwargs) # type: ignore[arg-type]
534
+ self.prompt = prompt
535
+ self.hitl_kind = hitl_kind
536
+ self.tool_name = tool_name
537
+ self.arguments = dict(arguments) if arguments else {}
538
+ self.schema = dict(schema) if isinstance(schema, dict) else None
539
+ self.timeout_sec = float(timeout_sec) if timeout_sec is not None else None
540
+ self.mounted_at = datetime.now()
541
+ self._timer: Timer | None = None
542
+ self._expired = False
543
+
544
+ def on_mount(self) -> None:
545
+ self._render_card()
546
+ if self.timeout_sec is not None and self.timeout_sec > 0:
547
+ self._timer = self.set_interval(1.0, self._tick_timeout)
548
+
549
+ def on_unmount(self) -> None:
550
+ if self._timer is not None:
551
+ self._timer.stop()
552
+ self._timer = None
553
+
554
+ def _remaining_sec(self) -> float | None:
555
+ if self.timeout_sec is None:
556
+ return None
557
+ elapsed = (datetime.now() - self.mounted_at).total_seconds()
558
+ return max(0.0, self.timeout_sec - elapsed)
559
+
560
+ def _timeout_line(self) -> str | None:
561
+ remaining = self._remaining_sec()
562
+ if remaining is None:
563
+ return None
564
+ total = int(remaining)
565
+ mins, secs = divmod(total, 60)
566
+ return f"timeout in {mins}:{secs:02d}"
567
+
568
+ def _render_card(self) -> None:
569
+ content = Text()
570
+ if self.hitl_kind == "elicit":
571
+ content.append("input needed\n", style="bold yellow")
572
+ else:
573
+ content.append("approval needed\n", style="bold yellow")
574
+ content.append(self.prompt.rstrip() + "\n", style="yellow")
575
+ if self.hitl_kind == "confirm" and self.tool_name:
576
+ content.append(f"tool: {self.tool_name}\n", style="dim yellow")
577
+ if self.arguments:
578
+ content.append(
579
+ f"args: {format_args_preview(self.arguments)}\n",
580
+ style="dim yellow",
581
+ )
582
+ if self.schema:
583
+ for line in format_schema_field_lines(self.schema):
584
+ content.append(line + "\n", style="dim yellow")
585
+ timeout_line = self._timeout_line()
586
+ if timeout_line:
587
+ content.append(timeout_line + "\n", style="dim")
588
+ if self.hitl_kind == "elicit":
589
+ hint = "Enter submit · Ctrl-C cancel"
590
+ else:
591
+ hint = "y approve · n deny · Ctrl-C cancel"
592
+ content.append(hint, style="dim")
593
+ self.update(content)
594
+
595
+ def _tick_timeout(self) -> None:
596
+ if self._expired:
597
+ return
598
+ remaining = self._remaining_sec()
599
+ if remaining is None:
600
+ return
601
+ if remaining <= 0:
602
+ self._expired = True
603
+ if self._timer is not None:
604
+ self._timer.stop()
605
+ self._timer = None
606
+ app = self.app
607
+ resolve = getattr(app, "_resolve_hitl_timeout", None)
608
+ if callable(resolve):
609
+ resolve()
610
+ return
611
+ self._render_card()
612
+
613
+
614
+ class ComposerBusySpinner(Static):
615
+ """One-glyph spinner beside the composer while the agent turn is running."""
616
+
617
+ DEFAULT_CSS = """
618
+ ComposerBusySpinner {
619
+ width: 2;
620
+ height: 1;
621
+ min-height: 1;
622
+ content-align: center middle;
623
+ color: $muted;
624
+ padding: 0;
625
+ }
626
+ ComposerBusySpinner.-busy {
627
+ color: $accent;
628
+ }
629
+ """
630
+
631
+ def __init__(self, **kwargs: object) -> None:
632
+ super().__init__(" ", **kwargs) # type: ignore[arg-type]
633
+ self._spin_i = 0
634
+ self._timer: Timer | None = None
635
+ self._busy = False
636
+
637
+ @property
638
+ def busy(self) -> bool:
639
+ return self._busy
640
+
641
+ def set_busy(self, busy: bool) -> None:
642
+ if busy == self._busy:
643
+ return
644
+ self._busy = busy
645
+ if self._timer is not None:
646
+ self._timer.stop()
647
+ self._timer = None
648
+ if not busy:
649
+ self.remove_class("-busy")
650
+ self.update(" ")
651
+ return
652
+ self.add_class("-busy")
653
+ self._spin_i = 0
654
+ if bool(getattr(self.app, "animations_enabled", True)):
655
+ self._timer = self.set_interval(0.08, self._tick)
656
+ self._tick()
657
+ else:
658
+ self.update("●")
659
+
660
+ def _tick(self) -> None:
661
+ if not self._busy:
662
+ return
663
+ glyph = _SPIN[self._spin_i % len(_SPIN)]
664
+ self._spin_i += 1
665
+ self.update(glyph)
666
+
667
+ def on_unmount(self) -> None:
668
+ if self._timer is not None:
669
+ self._timer.stop()
670
+ self._timer = None
671
+
672
+
673
+ class Composer(TextArea):
674
+ """Growing multiline input: Enter sends, Shift+Enter/Ctrl+J newline, history, slash."""
675
+
676
+ class Submitted(Message):
677
+ def __init__(self, text: str) -> None:
678
+ super().__init__()
679
+ self.text = text
680
+
681
+ BINDINGS = [
682
+ Binding("shift+enter", "insert_newline", "Newline", show=False),
683
+ Binding("ctrl+j", "insert_newline", "Newline", show=False),
684
+ Binding("alt+enter", "insert_newline", "Newline", show=False),
685
+ Binding("up", "history_up", "History", show=False),
686
+ Binding("down", "history_down", "History", show=False),
687
+ Binding("ctrl+r", "history_search", "Search", show=False),
688
+ Binding("escape", "cancel_search", "Cancel search", show=False),
689
+ Binding("ctrl+g", "cancel_search", "Cancel search", show=False),
690
+ Binding("tab", "slash_complete", "Complete", show=False),
691
+ ]
692
+
693
+ def __init__(self, history: list[str] | None = None) -> None:
694
+ super().__init__(
695
+ "",
696
+ id="prompt",
697
+ soft_wrap=True,
698
+ show_line_numbers=False,
699
+ tab_behavior="indent",
700
+ placeholder=_COMPOSER_PLACEHOLDER,
701
+ )
702
+ self._history = list(history or [])
703
+ self._history_index: int | None = None
704
+ self._draft = ""
705
+ self._prefix_matches: list[str] | None = None
706
+ self._prefix_index: int | None = None
707
+ self._paste_guard = False
708
+ self._search_mode = False
709
+ self._search_query = ""
710
+ self._search_draft = ""
711
+ self._search_matches: list[str] = []
712
+ self._search_index = 0
713
+
714
+ @property
715
+ def in_search(self) -> bool:
716
+ return self._search_mode
717
+
718
+ @property
719
+ def search_query(self) -> str:
720
+ return self._search_query
721
+
722
+ def push_history(self, line: str) -> None:
723
+ text = line.rstrip("\n")
724
+ if text.strip():
725
+ self._history.append(text)
726
+ if len(self._history) > _HISTORY_LIMIT:
727
+ self._history = self._history[-_HISTORY_LIMIT:]
728
+ self._history_index = None
729
+ self._prefix_matches = None
730
+ self._prefix_index = None
731
+ self._draft = ""
732
+
733
+ def action_insert_newline(self) -> None:
734
+ if self._search_mode:
735
+ return
736
+ self.insert("\n")
737
+ self._sync_height()
738
+
739
+ def action_slash_complete(self) -> None:
740
+ if self._search_mode:
741
+ return
742
+ with contextlib.suppress(Exception):
743
+ app = self.app
744
+ if hasattr(app, "complete_slash_from_palette"):
745
+ filled = app.complete_slash_from_palette()
746
+ if filled:
747
+ self.load_text(filled)
748
+ self._sync_height()
749
+
750
+ def action_history_search(self) -> None:
751
+ if not self._history:
752
+ return
753
+ if not self._search_mode:
754
+ self._search_mode = True
755
+ self._search_draft = self.text
756
+ self._search_query = ""
757
+ self._search_matches = list(reversed(self._history))
758
+ self._search_index = 0
759
+ self._apply_search_match()
760
+ else:
761
+ if self._search_matches:
762
+ self._search_index = (self._search_index + 1) % len(self._search_matches)
763
+ self._apply_search_match()
764
+ self._notify_status()
765
+
766
+ def action_cancel_search(self) -> None:
767
+ if not self._search_mode:
768
+ return
769
+ self._exit_search(restore_draft=True)
770
+
771
+ def _apply_search_match(self) -> None:
772
+ if self._search_matches:
773
+ self.load_text(self._search_matches[self._search_index])
774
+ else:
775
+ self.load_text("")
776
+ self._sync_height()
777
+
778
+ def _refilter_search(self) -> None:
779
+ q = self._search_query.lower()
780
+ matches = [h for h in reversed(self._history) if q in h.lower()]
781
+ self._search_matches = matches
782
+ self._search_index = 0
783
+ self._apply_search_match()
784
+ self._notify_status()
785
+
786
+ def _exit_search(self, *, restore_draft: bool, accept: bool = False) -> None:
787
+ if not self._search_mode:
788
+ return
789
+ accepted = self.text if accept else None
790
+ self._search_mode = False
791
+ self._search_query = ""
792
+ self._search_matches = []
793
+ self._search_index = 0
794
+ if restore_draft:
795
+ self.load_text(self._search_draft)
796
+ elif accept and accepted is not None:
797
+ self.load_text(accepted)
798
+ self._search_draft = ""
799
+ self._sync_height()
800
+ self._notify_status()
801
+
802
+ def _notify_status(self) -> None:
803
+ with contextlib.suppress(Exception):
804
+ app = self.app
805
+ if hasattr(app, "_refresh_status"):
806
+ app._refresh_status()
807
+
808
+ def action_history_up(self) -> None:
809
+ if self._search_mode:
810
+ return
811
+ with contextlib.suppress(Exception):
812
+ app = self.app
813
+ if hasattr(app, "slash_palette_move") and app.slash_palette_move(-1):
814
+ return
815
+ row, _col = self.cursor_location
816
+ if row > 0 or not self._history:
817
+ self.action_cursor_up()
818
+ return
819
+
820
+ if self._prefix_matches is not None:
821
+ if self._prefix_index is not None and self._prefix_index > 0:
822
+ self._prefix_index -= 1
823
+ self.load_text(self._prefix_matches[self._prefix_index])
824
+ self._sync_height()
825
+ return
826
+
827
+ if self._history_index is not None:
828
+ if self._history_index > 0:
829
+ self._history_index -= 1
830
+ self.load_text(self._history[self._history_index])
831
+ self._sync_height()
832
+ return
833
+
834
+ self._draft = self.text
835
+ prefix = self._draft
836
+ if prefix.strip():
837
+ matches = [h for h in self._history if h.startswith(prefix)]
838
+ if matches:
839
+ self._prefix_matches = matches
840
+ self._prefix_index = len(matches) - 1
841
+ self.load_text(matches[self._prefix_index])
842
+ self._sync_height()
843
+ return
844
+
845
+ self._history_index = len(self._history) - 1
846
+ self.load_text(self._history[self._history_index])
847
+ self._sync_height()
848
+
849
+ def action_history_down(self) -> None:
850
+ if self._search_mode:
851
+ return
852
+ with contextlib.suppress(Exception):
853
+ app = self.app
854
+ if hasattr(app, "slash_palette_move") and app.slash_palette_move(1):
855
+ return
856
+ if self._prefix_matches is not None:
857
+ row, _col = self.cursor_location
858
+ last_row = max(0, self.document.line_count - 1)
859
+ if row < last_row:
860
+ self.action_cursor_down()
861
+ return
862
+ assert self._prefix_index is not None
863
+ if self._prefix_index < len(self._prefix_matches) - 1:
864
+ self._prefix_index += 1
865
+ self.load_text(self._prefix_matches[self._prefix_index])
866
+ else:
867
+ self._prefix_matches = None
868
+ self._prefix_index = None
869
+ self.load_text(self._draft)
870
+ self._sync_height()
871
+ return
872
+
873
+ if self._history_index is None:
874
+ self.action_cursor_down()
875
+ return
876
+ row, _col = self.cursor_location
877
+ last_row = max(0, self.document.line_count - 1)
878
+ if row < last_row:
879
+ self.action_cursor_down()
880
+ return
881
+ if self._history_index < len(self._history) - 1:
882
+ self._history_index += 1
883
+ self.load_text(self._history[self._history_index])
884
+ else:
885
+ self._history_index = None
886
+ self.load_text(self._draft)
887
+ self._sync_height()
888
+
889
+ async def _on_paste(self, event: Paste) -> None:
890
+ self._paste_guard = True
891
+ await super()._on_paste(event)
892
+ self._sync_height()
893
+ self.set_timer(0.1, self._clear_paste_guard)
894
+
895
+ def _clear_paste_guard(self) -> None:
896
+ self._paste_guard = False
897
+
898
+ def _on_key(self, event: Key) -> None:
899
+ if self._search_mode:
900
+ self._handle_search_key(event)
901
+ return
902
+ if event.key == "tab":
903
+ with contextlib.suppress(Exception):
904
+ app = self.app
905
+ if hasattr(app, "complete_slash_from_palette"):
906
+ filled = app.complete_slash_from_palette()
907
+ if filled:
908
+ self.load_text(filled)
909
+ self._sync_height()
910
+ event.prevent_default()
911
+ event.stop()
912
+ return
913
+ if event.key == "enter":
914
+ if self._paste_guard:
915
+ self.insert("\n")
916
+ self._sync_height()
917
+ event.prevent_default()
918
+ event.stop()
919
+ return
920
+ text = self.text
921
+ with contextlib.suppress(Exception):
922
+ app = self.app
923
+ if hasattr(app, "slash_submit_text"):
924
+ override = app.slash_submit_text(text)
925
+ if override is not None:
926
+ text = override
927
+ self.clear()
928
+ self._history_index = None
929
+ self._prefix_matches = None
930
+ self._prefix_index = None
931
+ self._sync_height()
932
+ self.post_message(self.Submitted(text))
933
+ event.prevent_default()
934
+ event.stop()
935
+ return
936
+ super()._on_key(event)
937
+ self.call_after_refresh(self._sync_height)
938
+
939
+ def _handle_search_key(self, event: Key) -> None:
940
+ key = event.key
941
+ if key == "enter":
942
+ self._exit_search(restore_draft=False, accept=True)
943
+ event.prevent_default()
944
+ event.stop()
945
+ return
946
+ if key in {"escape", "ctrl+g"}:
947
+ self._exit_search(restore_draft=True)
948
+ event.prevent_default()
949
+ event.stop()
950
+ return
951
+ if key == "ctrl+r":
952
+ self.action_history_search()
953
+ event.prevent_default()
954
+ event.stop()
955
+ return
956
+ if key == "backspace":
957
+ self._search_query = self._search_query[:-1]
958
+ self._refilter_search()
959
+ event.prevent_default()
960
+ event.stop()
961
+ return
962
+ if event.is_printable and event.character:
963
+ self._search_query += event.character
964
+ self._refilter_search()
965
+ event.prevent_default()
966
+ event.stop()
967
+ return
968
+ event.prevent_default()
969
+ event.stop()
970
+
971
+ def _sync_height(self) -> None:
972
+ lines = max(1, self.document.line_count)
973
+ wrapped = max(lines, 1 + self.text.count("\n"))
974
+ self.styles.height = min(8, max(1, wrapped))
975
+
976
+
977
+ class TranscriptPane(VerticalScroll):
978
+ """Transcript scroller that syncs sticky auto-follow from scroll position."""
979
+
980
+ def on_mount(self) -> None:
981
+ # Stick to bottom as turns stream in; ChatApp releases on user scroll-up.
982
+ self.anchor(True)
983
+
984
+ def watch_scroll_y(self, old: float, new: float) -> None:
985
+ # Must preserve Widget.watch_scroll_y behavior — overriding without this
986
+ # leaves the scrollbar thumb stuck and skips _refresh_scroll.
987
+ if self.show_vertical_scrollbar:
988
+ self.vertical_scrollbar.position = new
989
+ if self._anchored and self._anchor_released:
990
+ self._check_anchor()
991
+ if round(old) != round(new):
992
+ self._refresh_scroll()
993
+ app = self.app
994
+ on_scroll = getattr(app, "_on_transcript_scroll", None)
995
+ if callable(on_scroll):
996
+ on_scroll()