batrachian-toad 0.5.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. toad/widgets/welcome.py +31 -0
toad/screens/store.py ADDED
@@ -0,0 +1,476 @@
1
+ from contextlib import suppress
2
+ from dataclasses import dataclass
3
+ from itertools import zip_longest
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Literal, Self
7
+
8
+ from textual.binding import Binding
9
+ from textual.screen import Screen
10
+ from textual import events
11
+ from textual import work
12
+ from textual import getters
13
+ from textual import on
14
+ from textual.app import ComposeResult
15
+ from textual.content import Content
16
+ from textual.css.query import NoMatches
17
+ from textual.message import Message
18
+ from textual import containers
19
+ from textual import widgets
20
+
21
+ import toad
22
+ from toad.app import ToadApp
23
+ from toad.pill import pill
24
+ from toad.widgets.mandelbrot import Mandelbrot
25
+ from toad.widgets.grid_select import GridSelect
26
+ from toad.agent_schema import Agent
27
+ from toad.agents import read_agents
28
+
29
+
30
+ QR = """\
31
+ █▀▀▀▀▀█ ▄█ ▄▄█▄▄█ █▀▀▀▀▀█
32
+ █ ███ █ ▄█▀█▄▄█▄  █ ███ █
33
+ █ ▀▀▀ █ ▄ █ ▀▀▄▄▀ █ ▀▀▀ █
34
+ ▀▀▀▀▀▀▀ ▀ ▀ ▀ █ █ ▀▀▀▀▀▀▀
35
+ █▀██▀ ▀█▀█▀▄▄█   ▀ █ ▀ █ 
36
+  █ ▀▄▄▀▄▄█▄▄█▀██▄▄▄▄ ▀ ▀█
37
+ ▄▀▄▀▀▄▀ █▀▄▄▄▀▄ ▄▀▀█▀▄▀█▀
38
+ █ ▄ ▀▀▀█▀ █ ▀ █▀ ▀ ██▀ ▀█
39
+ ▀  ▀▀ ▀▀▄▀▄▄▀▀▄▀█▀▀▀█▄▀  
40
+ █▀▀▀▀▀█ ▀▄█▄▀▀  █ ▀ █▄▀▀█
41
+ █ ███ █ ██▄▄▀▀█▀▀██▀█▄██▄
42
+ █ ▀▀▀ █ ██▄▄ ▀  ▄▀ ▄▄█▀ █
43
+ ▀▀▀▀▀▀▀ ▀▀▀  ▀   ▀▀▀▀▀▀▀▀"""
44
+
45
+
46
+ @dataclass
47
+ class LaunchAgent(Message):
48
+ identity: str
49
+
50
+
51
+ class AgentItem(containers.VerticalGroup):
52
+ """An entry in the Agent grid select."""
53
+
54
+ def __init__(self, agent: Agent) -> None:
55
+ self._agent = agent
56
+ super().__init__()
57
+
58
+ @property
59
+ def agent(self) -> Agent:
60
+ return self._agent
61
+
62
+ def compose(self) -> ComposeResult:
63
+ agent = self._agent
64
+ with containers.Grid():
65
+ yield widgets.Label(agent["name"], id="name")
66
+ tag = pill(agent["type"], "$secondary-muted", "$text-primary")
67
+ yield widgets.Label(tag, id="type")
68
+ yield widgets.Label(agent["author_name"], id="author")
69
+ yield widgets.Static(agent["description"], id="description")
70
+
71
+
72
+ class LauncherGridSelect(GridSelect):
73
+ BINDING_GROUP_TITLE = "Launcher"
74
+
75
+ app = getters.app(ToadApp)
76
+ BINDINGS = [
77
+ Binding(
78
+ "enter",
79
+ "select",
80
+ "Details",
81
+ tooltip="Open agent details",
82
+ ),
83
+ Binding(
84
+ "space",
85
+ "launch",
86
+ "Launch",
87
+ tooltip="Launch highlighted agent",
88
+ ),
89
+ ]
90
+
91
+ def action_details(self) -> None:
92
+ if self.highlighted is None:
93
+ return
94
+ agent_item = self.children[self.highlighted]
95
+ assert isinstance(agent_item, LauncherItem)
96
+ self.post_message(StoreScreen.OpenAgentDetails(agent_item._agent["identity"]))
97
+
98
+ def action_remove(self) -> None:
99
+ agents = self.app.settings.get("launcher.agents", str).splitlines()
100
+ if self.highlighted is None:
101
+ return
102
+ try:
103
+ del agents[self.highlighted]
104
+ except IndexError:
105
+ pass
106
+ else:
107
+ self.app.settings.set("launcher.agents", "\n".join(agents))
108
+
109
+ def action_launch(self) -> None:
110
+ if self.highlighted is None:
111
+ return
112
+ child = self.children[self.highlighted]
113
+ assert isinstance(child, LauncherItem)
114
+ self.post_message(LaunchAgent(child.agent["identity"]))
115
+
116
+
117
+ class Launcher(containers.VerticalGroup):
118
+ app = getters.app(ToadApp)
119
+ grid_select = getters.query_one("#launcher-grid-select", GridSelect)
120
+ DIGITS = "123456789ABCDEF"
121
+
122
+ def __init__(
123
+ self,
124
+ agents: dict[str, Agent],
125
+ *,
126
+ name: str | None = None,
127
+ id: str | None = None,
128
+ classes: str | None = None,
129
+ ) -> None:
130
+ self._agents = agents
131
+ super().__init__(name=name, id=id, classes=classes)
132
+
133
+ @property
134
+ def highlighted(self) -> int | None:
135
+ return self.grid_select.highlighted
136
+
137
+ @highlighted.setter
138
+ def highlighted(self, value: int) -> None:
139
+ self.grid_select.highlighted = value
140
+
141
+ def focus(self, scroll_visible: bool = True) -> Self:
142
+ try:
143
+ self.grid_select.focus(scroll_visible=scroll_visible)
144
+ except NoMatches:
145
+ pass
146
+ return self
147
+
148
+ def compose(self) -> ComposeResult:
149
+ launcher_agents = list(
150
+ dict.fromkeys(
151
+ identity
152
+ for identity in self.app.settings.get(
153
+ "launcher.agents", str
154
+ ).splitlines()
155
+ if identity.strip()
156
+ )
157
+ )
158
+ agents = self._agents
159
+ self.set_class(not launcher_agents, "-empty")
160
+ if launcher_agents:
161
+ with LauncherGridSelect(
162
+ id="launcher-grid-select", min_column_width=32, max_column_width=32
163
+ ):
164
+ for digit, identity in zip_longest(self.DIGITS, launcher_agents):
165
+ if identity is None:
166
+ break
167
+ yield LauncherItem(digit or "", agents[identity])
168
+
169
+ if not launcher_agents:
170
+ yield widgets.Label("Chose your fighter below!", classes="no-agents")
171
+
172
+
173
+ class LauncherItem(containers.VerticalGroup):
174
+ """An entry in the Agent grid select."""
175
+
176
+ def __init__(self, digit: str, agent: Agent) -> None:
177
+ self._digit = digit
178
+ self._agent = agent
179
+ super().__init__()
180
+
181
+ @property
182
+ def agent(self) -> Agent:
183
+ return self._agent
184
+
185
+ def compose(self) -> ComposeResult:
186
+ agent = self._agent
187
+ with containers.HorizontalGroup():
188
+ if self._digit:
189
+ yield widgets.Digits(self._digit)
190
+ with containers.VerticalGroup():
191
+ yield widgets.Label(agent["name"], id="name")
192
+ yield widgets.Label(agent["author_name"], id="author")
193
+ yield widgets.Static(agent["description"], id="description")
194
+
195
+
196
+ class AgentGridSelect(GridSelect):
197
+ BINDINGS = [
198
+ Binding("enter", "select", "Details", tooltip="Open agent details"),
199
+ Binding("space", "launch", "Launch", tooltip="Launch highlighted agent"),
200
+ ]
201
+ BINDING_GROUP_TITLE = "Agent Select"
202
+
203
+ def action_launch(self) -> None:
204
+ if self.highlighted is None:
205
+ return
206
+ child = self.children[self.highlighted]
207
+ assert isinstance(child, AgentItem)
208
+ self.post_message(LaunchAgent(child.agent["identity"]))
209
+
210
+
211
+ class Container(containers.VerticalScroll):
212
+ BINDING_GROUP_TITLE = "View"
213
+
214
+ def allow_focus(self) -> bool:
215
+ """Only allow focus when we can scroll."""
216
+ return super().allow_focus() and self.show_vertical_scrollbar
217
+
218
+
219
+ class StoreScreen(Screen):
220
+ BINDING_GROUP_TITLE = "Screen"
221
+ CSS_PATH = "store.tcss"
222
+ FOCUS_GROUP = Binding.Group("Focus")
223
+ BINDINGS = [
224
+ Binding(
225
+ "tab",
226
+ "app.focus_next",
227
+ "Focus Next",
228
+ group=FOCUS_GROUP,
229
+ ),
230
+ Binding(
231
+ "shift+tab",
232
+ "app.focus_previous",
233
+ "Focus Previous",
234
+ group=FOCUS_GROUP,
235
+ ),
236
+ Binding(
237
+ "null",
238
+ "quick_launch",
239
+ "Quick launch",
240
+ key_display="1-9 a-f",
241
+ ),
242
+ ]
243
+
244
+ agents_view = getters.query_one("#agents-view", AgentGridSelect)
245
+ launcher = getters.query_one("#launcher", Launcher)
246
+ container = getters.query_one("#container", Container)
247
+
248
+ app = getters.app(ToadApp)
249
+
250
+ @dataclass
251
+ class OpenAgentDetails(Message):
252
+ identity: str
253
+
254
+ def __init__(
255
+ self, name: str | None = None, id: str | None = None, classes: str | None = None
256
+ ):
257
+ self._agents: dict[str, Agent] = {}
258
+ super().__init__(name=name, id=id, classes=classes)
259
+
260
+ @property
261
+ def agents(self) -> dict[str, Agent]:
262
+ return self._agents
263
+
264
+ def compose(self) -> ComposeResult:
265
+ with containers.VerticalGroup(id="title-container"):
266
+ with containers.Grid(id="title-grid"):
267
+ yield Mandelbrot()
268
+ yield widgets.Label(self.get_info(), id="info")
269
+
270
+ yield Container(id="container", can_focus=False)
271
+ yield widgets.Footer()
272
+
273
+ def get_info(self) -> Content:
274
+ toad_version = toad.get_version()
275
+ content = Content.assemble(
276
+ Content.from_markup("🐸 Toad"),
277
+ pill(f"v{toad_version}", "$primary-muted", "$text-primary"),
278
+ ("\nThe universal interface for AI in your terminal", "$text-success"),
279
+ (
280
+ "\nSoftware lovingly crafted by hand (with a dash of AI) in Edinburgh, Scotland",
281
+ "dim",
282
+ ),
283
+ "\n",
284
+ (
285
+ Content.from_markup(
286
+ "\nConsider sponsoring [@click=screen.url('https://github.com/sponsors/willmcgugan')]@willmcgugan[/] to support future updates"
287
+ )
288
+ ),
289
+ "\n\n",
290
+ (
291
+ Content.from_markup(
292
+ "[dim]Code: [@click=screen.url('https://github.com/batrachianai/toad')]Repository[/] "
293
+ "Bugs: [@click=screen.url('https://github.com/batrachianai/toad/discussions')]Discussions[/]"
294
+ )
295
+ ),
296
+ )
297
+
298
+ return content
299
+
300
+ def action_url(self, url: str) -> None:
301
+ import webbrowser
302
+
303
+ webbrowser.open(url)
304
+
305
+ def compose_agents(self) -> ComposeResult:
306
+ agents = self._agents
307
+
308
+ yield Launcher(agents, id="launcher")
309
+
310
+ ordered_agents = sorted(
311
+ agents.values(), key=lambda agent: agent["name"].casefold()
312
+ )
313
+
314
+ recommended_agents = [
315
+ agent for agent in ordered_agents if agent.get("recommended", False)
316
+ ]
317
+ if recommended_agents:
318
+ with containers.VerticalGroup(id="sponsored-agents", classes="recommended"):
319
+ yield widgets.Static("Recommended", classes="heading")
320
+ with AgentGridSelect(classes="agents-picker", min_column_width=40):
321
+ for agent in recommended_agents:
322
+ yield AgentItem(agent)
323
+
324
+ coding_agents = [agent for agent in ordered_agents if agent["type"] == "coding"]
325
+ if coding_agents:
326
+ yield widgets.Static("Coding agents", classes="heading")
327
+ with AgentGridSelect(classes="agents-picker", min_column_width=40):
328
+ for agent in coding_agents:
329
+ yield AgentItem(agent)
330
+
331
+ chat_bots = [agent for agent in ordered_agents if agent["type"] == "chat"]
332
+ if chat_bots:
333
+ yield widgets.Static("Chat & more", classes="heading")
334
+ with AgentGridSelect(classes="agents-picker", min_column_width=40):
335
+ for agent in chat_bots:
336
+ yield AgentItem(agent)
337
+
338
+ def move_focus(self, direction: Literal[-1] | Literal[+1]) -> None:
339
+ if isinstance(self.focused, GridSelect):
340
+ focus_chain = list(self.query(GridSelect))
341
+ if self.focused in focus_chain:
342
+ index = focus_chain.index(self.focused)
343
+ new_focus = focus_chain[(index + direction) % len(focus_chain)]
344
+ if direction == -1:
345
+ new_focus.highlight_last()
346
+ else:
347
+ new_focus.highlight_first()
348
+ new_focus.focus(scroll_visible=False)
349
+
350
+ @on(GridSelect.LeaveUp)
351
+ def on_grid_select_leave_up(self, event: GridSelect.LeaveUp):
352
+ event.stop()
353
+ self.move_focus(-1)
354
+
355
+ @on(GridSelect.LeaveDown)
356
+ def on_grid_select_leave_down(self, event: GridSelect.LeaveUp):
357
+ event.stop()
358
+ self.move_focus(+1)
359
+
360
+ @on(GridSelect.Selected, ".agents-picker")
361
+ @work
362
+ async def on_grid_select_selected(self, event: GridSelect.Selected):
363
+ assert isinstance(event.selected_widget, AgentItem)
364
+ from toad.screens.agent_modal import AgentModal
365
+
366
+ modal_response = await self.app.push_screen_wait(
367
+ AgentModal(event.selected_widget.agent)
368
+ )
369
+ self.app.save_settings()
370
+ if modal_response == "launch":
371
+ self.post_message(LaunchAgent(event.selected_widget.agent["identity"]))
372
+
373
+ @on(OpenAgentDetails)
374
+ @work
375
+ async def open_agent_detail(self, message: OpenAgentDetails) -> None:
376
+ from toad.screens.agent_modal import AgentModal
377
+
378
+ try:
379
+ agent = self._agents[message.identity]
380
+ except KeyError:
381
+ return
382
+ modal_response = await self.app.push_screen_wait(AgentModal(agent))
383
+ self.app.save_settings()
384
+ if modal_response == "launch":
385
+ self.post_message(LaunchAgent(agent["identity"]))
386
+
387
+ @on(GridSelect.Selected, "#launcher GridSelect")
388
+ @work
389
+ async def on_launcher_selected(self, event: GridSelect.Selected):
390
+ launcher_item = event.selected_widget
391
+ assert isinstance(launcher_item, LauncherItem)
392
+
393
+ from toad.screens.agent_modal import AgentModal
394
+
395
+ modal_response = await self.app.push_screen_wait(
396
+ AgentModal(launcher_item.agent)
397
+ )
398
+ self.app.save_settings()
399
+ if modal_response == "launch":
400
+ self.post_message(LaunchAgent(launcher_item.agent["identity"]))
401
+
402
+ @work
403
+ async def launch_agent(self, agent_identity: str) -> None:
404
+ from toad.screens.main import MainScreen
405
+
406
+ agent = self.agents[agent_identity]
407
+ project_path = Path(self.app.project_dir or os.getcwd())
408
+ screen = MainScreen(project_path, agent).data_bind(
409
+ column=ToadApp.column,
410
+ column_width=ToadApp.column_width,
411
+ )
412
+ await self.app.push_screen_wait(screen)
413
+
414
+ @on(LaunchAgent)
415
+ def on_launch_agent(self, message: LaunchAgent) -> None:
416
+ self.launch_agent(message.identity)
417
+
418
+ @work
419
+ async def on_mount(self) -> None:
420
+ self.app.settings_changed_signal.subscribe(self, self.setting_updated)
421
+ try:
422
+ self._agents = await read_agents()
423
+ except Exception as error:
424
+ self.notify(
425
+ f"Failed to read agents data ({error})",
426
+ title="Agents data",
427
+ severity="error",
428
+ )
429
+ else:
430
+ await self.container.mount_compose(self.compose_agents())
431
+ with suppress(NoMatches):
432
+ first_grid = self.container.query(GridSelect).first()
433
+ first_grid.focus(scroll_visible=False)
434
+
435
+ async def setting_updated(self, setting: tuple[str, object]) -> None:
436
+ key, value = setting
437
+ if key == "launcher.agents":
438
+ await self.launcher.recompose()
439
+
440
+ def focus_screen():
441
+ try:
442
+ self.screen.query(GridSelect).focus()
443
+ except Exception:
444
+ pass
445
+
446
+ self.call_later(focus_screen)
447
+
448
+ def on_key(self, event: events.Key) -> None:
449
+ if event.character is None:
450
+ return
451
+ LAUNCHER_KEYS = "123456789abcdef"
452
+ if event.character in LAUNCHER_KEYS:
453
+ launch_item_offset = LAUNCHER_KEYS.find(event.character)
454
+ try:
455
+ self.launcher.grid_select.children[launch_item_offset]
456
+ except IndexError:
457
+ self.notify(
458
+ f"No agent on key [b]{LAUNCHER_KEYS[launch_item_offset]}",
459
+ title="Quick launch",
460
+ severity="error",
461
+ )
462
+ self.app.bell()
463
+ return
464
+ self.launcher.focus()
465
+ self.launcher.highlighted = launch_item_offset
466
+
467
+ def action_quick_launch(self) -> None:
468
+ self.launcher.focus()
469
+
470
+
471
+ if __name__ == "__main__":
472
+ from toad.app import ToadApp
473
+
474
+ app = ToadApp(mode="store")
475
+
476
+ app.run()