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,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()
|