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
toad/os.py
ADDED
|
File without changes
|
toad/path_complete.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def longest_common_prefix(strings: list[str]) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Find the longest common prefix among a list of strings.
|
|
10
|
+
|
|
11
|
+
Arguments:
|
|
12
|
+
strings: List of strings
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The longest common prefix string
|
|
16
|
+
"""
|
|
17
|
+
if not strings:
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
# Start with the first string as reference
|
|
21
|
+
prefix: str = strings[0]
|
|
22
|
+
|
|
23
|
+
# Compare with each subsequent string
|
|
24
|
+
for current_string in strings[1:]:
|
|
25
|
+
# Reduce prefix until it matches the start of current string
|
|
26
|
+
while not current_string.startswith(prefix):
|
|
27
|
+
prefix = prefix[:-1]
|
|
28
|
+
if not prefix:
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
return prefix
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DirectoryReadTask:
|
|
35
|
+
"""A task to read a directory."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, path: Path) -> None:
|
|
38
|
+
self.path = path
|
|
39
|
+
self.done_event = asyncio.Event()
|
|
40
|
+
self.directory_listing: list[Path] = []
|
|
41
|
+
self._task: asyncio.Task | None = None
|
|
42
|
+
|
|
43
|
+
def read(self) -> None:
|
|
44
|
+
# TODO: Should this be cancellable, or have a maximum number of paths for the case of very large directories?
|
|
45
|
+
for path in self.path.iterdir():
|
|
46
|
+
self.directory_listing.append(path)
|
|
47
|
+
|
|
48
|
+
def start(self) -> None:
|
|
49
|
+
asyncio.create_task(self.run(), name=f"DirectoryReadTask({str(self.path)!r})")
|
|
50
|
+
|
|
51
|
+
async def run(self):
|
|
52
|
+
await asyncio.to_thread(self.read)
|
|
53
|
+
self.done_event.set()
|
|
54
|
+
|
|
55
|
+
async def wait(self) -> list[Path]:
|
|
56
|
+
await self.done_event.wait()
|
|
57
|
+
return self.directory_listing
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PathComplete:
|
|
61
|
+
"""Auto completes paths."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self.read_tasks: dict[Path, DirectoryReadTask] = {}
|
|
65
|
+
self.directory_listings: dict[Path, list[Path]] = {}
|
|
66
|
+
|
|
67
|
+
async def __call__(
|
|
68
|
+
self,
|
|
69
|
+
current_working_directory: Path,
|
|
70
|
+
path: str,
|
|
71
|
+
*,
|
|
72
|
+
exclude_type: Literal["file"] | Literal["dir"] | None = None,
|
|
73
|
+
) -> tuple[str | None, list[str] | None]:
|
|
74
|
+
current_working_directory = (
|
|
75
|
+
current_working_directory.expanduser().resolve().absolute()
|
|
76
|
+
)
|
|
77
|
+
directory_path = (current_working_directory / Path(path).expanduser()).resolve()
|
|
78
|
+
|
|
79
|
+
node: str = path
|
|
80
|
+
if not directory_path.is_dir():
|
|
81
|
+
node = directory_path.name
|
|
82
|
+
directory_path = directory_path.parent
|
|
83
|
+
|
|
84
|
+
if (listing := self.directory_listings.get(directory_path)) is None:
|
|
85
|
+
read_task = DirectoryReadTask(directory_path)
|
|
86
|
+
self.read_tasks[directory_path] = read_task
|
|
87
|
+
read_task.start()
|
|
88
|
+
listing = await read_task.wait()
|
|
89
|
+
|
|
90
|
+
if exclude_type is not None:
|
|
91
|
+
if exclude_type == "dir":
|
|
92
|
+
listing = [
|
|
93
|
+
listing_path
|
|
94
|
+
for listing_path in listing
|
|
95
|
+
if not listing_path.is_dir()
|
|
96
|
+
]
|
|
97
|
+
else:
|
|
98
|
+
listing = [
|
|
99
|
+
listing_path for listing_path in listing if listing_path.is_dir()
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
if not node:
|
|
103
|
+
return None, [listing_path.name for listing_path in listing]
|
|
104
|
+
|
|
105
|
+
matching_nodes = [
|
|
106
|
+
listing_path
|
|
107
|
+
for listing_path in listing
|
|
108
|
+
if listing_path.name.startswith(node)
|
|
109
|
+
]
|
|
110
|
+
if not (matching_nodes):
|
|
111
|
+
# Nothing matches
|
|
112
|
+
return None, None
|
|
113
|
+
|
|
114
|
+
if not (
|
|
115
|
+
prefix := longest_common_prefix(
|
|
116
|
+
[node_path.name for node_path in matching_nodes]
|
|
117
|
+
)
|
|
118
|
+
):
|
|
119
|
+
return None, None
|
|
120
|
+
|
|
121
|
+
picked_path = directory_path / prefix
|
|
122
|
+
path_size = (
|
|
123
|
+
len(str(Path(directory_path).expanduser().resolve())) + 1 + len(node)
|
|
124
|
+
)
|
|
125
|
+
completed_prefix = str(picked_path)[path_size:]
|
|
126
|
+
path_options = [
|
|
127
|
+
str(path)[path_size + len(completed_prefix) :] for path in matching_nodes
|
|
128
|
+
]
|
|
129
|
+
path_options = [name for name in path_options if name]
|
|
130
|
+
|
|
131
|
+
if picked_path.is_dir() and not path_options:
|
|
132
|
+
completed_prefix += os.sep
|
|
133
|
+
|
|
134
|
+
return completed_prefix or None, path_options
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
|
|
139
|
+
async def run():
|
|
140
|
+
path_complete = PathComplete()
|
|
141
|
+
cwd = Path("~/sandbox")
|
|
142
|
+
|
|
143
|
+
print(await path_complete(cwd, "~/p"))
|
|
144
|
+
|
|
145
|
+
asyncio.run(run())
|
toad/path_filter.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from itertools import chain
|
|
2
|
+
from typing import Iterable, Sequence
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import pathspec
|
|
5
|
+
import pathspec.patterns
|
|
6
|
+
from pathspec import GitIgnoreSpec
|
|
7
|
+
|
|
8
|
+
import rich.repr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_path_spec(git_ignore_path: Path) -> GitIgnoreSpec | None:
|
|
12
|
+
"""Get a path spec instance if there is a .gitignore file present.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
git_ignore_path): Path to .gitignore.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A `PathSpec` instance.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
if git_ignore_path.is_file():
|
|
22
|
+
try:
|
|
23
|
+
spec_text = git_ignore_path.read_text(encoding="utf-8")
|
|
24
|
+
except Exception:
|
|
25
|
+
# Permissions, encoding issue?
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
spec = GitIgnoreSpec.from_lines(
|
|
29
|
+
pathspec.patterns.GitWildMatchPattern, spec_text.splitlines()
|
|
30
|
+
)
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
return spec
|
|
34
|
+
except OSError:
|
|
35
|
+
return None
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@rich.repr.auto
|
|
40
|
+
class PathFilter:
|
|
41
|
+
"""Filter paths according to .gitignore files."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, root: Path, path_specs: Iterable[GitIgnoreSpec] | None = None
|
|
45
|
+
) -> None:
|
|
46
|
+
self._root = root
|
|
47
|
+
self._default_specs = [] if path_specs is None else list(path_specs)
|
|
48
|
+
self._path_specs: dict[Path, Sequence[GitIgnoreSpec]] = {}
|
|
49
|
+
|
|
50
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
51
|
+
yield (str(self._root),)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_git_root(cls, path: Path) -> PathFilter:
|
|
55
|
+
"""Load all path specs from parent directories up to the most recent directory with .git
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: A directory path.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
PathFilter instance.
|
|
62
|
+
"""
|
|
63
|
+
filter_root = path
|
|
64
|
+
path_specs: list[GitIgnoreSpec] = []
|
|
65
|
+
try:
|
|
66
|
+
while (parent := path.parent) != parent:
|
|
67
|
+
if (path_spec := load_path_spec(path / ".gitignore")) is not None:
|
|
68
|
+
path_specs.append(path_spec)
|
|
69
|
+
if (path / ".git").is_dir():
|
|
70
|
+
break
|
|
71
|
+
path = parent
|
|
72
|
+
else:
|
|
73
|
+
del path_specs[:]
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
return PathFilter(filter_root, reversed(path_specs))
|
|
77
|
+
|
|
78
|
+
def get_path_specs(self, path: Path) -> Sequence[GitIgnoreSpec]:
|
|
79
|
+
"""Get a sequence of path specs applicable to the give path.
|
|
80
|
+
|
|
81
|
+
This will inherit path specs up to the root path of the filtr.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
path: A directory path
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A sequence of path specs.
|
|
88
|
+
"""
|
|
89
|
+
if (cached_path_specs := self._path_specs.get(path)) is not None:
|
|
90
|
+
return cached_path_specs
|
|
91
|
+
path_spec = load_path_spec(path / ".gitignore")
|
|
92
|
+
if path == self._root:
|
|
93
|
+
path_specs = [path_spec] if path_spec is not None else []
|
|
94
|
+
else:
|
|
95
|
+
parent_path_specs = self.get_path_specs(path.parent)
|
|
96
|
+
path_specs = (
|
|
97
|
+
parent_path_specs
|
|
98
|
+
if path_spec is None
|
|
99
|
+
else [*parent_path_specs, path_spec]
|
|
100
|
+
)
|
|
101
|
+
self._path_specs[path] = path_specs
|
|
102
|
+
return path_specs
|
|
103
|
+
|
|
104
|
+
def match(self, path: Path) -> bool:
|
|
105
|
+
"""Match a path againt the path filter.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
`True` if the path should be removed, `False` if it should be included.
|
|
109
|
+
"""
|
|
110
|
+
if path.name == ".git":
|
|
111
|
+
return True
|
|
112
|
+
path_specs = self.get_path_specs(path.parent)
|
|
113
|
+
for path_spec in chain(self._default_specs, path_specs):
|
|
114
|
+
if path_spec.match_file(path):
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
path_filter = PathFilter.from_git_root(Path("."))
|
|
121
|
+
|
|
122
|
+
for path in Path(".").iterdir():
|
|
123
|
+
print(path_filter.match(path), path)
|
|
124
|
+
print(path_filter)
|
toad/paths.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
from xdg_base_dirs import xdg_config_home, xdg_data_home, xdg_state_home
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
APP_NAME: Final[str] = "toad"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def path_to_name(path: Path) -> str:
|
|
12
|
+
"""Converts a path to a name (suitable as a path component).
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: A path.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A stringified version of the path.
|
|
19
|
+
"""
|
|
20
|
+
name = str(path.resolve()).lstrip("/").replace("/", "-")
|
|
21
|
+
return name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_data() -> Path:
|
|
25
|
+
"""Return (possibly creating) the application data directory."""
|
|
26
|
+
path = xdg_data_home() / APP_NAME
|
|
27
|
+
with suppress(OSError):
|
|
28
|
+
path.mkdir(0o700, exist_ok=True, parents=True)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config() -> Path:
|
|
33
|
+
"""Return (possibly creating) the application config directory."""
|
|
34
|
+
path = xdg_config_home() / APP_NAME
|
|
35
|
+
with suppress(OSError):
|
|
36
|
+
path.mkdir(0o700, exist_ok=True, parents=True)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_state() -> Path:
|
|
41
|
+
"""Return (possibly creating) the application state directory."""
|
|
42
|
+
path = xdg_state_home() / APP_NAME
|
|
43
|
+
with suppress(OSError):
|
|
44
|
+
path.mkdir(0o700, exist_ok=True, parents=True)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_project_data(project_path: Path) -> Path:
|
|
49
|
+
"""Get a directory for per-project data.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
project_path: Path of project.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
project_data_path = get_data() / path_to_name(project_path)
|
|
56
|
+
with suppress(OSError):
|
|
57
|
+
project_data_path.mkdir(0o700, exist_ok=True, parents=True)
|
|
58
|
+
return project_data_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_log() -> Path:
|
|
62
|
+
"""Get a directory for logs.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Path to log directory.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
path = get_state() / "logs"
|
|
69
|
+
with suppress(OSError):
|
|
70
|
+
path.mkdir(0o700, exist_ok=True, parents=True)
|
|
71
|
+
return path
|
toad/pill.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from textual.content import Content
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def pill(text: Content | str, background: str, foreground: str) -> Content:
|
|
5
|
+
"""Format text as a pill (half block ends).
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
text: Pill contents as Content object or text.
|
|
9
|
+
background: Background color.
|
|
10
|
+
foreground: Foreground color.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Pill content.
|
|
14
|
+
"""
|
|
15
|
+
content = Content(text) if isinstance(text, str) else text
|
|
16
|
+
main_style = f"{foreground} on {background}"
|
|
17
|
+
end_style = f"{background} on transparent r"
|
|
18
|
+
pill_content = Content.assemble(
|
|
19
|
+
("▌", end_style),
|
|
20
|
+
content.stylize(main_style),
|
|
21
|
+
("▐", end_style),
|
|
22
|
+
)
|
|
23
|
+
return pill_content
|
toad/prompt/extract.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import re2 as re
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
RE_MATCH_FILE_PROMPT = re.compile(r"@(\S+)|@\"(.*)\"")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_paths_from_prompt(prompt: str) -> Iterable[tuple[str, int, int]]:
|
|
9
|
+
"""Find file syntax in prompts.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
prompt: A line of prompt.
|
|
13
|
+
|
|
14
|
+
Yields:
|
|
15
|
+
A tuple of (PATH, START, END).
|
|
16
|
+
"""
|
|
17
|
+
for match in RE_MATCH_FILE_PROMPT.finditer(prompt):
|
|
18
|
+
path, quoted_path = match.groups()
|
|
19
|
+
yield (path or quoted_path, match.start(0), match.end(0))
|
toad/prompt/resource.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import mimetypes
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Resource:
|
|
8
|
+
root: Path
|
|
9
|
+
path: Path
|
|
10
|
+
mime_type: str
|
|
11
|
+
text: str | None
|
|
12
|
+
data: bytes | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResourceError(Exception):
|
|
16
|
+
"""An error occurred reading a resource."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResourceNotRelative(ResourceError):
|
|
20
|
+
"""Attempted to read a resource, not in the project directory."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResourceReadError(ResourceError):
|
|
24
|
+
"""Failed to read the resource."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_resource(root: Path, path: Path) -> Resource:
|
|
28
|
+
"""Load a resource from the project directory.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
root: The project root.
|
|
32
|
+
path: Relative path within project.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A resource.
|
|
36
|
+
"""
|
|
37
|
+
resource_path = root / path
|
|
38
|
+
|
|
39
|
+
if not resource_path.is_relative_to(root):
|
|
40
|
+
raise ResourceNotRelative("Resource path is not relative to project root.")
|
|
41
|
+
|
|
42
|
+
mime_type, encoding = mimetypes.guess_file_type(resource_path)
|
|
43
|
+
if mime_type is None:
|
|
44
|
+
mime_type = "application/octet-stream"
|
|
45
|
+
|
|
46
|
+
data: bytes | None
|
|
47
|
+
text: str | None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
if encoding is not None:
|
|
51
|
+
data = resource_path.read_bytes()
|
|
52
|
+
text = None
|
|
53
|
+
else:
|
|
54
|
+
data = None
|
|
55
|
+
text = resource_path.read_text(encoding, errors="replace")
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
raise ResourceReadError(f"File not found {str(path)!r}")
|
|
58
|
+
except Exception as error:
|
|
59
|
+
raise ResourceReadError(f"Failed to read {str(path)!r}; {error}")
|
|
60
|
+
|
|
61
|
+
resource = Resource(
|
|
62
|
+
root,
|
|
63
|
+
resource_path,
|
|
64
|
+
mime_type=mime_type,
|
|
65
|
+
text=text,
|
|
66
|
+
data=data,
|
|
67
|
+
)
|
|
68
|
+
return resource
|
toad/protocol.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable, Iterable
|
|
2
|
+
|
|
3
|
+
from textual.widget import Widget
|
|
4
|
+
|
|
5
|
+
from toad.menus import MenuItem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class BlockProtocol(Protocol):
|
|
10
|
+
def block_cursor_up(self) -> Widget | None: ...
|
|
11
|
+
def block_cursor_down(self) -> Widget | None: ...
|
|
12
|
+
def get_cursor_block(self) -> Widget | None: ...
|
|
13
|
+
def block_cursor_clear(self) -> None: ...
|
|
14
|
+
def block_select(self, widget: Widget) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class MenuProtocol(Protocol):
|
|
19
|
+
def get_block_menu(self) -> Iterable[MenuItem]: ...
|
|
20
|
+
def get_block_content(self, destination: str) -> str | None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class ExpandProtocol(Protocol):
|
|
25
|
+
def can_expand(self) -> bool: ...
|
|
26
|
+
def expand_block(self) -> None: ...
|
|
27
|
+
def collapse_block(self) -> None: ...
|
|
28
|
+
def is_block_expanded(self) -> bool: ...
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual import on, work
|
|
5
|
+
from textual import containers
|
|
6
|
+
from textual import getters
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual import widgets
|
|
9
|
+
from textual.widget import Widget
|
|
10
|
+
|
|
11
|
+
from toad.app import ToadApp
|
|
12
|
+
from toad.widgets.command_pane import CommandPane
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActionModal(ModalScreen):
|
|
19
|
+
"""Executes an action command."""
|
|
20
|
+
|
|
21
|
+
command_pane = getters.query_one(CommandPane)
|
|
22
|
+
ok_button = getters.query_one("#ok", widgets.Button)
|
|
23
|
+
|
|
24
|
+
app = getters.app(ToadApp)
|
|
25
|
+
|
|
26
|
+
BINDINGS = [("escape", "dismiss_modal", "Dismiss")]
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
action: str,
|
|
31
|
+
agent: str,
|
|
32
|
+
title: str,
|
|
33
|
+
command: str,
|
|
34
|
+
*,
|
|
35
|
+
bootstrap_uv: bool = False,
|
|
36
|
+
name: str | None = None,
|
|
37
|
+
id: str | None = None,
|
|
38
|
+
classes: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._action = action
|
|
41
|
+
self._agent = agent
|
|
42
|
+
self._title = title
|
|
43
|
+
self._command = command
|
|
44
|
+
self._bootstrap_uv = bootstrap_uv
|
|
45
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
46
|
+
|
|
47
|
+
def get_loading_widget(self) -> Widget:
|
|
48
|
+
return widgets.LoadingIndicator()
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
with containers.VerticalGroup(id="container"):
|
|
52
|
+
yield CommandPane()
|
|
53
|
+
yield widgets.Button("OK", id="ok", disabled=True)
|
|
54
|
+
|
|
55
|
+
def enable_button(self) -> None:
|
|
56
|
+
self.ok_button.loading = False
|
|
57
|
+
self.ok_button.disabled = False
|
|
58
|
+
self.ok_button.focus()
|
|
59
|
+
|
|
60
|
+
@on(CommandPane.CommandComplete)
|
|
61
|
+
def on_command_complete(self, event: CommandPane.CommandComplete) -> None:
|
|
62
|
+
self.enable_button()
|
|
63
|
+
|
|
64
|
+
def on_mount(self) -> None:
|
|
65
|
+
self.ok_button.loading = True
|
|
66
|
+
self.command_pane.border_title = self._title
|
|
67
|
+
|
|
68
|
+
self.run_command()
|
|
69
|
+
|
|
70
|
+
@work()
|
|
71
|
+
async def run_command(self) -> None:
|
|
72
|
+
"""Write and execute the command."""
|
|
73
|
+
self.command_pane.anchor()
|
|
74
|
+
if self._bootstrap_uv and shutil.which("uv") is None:
|
|
75
|
+
# Bootstrap UV if required
|
|
76
|
+
await self.command_pane.write(f"$ {UV_INSTALL}\n")
|
|
77
|
+
await self.command_pane.execute(UV_INSTALL, final=False)
|
|
78
|
+
|
|
79
|
+
await self.command_pane.write(f"$ {self._command}\n")
|
|
80
|
+
action_task = self.command_pane.execute(self._command)
|
|
81
|
+
await action_task
|
|
82
|
+
self.app.capture_event(
|
|
83
|
+
"agent-action",
|
|
84
|
+
action=self._action,
|
|
85
|
+
agent=self._agent,
|
|
86
|
+
fail=self.command_pane.return_code != 0,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@on(widgets.Button.Pressed)
|
|
90
|
+
def on_button_pressed(self) -> None:
|
|
91
|
+
self.action_dismiss_modal()
|
|
92
|
+
|
|
93
|
+
def action_dismiss_modal(self) -> None:
|
|
94
|
+
self.dismiss(self.command_pane.return_code)
|