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.
- batrachian_toad-0.5.22.dist-info/METADATA +197 -0
- batrachian_toad-0.5.22.dist-info/RECORD +120 -0
- batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
- batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
- batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
- toad/__init__.py +46 -0
- toad/__main__.py +4 -0
- toad/_loop.py +86 -0
- toad/about.py +90 -0
- toad/acp/agent.py +671 -0
- toad/acp/api.py +47 -0
- toad/acp/encode_tool_call_id.py +12 -0
- toad/acp/messages.py +138 -0
- toad/acp/prompt.py +54 -0
- toad/acp/protocol.py +426 -0
- toad/agent.py +62 -0
- toad/agent_schema.py +70 -0
- toad/agents.py +45 -0
- toad/ansi/__init__.py +1 -0
- toad/ansi/_ansi.py +1612 -0
- toad/ansi/_ansi_colors.py +264 -0
- toad/ansi/_control_codes.py +37 -0
- toad/ansi/_keys.py +251 -0
- toad/ansi/_sgr_styles.py +64 -0
- toad/ansi/_stream_parser.py +418 -0
- toad/answer.py +22 -0
- toad/app.py +557 -0
- toad/atomic.py +37 -0
- toad/cli.py +257 -0
- toad/code_analyze.py +28 -0
- toad/complete.py +34 -0
- toad/constants.py +58 -0
- toad/conversation_markdown.py +19 -0
- toad/danger.py +371 -0
- toad/data/agents/ampcode.com.toml +51 -0
- toad/data/agents/augmentcode.com.toml +40 -0
- toad/data/agents/claude.com.toml +41 -0
- toad/data/agents/docker.com.toml +59 -0
- toad/data/agents/geminicli.com.toml +28 -0
- toad/data/agents/goose.ai.toml +51 -0
- toad/data/agents/inference.huggingface.co.toml +33 -0
- toad/data/agents/kimi.com.toml +35 -0
- toad/data/agents/openai.com.toml +53 -0
- toad/data/agents/opencode.ai.toml +61 -0
- toad/data/agents/openhands.dev.toml +44 -0
- toad/data/agents/stakpak.dev.toml +61 -0
- toad/data/agents/vibe.mistral.ai.toml +27 -0
- toad/data/agents/vtcode.dev.toml +62 -0
- toad/data/images/frog.png +0 -0
- toad/data/sounds/turn-over.wav +0 -0
- toad/db.py +5 -0
- toad/dec.py +332 -0
- toad/directory.py +234 -0
- toad/directory_watcher.py +96 -0
- toad/fuzzy.py +140 -0
- toad/gist.py +2 -0
- toad/history.py +138 -0
- toad/jsonrpc.py +576 -0
- toad/menus.py +14 -0
- toad/messages.py +74 -0
- toad/option_content.py +51 -0
- toad/os.py +0 -0
- toad/path_complete.py +145 -0
- toad/path_filter.py +124 -0
- toad/paths.py +71 -0
- toad/pill.py +23 -0
- toad/prompt/extract.py +19 -0
- toad/prompt/resource.py +68 -0
- toad/protocol.py +28 -0
- toad/screens/action_modal.py +94 -0
- toad/screens/agent_modal.py +172 -0
- toad/screens/command_edit_modal.py +58 -0
- toad/screens/main.py +192 -0
- toad/screens/permissions.py +390 -0
- toad/screens/permissions.tcss +72 -0
- toad/screens/settings.py +254 -0
- toad/screens/settings.tcss +101 -0
- toad/screens/store.py +476 -0
- toad/screens/store.tcss +261 -0
- toad/settings.py +354 -0
- toad/settings_schema.py +318 -0
- toad/shell.py +263 -0
- toad/shell_read.py +42 -0
- toad/slash_command.py +34 -0
- toad/toad.tcss +752 -0
- toad/version.py +80 -0
- toad/visuals/columns.py +273 -0
- toad/widgets/agent_response.py +79 -0
- toad/widgets/agent_thought.py +41 -0
- toad/widgets/command_pane.py +224 -0
- toad/widgets/condensed_path.py +93 -0
- toad/widgets/conversation.py +1626 -0
- toad/widgets/danger_warning.py +65 -0
- toad/widgets/diff_view.py +709 -0
- toad/widgets/flash.py +81 -0
- toad/widgets/future_text.py +126 -0
- toad/widgets/grid_select.py +223 -0
- toad/widgets/highlighted_textarea.py +180 -0
- toad/widgets/mandelbrot.py +294 -0
- toad/widgets/markdown_note.py +13 -0
- toad/widgets/menu.py +147 -0
- toad/widgets/non_selectable_label.py +5 -0
- toad/widgets/note.py +18 -0
- toad/widgets/path_search.py +381 -0
- toad/widgets/plan.py +180 -0
- toad/widgets/project_directory_tree.py +74 -0
- toad/widgets/prompt.py +741 -0
- toad/widgets/question.py +337 -0
- toad/widgets/shell_result.py +35 -0
- toad/widgets/shell_terminal.py +18 -0
- toad/widgets/side_bar.py +74 -0
- toad/widgets/slash_complete.py +211 -0
- toad/widgets/strike_text.py +66 -0
- toad/widgets/terminal.py +526 -0
- toad/widgets/terminal_tool.py +338 -0
- toad/widgets/throbber.py +90 -0
- toad/widgets/tool_call.py +303 -0
- toad/widgets/user_input.py +23 -0
- toad/widgets/version.py +5 -0
- 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()
|