batrachian-toad 0.5.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. toad/widgets/welcome.py +31 -0
@@ -0,0 +1,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
@@ -0,0 +1,5 @@
1
+ from textual.widgets import Static
2
+
3
+
4
+ class Version(Static):
5
+ pass
@@ -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")