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/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))
@@ -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)