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,172 @@
1
+ from typing import cast
2
+
3
+ from textual import on
4
+ from textual import getters
5
+ from textual.app import ComposeResult
6
+
7
+ from textual import work
8
+ from textual.screen import ModalScreen
9
+ from textual import containers
10
+ from textual import widgets
11
+ from textual.reactive import var
12
+
13
+ import toad
14
+ from textual.binding import Binding
15
+ from toad.agent_schema import Action, Agent, OS, Command
16
+ from toad.app import ToadApp
17
+
18
+
19
+ class DescriptionContainer(containers.VerticalScroll):
20
+ def allow_focus(self) -> bool:
21
+ """Focus only if it can be scrolled."""
22
+ return self.show_vertical_scrollbar
23
+
24
+
25
+ class AgentModal(ModalScreen):
26
+ AUTO_FOCUS = "Select#action-select"
27
+
28
+ BINDINGS = [
29
+ Binding("escape", "dismiss(None)", "Dismiss", show=False),
30
+ Binding("space", "dismiss('launch')", "Launch agent", priority=True),
31
+ ]
32
+
33
+ action = var("")
34
+
35
+ app = getters.app(ToadApp)
36
+ action_select = getters.query_one("#action-select", widgets.Select)
37
+ launcher_checkbox = getters.query_one("#launcher-checkbox", widgets.Checkbox)
38
+
39
+ def __init__(self, agent: Agent) -> None:
40
+ self._agent = agent
41
+ super().__init__()
42
+
43
+ def compose(self) -> ComposeResult:
44
+ launcher_set = frozenset(
45
+ self.app.settings.get("launcher.agents", str).splitlines()
46
+ )
47
+
48
+ agent = self._agent
49
+
50
+ app = self.app
51
+ launcher_set = frozenset(app.settings.get("launcher.agents", str).splitlines())
52
+ agent = self._agent
53
+ actions = agent["actions"]
54
+
55
+ script_os = cast(OS, toad.os)
56
+ if script_os not in actions:
57
+ script_os = "*"
58
+
59
+ commands: dict[Action, Command] = actions[cast(OS, script_os)]
60
+ script_choices = [
61
+ (action["description"], name) for name, action in commands.items()
62
+ ]
63
+ script_choices.append((f"Launch {agent['name']}", "__launch__"))
64
+
65
+ with containers.Vertical(id="container"):
66
+ with DescriptionContainer(id="description-container"):
67
+ yield widgets.Markdown(agent["help"], id="description")
68
+ with containers.VerticalGroup():
69
+ if "install_acp" in commands:
70
+ yield widgets.Static(
71
+ f"{agent['name']} requires an ACP adapter to work with Toad. Install from the actions list.",
72
+ classes="acp-warning",
73
+ )
74
+ with containers.HorizontalGroup():
75
+ yield widgets.Checkbox(
76
+ "Show in launcher",
77
+ value=agent["identity"] in launcher_set,
78
+ id="launcher-checkbox",
79
+ )
80
+ yield widgets.Select(
81
+ script_choices,
82
+ prompt="Actions",
83
+ allow_blank=True,
84
+ id="action-select",
85
+ )
86
+ yield widgets.Button(
87
+ "Go", variant="primary", id="run-action", disabled=True
88
+ )
89
+ yield widgets.Footer()
90
+
91
+ def on_mount(self) -> None:
92
+ self.query_one("Footer").styles.animate("opacity", 1.0, duration=500 / 1000)
93
+
94
+ @on(widgets.Checkbox.Changed)
95
+ def on_checkbox_changed(self, event: widgets.Select.Changed) -> None:
96
+ launcher_agents = self.app.settings.get("launcher.agents", str).splitlines()
97
+ agent_identity = self._agent["identity"]
98
+ if agent_identity in launcher_agents:
99
+ launcher_agents.remove(agent_identity)
100
+ if event.value:
101
+ launcher_agents.insert(0, agent_identity)
102
+ self.app.settings.set("launcher.agents", "\n".join(launcher_agents))
103
+
104
+ @on(widgets.Select.Changed)
105
+ def on_select_changed(self, event: widgets.Select.Changed) -> None:
106
+ self.action = event.value if isinstance(event.value, str) else ""
107
+
108
+ @work
109
+ @on(widgets.Button.Pressed)
110
+ async def on_button_pressed(self) -> None:
111
+ agent = self._agent
112
+ action = self.action_select.value
113
+
114
+ assert isinstance(action, str)
115
+ if action == "__launch__":
116
+ self.dismiss("launch")
117
+ return
118
+
119
+ agent_actions = self._agent["actions"]
120
+
121
+ if (commands := agent_actions.get(toad.os, None)) is None:
122
+ commands = agent_actions.get("*", None)
123
+ if commands is None:
124
+ self.notify(
125
+ "Action is not available on this platform",
126
+ title="Agent action",
127
+ severity="error",
128
+ )
129
+ return
130
+ command = commands[action]
131
+
132
+ from toad.screens.action_modal import ActionModal
133
+ from toad.screens.command_edit_modal import CommandEditModal
134
+
135
+ title = command["description"]
136
+ agent_id = self._agent["identity"]
137
+ action_command = command["command"]
138
+ bootstrap_uv = command.get("bootstrap_uv", False)
139
+
140
+ agent = self._agent
141
+ # Focus the select
142
+ # It's unlikely the user wants to re-run the action
143
+ self.action_select.focus()
144
+
145
+ action_command = await self.app.push_screen_wait(
146
+ CommandEditModal(action_command)
147
+ )
148
+ if action_command is None:
149
+ return
150
+
151
+ return_code = await self.app.push_screen_wait(
152
+ ActionModal(
153
+ action,
154
+ agent_id,
155
+ title,
156
+ action_command,
157
+ bootstrap_uv=bootstrap_uv,
158
+ )
159
+ )
160
+ if return_code == 0 and action in {"install", "install-acp"}:
161
+ # Add to launcher if we installed something
162
+ if not self.launcher_checkbox.value:
163
+ self.notify(
164
+ f"{agent['name']} has been added to your launcher",
165
+ title="Add agent",
166
+ severity="information",
167
+ )
168
+ self.launcher_checkbox.value = True
169
+
170
+ def watch_action(self, action: str) -> None:
171
+ go_button = self.query_one("#run-action", widgets.Button)
172
+ go_button.disabled = not action
@@ -0,0 +1,58 @@
1
+ from textual import on, work
2
+ from textual.app import ComposeResult
3
+ from textual.screen import ModalScreen
4
+ from textual import widgets
5
+ from textual import containers
6
+ from textual import getters
7
+
8
+
9
+ class CommandEditModal(ModalScreen[str | None]):
10
+ BINDINGS = [("escape", "dismiss", "Dismiss")]
11
+
12
+ text_area = getters.query_one(widgets.TextArea)
13
+
14
+ def __init__(
15
+ self,
16
+ command: str,
17
+ name: str | None = None,
18
+ id: str | None = None,
19
+ classes: str | None = None,
20
+ ):
21
+ self.command = command
22
+ super().__init__(name=name, id=id, classes=classes)
23
+
24
+ def compose(self) -> ComposeResult:
25
+ with containers.VerticalGroup(id="container"):
26
+ yield widgets.Static(
27
+ "Toad will run the following command(s).\n\nEdit if you want to.",
28
+ classes="instructions",
29
+ )
30
+ yield widgets.TextArea(
31
+ self.command, language="bash", highlight_cursor_line=False
32
+ )
33
+ with containers.HorizontalGroup(id="button-container"):
34
+ yield widgets.Button("OK", variant="primary", id="ok")
35
+ yield widgets.Button(
36
+ "Cancel", id="cancel", action="screen.dismiss(None)"
37
+ )
38
+ yield widgets.Footer()
39
+
40
+ @on(widgets.Button.Pressed, "#ok")
41
+ def on_ok_pressed(self, event: widgets.Button.Pressed) -> None:
42
+ self.dismiss(self.text_area.text)
43
+
44
+ @on(widgets.Button.Pressed, "#cancel")
45
+ def on_cancel_pressed(self, event: widgets.Button.Pressed) -> None:
46
+ self.dismiss(None)
47
+
48
+
49
+ if __name__ == "__main__":
50
+ from textual.app import App
51
+
52
+ class ModalApp(App):
53
+ @work
54
+ async def on_mount(self) -> None:
55
+ result = await self.push_screen_wait(CommandEditModal("ls -al"))
56
+ self.notify(str(result))
57
+
58
+ ModalApp().run()
toad/screens/main.py ADDED
@@ -0,0 +1,192 @@
1
+ from functools import partial
2
+ from pathlib import Path
3
+ import random
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual import getters
8
+ from textual.binding import Binding
9
+ from textual.command import Hit, Hits, Provider, DiscoveryHit
10
+ from textual.content import Content
11
+ from textual.screen import Screen
12
+ from textual.reactive import var, reactive
13
+ from textual.widgets import Footer, OptionList, DirectoryTree, Tree
14
+ from textual import containers
15
+ from textual.widget import Widget
16
+
17
+
18
+ from toad.app import ToadApp
19
+ from toad import messages
20
+ from toad.agent_schema import Agent
21
+ from toad.acp import messages as acp_messages
22
+ from toad.widgets.plan import Plan
23
+ from toad.widgets.throbber import Throbber
24
+ from toad.widgets.conversation import Conversation
25
+ from toad.widgets.project_directory_tree import ProjectDirectoryTree
26
+ from toad.widgets.side_bar import SideBar
27
+
28
+
29
+ class ModeProvider(Provider):
30
+ async def search(self, query: str) -> Hits:
31
+ """Search for Python files."""
32
+ matcher = self.matcher(query)
33
+
34
+ screen = self.screen
35
+ assert isinstance(screen, MainScreen)
36
+
37
+ for mode in sorted(
38
+ screen.conversation.modes.values(), key=lambda mode: mode.name
39
+ ):
40
+ command = mode.name
41
+ score = matcher.match(command)
42
+ if score > 0:
43
+ yield Hit(
44
+ score,
45
+ matcher.highlight(command),
46
+ partial(screen.conversation.set_mode, mode.id),
47
+ help=mode.description,
48
+ )
49
+
50
+ async def discover(self) -> Hits:
51
+ screen = self.screen
52
+ assert isinstance(screen, MainScreen)
53
+
54
+ for mode in sorted(
55
+ screen.conversation.modes.values(), key=lambda mode: mode.name
56
+ ):
57
+ yield DiscoveryHit(
58
+ mode.name,
59
+ partial(screen.conversation.set_mode, mode.id),
60
+ help=mode.description,
61
+ )
62
+
63
+
64
+ class MainScreen(Screen, can_focus=False):
65
+ AUTO_FOCUS = "Conversation Prompt TextArea"
66
+
67
+ COMMANDS = {ModeProvider}
68
+ BINDINGS = [
69
+ Binding("f3", "show_sidebar", "Sidebar"),
70
+ ]
71
+
72
+ BINDING_GROUP_TITLE = "Screen"
73
+ busy_count = var(0)
74
+ throbber: getters.query_one[Throbber] = getters.query_one("#throbber")
75
+ conversation = getters.query_one(Conversation)
76
+ side_bar = getters.query_one(SideBar)
77
+ project_directory_tree = getters.query_one("#project_directory_tree")
78
+
79
+ column = reactive(False)
80
+ column_width = reactive(100)
81
+ scrollbar = reactive("")
82
+ project_path: var[Path] = var(Path("./").expanduser().absolute())
83
+
84
+ app = getters.app(ToadApp)
85
+
86
+ def __init__(self, project_path: Path, agent: Agent | None = None) -> None:
87
+ super().__init__()
88
+ self.set_reactive(MainScreen.project_path, project_path)
89
+ self._agent = agent
90
+
91
+ def get_loading_widget(self) -> Widget:
92
+ throbber = self.app.settings.get("ui.throbber", str)
93
+ if throbber == "quotes":
94
+ from toad.app import QUOTES
95
+ from toad.widgets.future_text import FutureText
96
+
97
+ quotes = QUOTES.copy()
98
+ random.shuffle(quotes)
99
+ return FutureText([Content(quote) for quote in quotes])
100
+ return super().get_loading_widget()
101
+
102
+ def compose(self) -> ComposeResult:
103
+ with containers.Center():
104
+ yield SideBar(
105
+ SideBar.Panel("Plan", Plan([])),
106
+ SideBar.Panel(
107
+ "Project",
108
+ ProjectDirectoryTree(
109
+ self.project_path,
110
+ id="project_directory_tree",
111
+ ),
112
+ flex=True,
113
+ ),
114
+ )
115
+ yield Conversation(self.project_path, self._agent).data_bind(
116
+ project_path=MainScreen.project_path,
117
+ column=MainScreen.column,
118
+ )
119
+ yield Footer()
120
+
121
+ def update_node_styles(self, animate: bool = True) -> None:
122
+ self.conversation.update_node_styles(animate=animate)
123
+ self.query_one(Footer).update_node_styles(animate=animate)
124
+ self.query_one(SideBar).update_node_styles(animate=animate)
125
+
126
+ @on(messages.ProjectDirectoryUpdated)
127
+ async def on_project_directory_update(self) -> None:
128
+ await self.query_one(ProjectDirectoryTree).reload()
129
+
130
+ @on(DirectoryTree.FileSelected, "ProjectDirectoryTree")
131
+ def on_project_directory_tree_selected(self, event: Tree.NodeSelected):
132
+ if (data := event.node.data) is not None:
133
+ self.conversation.insert_path_into_prompt(data.path)
134
+
135
+ @on(acp_messages.Plan)
136
+ async def on_acp_plan(self, message: acp_messages.Plan):
137
+ message.stop()
138
+ entries = [
139
+ Plan.Entry(
140
+ Content(entry["content"]),
141
+ entry.get("priority", "medium"),
142
+ entry.get("status", "pending"),
143
+ )
144
+ for entry in message.entries
145
+ ]
146
+ self.query_one("SideBar Plan", Plan).entries = entries
147
+
148
+ def on_mount(self) -> None:
149
+ for tree in self.query("#project_directory_tree").results(DirectoryTree):
150
+ tree.data_bind(path=MainScreen.project_path)
151
+ for tree in self.query(DirectoryTree):
152
+ # tree.show_guides = False
153
+ tree.guide_depth = 3
154
+
155
+ @on(OptionList.OptionHighlighted)
156
+ def on_option_list_option_highlighted(
157
+ self, event: OptionList.OptionHighlighted
158
+ ) -> None:
159
+ if event.option.id is not None:
160
+ self.conversation.prompt.suggest(event.option.id)
161
+
162
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
163
+ if action == "show_sidebar" and self.side_bar.has_focus_within:
164
+ return False
165
+ return True
166
+
167
+ def action_show_sidebar(self) -> None:
168
+ self.side_bar.query_one("Collapsible CollapsibleTitle").focus()
169
+
170
+ def action_focus_prompt(self) -> None:
171
+ self.conversation.focus_prompt()
172
+
173
+ @on(SideBar.Dismiss)
174
+ def on_side_bar_dismiss(self, message: SideBar.Dismiss):
175
+ message.stop()
176
+ self.conversation.focus_prompt()
177
+
178
+ def watch_column(self, column: bool) -> None:
179
+ self.conversation.styles.max_width = (
180
+ max(10, self.column_width) if column else None
181
+ )
182
+
183
+ def watch_column_width(self, column_width: int) -> None:
184
+ self.conversation.styles.max_width = (
185
+ max(10, column_width) if self.column else None
186
+ )
187
+
188
+ def watch_scrollbar(self, old_scrollbar: str, scrollbar: str) -> None:
189
+ if old_scrollbar:
190
+ self.conversation.remove_class(f"-scrollbar-{old_scrollbar}")
191
+ if scrollbar:
192
+ self.conversation.add_class(f"-scrollbar-{scrollbar}")