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
toad/widgets/prompt.py ADDED
@@ -0,0 +1,741 @@
1
+ from pathlib import Path
2
+ import shlex
3
+ from typing import Callable, Literal, Self
4
+
5
+ from textual import on
6
+ from textual.reactive import var, Initialize
7
+ from textual.app import ComposeResult
8
+
9
+ from textual.actions import SkipAction
10
+ from textual.binding import Binding
11
+
12
+ from textual.content import Content
13
+ from textual import getters
14
+ from textual.message import Message
15
+ from textual.widgets import OptionList, TextArea, Label
16
+ from textual import containers
17
+ from textual.widget import Widget
18
+ from textual.widgets.option_list import Option
19
+ from textual.widgets.text_area import Selection
20
+ from textual import events
21
+
22
+ from toad.app import ToadApp
23
+ from toad import messages
24
+ from toad.widgets.highlighted_textarea import HighlightedTextArea
25
+ from toad.widgets.condensed_path import CondensedPath
26
+ from toad.widgets.path_search import PathSearch
27
+ from toad.widgets.plan import Plan
28
+ from toad.widgets.question import Ask, Question
29
+ from toad.widgets.slash_complete import SlashComplete
30
+ from toad.messages import UserInputSubmitted
31
+ from toad.slash_command import SlashCommand
32
+ from toad.prompt.extract import extract_paths_from_prompt
33
+ from toad.acp.agent import Mode
34
+ from toad.path_complete import PathComplete
35
+
36
+
37
+ class ModeSwitcher(OptionList):
38
+ BINDINGS = [Binding("escape", "dismiss")]
39
+
40
+ @on(OptionList.OptionSelected)
41
+ def on_option_selected(self, event: OptionList.OptionSelected):
42
+ self.post_message(messages.ChangeMode(event.option_id))
43
+ self.blur()
44
+
45
+ def action_dismiss(self):
46
+ self.blur()
47
+
48
+
49
+ class InvokeFileSearch(Message):
50
+ pass
51
+
52
+
53
+ class InvokeSlashComplete(Message):
54
+ pass
55
+
56
+
57
+ class AgentInfo(Label):
58
+ pass
59
+
60
+
61
+ class ModeInfo(Label):
62
+ pass
63
+
64
+
65
+ class StatusLine(Label):
66
+ status: var[str] = var("")
67
+
68
+ def watch_status(self, status: str) -> None:
69
+ self.set_class(not bool(status), "-hidden")
70
+ self.update(status)
71
+ self.tooltip = status
72
+
73
+
74
+ class PromptContainer(containers.HorizontalGroup):
75
+ def on_mouse_down(self, event: events.MouseUp) -> None:
76
+ for child in self.query("*"):
77
+ if child.has_focus:
78
+ return
79
+ prompt_text_area = self.query_one(PromptTextArea)
80
+ if not prompt_text_area.has_focus:
81
+ prompt_text_area.focus()
82
+
83
+
84
+ class PromptTextArea(HighlightedTextArea):
85
+ BINDING_GROUP_TITLE = "Prompt"
86
+
87
+ BINDINGS = [
88
+ Binding(
89
+ "enter",
90
+ "submit",
91
+ "Send",
92
+ key_display="⏎",
93
+ priority=True,
94
+ tooltip="Send the prompt to the agent",
95
+ ),
96
+ Binding(
97
+ "ctrl+j,shift+enter",
98
+ "newline",
99
+ "Line",
100
+ key_display="⇧+⏎",
101
+ tooltip="Insert a new line character",
102
+ ),
103
+ Binding(
104
+ "ctrl+j,shift+enter",
105
+ "multiline_submit",
106
+ "Send",
107
+ key_display="⇧+⏎",
108
+ tooltip="Send the prompt to the agent",
109
+ ),
110
+ Binding(
111
+ "tab",
112
+ "tab_complete",
113
+ "Complete",
114
+ tooltip="Complete path (if possible)",
115
+ priority=True,
116
+ show=False,
117
+ ),
118
+ ]
119
+
120
+ app = getters.app(ToadApp)
121
+
122
+ auto_completes: var[list[Option]] = var(list)
123
+ multi_line = var(False, bindings=True)
124
+ shell_mode = var(False, bindings=True)
125
+ agent_ready: var[bool] = var(False)
126
+ path_complete: var[PathComplete] = var(Initialize(lambda obj: PathComplete()))
127
+ suggestions: var[list[str] | None] = var(None)
128
+ suggestions_index: var[int] = var(0)
129
+
130
+ project_path = var(Path())
131
+ working_directory = var("")
132
+
133
+ slash_commands: var[list[SlashCommand]] = var([])
134
+ slash_command_prefixes: var[tuple[str, ...]] = var(())
135
+
136
+ class Submitted(Message):
137
+ def __init__(self, markdown: str) -> None:
138
+ self.markdown = markdown
139
+ super().__init__()
140
+
141
+ class RequestShellMode(Message):
142
+ pass
143
+
144
+ class CancelShell(Message):
145
+ pass
146
+
147
+ def watch_slash_commands(self, slash_commands: list[SlashCommand]) -> None:
148
+ """A tuple of slash commands for performance reasons (used with `str.startswith`)."""
149
+ self.slash_command_prefixes = tuple(
150
+ [slash_command.command for slash_command in slash_commands]
151
+ )
152
+
153
+ def highlight_slash_command(self, text: str) -> Content:
154
+ """Override slash command highlighting."""
155
+
156
+ if text.startswith(self.slash_command_prefixes):
157
+ content = Content(text)
158
+ for slash_command in self.slash_commands:
159
+ if text.startswith(slash_command.command + " "):
160
+ content = content.stylize(
161
+ "$text-success", 0, len(slash_command.command)
162
+ )
163
+ break
164
+ return content
165
+ return Content(text)
166
+
167
+ def highlight_shell(self, text: str) -> Content:
168
+ """Override shell highlighting with additional danger detection."""
169
+ content = super().highlight_shell(text)
170
+
171
+ if not self.app.settings.get("shell.warn_dangerous", bool):
172
+ return content
173
+
174
+ from toad import danger
175
+
176
+ spans, _danger_level = danger.detect(
177
+ str(self.project_path), self.working_directory, content.plain
178
+ )
179
+ content = content.add_spans(spans)
180
+ return content
181
+
182
+ def on_mount(self) -> None:
183
+ self.highlight_cursor_line = False
184
+ self.hide_suggestion_on_blur = False
185
+
186
+ def on_key(self, event: events.Key) -> None:
187
+ if (
188
+ not self.shell_mode
189
+ and self.cursor_location == (0, 0)
190
+ and event.character in {"!", "$"}
191
+ ):
192
+ self.post_message(self.RequestShellMode())
193
+ event.prevent_default()
194
+ elif self.shell_mode and event.key == "tab":
195
+ event.prevent_default()
196
+ else:
197
+ self.suggestions = None
198
+ self.suggestion = ""
199
+
200
+ def update_suggestion(self) -> None:
201
+ prompt = self.query_ancestor(Prompt)
202
+
203
+ if self.selection.start == self.selection.end and self.text.startswith("/"):
204
+ return
205
+
206
+ if self.shell_mode and self.cursor_at_end_of_text and "\n" not in self.text:
207
+ if prompt.complete_callback is not None:
208
+ if completes := prompt.complete_callback(self.text):
209
+ self.suggestion = completes[-1]
210
+
211
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
212
+ if action == "newline" and self.multi_line:
213
+ return False
214
+ if action == "submit" and self.multi_line:
215
+ return False
216
+ if action == "multiline_submit":
217
+ return self.multi_line
218
+ return True
219
+
220
+ def action_multiline_submit(self) -> None:
221
+ if not self.agent_ready:
222
+ self.app.bell()
223
+ self.post_message(
224
+ messages.Flash(
225
+ "Agent is not ready. Please wait while the agent connects…",
226
+ "warning",
227
+ )
228
+ )
229
+ return
230
+ self.post_message(UserInputSubmitted(self.text, self.shell_mode))
231
+ self.clear()
232
+
233
+ def action_newline(self) -> None:
234
+ self.insert("\n")
235
+
236
+ def action_submit(self) -> None:
237
+ if not self.agent_ready and not self.shell_mode:
238
+ self.app.bell()
239
+ self.post_message(
240
+ messages.Flash(
241
+ "Agent is not ready. Please wait while the agent connects…",
242
+ "warning",
243
+ )
244
+ )
245
+ return
246
+ if self.suggestion:
247
+ if " " not in self.text:
248
+ self.insert(self.suggestion + " ")
249
+ else:
250
+ prompt = self.query_ancestor(Prompt)
251
+ last_token = shlex.split(self.text + self.suggestion)[-1]
252
+ last_token_path = Path(prompt.working_directory) / last_token
253
+ if last_token_path.is_dir():
254
+ self.insert(self.suggestion)
255
+ else:
256
+ self.insert(self.suggestion + " ")
257
+ self.suggestion = ""
258
+ return
259
+ self.post_message(UserInputSubmitted(self.text, self.shell_mode))
260
+ self.clear()
261
+
262
+ def action_cursor_up(self, select: bool = False):
263
+ if self.selection.is_empty and not select:
264
+ row, _column = self.selection[0]
265
+ if row == 0:
266
+ self.post_message(messages.HistoryMove(-1, self.shell_mode, self.text))
267
+ return
268
+ super().action_cursor_up(select)
269
+
270
+ def action_cursor_down(self, select: bool = False):
271
+ if self.selection.is_empty and not select:
272
+ row, _column = self.selection[0]
273
+ if row == (self.wrapped_document.height - 1):
274
+ self.post_message(messages.HistoryMove(+1, self.shell_mode, self.text))
275
+ return
276
+ super().action_cursor_down(select)
277
+
278
+ def action_delete_left(self) -> None:
279
+ selection = self.selection
280
+ if selection.start == selection.end and self.selection.end == (0, 0):
281
+ self.post_message(self.CancelShell())
282
+ return
283
+ return super().action_delete_left()
284
+
285
+ async def action_tab_complete(self) -> None:
286
+ if not self.shell_mode:
287
+ return
288
+
289
+ import shlex
290
+
291
+ prompt = self.query_ancestor(Prompt)
292
+
293
+ if not self.cursor_at_end_of_text:
294
+ return
295
+
296
+ _cursor_row, cursor_column = prompt.prompt_text_area.selection.end
297
+ pre_complete = self.text[:cursor_column]
298
+ post_complete = self.text[cursor_column:]
299
+ shlex_tokens = shlex.split(pre_complete)
300
+ if not shlex_tokens:
301
+ return
302
+
303
+ command = shlex_tokens[0]
304
+
305
+ exclude_node_type: Literal["file"] | Literal["dir"] | None = None
306
+ if (
307
+ command
308
+ in self.app.settings.get("shell.directory_commands", str).splitlines()
309
+ ):
310
+ exclude_node_type = "file"
311
+ elif command in self.app.settings.get("shell.file_commands", str).splitlines():
312
+ exclude_node_type = "dir"
313
+
314
+ tab_complete, suggestions = await self.path_complete(
315
+ Path(prompt.working_directory),
316
+ shlex_tokens[-1],
317
+ exclude_type=exclude_node_type,
318
+ )
319
+
320
+ if tab_complete is not None:
321
+ shlex_tokens = shlex_tokens[:-1] + [shlex_tokens[-1] + tab_complete]
322
+ path_component = Path(prompt.working_directory) / shlex_tokens[-1]
323
+ if path_component.is_file():
324
+ spaces = " "
325
+ else:
326
+ spaces = ""
327
+
328
+ self.clear()
329
+ self.insert(
330
+ " ".join(token.replace(" ", "\\ ") for token in shlex_tokens)
331
+ + post_complete
332
+ + spaces
333
+ )
334
+ self.suggestions = None
335
+ else:
336
+ if suggestions != self.suggestions:
337
+ self.suggestions = suggestions or None
338
+ self.suggestions_index = 0
339
+ if suggestions:
340
+ self.suggestion = suggestions[0]
341
+ elif self.suggestions:
342
+ self.suggestions_index = (self.suggestions_index + 1) % len(
343
+ self.suggestions
344
+ )
345
+ self.suggestion = self.suggestions[self.suggestions_index]
346
+
347
+ async def watch_selection(
348
+ self, previous_selection: Selection, selection: Selection
349
+ ) -> None:
350
+ if previous_selection == selection:
351
+ return
352
+ if selection.start == selection.end:
353
+ previous_y, previous_x = previous_selection.end
354
+ y, x = selection.end
355
+ if y == previous_y:
356
+ direction = -1 if x < previous_x else +1
357
+ else:
358
+ direction = 0
359
+ line = self.document.get_line(y)
360
+
361
+ if (
362
+ not self.shell_mode
363
+ and y == 0
364
+ and x == 1
365
+ and direction == +1
366
+ and line
367
+ and line[0] == "/"
368
+ ):
369
+ self.post_message(InvokeSlashComplete())
370
+ return
371
+
372
+ if y == 0 and line and line[0] == "/" and direction == -1:
373
+ if line in self.slash_command_prefixes:
374
+ self.selection = Selection((0, 0), (0, len(line)))
375
+ return
376
+
377
+ for _path, start, end in extract_paths_from_prompt(line):
378
+ if x > start and x < end:
379
+ self.selection = Selection((y, start), (y, end))
380
+ break
381
+ if direction == -1 and x == end:
382
+ self.selection = Selection((y, start), (y, end))
383
+ break
384
+
385
+ if x > 0 and x <= len(line) and line[x - 1] == "@":
386
+ remaining_line = line[x + 1 :]
387
+ if not remaining_line or remaining_line[0].isspace():
388
+ self.post_message(InvokeFileSearch())
389
+
390
+
391
+ class Prompt(containers.VerticalGroup):
392
+ BINDINGS = [
393
+ Binding("escape", "dismiss", "Dismiss"),
394
+ ]
395
+
396
+ PROMPT_NULL = " "
397
+ PROMPT_SHELL = Content.styled("$", "$text-primary")
398
+ PROMPT_AI = Content.styled("❯", "$text-secondary")
399
+ PROMPT_MULTILINE = Content.styled("☰", "$text-secondary")
400
+
401
+ prompt_container = getters.query_one("#prompt-container", Widget)
402
+ prompt_text_area = getters.query_one(PromptTextArea)
403
+ prompt_label = getters.query_one("#prompt", Label)
404
+ current_directory = getters.query_one(CondensedPath)
405
+ path_search = getters.query_one(PathSearch)
406
+ slash_complete = getters.query_one(SlashComplete)
407
+ question = getters.query_one(Question)
408
+ mode_switcher = getters.query_one(ModeSwitcher)
409
+
410
+ slash_commands: var[list[SlashCommand]] = var(list)
411
+ shell_mode = var(False)
412
+ multi_line = var(False)
413
+ show_path_search = var(False, toggle_class="-show-path-search", bindings=True)
414
+ show_slash_complete = var(False, toggle_class="-show-slash-complete", bindings=True)
415
+ project_path = var(Path())
416
+ working_directory = var("")
417
+ agent_info = var(Content(""))
418
+ _ask: var[Ask | None] = var(None)
419
+ plan: var[list[Plan.Entry]]
420
+ agent_ready: var[bool] = var(False)
421
+ current_mode: var[Mode | None] = var(None)
422
+ modes: var[dict[str, Mode] | None] = var(None)
423
+ status: var[str] = var("")
424
+
425
+ app = getters.app(ToadApp)
426
+
427
+ def __init__(
428
+ self,
429
+ project_path: Path,
430
+ *,
431
+ name: str | None = None,
432
+ id: str | None = None,
433
+ classes: str | None = None,
434
+ disabled: bool = False,
435
+ complete_callback: Callable[[str], list[str]] | None = None,
436
+ ):
437
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
438
+ self.ask_queue: list[Ask] = []
439
+ self.complete_callback = complete_callback
440
+ self.project_path = project_path
441
+
442
+ @property
443
+ def text(self) -> str:
444
+ return self.prompt_text_area.text
445
+
446
+ @text.setter
447
+ def text(self, text: str) -> None:
448
+ self.prompt_text_area.text = text
449
+ self.prompt_text_area.selection = Selection.cursor(
450
+ self.prompt_text_area.get_cursor_line_end_location()
451
+ )
452
+
453
+ def watch_current_mode(self, mode: Mode | None) -> None:
454
+ self.set_class(mode is not None, "-has-mode")
455
+ if mode is not None:
456
+ tooltip = Content.from_markup(
457
+ "[b]$description[/]\n\n[dim](click to open mode switcher)",
458
+ description=mode.description,
459
+ )
460
+ self.query_one(ModeInfo).with_tooltip(tooltip).update(mode.name)
461
+ self.watch_modes(self.modes)
462
+
463
+ async def watch_project_path(self) -> None:
464
+ """Initial refresh of paths."""
465
+ self.call_later(self.path_search.refresh_paths)
466
+
467
+ def ask(self, ask: Ask) -> None:
468
+ """Replace the textarea prompt with a menu of options.
469
+
470
+ Args:
471
+ ask: An `Ask` instance which contains a question and responses.
472
+ """
473
+ self.ask_queue.append(ask)
474
+ if self._ask is None:
475
+ self._ask = self.ask_queue.pop(0)
476
+
477
+ @on(events.Click, "ModeInfo")
478
+ def on_click(self):
479
+ self.mode_switcher.focus()
480
+
481
+ def watch_modes(self, modes: dict[str, Mode] | None) -> None:
482
+ from toad.visuals.columns import Columns
483
+
484
+ columns = Columns("auto", "auto", "flex")
485
+ if modes is not None:
486
+ mode_list = sorted(modes.values(), key=lambda mode: mode.name.lower())
487
+ for mode in mode_list:
488
+ columns.add_row(
489
+ (
490
+ Content.styled("✔", "$text-success")
491
+ if self.current_mode and mode.id == self.current_mode.id
492
+ else ""
493
+ ),
494
+ Content.from_markup("[bold]$mode[/]", mode=mode.name),
495
+ Content.styled(mode.description or "", "dim"),
496
+ )
497
+ else:
498
+ mode_list = []
499
+
500
+ self.mode_switcher.set_options(
501
+ [Option(row, id=mode.id) for row, mode in zip(columns, mode_list)]
502
+ )
503
+ if self.current_mode is not None:
504
+ self.mode_switcher.highlighted = self.mode_switcher.get_option_index(
505
+ self.current_mode.id
506
+ )
507
+
508
+ def watch_agent_ready(self, ready: bool) -> None:
509
+ self.set_class(not ready, "-not-ready")
510
+ if ready:
511
+ self.query_one(AgentInfo).update(self.agent_info)
512
+
513
+ def watch_agent_info(self, agent_info: Content) -> None:
514
+ if self.agent_ready:
515
+ self.query_one(AgentInfo).update(agent_info)
516
+ else:
517
+ self.query_one(AgentInfo).update("Initializing…")
518
+
519
+ def watch_multiline(self) -> None:
520
+ self.update_prompt()
521
+
522
+ def watch_shell_mode(self) -> None:
523
+ self.update_prompt()
524
+
525
+ def watch_working_directory(self, working_directory: str) -> None:
526
+ if not working_directory:
527
+ return
528
+ out_of_bounds = not Path(working_directory).is_relative_to(self.project_path)
529
+ if out_of_bounds and not self.has_class("-working-directory-out-of-bounds"):
530
+ self.post_message(
531
+ messages.Flash(
532
+ "You have navigated away from the project directory",
533
+ style="error",
534
+ duration=5,
535
+ )
536
+ )
537
+ self.set_class(
538
+ out_of_bounds,
539
+ "-working-directory-out-of-bounds",
540
+ )
541
+
542
+ def watch__ask(self, ask: Ask | None) -> None:
543
+ self.set_class(ask is not None, "-mode-ask")
544
+ if ask is None:
545
+ self.prompt_text_area.focus()
546
+ else:
547
+ self.question.update(ask)
548
+ self.question.focus()
549
+
550
+ def update_prompt(self):
551
+ """Update the prompt according to the current mode."""
552
+ if self.shell_mode:
553
+ self.prompt_label.update(self.PROMPT_SHELL, layout=False)
554
+ self.add_class("-shell-mode")
555
+ self.prompt_text_area.placeholder = Content.from_markup(
556
+ "Enter shell command\t[r]▌esc▐[/r] prompt mode"
557
+ ).expand_tabs(8)
558
+ self.prompt_text_area.highlight_language = "shell"
559
+ else:
560
+ self.prompt_label.update(
561
+ self.PROMPT_MULTILINE if self.multi_line else self.PROMPT_AI,
562
+ layout=False,
563
+ )
564
+ self.remove_class("-shell-mode")
565
+
566
+ self.prompt_text_area.placeholder = Content.assemble(
567
+ "What would you like to do?\t".expandtabs(8),
568
+ ("▌!▐", "r"),
569
+ " shell ",
570
+ ("▌/▐", "r"),
571
+ " commands ",
572
+ ("▌@▐", "r"),
573
+ " files",
574
+ )
575
+ self.prompt_text_area.highlight_language = "markdown"
576
+
577
+ @property
578
+ def likely_shell(self) -> bool:
579
+ text = self.prompt_text_area.text
580
+ if "\n" in text or " " in text or not text.strip():
581
+ return False
582
+
583
+ shell_commands = {
584
+ command.strip()
585
+ for command in self.app.settings.get(
586
+ "shell.allow_commands", expect_type=str
587
+ ).split()
588
+ }
589
+ if text.split(" ", 1)[0] in shell_commands:
590
+ return True
591
+ return False
592
+
593
+ @property
594
+ def is_shell_mode(self) -> bool:
595
+ return self.shell_mode or self.likely_shell
596
+
597
+ def focus(self, scroll_visible: bool = True) -> Self:
598
+ if self._ask is not None:
599
+ self.question.focus()
600
+ else:
601
+ self.query(HighlightedTextArea).focus()
602
+ return self
603
+
604
+ def append(self, text: str) -> None:
605
+ self.query_one(HighlightedTextArea).insert(
606
+ text, maintain_selection_offset=False
607
+ )
608
+
609
+ def watch_show_path_search(self, show: bool) -> None:
610
+ self.prompt_text_area.suggestion = ""
611
+
612
+ def watch_show_slash_complete(self, show: bool) -> None:
613
+ self.slash_complete.focus()
614
+
615
+ def project_directory_updated(self) -> None:
616
+ """Called when there is may be new files"""
617
+ self.path_search.refresh_paths()
618
+
619
+ @on(PromptTextArea.RequestShellMode)
620
+ def on_request_shell_mode(self, event: PromptTextArea.RequestShellMode):
621
+ self.shell_mode = True
622
+ self.update_prompt()
623
+
624
+ @on(TextArea.Changed)
625
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
626
+ text = event.text_area.text
627
+
628
+ self.multi_line = "\n" in text or "```" in text
629
+
630
+ if not self.multi_line and self.likely_shell:
631
+ self.shell_mode = True
632
+
633
+ self.update_prompt()
634
+
635
+ @on(PromptTextArea.CancelShell)
636
+ def on_cancel_shell(self, event: PromptTextArea.CancelShell):
637
+ self.shell_mode = False
638
+
639
+ @on(InvokeFileSearch)
640
+ def on_invoke_file_search(self, event: InvokeFileSearch) -> None:
641
+ event.stop()
642
+ if not self.shell_mode:
643
+ self.show_path_search = True
644
+ self.path_search.reset()
645
+
646
+ @on(InvokeSlashComplete)
647
+ def on_invoke_slash_complete(self, event: InvokeSlashComplete) -> None:
648
+ event.stop()
649
+ self.show_slash_complete = True
650
+
651
+ @on(messages.PromptSuggestion)
652
+ def on_prompt_suggestion(self, event: messages.PromptSuggestion) -> None:
653
+ event.stop()
654
+ self.prompt_text_area.suggestion = event.suggestion
655
+
656
+ @on(SlashComplete.Completed)
657
+ def on_slash_complete_completed(self, event: SlashComplete.Completed) -> None:
658
+ self.prompt_text_area.clear()
659
+ self.prompt_text_area.insert(f"{event.command} ")
660
+
661
+ @on(messages.Dismiss)
662
+ def on_dismiss(self, event: messages.Dismiss) -> None:
663
+ event.stop()
664
+ if event.widget is self.slash_complete:
665
+ self.show_slash_complete = False
666
+ self.prompt_text_area.suggestion = ""
667
+ self.focus()
668
+ elif event.widget is self.path_search:
669
+ self.show_path_search = False
670
+ self.focus()
671
+
672
+ @on(messages.InsertPath)
673
+ def on_insert_path(self, event: messages.InsertPath) -> None:
674
+ event.stop()
675
+ if " " in event.path:
676
+ path = f'"{event.path}"'
677
+ else:
678
+ path = event.path
679
+ if (
680
+ self.prompt_text_area.get_text_range(*self.prompt_text_area.selection)
681
+ != " "
682
+ ):
683
+ path += " "
684
+ self.prompt_text_area.insert(path)
685
+
686
+ @on(Question.Answer)
687
+ def on_question_answer(self, event: Question.Answer) -> None:
688
+ """Question has been answered."""
689
+ event.stop()
690
+
691
+ def remove_question() -> None:
692
+ """Remove the question and restore the text prompt."""
693
+ if self.ask_queue:
694
+ self._ask = self.ask_queue.pop(0)
695
+ else:
696
+ self._ask = None
697
+
698
+ if self._ask is not None and (callback := self._ask.callback) is not None:
699
+ callback(event.answer)
700
+
701
+ self.set_timer(0.3, remove_question)
702
+
703
+ def suggest(self, suggestion: str) -> None:
704
+ if suggestion.startswith(self.text) and self.text != suggestion:
705
+ self.prompt_text_area.suggestion = suggestion[len(self.text) :]
706
+
707
+ def compose(self) -> ComposeResult:
708
+ yield PathSearch(self.project_path).data_bind(root=Prompt.project_path)
709
+ yield SlashComplete().data_bind(slash_commands=Prompt.slash_commands)
710
+ with PromptContainer(id="prompt-container"):
711
+ yield Question()
712
+ with containers.HorizontalGroup(id="text-prompt"):
713
+ yield Label(self.PROMPT_AI, id="prompt")
714
+ yield PromptTextArea().data_bind(
715
+ multi_line=Prompt.multi_line,
716
+ shell_mode=Prompt.shell_mode,
717
+ agent_ready=Prompt.agent_ready,
718
+ project_path=Prompt.project_path,
719
+ working_directory=Prompt.working_directory,
720
+ slash_commands=Prompt.slash_commands,
721
+ )
722
+ with containers.HorizontalGroup(id="info-container"):
723
+ yield AgentInfo()
724
+ yield CondensedPath().data_bind(path=Prompt.working_directory)
725
+ yield StatusLine(markup=False).data_bind(status=Prompt.status)
726
+ yield ModeSwitcher()
727
+ yield ModeInfo("mode")
728
+
729
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
730
+ return True
731
+
732
+ def action_dismiss(self) -> None:
733
+ if self.prompt_text_area.suggestion:
734
+ self.prompt_text_area.suggestion = ""
735
+ return
736
+ if self.shell_mode:
737
+ self.shell_mode = False
738
+ elif self.show_slash_complete:
739
+ self.show_slash_complete = False
740
+ else:
741
+ raise SkipAction()