batrachian-toad 0.5.22__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 (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. toad/widgets/welcome.py +31 -0
@@ -0,0 +1,1626 @@
1
+ from __future__ import annotations
2
+
3
+ from asyncio import Future
4
+ import asyncio
5
+ from contextlib import suppress
6
+ from itertools import filterfalse
7
+ from operator import attrgetter
8
+ from typing import TYPE_CHECKING, Literal
9
+ from pathlib import Path
10
+ from time import monotonic
11
+
12
+ from typing import Callable, Any
13
+
14
+ from textual import log, on, work
15
+ from textual.app import ComposeResult
16
+ from textual import containers
17
+ from textual import getters
18
+ from textual import events
19
+ from textual.actions import SkipAction
20
+ from textual.binding import Binding
21
+ from textual.content import Content
22
+ from textual.geometry import clamp
23
+ from textual.css.query import NoMatches
24
+ from textual.widget import Widget
25
+ from textual.widgets import Static
26
+ from textual.widgets.markdown import MarkdownBlock, MarkdownFence
27
+ from textual.geometry import Offset, Spacing
28
+ from textual.reactive import var
29
+ from textual.layouts.grid import GridLayout
30
+ from textual.layout import WidgetPlacement
31
+
32
+
33
+ from toad import jsonrpc, messages
34
+ from toad import paths
35
+ from toad.agent_schema import Agent as AgentData
36
+ from toad.acp import messages as acp_messages
37
+ from toad.app import ToadApp
38
+ from toad.acp import protocol as acp_protocol
39
+ from toad.acp.agent import Mode
40
+ from toad.answer import Answer
41
+ from toad.agent import AgentBase, AgentReady, AgentFail
42
+ from toad.directory_watcher import DirectoryWatcher, DirectoryChanged
43
+ from toad.history import History
44
+ from toad.widgets.flash import Flash
45
+ from toad.widgets.menu import Menu
46
+ from toad.widgets.note import Note
47
+ from toad.widgets.prompt import Prompt
48
+ from toad.widgets.terminal import Terminal
49
+ from toad.widgets.throbber import Throbber
50
+ from toad.widgets.user_input import UserInput
51
+ from toad.shell import Shell, CurrentWorkingDirectoryChanged
52
+ from toad.slash_command import SlashCommand
53
+ from toad.protocol import BlockProtocol, MenuProtocol, ExpandProtocol
54
+ from toad.menus import MenuItem
55
+
56
+ if TYPE_CHECKING:
57
+ from toad.widgets.terminal import Terminal
58
+ from toad.widgets.agent_response import AgentResponse
59
+ from toad.widgets.agent_thought import AgentThought
60
+ from toad.widgets.terminal_tool import TerminalTool
61
+
62
+
63
+ AGENT_FAIL_HELP = """\
64
+ ## Agent failed to run
65
+
66
+ **The agent failed to start.**
67
+
68
+ Check that the agent is installed and up-to-date.
69
+
70
+ Note that some agents require an ACP adapter to be installed to work with Toad.
71
+
72
+ - Exit the app, and run `toad` agin
73
+ - Select the agent and hit ENTER
74
+ - Click the dropdown, select "Install"
75
+ - Click the GO button
76
+ - Repeat the process to install an ACP adapter (if required)
77
+
78
+ Some agents may require you to restart your shell (open a new terminal) after installing.
79
+
80
+ If that fails, ask for help in [Discussions](https://github.com/batrachianai/toad/discussions)!
81
+
82
+ https://github.com/batrachianai/toad/discussions
83
+ """
84
+
85
+ HELP_URL = "https://github.com/batrachianai/toad/discussions"
86
+
87
+ STOP_REASON_MAX_TOKENS = f"""\
88
+ ## Maximum tokens reached
89
+
90
+ $AGENT reported that your account is out of tokens.
91
+
92
+ - You may need to purchase additional tokens, or fund your account.
93
+ - If your account has tokens, try running any login or auth process again.
94
+
95
+ If that fails, ask on {HELP_URL}
96
+ """
97
+
98
+ STOP_REASON_MAX_TURN_REQUESTS = f"""\
99
+ ## Maximum model requests reached
100
+
101
+ $AGENT has exceeded the maximum number of model requests in a single turn.
102
+
103
+ Need help? Ask on {HELP_URL}
104
+ """
105
+
106
+ STOP_REASON_REFUSAL = """\
107
+ ## Agent refusal
108
+
109
+ $AGENT has refused to continue.
110
+
111
+ Need help? Ask on {HELP_URL}
112
+ """
113
+
114
+
115
+ class Loading(Static):
116
+ """Tiny widget to show loading indicator."""
117
+
118
+ DEFAULT_CLASSES = "block"
119
+ DEFAULT_CSS = """
120
+ Loading {
121
+ height: auto;
122
+ }
123
+ """
124
+
125
+
126
+ class Cursor(Static):
127
+ """The block 'cursor' -- A vertical line to the left of a block in the conversation that
128
+ is used to navigate the discussion history.
129
+ """
130
+
131
+ follow_widget: var[Widget | None] = var(None)
132
+ blink = var(True, toggle_class="-blink")
133
+
134
+ def on_mount(self) -> None:
135
+ self.visible = False
136
+ self.blink_timer = self.set_interval(0.5, self._update_blink, pause=True)
137
+
138
+ def _update_blink(self) -> None:
139
+ if self.query_ancestor(Window).has_focus and self.screen.is_active:
140
+ self.blink = not self.blink
141
+ else:
142
+ self.blink = False
143
+
144
+ def watch_follow_widget(self, widget: Widget | None) -> None:
145
+ self.visible = widget is not None
146
+
147
+ def update_follow(self) -> None:
148
+ if self.follow_widget and self.follow_widget.is_attached:
149
+ self.styles.height = max(1, self.follow_widget.outer_size.height)
150
+ follow_y = (
151
+ self.follow_widget.virtual_region.y
152
+ + self.follow_widget.parent.virtual_region.y
153
+ )
154
+ self.offset = Offset(0, follow_y)
155
+
156
+ def follow(self, widget: Widget | None) -> None:
157
+ self.follow_widget = widget
158
+ self.blink = False
159
+ if widget is None:
160
+ self.visible = False
161
+ self.blink_timer.reset()
162
+ self.blink_timer.pause()
163
+ else:
164
+ self.visible = True
165
+ self.blink_timer.reset()
166
+ self.blink_timer.resume()
167
+ self.update_follow()
168
+
169
+
170
+ class Contents(containers.VerticalGroup, can_focus=False):
171
+ def process_layout(
172
+ self, placements: list[WidgetPlacement]
173
+ ) -> list[WidgetPlacement]:
174
+ if placements:
175
+ last_placement = placements[-1]
176
+ top, right, bottom, left = last_placement.margin
177
+ placements[-1] = last_placement._replace(
178
+ margin=Spacing(top, right, 0, left)
179
+ )
180
+ return placements
181
+
182
+
183
+ class ContentsGrid(containers.Grid):
184
+ def pre_layout(self, layout) -> None:
185
+ assert isinstance(layout, GridLayout)
186
+ layout.stretch_height = True
187
+
188
+
189
+ class Window(containers.VerticalScroll):
190
+ BINDING_GROUP_TITLE = "View"
191
+ BINDINGS = [Binding("end", "screen.focus_prompt", "Prompt")]
192
+
193
+ def update_node_styles(self, animate: bool = True) -> None:
194
+ pass
195
+
196
+
197
+ class Conversation(containers.Vertical):
198
+ """Holds the agent conversation (input, output, and various controls / information)."""
199
+
200
+ BINDING_GROUP_TITLE = "Conversation"
201
+ CURSOR_BINDING_GROUP = Binding.Group(description="Cursor")
202
+ BINDINGS = [
203
+ Binding(
204
+ "alt+up",
205
+ "cursor_up",
206
+ "Block cursor up",
207
+ priority=True,
208
+ group=CURSOR_BINDING_GROUP,
209
+ ),
210
+ Binding(
211
+ "alt+down",
212
+ "cursor_down",
213
+ "Block cursor down",
214
+ group=CURSOR_BINDING_GROUP,
215
+ ),
216
+ Binding(
217
+ "enter",
218
+ "select_block",
219
+ "Select",
220
+ tooltip="Select this block",
221
+ ),
222
+ Binding(
223
+ "space",
224
+ "expand_block",
225
+ "Expand",
226
+ key_display="␣",
227
+ tooltip="Expand cursor block",
228
+ ),
229
+ Binding(
230
+ "space",
231
+ "collapse_block",
232
+ "Collapse",
233
+ key_display="␣",
234
+ tooltip="Collapse cursor block",
235
+ ),
236
+ Binding(
237
+ "escape",
238
+ "cancel",
239
+ "Cancel",
240
+ tooltip="Cancel agent's turn",
241
+ ),
242
+ Binding(
243
+ "ctrl+f",
244
+ "focus_terminal",
245
+ "Focus",
246
+ tooltip="Focus the active terminal",
247
+ priority=True,
248
+ ),
249
+ Binding(
250
+ "ctrl+o",
251
+ "mode_switcher",
252
+ "Modes",
253
+ tooltip="Open the mode switcher",
254
+ ),
255
+ Binding(
256
+ "ctrl+c",
257
+ "interrupt",
258
+ "Interrupt",
259
+ tooltip="Interrupt running command",
260
+ ),
261
+ ]
262
+
263
+ busy_count = var(0)
264
+ cursor_offset = var(-1, init=False)
265
+ project_path = var(Path("./").expanduser().absolute())
266
+ working_directory: var[str] = var("")
267
+ _blocks: var[list[MarkdownBlock] | None] = var(None)
268
+
269
+ throbber: getters.query_one[Throbber] = getters.query_one("#throbber")
270
+ contents = getters.query_one(Contents)
271
+ window = getters.query_one(Window)
272
+ cursor = getters.query_one(Cursor)
273
+ prompt = getters.query_one(Prompt)
274
+ app = getters.app(ToadApp)
275
+
276
+ _shell: var[Shell | None] = var(None)
277
+ shell_history_index: var[int] = var(0, init=False)
278
+ prompt_history_index: var[int] = var(0, init=False)
279
+
280
+ agent: var[AgentBase | None] = var(None, bindings=True)
281
+ agent_info: var[Content] = var(Content())
282
+ agent_ready: var[bool] = var(False)
283
+ modes: var[dict[str, Mode]] = var({}, bindings=True)
284
+ current_mode: var[Mode | None] = var(None)
285
+ turn: var[Literal["agent", "client"] | None] = var(None, bindings=True)
286
+ status: var[str] = var("")
287
+ column: var[bool] = var(False, toggle_class="-column")
288
+
289
+ def __init__(self, project_path: Path, agent: AgentData | None = None) -> None:
290
+ super().__init__()
291
+
292
+ project_path = project_path.resolve().absolute()
293
+
294
+ self.set_reactive(Conversation.project_path, project_path)
295
+ self.set_reactive(Conversation.working_directory, str(project_path))
296
+ self.agent_slash_commands: list[SlashCommand] = []
297
+ self.terminals: dict[str, TerminalTool] = {}
298
+ self._loading: Loading | None = None
299
+ self._agent_response: AgentResponse | None = None
300
+ self._agent_thought: AgentThought | None = None
301
+ self._last_escape_time: float = monotonic()
302
+ self._agent_data = agent
303
+ self._agent_fail = False
304
+ self._mouse_down_offset: Offset | None = None
305
+
306
+ self._focusable_terminals: list[Terminal] = []
307
+
308
+ self.project_data_path = paths.get_project_data(project_path)
309
+ self.shell_history = History(self.project_data_path / "shell_history.jsonl")
310
+ self.prompt_history = History(self.project_data_path / "prompt_history.jsonl")
311
+
312
+ self.session_start_time: float | None = None
313
+ self._terminal_count = 0
314
+ self._require_check_prune = False
315
+
316
+ self._turn_count = 0
317
+ self._shell_count = 0
318
+
319
+ self._directory_changed = False
320
+ self._directory_watcher: DirectoryWatcher | None = None
321
+
322
+ @property
323
+ def agent_title(self) -> str | None:
324
+ if self._agent_data is not None:
325
+ return self._agent_data["name"]
326
+ return None
327
+
328
+ @property
329
+ def is_watching_directory(self) -> bool:
330
+ """Is the directory watcher enabled and watching?"""
331
+ if self._directory_watcher is None:
332
+ return False
333
+ return self._directory_watcher.enabled
334
+
335
+ def validate_shell_history_index(self, index: int) -> int:
336
+ return clamp(index, -self.shell_history.size, 0)
337
+
338
+ def validate_prompt_history_index(self, index: int) -> int:
339
+ return clamp(index, -self.prompt_history.size, 0)
340
+
341
+ def shell_complete(self, prefix: str) -> list[str]:
342
+ return self.shell_history.complete(prefix)
343
+
344
+ def insert_path_into_prompt(self, path: Path) -> None:
345
+ try:
346
+ insert_path_text = str(path.relative_to(self.project_path))
347
+ except Exception:
348
+ self.app.bell()
349
+ return
350
+
351
+ insert_text = (
352
+ f'@"{insert_path_text}"'
353
+ if " " in insert_path_text
354
+ else f"@{insert_path_text}"
355
+ )
356
+ self.prompt.prompt_text_area.insert(insert_text)
357
+ self.prompt.prompt_text_area.insert(" ")
358
+
359
+ async def watch_shell_history_index(self, previous_index: int, index: int) -> None:
360
+ if previous_index == 0:
361
+ self.shell_history.current = self.prompt.text
362
+ try:
363
+ history_entry = await self.shell_history.get_entry(index)
364
+ except IndexError:
365
+ pass
366
+ else:
367
+ self.prompt.text = history_entry["input"]
368
+ self.prompt.shell_mode = True
369
+
370
+ async def watch_prompt_history_index(self, previous_index: int, index: int) -> None:
371
+ if previous_index == 0:
372
+ self.prompt_history.current = self.prompt.text
373
+ try:
374
+ history_entry = await self.prompt_history.get_entry(index)
375
+ except IndexError:
376
+ pass
377
+ else:
378
+ self.prompt.text = history_entry["input"]
379
+
380
+ @on(events.Key)
381
+ async def on_key(self, event: events.Key):
382
+ if (
383
+ event.character is not None
384
+ and event.is_printable
385
+ and (event.character.isalnum() or event.character in "$/!")
386
+ and self.window.has_focus
387
+ ):
388
+ self.prompt.focus()
389
+ self.prompt.prompt_text_area.post_message(event)
390
+
391
+ def compose(self) -> ComposeResult:
392
+ yield Throbber(id="throbber")
393
+ with Window():
394
+ with ContentsGrid():
395
+ with containers.VerticalGroup(id="cursor-container"):
396
+ yield Cursor()
397
+ yield Contents(id="contents")
398
+ yield Flash()
399
+ yield Prompt(
400
+ self.project_path, complete_callback=self.shell_complete
401
+ ).data_bind(
402
+ project_path=Conversation.project_path,
403
+ working_directory=Conversation.working_directory,
404
+ agent_info=Conversation.agent_info,
405
+ agent_ready=Conversation.agent_ready,
406
+ current_mode=Conversation.current_mode,
407
+ modes=Conversation.modes,
408
+ status=Conversation.status,
409
+ )
410
+
411
+ @property
412
+ def _terminal(self) -> Terminal | None:
413
+ """Return the last focusable terminal, if there is one.
414
+
415
+ Returns:
416
+ A focusable (non finalized) terminal.
417
+ """
418
+ # Terminals should be removed in response to the Terminal.FInalized message
419
+ # This is a bit of a sanity check
420
+ self._focusable_terminals[:] = list(
421
+ filterfalse(attrgetter("is_finalized"), self._focusable_terminals)
422
+ )
423
+
424
+ for terminal in reversed(self._focusable_terminals):
425
+ if terminal.display:
426
+ return terminal
427
+ return None
428
+
429
+ def add_focusable_terminal(self, terminal: Terminal) -> None:
430
+ """Add a focusable terminal.
431
+
432
+ Args:
433
+ terminal: Terminal instance.
434
+ """
435
+ if not terminal.is_finalized:
436
+ self._focusable_terminals.append(terminal)
437
+
438
+ @on(DirectoryChanged)
439
+ def on_directory_changed(self, event: DirectoryChanged) -> None:
440
+ event.stop()
441
+ self._directory_changed = True
442
+
443
+ @on(Terminal.Finalized)
444
+ def on_terminal_finalized(self, event: Terminal.Finalized) -> None:
445
+ """Terminal was finalized, so we can remove it from the list."""
446
+ try:
447
+ self._focusable_terminals.remove(event.terminal)
448
+ except ValueError:
449
+ pass
450
+
451
+ if self._directory_changed or not self.is_watching_directory:
452
+ self.prompt.project_directory_updated()
453
+ self._directory_changed = False
454
+ self.post_message(messages.ProjectDirectoryUpdated())
455
+
456
+ @on(Terminal.AlternateScreenChanged)
457
+ def on_terminal_alternate_screen_(
458
+ self, event: Terminal.AlternateScreenChanged
459
+ ) -> None:
460
+ """A terminal enabled or disabled alternate screen."""
461
+ if event.enabled:
462
+ event.terminal.focus()
463
+ else:
464
+ self.focus_prompt()
465
+
466
+ @on(events.DescendantFocus, "Terminal")
467
+ def on_terminal_focus(self, event: events.DescendantFocus) -> None:
468
+ self.flash("Press [b]escape[/b] [i]twice[/] to exit terminal", style="success")
469
+
470
+ @on(events.DescendantBlur, "Terminal")
471
+ def on_terminal_blur(self, event: events.DescendantFocus) -> None:
472
+ self.focus_prompt()
473
+
474
+ @on(messages.Flash)
475
+ def on_flash(self, event: messages.Flash) -> None:
476
+ event.stop()
477
+ self.flash(event.content, duration=event.duration, style=event.style)
478
+
479
+ def flash(
480
+ self,
481
+ content: str | Content,
482
+ *,
483
+ duration: float | None = None,
484
+ style: Literal["default", "warning", "error", "success"] = "default",
485
+ ) -> None:
486
+ """Flash a single-line message to the user.
487
+
488
+ Args:
489
+ content: Content to flash.
490
+ style: A semantic style.
491
+ duration: Duration in seconds of the flash, or `None` to use default in settings.
492
+ """
493
+ self.query_one(Flash).flash(content, duration=duration, style=style)
494
+
495
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
496
+ if action == "focus_terminal":
497
+ return None if self._terminal is None else True
498
+ if action == "mode_switcher":
499
+ return bool(self.modes)
500
+ if action == "cancel":
501
+ return True if (self.agent and self.turn == "agent") else None
502
+ if action in {"expand_block", "collapse_block"}:
503
+ if (cursor_block := self.cursor_block) is None:
504
+ return False
505
+ elif isinstance(cursor_block, ExpandProtocol):
506
+ if action == "expand_block":
507
+ return False if cursor_block.is_block_expanded() else True
508
+ else:
509
+ return True if cursor_block.is_block_expanded() else False
510
+ return None if action == "expand_block" else False
511
+
512
+ return True
513
+
514
+ async def action_focus_terminal(self) -> None:
515
+ if self._terminal is not None:
516
+ self._terminal.focus()
517
+ else:
518
+ self.flash("Nothing to focus...", style="error")
519
+
520
+ async def action_expand_block(self) -> None:
521
+ if (cursor_block := self.cursor_block) is not None:
522
+ if isinstance(cursor_block, ExpandProtocol):
523
+ cursor_block.expand_block()
524
+ self.refresh_bindings()
525
+ self.call_after_refresh(self.cursor.follow, cursor_block)
526
+
527
+ async def action_collapse_block(self) -> None:
528
+ if (cursor_block := self.cursor_block) is not None:
529
+ if isinstance(cursor_block, ExpandProtocol):
530
+ cursor_block.collapse_block()
531
+ self.refresh_bindings()
532
+ self.call_after_refresh(self.cursor.follow, cursor_block)
533
+
534
+ async def post_agent_response(self, fragment: str = "") -> AgentResponse:
535
+ """Get or create an agent response widget."""
536
+ from toad.widgets.agent_response import AgentResponse
537
+
538
+ if self._agent_response is None:
539
+ self._agent_response = agent_response = AgentResponse(fragment)
540
+ await self.post(agent_response)
541
+ else:
542
+ await self._agent_response.append_fragment(fragment)
543
+ return self._agent_response
544
+
545
+ async def post_agent_thought(self, thought_fragment: str) -> AgentThought:
546
+ """Get or create an agent thought widget."""
547
+ from toad.widgets.agent_thought import AgentThought
548
+
549
+ if self._agent_thought is None:
550
+ self._agent_thought = AgentThought(thought_fragment)
551
+ await self.post(self._agent_thought)
552
+ else:
553
+ await self._agent_thought.append_fragment(thought_fragment)
554
+ return self._agent_thought
555
+
556
+ @property
557
+ def cursor_block(self) -> Widget | None:
558
+ """The block next to the cursor, or `None` if no block cursor."""
559
+ if self.cursor_offset == -1 or not self.contents.displayed_children:
560
+ return None
561
+ try:
562
+ block_widget = self.contents.displayed_children[self.cursor_offset]
563
+ except IndexError:
564
+ return None
565
+ return block_widget
566
+
567
+ @property
568
+ def cursor_block_child(self) -> Widget | None:
569
+ if (cursor_block := self.cursor_block) is not None:
570
+ if isinstance(cursor_block, BlockProtocol):
571
+ return cursor_block.get_cursor_block()
572
+ return cursor_block
573
+
574
+ def get_cursor_block[BlockType](
575
+ self, block_type: type[BlockType] = Widget
576
+ ) -> BlockType | None:
577
+ """Get the cursor block if it matches a type.
578
+
579
+ Args:
580
+ block_type: The expected type.
581
+
582
+ Returns:
583
+ The widget next to the cursor, or `None` if the types don't match.
584
+ """
585
+ cursor_block = self.cursor_block_child
586
+ if isinstance(cursor_block, block_type):
587
+ return cursor_block
588
+ return None
589
+
590
+ @on(AgentReady)
591
+ async def on_agent_ready(self) -> None:
592
+ self.session_start_time = monotonic()
593
+ if self.agent is not None:
594
+ content = Content.assemble(self.agent.get_info(), " connected")
595
+ self.flash(content, style="success")
596
+ if self._agent_data is not None:
597
+ self.app.capture_event(
598
+ "agent-session-begin",
599
+ agent=self._agent_data["identity"],
600
+ )
601
+
602
+ self.agent_ready = True
603
+
604
+ async def on_unmount(self) -> None:
605
+ if self._directory_watcher is not None:
606
+ self._directory_watcher.stop()
607
+ if self.agent is not None:
608
+ await self.agent.stop()
609
+
610
+ if self._agent_data is not None and self.session_start_time is not None:
611
+ session_time = monotonic() - self.session_start_time
612
+ await self.app.capture_event(
613
+ "agent-session-end",
614
+ agent=self._agent_data["identity"],
615
+ duration=session_time,
616
+ agent_session_fail=self._agent_fail,
617
+ shell_count=self._shell_count,
618
+ turn_count=self._turn_count,
619
+ ).wait()
620
+
621
+ @on(AgentFail)
622
+ async def on_agent_fail(self, message: AgentFail) -> None:
623
+ self.agent_ready = True
624
+ self._agent_fail = True
625
+ self.notify(message.message, title="Agent failure", severity="error", timeout=5)
626
+
627
+ if self._agent_data is not None:
628
+ self.app.capture_event(
629
+ "agent-session-error",
630
+ agent=self._agent_data["identity"],
631
+ message=message.message,
632
+ details=message.details,
633
+ )
634
+
635
+ if message.message:
636
+ error = Content.assemble(
637
+ Content.from_markup(message.message).stylize("$text-error"),
638
+ " - ",
639
+ Content.from_markup(message.details.strip()).stylize("dim"),
640
+ )
641
+ else:
642
+ error = Content.from_markup(message.details.strip()).stylize("$text-error")
643
+ await self.post(Note(error, classes="-error"))
644
+
645
+ from toad.widgets.markdown_note import MarkdownNote
646
+
647
+ await self.post(MarkdownNote(AGENT_FAIL_HELP))
648
+
649
+ @on(messages.WorkStarted)
650
+ def on_work_started(self) -> None:
651
+ self.busy_count += 1
652
+
653
+ @on(messages.WorkFinished)
654
+ def on_work_finished(self) -> None:
655
+ self.busy_count -= 1
656
+
657
+ @work
658
+ @on(messages.ChangeMode)
659
+ async def on_change_mode(self, event: messages.ChangeMode) -> None:
660
+ if (agent := self.agent) is None:
661
+ return
662
+ if event.mode_id is None:
663
+ self.current_mode = None
664
+ else:
665
+ if (error := await agent.set_mode(event.mode_id)) is not None:
666
+ self.notify(error, title="Set Mode", severity="error")
667
+ elif (new_mode := self.modes.get(event.mode_id)) is not None:
668
+ self.current_mode = new_mode
669
+ self.flash(
670
+ Content.from_markup("Mode changed to [b]$mode", mode=new_mode.name),
671
+ style="success",
672
+ )
673
+
674
+ @on(acp_messages.ModeUpdate)
675
+ def on_mode_update(self, event: acp_messages.ModeUpdate) -> None:
676
+ if (modes := self.modes) is not None:
677
+ if (mode := modes.get(event.current_mode)) is not None:
678
+ self.current_mode = mode
679
+
680
+ @on(messages.UserInputSubmitted)
681
+ async def on_user_input_submitted(self, event: messages.UserInputSubmitted) -> None:
682
+ if not event.body.strip():
683
+ return
684
+ if event.shell:
685
+ await self.shell_history.append(event.body)
686
+ self.shell_history_index = 0
687
+ await self.post_shell(event.body)
688
+ elif text := event.body.strip():
689
+ await self.prompt_history.append(event.body)
690
+ self.prompt_history_index = 0
691
+ if text.startswith("/") and await self.slash_command(text):
692
+ # Toad has processed the slash command.
693
+ return
694
+ await self.post(UserInput(text))
695
+ self._loading = await self.post(Loading("Please wait..."), loading=True)
696
+ await asyncio.sleep(0)
697
+ self.send_prompt_to_agent(text)
698
+
699
+ @work
700
+ async def send_prompt_to_agent(self, prompt: str) -> None:
701
+ if self.agent is not None:
702
+ stop_reason: str | None = None
703
+ self.busy_count += 1
704
+ try:
705
+ self.turn = "agent"
706
+ stop_reason = await self.agent.send_prompt(prompt)
707
+ except jsonrpc.APIError:
708
+ self.turn = "client"
709
+ finally:
710
+ self.busy_count -= 1
711
+ self.call_later(self.agent_turn_over, stop_reason)
712
+
713
+ async def agent_turn_over(self, stop_reason: str | None) -> None:
714
+ """Called when the agent's turn is over.
715
+
716
+ Args:
717
+ stop_reason: The stop reason returned from the Agent, or `None`.
718
+ """
719
+ if self._agent_thought is not None and self._agent_thought.loading:
720
+ await self._agent_thought.remove()
721
+
722
+ self.turn = "client"
723
+ if self._agent_thought is not None and self._agent_thought.loading:
724
+ await self._agent_thought.remove()
725
+ if self._loading is not None:
726
+ await self._loading.remove()
727
+ self._agent_response = None
728
+ self._agent_thought = None
729
+
730
+ if self._directory_changed or not self.is_watching_directory:
731
+ self._directory_changed = False
732
+ self.post_message(messages.ProjectDirectoryUpdated())
733
+ self.prompt.project_directory_updated()
734
+
735
+ self._turn_count += 1
736
+
737
+ if stop_reason != "end_turn":
738
+ from toad.widgets.markdown_note import MarkdownNote
739
+
740
+ agent = (self.agent_title or "agent").title()
741
+
742
+ if stop_reason == "max_tokens":
743
+ await self.post(
744
+ MarkdownNote(
745
+ STOP_REASON_MAX_TOKENS.replace("$AGENT", agent),
746
+ classes="-stop-reason",
747
+ )
748
+ )
749
+ elif stop_reason == "max_turn_requests":
750
+ await self.post(
751
+ MarkdownNote(
752
+ STOP_REASON_MAX_TURN_REQUESTS.replace("$AGENT", agent),
753
+ classes="-stop-reason",
754
+ )
755
+ )
756
+ elif stop_reason == "refusal":
757
+ await self.post(
758
+ MarkdownNote(
759
+ STOP_REASON_REFUSAL.replace("$AGENT", agent),
760
+ classes="-stop-reason",
761
+ )
762
+ )
763
+
764
+ if self.app.settings.get("notifications.turn_over", bool):
765
+ self.app.system_notify(
766
+ f"{self.agent_title} has finished working",
767
+ title="Waiting for input",
768
+ sound="turn-over",
769
+ )
770
+
771
+ @on(Menu.OptionSelected)
772
+ async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None:
773
+ event.stop()
774
+ event.menu.display = False
775
+ if event.action is not None:
776
+ await self.run_action(event.action, {"block": event.owner})
777
+ if (cursor_block := self.get_cursor_block()) is not None:
778
+ self.call_after_refresh(self.cursor.follow, cursor_block)
779
+ self.call_after_refresh(event.menu.remove)
780
+
781
+ @on(Menu.Dismissed)
782
+ async def on_menu_dismissed(self, event: Menu.Dismissed) -> None:
783
+ event.stop()
784
+ if event.menu.has_focus:
785
+ self.window.focus(scroll_visible=False)
786
+ await event.menu.remove()
787
+
788
+ @on(CurrentWorkingDirectoryChanged)
789
+ def on_current_working_directory_changed(
790
+ self, event: CurrentWorkingDirectoryChanged
791
+ ) -> None:
792
+ self.working_directory = str(Path(event.path).resolve().absolute())
793
+
794
+ def watch_busy_count(self, busy: int) -> None:
795
+ self.throbber.set_class(busy > 0, "-busy")
796
+
797
+ @on(acp_messages.UpdateStatusLine)
798
+ async def on_update_status_line(self, message: acp_messages.UpdateStatusLine):
799
+ self.status = message.status_line
800
+
801
+ @on(acp_messages.Update)
802
+ async def on_acp_agent_message(self, message: acp_messages.Update):
803
+ message.stop()
804
+ self._agent_thought = None
805
+ await self.post_agent_response(message.text)
806
+
807
+ @on(acp_messages.Thinking)
808
+ async def on_acp_agent_thinking(self, message: acp_messages.Thinking):
809
+ message.stop()
810
+ await self.post_agent_thought(message.text)
811
+
812
+ @on(acp_messages.RequestPermission)
813
+ async def on_acp_request_permission(self, message: acp_messages.RequestPermission):
814
+ message.stop()
815
+ options = [
816
+ Answer(option["name"], option["optionId"], option["kind"])
817
+ for option in message.options
818
+ ]
819
+ self.request_permissions(
820
+ message.result_future,
821
+ options,
822
+ message.tool_call,
823
+ )
824
+ self._agent_response = None
825
+ self._agent_thought = None
826
+
827
+ @on(acp_messages.Plan)
828
+ async def on_acp_plan(self, message: acp_messages.Plan):
829
+ from toad.widgets.plan import Plan
830
+
831
+ entries = [
832
+ Plan.Entry(
833
+ Content(entry["content"]),
834
+ entry.get("priority", "medium"),
835
+ entry.get("status", "pending"),
836
+ )
837
+ for entry in message.entries
838
+ ]
839
+
840
+ if self.contents.children and isinstance(
841
+ (current_plan := self.contents.children[-1]), Plan
842
+ ):
843
+ current_plan.entries = entries
844
+ else:
845
+ await self.post(Plan(entries))
846
+
847
+ @on(acp_messages.ToolCallUpdate)
848
+ @on(acp_messages.ToolCall)
849
+ async def on_acp_tool_call_update(
850
+ self, message: acp_messages.ToolCall | acp_messages.ToolCallUpdate
851
+ ):
852
+ from toad.widgets.tool_call import ToolCall
853
+
854
+ tool_call = message.tool_call
855
+
856
+ if tool_call.get("status", None) in (None, "completed"):
857
+ self._agent_thought = None
858
+ self._agent_response = None
859
+
860
+ tool_id = message.tool_id
861
+ try:
862
+ existing_tool_call: ToolCall | None = self.contents.get_child_by_id(
863
+ tool_id, ToolCall
864
+ )
865
+ except NoMatches:
866
+ await self.post(ToolCall(tool_call, id=message.tool_id))
867
+ else:
868
+ existing_tool_call.tool_call = tool_call
869
+
870
+ @on(acp_messages.AvailableCommandsUpdate)
871
+ async def on_acp_available_commands_update(
872
+ self, message: acp_messages.AvailableCommandsUpdate
873
+ ):
874
+ slash_commands: list[SlashCommand] = []
875
+ for available_command in message.commands:
876
+ input = available_command.get("input", {}) or {}
877
+ slash_command = SlashCommand(
878
+ f"/{available_command['name']}",
879
+ available_command["description"],
880
+ hint=input.get("hint"),
881
+ )
882
+ slash_commands.append(slash_command)
883
+ self.agent_slash_commands = slash_commands
884
+ self.update_slash_commands()
885
+
886
+ def get_terminal(self, terminal_id: str) -> TerminalTool | None:
887
+ """Get a terminal from its id.
888
+
889
+ Args:
890
+ terminal_id: ID of the terminal.
891
+
892
+ Returns:
893
+ Terminal instance, or `None` if no terminal was found.
894
+ """
895
+ from toad.widgets.terminal_tool import TerminalTool
896
+
897
+ try:
898
+ terminal = self.contents.query_one(f"#{terminal_id}", TerminalTool)
899
+ except NoMatches:
900
+ return None
901
+ if terminal.released:
902
+ return None
903
+ return terminal
904
+
905
+ async def action_interrupt(self) -> None:
906
+ terminal = self._terminal
907
+ if terminal is not None and not terminal.is_finalized:
908
+ await self.shell.interrupt()
909
+ # self._shell = None
910
+ self.flash("Command interrupted", style="success")
911
+ else:
912
+ raise SkipAction()
913
+
914
+ @work
915
+ @on(acp_messages.CreateTerminal)
916
+ async def on_acp_create_terminal(self, message: acp_messages.CreateTerminal):
917
+ from toad.widgets.terminal_tool import TerminalTool, Command
918
+
919
+ command = Command(
920
+ message.command,
921
+ message.args or [],
922
+ message.env or {},
923
+ message.cwd or str(self.project_path),
924
+ )
925
+ width = self.window.size.width - 5 - self.window.styles.scrollbar_size_vertical
926
+ height = self.window.scrollable_content_region.height - 2
927
+
928
+ terminal = TerminalTool(
929
+ command,
930
+ output_byte_limit=message.output_byte_limit,
931
+ id=message.terminal_id,
932
+ minimum_terminal_width=width,
933
+ )
934
+ self.terminals[message.terminal_id] = terminal
935
+ terminal.display = False
936
+
937
+ try:
938
+ await terminal.start(width, height)
939
+ except Exception as error:
940
+ log(str(error))
941
+ message.result_future.set_result(False)
942
+ return
943
+
944
+ try:
945
+ await self.post(terminal)
946
+ except Exception:
947
+ message.result_future.set_result(False)
948
+ else:
949
+ message.result_future.set_result(True)
950
+
951
+ @on(acp_messages.KillTerminal)
952
+ async def on_acp_kill_terminal(self, message: acp_messages.KillTerminal):
953
+ if (terminal := self.get_terminal(message.terminal_id)) is not None:
954
+ terminal.kill()
955
+
956
+ @on(acp_messages.GetTerminalState)
957
+ def on_acp_get_terminal_state(self, message: acp_messages.GetTerminalState):
958
+ if (terminal := self.get_terminal(message.terminal_id)) is None:
959
+ message.result_future.set_exception(
960
+ KeyError(f"No terminal with id {message.terminal_id!r}")
961
+ )
962
+ else:
963
+ message.result_future.set_result(terminal.tool_state)
964
+
965
+ @on(acp_messages.ReleaseTerminal)
966
+ def on_acp_terminal_release(self, message: acp_messages.ReleaseTerminal):
967
+ if (terminal := self.get_terminal(message.terminal_id)) is not None:
968
+ terminal.kill()
969
+ terminal.release()
970
+
971
+ @work
972
+ @on(acp_messages.WaitForTerminalExit)
973
+ async def on_acp_wait_for_terminal_exit(
974
+ self, message: acp_messages.WaitForTerminalExit
975
+ ):
976
+ if (terminal := self.get_terminal(message.terminal_id)) is None:
977
+ message.result_future.set_exception(
978
+ KeyError(f"No terminal with id {message.terminal_id!r}")
979
+ )
980
+ else:
981
+ return_code, signal = await terminal.wait_for_exit()
982
+ message.result_future.set_result((return_code or 0, signal))
983
+
984
+ def set_mode(self, mode_id: str) -> bool:
985
+ """Set the mode give its id (if it exists).
986
+
987
+ Args:
988
+ mode_id: Id of mode.
989
+
990
+ Returns:
991
+ `True` if the mode was changed, `False` if it didn't exist.
992
+ """
993
+ if (mode := self.modes.get(mode_id)) is not None:
994
+ self.current_mode = mode
995
+ return True
996
+ self.notify(
997
+ f"Node mode called '{mode_id}'",
998
+ title="Error setting mode",
999
+ severity="error",
1000
+ )
1001
+ return False
1002
+
1003
+ @on(acp_messages.SetModes)
1004
+ async def on_acp_set_modes(self, message: acp_messages.SetModes):
1005
+ self.modes = message.modes
1006
+ self.current_mode = self.modes[message.current_mode]
1007
+
1008
+ @on(messages.HistoryMove)
1009
+ async def on_history_move(self, message: messages.HistoryMove) -> None:
1010
+ message.stop()
1011
+ if message.shell:
1012
+ await self.shell_history.open()
1013
+
1014
+ if self.shell_history_index == 0:
1015
+ current_shell_command = ""
1016
+ else:
1017
+ current_shell_command = (
1018
+ await self.shell_history.get_entry(self.shell_history_index)
1019
+ )["input"]
1020
+ while True:
1021
+ self.shell_history_index += message.direction
1022
+ new_entry = await self.shell_history.get_entry(self.shell_history_index)
1023
+ if (new_entry)["input"] != current_shell_command:
1024
+ break
1025
+ if message.direction == +1 and self.shell_history_index == 0:
1026
+ break
1027
+ if (
1028
+ message.direction == -1
1029
+ and self.shell_history_index <= -self.shell_history.size
1030
+ ):
1031
+ break
1032
+ else:
1033
+ await self.prompt_history.open()
1034
+ self.prompt_history_index += message.direction
1035
+
1036
+ @work
1037
+ async def request_permissions(
1038
+ self,
1039
+ result_future: Future[Answer],
1040
+ options: list[Answer],
1041
+ tool_call_update: acp_protocol.ToolCallUpdatePermissionRequest,
1042
+ ) -> None:
1043
+ kind = tool_call_update.get("kind")
1044
+
1045
+ title: str | None = None
1046
+ if kind is None:
1047
+ from toad.widgets.tool_call import ToolCall
1048
+
1049
+ if (contents := tool_call_update.get("content")) is not None:
1050
+ title = tool_call_update.get("title")
1051
+ for content in contents:
1052
+ match content:
1053
+ case {"type": "text", "content": {"text": text}}:
1054
+ await self.post(ToolCall(text))
1055
+
1056
+ def answer_callback(answer: Answer) -> None:
1057
+ result_future.set_result(answer)
1058
+
1059
+ self.ask(options, title or "", answer_callback)
1060
+ return
1061
+
1062
+ if kind == "edit":
1063
+ from toad.screens.permissions import PermissionsScreen
1064
+
1065
+ async def populate(screen: PermissionsScreen) -> None:
1066
+ if (contents := tool_call_update.get("content")) is None:
1067
+ return
1068
+ for content in contents:
1069
+ match content:
1070
+ case {
1071
+ "type": "diff",
1072
+ "oldText": old_text,
1073
+ "newText": new_text,
1074
+ "path": path,
1075
+ }:
1076
+ await screen.add_diff(path, path, old_text, new_text)
1077
+
1078
+ permissions_screen = PermissionsScreen(options, populate_callback=populate)
1079
+ result = await self.app.push_screen_wait(permissions_screen)
1080
+ result_future.set_result(result)
1081
+ else:
1082
+ title = tool_call_update.get("title", "") or ""
1083
+
1084
+ def answer_callback(answer: Answer) -> None:
1085
+ result_future.set_result(answer)
1086
+
1087
+ self.ask(options, title, answer_callback)
1088
+
1089
+ async def post_tool_call(
1090
+ self, tool_call_update: acp_protocol.ToolCallUpdate
1091
+ ) -> None:
1092
+ if (contents := tool_call_update.get("content")) is None:
1093
+ return
1094
+
1095
+ for content in contents:
1096
+ match content:
1097
+ case {
1098
+ "type": "diff",
1099
+ "oldText": old_text,
1100
+ "newText": new_text,
1101
+ "path": path,
1102
+ }:
1103
+ await self.post_diff(path, old_text, new_text)
1104
+
1105
+ async def post_diff(self, path: str, before: str | None, after: str) -> None:
1106
+ """Post a diff view.
1107
+
1108
+ Args:
1109
+ path: Path to the file.
1110
+ before: Content of file before edit.
1111
+ after: Content of file after edit.
1112
+ """
1113
+ from toad.widgets.diff_view import DiffView
1114
+
1115
+ diff_view = DiffView(path, path, before or "", after, classes="block")
1116
+ diff_view_setting = self.app.settings.get("diff.view", str)
1117
+ diff_view.split = diff_view_setting == "split"
1118
+ diff_view.auto_split = diff_view_setting == "auto"
1119
+ await self.post(diff_view)
1120
+
1121
+ def ask(
1122
+ self,
1123
+ options: list[Answer],
1124
+ question: str = "",
1125
+ callback: Callable[[Answer], Any] | None = None,
1126
+ ) -> None:
1127
+ """Replace the prompt with a dialog to ask a question
1128
+
1129
+ Args:
1130
+ question: Question to ask or empty string to omit.
1131
+ options: A list of (ANSWER, ANSWER_ID) tuples.
1132
+ callback: Optional callable that will be invoked with the result.
1133
+ """
1134
+ from toad.widgets.question import Ask
1135
+
1136
+ self.agent_info
1137
+
1138
+ if self.agent_title:
1139
+ notify_title = f"[{self.agent_title}] {question}"
1140
+ else:
1141
+ notify_title = question
1142
+ notify_message = "\n".join(f" • {option.text}" for option in options)
1143
+ self.app.system_notify(notify_message, title=notify_title)
1144
+
1145
+ self.prompt.ask(Ask(question, options, callback))
1146
+
1147
+ def _build_slash_commands(self) -> list[SlashCommand]:
1148
+ slash_commands = [
1149
+ SlashCommand("/toad:about", "About Toad"),
1150
+ ]
1151
+ slash_commands.extend(self.agent_slash_commands)
1152
+ deduplicated_slash_commands = {
1153
+ slash_command.command: slash_command for slash_command in slash_commands
1154
+ }
1155
+ slash_commands = sorted(
1156
+ deduplicated_slash_commands.values(), key=attrgetter("command")
1157
+ )
1158
+ return slash_commands
1159
+
1160
+ def update_slash_commands(self) -> None:
1161
+ """Update slash commands, which may have changed since mounting."""
1162
+ self.prompt.slash_commands = self._build_slash_commands()
1163
+
1164
+ async def on_mount(self) -> None:
1165
+ self.trap_focus()
1166
+ self.prompt.focus()
1167
+ self.prompt.slash_commands = self._build_slash_commands()
1168
+ self.call_after_refresh(self.post_welcome)
1169
+ self.app.settings_changed_signal.subscribe(self, self._settings_changed)
1170
+
1171
+ self.shell_history.complete.add_words(
1172
+ self.app.settings.get("shell.allow_commands", expect_type=str).split()
1173
+ )
1174
+ self.shell
1175
+ if self._agent_data is not None:
1176
+
1177
+ def start_agent() -> None:
1178
+ """Start the agent after refreshing the UI."""
1179
+ assert self._agent_data is not None
1180
+ from toad.acp.agent import Agent
1181
+
1182
+ self.agent = Agent(self.project_path, self._agent_data)
1183
+ self.agent.start(self)
1184
+
1185
+ self.call_after_refresh(start_agent)
1186
+
1187
+ else:
1188
+ self.agent_ready = True
1189
+
1190
+ def _settings_changed(self, setting_item: tuple[str, str]) -> None:
1191
+ key, value = setting_item
1192
+ if key == "shell.allow_commands":
1193
+ self.shell_history.complete.add_words(value.split())
1194
+
1195
+ @work
1196
+ async def post_welcome(self) -> None:
1197
+ """Post any welcome content."""
1198
+
1199
+ def watch_agent(self, agent: AgentBase | None) -> None:
1200
+ if agent is None:
1201
+ self.agent_info = Content.styled("shell")
1202
+ else:
1203
+ self.agent_info = agent.get_info()
1204
+ self.agent_ready = False
1205
+
1206
+ async def watch_agent_ready(self, ready: bool) -> None:
1207
+ with suppress(asyncio.TimeoutError):
1208
+ async with asyncio.timeout(2.0):
1209
+ await self.shell.wait_for_ready()
1210
+ if ready:
1211
+ self._directory_watcher = DirectoryWatcher(self.project_path, self)
1212
+ self._directory_watcher.start()
1213
+ if ready and (agent_data := self._agent_data) is not None:
1214
+ welcome = agent_data.get("welcome", None)
1215
+ if welcome is not None:
1216
+ from toad.widgets.markdown_note import MarkdownNote
1217
+
1218
+ await self.post(MarkdownNote(welcome))
1219
+
1220
+ def on_mouse_down(self, event: events.MouseDown) -> None:
1221
+ self._mouse_down_offset = event.screen_offset
1222
+
1223
+ def on_click(self, event: events.Click) -> None:
1224
+ if (
1225
+ self._mouse_down_offset is not None
1226
+ and event.screen_offset != self._mouse_down_offset
1227
+ ):
1228
+ return
1229
+ widget = event.widget
1230
+
1231
+ contents = self.contents
1232
+ if self.screen.get_selected_text():
1233
+ return
1234
+ if widget is None or widget.is_maximized:
1235
+ return
1236
+ try:
1237
+ widget.query_ancestor(Prompt)
1238
+ except NoMatches:
1239
+ pass
1240
+ else:
1241
+ return
1242
+
1243
+ if widget in contents.displayed_children:
1244
+ self.cursor_offset = contents.displayed_children.index(widget)
1245
+ self.refresh_block_cursor()
1246
+ return
1247
+ for parent in widget.ancestors:
1248
+ if not isinstance(parent, Widget):
1249
+ break
1250
+ if (
1251
+ parent is self or parent is contents
1252
+ ) and widget in contents.displayed_children:
1253
+ self.cursor_offset = contents.displayed_children.index(widget)
1254
+ self.refresh_block_cursor()
1255
+ break
1256
+ if (
1257
+ isinstance(parent, BlockProtocol)
1258
+ and parent in contents.displayed_children
1259
+ ):
1260
+ self.cursor_offset = contents.displayed_children.index(parent)
1261
+ parent.block_select(widget)
1262
+ self.refresh_block_cursor()
1263
+ break
1264
+ widget = parent
1265
+
1266
+ async def post[WidgetType: Widget](
1267
+ self, widget: WidgetType, *, anchor: bool = True, loading: bool = False
1268
+ ) -> WidgetType:
1269
+ if self._loading is not None:
1270
+ await self._loading.remove()
1271
+ if not self.contents.is_attached:
1272
+ return widget
1273
+
1274
+ await self.contents.mount(widget)
1275
+ widget.loading = loading
1276
+ if anchor:
1277
+ self.window.anchor()
1278
+ self._require_check_prune = True
1279
+ self.call_after_refresh(self.check_prune)
1280
+ return widget
1281
+
1282
+ async def check_prune(self) -> None:
1283
+ """Check if a prune is required."""
1284
+ if self._require_check_prune:
1285
+ await self.prune_window(1500, 2500)
1286
+ self._require_check_prune = False
1287
+
1288
+ async def prune_window(self, low_mark: int, high_mark: int) -> None:
1289
+ """Remove older children to keep within a certain range.
1290
+
1291
+ Args:
1292
+ low_mark: Height to aim for.
1293
+ high_mark: Height to start pruning.
1294
+ """
1295
+
1296
+ assert high_mark >= low_mark
1297
+
1298
+ contents = self.contents
1299
+ height = contents.virtual_size.height
1300
+ if height <= high_mark:
1301
+ return
1302
+ prune_children: list[Widget] = []
1303
+ bottom_margin = 0
1304
+ prune_height = 0
1305
+ for child in contents.children:
1306
+ if not child.display:
1307
+ continue
1308
+ top, _, bottom, _ = child.styles.margin
1309
+ child_height = child.outer_size.height
1310
+ prune_height = (
1311
+ (prune_height - bottom_margin + max(bottom_margin, top))
1312
+ + bottom
1313
+ + child_height
1314
+ )
1315
+ bottom_margin = bottom
1316
+ if height - prune_height <= low_mark:
1317
+ break
1318
+ prune_children.append(child)
1319
+
1320
+ if prune_children:
1321
+ await contents.remove_children(prune_children)
1322
+
1323
+ async def new_terminal(self) -> Terminal:
1324
+ """Create a new interactive Terminal.
1325
+
1326
+ Args:
1327
+ width: Initial width of the terminal.
1328
+ display: Initial display.
1329
+
1330
+ Returns:
1331
+ A new (mounted) Terminal widget.
1332
+ """
1333
+ from toad.widgets.shell_terminal import ShellTerminal
1334
+
1335
+ if (terminal := self._terminal) is not None:
1336
+ if terminal.state.buffer.is_blank:
1337
+ terminal.finalize()
1338
+ await terminal.remove()
1339
+
1340
+ self._terminal_count += 1
1341
+
1342
+ terminal_width, terminal_height = self.get_terminal_dimensions()
1343
+ terminal = ShellTerminal(
1344
+ f"terminal #{self._terminal_count}",
1345
+ size=(terminal_width, terminal_height),
1346
+ get_terminal_dimensions=self.get_terminal_dimensions,
1347
+ )
1348
+
1349
+ terminal.display = False
1350
+ terminal = await self.post(terminal)
1351
+ self.add_focusable_terminal(terminal)
1352
+ self.refresh_bindings()
1353
+ return terminal
1354
+
1355
+ def get_terminal_dimensions(self) -> tuple[int, int]:
1356
+ """Get the default dimensions of new terminals.
1357
+
1358
+ Returns:
1359
+ Tuple of (WIDTH, HEIGHT)
1360
+ """
1361
+ terminal_width = max(
1362
+ 16,
1363
+ (self.window.size.width - 2 - self.window.styles.scrollbar_size_vertical),
1364
+ )
1365
+ terminal_height = max(8, self.window.scrollable_content_region.height - 4)
1366
+ return terminal_width, terminal_height
1367
+
1368
+ @property
1369
+ def shell(self) -> Shell:
1370
+ """A Shell instance."""
1371
+
1372
+ if self._shell is None or self._shell.is_finished:
1373
+ shell_command = self.app.settings.get(
1374
+ "shell.command",
1375
+ str,
1376
+ expand=False,
1377
+ )
1378
+ shell_start = self.app.settings.get(
1379
+ "shell.command_start",
1380
+ str,
1381
+ expand=False,
1382
+ )
1383
+ shell_directory = self.working_directory
1384
+ self._shell = Shell(
1385
+ self, shell_directory, shell=shell_command, start=shell_start
1386
+ )
1387
+ self._shell.start()
1388
+ return self._shell
1389
+
1390
+ async def post_shell(self, command: str) -> None:
1391
+ """Post a command to the shell.
1392
+
1393
+ Args:
1394
+ command: Command to execute.
1395
+ """
1396
+ from toad.widgets.shell_result import ShellResult
1397
+
1398
+ if command.strip():
1399
+ self._shell_count += 1
1400
+ await self.post(ShellResult(command))
1401
+ width, height = self.get_terminal_dimensions()
1402
+ await self.shell.send(command, width, height)
1403
+
1404
+ def action_cursor_up(self) -> None:
1405
+ if not self.contents.displayed_children or self.cursor_offset == 0:
1406
+ # No children
1407
+ return
1408
+ if self.cursor_offset == -1:
1409
+ # Start cursor at end
1410
+ self.cursor_offset = len(self.contents.displayed_children) - 1
1411
+ cursor_block = self.cursor_block
1412
+ if isinstance(cursor_block, BlockProtocol):
1413
+ cursor_block.block_cursor_clear()
1414
+ cursor_block.block_cursor_up()
1415
+ else:
1416
+ cursor_block = self.cursor_block
1417
+ if isinstance(cursor_block, BlockProtocol):
1418
+ if cursor_block.block_cursor_up() is None:
1419
+ self.cursor_offset -= 1
1420
+ cursor_block = self.cursor_block
1421
+ if isinstance(cursor_block, BlockProtocol):
1422
+ cursor_block.block_cursor_clear()
1423
+ cursor_block.block_cursor_up()
1424
+ else:
1425
+ # Move cursor up
1426
+ self.cursor_offset -= 1
1427
+ cursor_block = self.cursor_block
1428
+ if isinstance(cursor_block, BlockProtocol):
1429
+ cursor_block.block_cursor_clear()
1430
+ cursor_block.block_cursor_up()
1431
+ self.refresh_block_cursor()
1432
+
1433
+ def action_cursor_down(self) -> None:
1434
+ if not self.contents.displayed_children or self.cursor_offset == -1:
1435
+ # No children, or no cursor
1436
+ return
1437
+
1438
+ cursor_block = self.cursor_block
1439
+ if isinstance(cursor_block, BlockProtocol):
1440
+ if cursor_block.block_cursor_down() is None:
1441
+ self.cursor_offset += 1
1442
+ if self.cursor_offset >= len(self.contents.displayed_children):
1443
+ self.cursor_offset = -1
1444
+ self.refresh_block_cursor()
1445
+ return
1446
+ cursor_block = self.cursor_block
1447
+ if isinstance(cursor_block, BlockProtocol):
1448
+ cursor_block.block_cursor_clear()
1449
+ cursor_block.block_cursor_down()
1450
+ else:
1451
+ self.cursor_offset += 1
1452
+ if self.cursor_offset >= len(self.contents.displayed_children):
1453
+ self.cursor_offset = -1
1454
+ self.refresh_block_cursor()
1455
+ return
1456
+ cursor_block = self.cursor_block
1457
+ if isinstance(cursor_block, BlockProtocol):
1458
+ cursor_block.block_cursor_clear()
1459
+ cursor_block.block_cursor_down()
1460
+ self.refresh_block_cursor()
1461
+
1462
+ @work
1463
+ async def action_cancel(self) -> None:
1464
+ if monotonic() - self._last_escape_time < 3:
1465
+ if (agent := self.agent) is not None:
1466
+ if await agent.cancel():
1467
+ self.flash("Turn cancelled", style="success")
1468
+ else:
1469
+ self.flash("Agent declined to cancel. Please wait.", style="error")
1470
+ else:
1471
+ self.flash("Press [b]esc[/] again to cancel agent's turn")
1472
+ self._last_escape_time = monotonic()
1473
+
1474
+ def focus_prompt(self, reset_cursor: bool = True, scroll_end: bool = True) -> None:
1475
+ """Focus the prompt input.
1476
+
1477
+ Args:
1478
+ reset_cursor: Reset the block cursor.
1479
+ scroll_end: Scroll t the end of the content.
1480
+ """
1481
+ if reset_cursor:
1482
+ self.cursor_offset = -1
1483
+ self.cursor.visible = False
1484
+ if scroll_end:
1485
+ self.window.scroll_end()
1486
+ self.prompt.focus()
1487
+
1488
+ async def action_select_block(self) -> None:
1489
+ if (block := self.get_cursor_block(Widget)) is None:
1490
+ return
1491
+
1492
+ menu_options = [
1493
+ MenuItem("[u]C[/]opy to clipboard", "copy_to_clipboard", "c"),
1494
+ MenuItem("Co[u]p[/u]y to prompt", "copy_to_prompt", "p"),
1495
+ MenuItem("Open as S[u]V[/]G", "export_to_svg", "v"),
1496
+ ]
1497
+
1498
+ if block.allow_maximize:
1499
+ menu_options.append(MenuItem("[u]M[/u]aximize", "maximize_block", "m"))
1500
+
1501
+ if isinstance(block, MenuProtocol):
1502
+ menu_options.extend(block.get_block_menu())
1503
+ menu = Menu(block, menu_options)
1504
+ else:
1505
+ menu = Menu(block, menu_options)
1506
+
1507
+ menu.offset = Offset(1, block.region.offset.y)
1508
+ await self.mount(menu)
1509
+ menu.focus()
1510
+
1511
+ def action_copy_to_clipboard(self) -> None:
1512
+ block = self.get_cursor_block()
1513
+ if isinstance(block, MenuProtocol):
1514
+ text = block.get_block_content("clipboard")
1515
+ elif isinstance(block, MarkdownFence):
1516
+ text = block._content.plain
1517
+ elif isinstance(block, MarkdownBlock):
1518
+ text = block.source
1519
+ else:
1520
+ return
1521
+ if text:
1522
+ self.app.copy_to_clipboard(text)
1523
+ self.flash("Copied to clipboard")
1524
+
1525
+ def action_copy_to_prompt(self) -> None:
1526
+ block = self.get_cursor_block()
1527
+ if isinstance(block, MenuProtocol):
1528
+ text = block.get_block_content("prompt")
1529
+ elif isinstance(block, MarkdownFence):
1530
+ # Copy to prompt leaves MD formatting
1531
+ text = block.source
1532
+ elif isinstance(block, MarkdownBlock):
1533
+ text = block.source
1534
+ else:
1535
+ return
1536
+
1537
+ if text:
1538
+ self.prompt.append(text)
1539
+ self.flash("Copied to prompt")
1540
+ self.focus_prompt()
1541
+
1542
+ def action_maximize_block(self) -> None:
1543
+ if (block := self.get_cursor_block()) is not None:
1544
+ self.screen.maximize(block, container=False)
1545
+ block.focus()
1546
+
1547
+ def action_export_to_svg(self) -> None:
1548
+ block = self.get_cursor_block()
1549
+ if block is None:
1550
+ return
1551
+ import platformdirs
1552
+ from textual._compositor import Compositor
1553
+ from textual._files import generate_datetime_filename
1554
+
1555
+ width, height = block.outer_size
1556
+ compositor = Compositor()
1557
+ compositor.reflow(block, block.outer_size)
1558
+ render = compositor.render_full_update()
1559
+
1560
+ from rich.console import Console
1561
+ import io
1562
+ import os.path
1563
+
1564
+ console = Console(
1565
+ width=width,
1566
+ height=height,
1567
+ file=io.StringIO(),
1568
+ force_terminal=True,
1569
+ color_system="truecolor",
1570
+ record=True,
1571
+ legacy_windows=False,
1572
+ safe_box=False,
1573
+ )
1574
+ console.print(render)
1575
+ path = platformdirs.user_pictures_dir()
1576
+ svg_filename = generate_datetime_filename("Toad", ".svg", None)
1577
+ svg_path = os.path.expanduser(os.path.join(path, svg_filename))
1578
+ console.save_svg(svg_path)
1579
+ import webbrowser
1580
+
1581
+ webbrowser.open(f"file:///{svg_path}")
1582
+
1583
+ async def action_mode_switcher(self) -> None:
1584
+ self.prompt.mode_switcher.focus()
1585
+
1586
+ def refresh_block_cursor(self) -> None:
1587
+ if (cursor_block := self.cursor_block_child) is not None:
1588
+ self.window.focus()
1589
+ self.cursor.visible = True
1590
+ self.cursor.follow(cursor_block)
1591
+ self.call_after_refresh(
1592
+ self.window.scroll_to_center, cursor_block, immediate=True
1593
+ )
1594
+ else:
1595
+ self.cursor.visible = False
1596
+ self.window.anchor(False)
1597
+ self.window.scroll_end(duration=2 / 10)
1598
+ self.cursor.follow(None)
1599
+ self.prompt.focus()
1600
+ self.refresh_bindings()
1601
+
1602
+ async def slash_command(self, text: str) -> bool:
1603
+ """Give Toad the opertunity to process slash commands.
1604
+
1605
+ Args:
1606
+ text: The prompt, including the slash in the first position.
1607
+
1608
+ Returns:
1609
+ `True` if Toad has processed the slash command, `False` if it should
1610
+ be forwarded to the agent.
1611
+ """
1612
+ command, _, parameters = text[1:].partition(" ")
1613
+ if command == "toad:about":
1614
+ from toad import about
1615
+ from toad.widgets.markdown_note import MarkdownNote
1616
+
1617
+ app = self.app
1618
+ about_md = about.render(app)
1619
+ await self.post(MarkdownNote(about_md, classes="about"))
1620
+ self.app.copy_to_clipboard(about_md)
1621
+ self.notify(
1622
+ "A copy of /about-toad has been placed in your clipboard",
1623
+ title="About",
1624
+ )
1625
+ return True
1626
+ return False