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,303 @@
|
|
|
1
|
+
import re # re2 doesn't have MULTILINE
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual import events
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual import getters
|
|
9
|
+
|
|
10
|
+
from textual.content import Content
|
|
11
|
+
from textual.reactive import var
|
|
12
|
+
from textual.css.query import NoMatches
|
|
13
|
+
from textual import containers
|
|
14
|
+
from textual.widgets import Static, Markdown
|
|
15
|
+
|
|
16
|
+
from toad.app import ToadApp
|
|
17
|
+
from toad.acp import protocol
|
|
18
|
+
from toad.menus import MenuItem
|
|
19
|
+
from toad.pill import pill
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TextContent(Static):
|
|
23
|
+
DEFAULT_CSS = """
|
|
24
|
+
TextContent
|
|
25
|
+
{
|
|
26
|
+
height: auto;
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MarkdownContent(Markdown):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolCallItem(containers.HorizontalGroup):
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield Static(classes="icon")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToolCallDiff(Static):
|
|
41
|
+
DEFAULT_CSS = """
|
|
42
|
+
ToolCallDiff {
|
|
43
|
+
height: auto;
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolCallHeader(Static):
|
|
49
|
+
ALLOW_SELECT = False
|
|
50
|
+
DEFAULT_CSS = """
|
|
51
|
+
ToolCallHeader {
|
|
52
|
+
width: auto;
|
|
53
|
+
max-width: 1fr;
|
|
54
|
+
&:hover {
|
|
55
|
+
background: $panel;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ToolCall(containers.VerticalGroup):
|
|
62
|
+
DEFAULT_CLASSES = "block"
|
|
63
|
+
|
|
64
|
+
app = getters.app(ToadApp)
|
|
65
|
+
has_content: var[bool] = var(False, toggle_class="-has-content")
|
|
66
|
+
expanded: var[bool] = var(False, toggle_class="-expanded")
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
tool_call: protocol.ToolCall,
|
|
71
|
+
*,
|
|
72
|
+
id: str | None = None,
|
|
73
|
+
classes: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._tool_call = tool_call
|
|
76
|
+
super().__init__(id=id, classes=classes)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def tool_call(self) -> protocol.ToolCall:
|
|
80
|
+
return self._tool_call
|
|
81
|
+
|
|
82
|
+
@tool_call.setter
|
|
83
|
+
def tool_call(self, tool_call: protocol.ToolCall):
|
|
84
|
+
self._tool_call = tool_call
|
|
85
|
+
self.refresh(recompose=True)
|
|
86
|
+
|
|
87
|
+
def get_block_menu(self) -> Iterable[MenuItem]:
|
|
88
|
+
if self.expanded:
|
|
89
|
+
yield MenuItem("Collapse", "block.collapse", "x")
|
|
90
|
+
else:
|
|
91
|
+
yield MenuItem("Expand", "block.expand", "x")
|
|
92
|
+
|
|
93
|
+
def action_collapse(self) -> None:
|
|
94
|
+
self.expanded = False
|
|
95
|
+
|
|
96
|
+
def action_expand(self) -> None:
|
|
97
|
+
self.expanded = True
|
|
98
|
+
|
|
99
|
+
def get_block_content(self, destination: str) -> str | None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def can_expand(self) -> bool:
|
|
103
|
+
return self.has_content
|
|
104
|
+
|
|
105
|
+
def expand_block(self) -> None:
|
|
106
|
+
self.expanded = True
|
|
107
|
+
|
|
108
|
+
def collapse_block(self) -> None:
|
|
109
|
+
self.expanded = False
|
|
110
|
+
|
|
111
|
+
def is_block_expanded(self) -> bool:
|
|
112
|
+
return self.expanded
|
|
113
|
+
|
|
114
|
+
def compose(self) -> ComposeResult:
|
|
115
|
+
tool_call = self._tool_call
|
|
116
|
+
content: list[protocol.ToolCallContent] = tool_call.get("content", None) or []
|
|
117
|
+
title = tool_call.get("title", "title")
|
|
118
|
+
|
|
119
|
+
self.has_content = False
|
|
120
|
+
content_update = list(self._compose_content(content))
|
|
121
|
+
|
|
122
|
+
yield (header := ToolCallHeader(self.tool_call_header_content, markup=False))
|
|
123
|
+
header.tooltip = title
|
|
124
|
+
with containers.VerticalGroup(id="tool-content"):
|
|
125
|
+
yield from content_update
|
|
126
|
+
|
|
127
|
+
self.call_after_refresh(self.check_expand)
|
|
128
|
+
|
|
129
|
+
def check_expand(self) -> None:
|
|
130
|
+
"""Check if the tool call should auto-expand."""
|
|
131
|
+
if not self.has_content:
|
|
132
|
+
return
|
|
133
|
+
tool_call = self._tool_call
|
|
134
|
+
if tool_call.get("kind", "") == "read":
|
|
135
|
+
# Don't auto expand reads, as it can generate a lot of noise
|
|
136
|
+
return
|
|
137
|
+
tool_call_expand = self.app.settings.get("tools.expand", str, expand=False)
|
|
138
|
+
status = self._tool_call.get("status")
|
|
139
|
+
if tool_call_expand == "always":
|
|
140
|
+
self.expanded = True
|
|
141
|
+
elif tool_call_expand != "never" and status is not None:
|
|
142
|
+
if tool_call_expand == "success":
|
|
143
|
+
self.expanded = status == "completed"
|
|
144
|
+
elif tool_call_expand == "fail":
|
|
145
|
+
self.expanded = status == "failed"
|
|
146
|
+
elif tool_call_expand == "both":
|
|
147
|
+
self.expanded = status in ("completed", "failed")
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def tool_call_header_content(self) -> Content:
|
|
151
|
+
tool_call = self._tool_call
|
|
152
|
+
_kind = tool_call.get("kind", "tool")
|
|
153
|
+
title = tool_call.get("title", "title")
|
|
154
|
+
status = tool_call.get("status", "pending")
|
|
155
|
+
|
|
156
|
+
expand_icon: Content = Content()
|
|
157
|
+
if self.has_content:
|
|
158
|
+
expand_icon = Content("▼ " if self.expanded else "▶ ")
|
|
159
|
+
else:
|
|
160
|
+
expand_icon = Content.styled("▶ ", "$text 20%")
|
|
161
|
+
|
|
162
|
+
header = Content.assemble(expand_icon, "🔧 ", (title, "$text-success"))
|
|
163
|
+
|
|
164
|
+
if status == "pending":
|
|
165
|
+
header += Content.assemble(" ⏲")
|
|
166
|
+
elif status == "in_progress":
|
|
167
|
+
pass
|
|
168
|
+
elif status == "failed":
|
|
169
|
+
header += Content.assemble(" ", pill("failed", "$error-muted", "$error"))
|
|
170
|
+
elif status == "completed":
|
|
171
|
+
header += Content.from_markup(" [$success]✔")
|
|
172
|
+
return header
|
|
173
|
+
|
|
174
|
+
def watch_expanded(self) -> None:
|
|
175
|
+
try:
|
|
176
|
+
self.query_one(ToolCallHeader).update(self.tool_call_header_content)
|
|
177
|
+
except NoMatches:
|
|
178
|
+
pass
|
|
179
|
+
from toad.widgets.conversation import Conversation
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
conversation = self.query_ancestor(Conversation)
|
|
183
|
+
except NoMatches:
|
|
184
|
+
pass
|
|
185
|
+
else:
|
|
186
|
+
self.call_after_refresh(conversation.cursor.update_follow)
|
|
187
|
+
|
|
188
|
+
def watch_has_content(self) -> None:
|
|
189
|
+
try:
|
|
190
|
+
self.query_one(ToolCallHeader).update(self.tool_call_header_content)
|
|
191
|
+
except NoMatches:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
@on(events.Click, "ToolCallHeader")
|
|
195
|
+
def on_click_tool_call_header(self, event: events.Click) -> None:
|
|
196
|
+
event.stop()
|
|
197
|
+
if self.has_content:
|
|
198
|
+
self.expanded = not self.expanded
|
|
199
|
+
else:
|
|
200
|
+
self.app.bell()
|
|
201
|
+
|
|
202
|
+
def _compose_content(
|
|
203
|
+
self, tool_call_content: list[protocol.ToolCallContent]
|
|
204
|
+
) -> ComposeResult:
|
|
205
|
+
def compose_content_block(
|
|
206
|
+
content_block: protocol.ContentBlock,
|
|
207
|
+
) -> ComposeResult:
|
|
208
|
+
match content_block:
|
|
209
|
+
# TODO: This may need updating
|
|
210
|
+
# Docs claim this should be "plain" text
|
|
211
|
+
# However, I have seen simple text, text with ansi escape sequences, and Markdown returned
|
|
212
|
+
# I think this is a flaw in the spec.
|
|
213
|
+
# For now I will attempt a heuristic to guess what the content actually contains
|
|
214
|
+
# https://agentclientprotocol.com/protocol/schema#param-text
|
|
215
|
+
case {"type": "text", "text": text}:
|
|
216
|
+
if "\x1b" in text:
|
|
217
|
+
parsed_ansi_text = Text.from_ansi(text)
|
|
218
|
+
yield TextContent(Content.from_rich_text(parsed_ansi_text))
|
|
219
|
+
elif "```" in text or re.search(
|
|
220
|
+
r"^#{1,6}\s.*$", text, re.MULTILINE
|
|
221
|
+
):
|
|
222
|
+
yield MarkdownContent(text)
|
|
223
|
+
else:
|
|
224
|
+
yield TextContent(text, markup=False)
|
|
225
|
+
|
|
226
|
+
for content in tool_call_content:
|
|
227
|
+
match content:
|
|
228
|
+
case {"type": "content", "content": sub_content}:
|
|
229
|
+
yield from compose_content_block(sub_content)
|
|
230
|
+
self.has_content = True
|
|
231
|
+
case {
|
|
232
|
+
"type": "diff",
|
|
233
|
+
"path": path,
|
|
234
|
+
"oldText": old_text,
|
|
235
|
+
"newText": new_text,
|
|
236
|
+
}:
|
|
237
|
+
from toad.widgets.diff_view import DiffView
|
|
238
|
+
|
|
239
|
+
yield (diff_view := DiffView(path, path, old_text or "", new_text))
|
|
240
|
+
|
|
241
|
+
if isinstance(self.app, ToadApp):
|
|
242
|
+
diff_view_setting = self.app.settings.get("diff.view", str)
|
|
243
|
+
diff_view.split = diff_view_setting == "split"
|
|
244
|
+
diff_view.auto_split = diff_view_setting == "auto"
|
|
245
|
+
|
|
246
|
+
self.has_content = True
|
|
247
|
+
|
|
248
|
+
case {"type": "terminal", "terminalId": terminal_id}:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
from textual.app import App, ComposeResult
|
|
254
|
+
|
|
255
|
+
TOOL_CALL_READ: protocol.ToolCall = {
|
|
256
|
+
"sessionUpdate": "tool_call",
|
|
257
|
+
"toolCallId": "write_file-1759480341499",
|
|
258
|
+
"status": "completed",
|
|
259
|
+
"title": "Foo",
|
|
260
|
+
"content": [
|
|
261
|
+
{
|
|
262
|
+
"type": "diff",
|
|
263
|
+
"path": "fib.py",
|
|
264
|
+
"oldText": "",
|
|
265
|
+
"newText": 'def fibonacci(n):\n """Generates the Fibonacci sequence up to n terms."""\n a, b = 0, 1\n for _ in range(n):\n yield a\n a, b = b, a + b\n\nif __name__ == "__main__":\n for number in fibonacci(10):\n print(number)\n',
|
|
266
|
+
}
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
TOOL_CALL_CONTENT: protocol.ToolCall = {
|
|
271
|
+
"sessionUpdate": "tool_call",
|
|
272
|
+
"toolCallId": "run_shell_command-1759480356886",
|
|
273
|
+
"status": "completed",
|
|
274
|
+
"title": "Bar",
|
|
275
|
+
"content": [
|
|
276
|
+
{
|
|
277
|
+
"type": "content",
|
|
278
|
+
"content": {
|
|
279
|
+
"type": "text",
|
|
280
|
+
"text": "0\n1\n1\n2\n3\n5\n8\n13\n21\n34",
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
TOOL_CALL_EMPTY: protocol.ToolCall = {
|
|
287
|
+
"sessionUpdate": "tool_call",
|
|
288
|
+
"toolCallId": "run_shell_command-1759480356886",
|
|
289
|
+
"status": "completed",
|
|
290
|
+
"title": "Bar",
|
|
291
|
+
"content": [],
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
class ToolApp(App):
|
|
295
|
+
def on_mount(self) -> None:
|
|
296
|
+
self.theme = "dracula"
|
|
297
|
+
|
|
298
|
+
def compose(self) -> ComposeResult:
|
|
299
|
+
yield ToolCall(TOOL_CALL_READ)
|
|
300
|
+
yield ToolCall(TOOL_CALL_CONTENT)
|
|
301
|
+
yield ToolCall(TOOL_CALL_EMPTY)
|
|
302
|
+
|
|
303
|
+
ToolApp().run()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Iterable
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual import containers
|
|
4
|
+
from textual.widgets import Markdown
|
|
5
|
+
|
|
6
|
+
from toad.menus import MenuItem
|
|
7
|
+
from toad.widgets.non_selectable_label import NonSelectableLabel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UserInput(containers.HorizontalGroup):
|
|
11
|
+
def __init__(self, content: str) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.content = content
|
|
14
|
+
|
|
15
|
+
def compose(self) -> ComposeResult:
|
|
16
|
+
yield NonSelectableLabel("❯", id="prompt")
|
|
17
|
+
yield Markdown(self.content, id="content")
|
|
18
|
+
|
|
19
|
+
def get_block_menu(self) -> Iterable[MenuItem]:
|
|
20
|
+
yield from ()
|
|
21
|
+
|
|
22
|
+
def get_block_content(self, destination: str) -> str | None:
|
|
23
|
+
return self.content
|
toad/widgets/version.py
ADDED
toad/widgets/welcome.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual import containers
|
|
3
|
+
|
|
4
|
+
from textual.widgets import Label, Markdown
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ASCII_TOAD = r"""
|
|
8
|
+
_ _
|
|
9
|
+
(.)_(.)
|
|
10
|
+
_ ( _ ) _
|
|
11
|
+
/ \/`-----'\/ \
|
|
12
|
+
__\ ( ( ) ) /__
|
|
13
|
+
) /\ \._./ /\ (
|
|
14
|
+
)_/ /|\ /|\ \_(
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
WELCOME_MD = """\
|
|
19
|
+
## Toad v1.0
|
|
20
|
+
|
|
21
|
+
Welcome, **Will**!
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Welcome(containers.Vertical):
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
with containers.Center():
|
|
30
|
+
yield Label(ASCII_TOAD, id="logo")
|
|
31
|
+
yield Markdown(WELCOME_MD, id="message", classes="note")
|