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
|
@@ -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
|