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,381 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ import asyncio
5
+ from functools import lru_cache
6
+ from operator import itemgetter
7
+ import os
8
+ from pathlib import Path
9
+ import re2 as re
10
+ from typing import Sequence
11
+
12
+
13
+ from textual import on
14
+ from textual.app import ComposeResult
15
+ from textual.binding import Binding
16
+ from textual import work
17
+ from textual import getters
18
+ from textual import containers
19
+ from textual import events
20
+ from textual.actions import SkipAction
21
+
22
+ from textual.reactive import var, Initialize
23
+ from textual.content import Content, Span
24
+ from textual.strip import Strip
25
+ from textual.widget import Widget
26
+ from textual import widgets
27
+ from textual.widgets import OptionList, Input, DirectoryTree
28
+ from textual.widgets.option_list import Option
29
+
30
+
31
+ from toad import directory
32
+ from toad.fuzzy import FuzzySearch
33
+ from toad.messages import Dismiss, InsertPath, PromptSuggestion
34
+ from toad.path_filter import PathFilter
35
+ from toad.widgets.project_directory_tree import ProjectDirectoryTree
36
+
37
+
38
+ class PathFuzzySearch(FuzzySearch):
39
+ @classmethod
40
+ @lru_cache(maxsize=1024)
41
+ def get_first_letters(cls, candidate: str) -> frozenset[int]:
42
+ return frozenset(
43
+ {
44
+ 0,
45
+ *[match.start() + 1 for match in re.finditer(r"/", candidate)],
46
+ }
47
+ )
48
+
49
+ def score(self, candidate: str, positions: Sequence[int]) -> float:
50
+ """Score a search.
51
+
52
+ Args:
53
+ search: Search object.
54
+
55
+ Returns:
56
+ Score.
57
+ """
58
+ first_letters = self.get_first_letters(candidate)
59
+ # This is a heuristic, and can be tweaked for better results
60
+ # Boost first letter matches
61
+ offset_count = len(positions)
62
+ score: float = offset_count + len(first_letters.intersection(positions))
63
+
64
+ # if 0 in first_letters:
65
+ # score += 1
66
+
67
+ groups = 1
68
+ last_offset, *offsets = positions
69
+ for offset in offsets:
70
+ if offset != last_offset + 1:
71
+ groups += 1
72
+ last_offset = offset
73
+
74
+ # Boost to favor less groups
75
+ normalized_groups = (offset_count - (groups - 1)) / offset_count
76
+ score *= 1 + (normalized_groups * normalized_groups)
77
+ return score
78
+
79
+
80
+ class FuzzyInput(Input):
81
+ """Adds a Content placeholder to fuzzy input.
82
+
83
+ TODO: Add this ability to Textual.
84
+ """
85
+
86
+ def render_line(self, y: int) -> Strip:
87
+ if y == 0 and not self.value:
88
+ placeholder = Content.from_markup(self.placeholder).expand_tabs()
89
+ placeholder = placeholder.stylize(self.visual_style)
90
+ placeholder = placeholder.stylize(
91
+ self.get_visual_style("input--placeholder")
92
+ )
93
+ if self.has_focus:
94
+ cursor_style = self.get_visual_style("input--cursor")
95
+ if self._cursor_visible:
96
+ # If the placeholder is empty, there's no characters to stylise
97
+ # to make the cursor flash, so use a single space character
98
+ if len(placeholder) == 0:
99
+ placeholder = Content(" ")
100
+ placeholder = placeholder.stylize(cursor_style, 0, 1)
101
+
102
+ strip = Strip(placeholder.render_segments())
103
+ return strip
104
+
105
+ return super().render_line(y)
106
+
107
+
108
+ class PathSearch(containers.VerticalGroup):
109
+ CURSOR_BINDING_GROUP = Binding.Group(description="Move selection")
110
+ BINDINGS = [
111
+ Binding(
112
+ "up", "cursor_up", "Cursor up", group=CURSOR_BINDING_GROUP, priority=True
113
+ ),
114
+ Binding(
115
+ "down",
116
+ "cursor_down",
117
+ "Cursor down",
118
+ group=CURSOR_BINDING_GROUP,
119
+ priority=True,
120
+ ),
121
+ Binding("enter", "submit", "Insert path", priority=True, show=False),
122
+ Binding("escape", "dismiss", "Dismiss", priority=True, show=False),
123
+ Binding("tab", "switch_picker", "Switch picker", priority=True, show=False),
124
+ ]
125
+
126
+ def get_fuzzy_search(self) -> FuzzySearch:
127
+ return PathFuzzySearch(case_sensitive=False)
128
+
129
+ root: var[Path] = var(Path("./"))
130
+ paths: var[list[Path]] = var(list)
131
+ highlighted_paths: var[list[Content]] = var(list)
132
+ filtered_path_indices: var[list[int]] = var(list)
133
+ loaded = var(False)
134
+ filter = var("")
135
+ fuzzy_search: var[FuzzySearch] = var(Initialize(get_fuzzy_search))
136
+ show_tree_picker: var[bool] = var(False)
137
+
138
+ option_list = getters.query_one(OptionList)
139
+ tree_view = getters.query_one(ProjectDirectoryTree)
140
+ input = getters.query_one(Input)
141
+
142
+ def __init__(self, root: Path) -> None:
143
+ super().__init__()
144
+ self.root = root
145
+
146
+ def compose(self) -> ComposeResult:
147
+ with widgets.ContentSwitcher(initial="path-search-fuzzy"):
148
+ with containers.VerticalGroup(id="path-search-fuzzy"):
149
+ yield FuzzyInput(
150
+ compact=True, placeholder="fuzzy search \t[r]▌tab▐[/r] tree view"
151
+ )
152
+ yield OptionList()
153
+ with containers.VerticalGroup(id="path-search-tree"):
154
+ yield widgets.Static(
155
+ Content.from_markup(
156
+ "tree view \t[r]▌tab▐[/] fuzzy search"
157
+ ).expand_tabs(),
158
+ classes="message",
159
+ )
160
+ yield ProjectDirectoryTree(self.root).data_bind(path=PathSearch.root)
161
+
162
+ def on_mount(self) -> None:
163
+ tree = self.tree_view
164
+ tree.guide_depth = 2
165
+ tree.center_scroll = True
166
+
167
+ def watch_show_tree_picker(self, show_tree_picker: bool) -> None:
168
+ content_switcher = self.query_one(widgets.ContentSwitcher)
169
+ content_switcher.current = (
170
+ "path-search-tree" if show_tree_picker else "path-search-fuzzy"
171
+ )
172
+ if show_tree_picker:
173
+ self.tree_view.focus()
174
+
175
+ else:
176
+ self.input.focus()
177
+
178
+ def action_switch_picker(self) -> None:
179
+ self.show_tree_picker = not self.show_tree_picker
180
+
181
+ async def search(self, search: str) -> None:
182
+ if not search:
183
+ self.option_list.set_options(
184
+ [
185
+ Option(highlighted_path, highlighted_path.plain)
186
+ for highlighted_path in self.highlighted_paths[:100]
187
+ ],
188
+ )
189
+ return
190
+
191
+ fuzzy_search = self.fuzzy_search
192
+ fuzzy_search.cache.grow(len(self.paths))
193
+ scores: list[tuple[float, Sequence[int], Content]] = [
194
+ (
195
+ *fuzzy_search.match(search, highlighted_path.plain),
196
+ highlighted_path,
197
+ )
198
+ for highlighted_path in self.highlighted_paths
199
+ ]
200
+
201
+ scores = sorted(
202
+ [score for score in scores if score[0]], key=itemgetter(0), reverse=True
203
+ )
204
+ scores = scores[:20]
205
+
206
+ def highlight_offsets(path: Content, offsets: Sequence[int]) -> Content:
207
+ return path.add_spans(
208
+ [Span(offset, offset + 1, "underline") for offset in offsets]
209
+ )
210
+
211
+ self.option_list.set_options(
212
+ [
213
+ Option(
214
+ highlight_offsets(path, offsets) if index < 20 else path,
215
+ id=path.plain,
216
+ )
217
+ for index, (score, offsets, path) in enumerate(scores)
218
+ ]
219
+ )
220
+ with self.option_list.prevent(OptionList.OptionHighlighted):
221
+ self.option_list.highlighted = 0
222
+ self.post_message(PromptSuggestion(""))
223
+
224
+ def action_cursor_down(self) -> None:
225
+ if self.show_tree_picker:
226
+ self.tree_view.action_cursor_down()
227
+ else:
228
+ self.option_list.action_cursor_down()
229
+
230
+ def action_cursor_up(self) -> None:
231
+ if self.show_tree_picker:
232
+ self.tree_view.action_cursor_up()
233
+ else:
234
+ self.option_list.action_cursor_up()
235
+
236
+ def action_dismiss(self) -> None:
237
+ self.post_message(Dismiss(self))
238
+
239
+ def on_show(self) -> None:
240
+ self.focus()
241
+
242
+ def focus(self, scroll_visible: bool = False) -> Self:
243
+ if self.show_tree_picker:
244
+ return self.tree_view.focus(scroll_visible=scroll_visible)
245
+ else:
246
+ return self.input.focus(scroll_visible=scroll_visible)
247
+
248
+ def on_descendant_blur(self, event: events.DescendantBlur) -> None:
249
+ if self.show_tree_picker:
250
+ if event.widget == self.tree_view:
251
+ self.post_message(Dismiss(self))
252
+ else:
253
+ if event.widget == self.input:
254
+ self.post_message(Dismiss(self))
255
+
256
+ @on(DirectoryTree.NodeHighlighted)
257
+ def on_node_highlighted(self, event: DirectoryTree.NodeHighlighted) -> None:
258
+ event.stop()
259
+
260
+ dir_entry = event.node.data
261
+ if dir_entry is not None:
262
+ try:
263
+ path = Path(dir_entry.path).resolve().relative_to(self.root.resolve())
264
+ except ValueError:
265
+ # Being defensive here, shouldn't occur
266
+ return
267
+ tree_path = str(path)
268
+ self.post_message(PromptSuggestion(tree_path))
269
+
270
+ @on(DirectoryTree.FileSelected)
271
+ def on_file_selected(self, event: DirectoryTree.FileSelected) -> None:
272
+ event.stop()
273
+
274
+ dir_entry = event.node.data
275
+ if dir_entry is not None:
276
+ try:
277
+ path = Path(dir_entry.path).resolve().relative_to(self.root.resolve())
278
+ except ValueError:
279
+ return
280
+ tree_path = str(path)
281
+ self.post_message(InsertPath(tree_path))
282
+ self.post_message(Dismiss(self))
283
+
284
+ @on(Input.Changed)
285
+ async def on_input_changed(self, event: Input.Changed):
286
+ await self.search(event.value)
287
+
288
+ @on(OptionList.OptionHighlighted)
289
+ async def on_option_list_changed(self, event: OptionList.OptionHighlighted):
290
+ event.stop()
291
+ if event.option:
292
+ self.post_message(PromptSuggestion(event.option.id))
293
+
294
+ @on(OptionList.OptionSelected)
295
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
296
+ self.action_submit()
297
+
298
+ def action_submit(self):
299
+ if self.show_tree_picker:
300
+ raise SkipAction()
301
+
302
+ elif (highlighted := self.option_list.highlighted) is not None:
303
+ option = self.option_list.options[highlighted]
304
+ if option.id:
305
+ self.post_message(InsertPath(option.id))
306
+ self.post_message(Dismiss(self))
307
+
308
+ def get_path_filter(self, project_path: Path) -> PathFilter:
309
+ """Get a PathFilter insance for the give project path.
310
+
311
+ Args:
312
+ project_path: Project path.
313
+
314
+ Returns:
315
+ `PathFilter` object.
316
+ """
317
+ path_filter = PathFilter.from_git_root(project_path)
318
+ return path_filter
319
+
320
+ def reset(self) -> None:
321
+ """Reset and focus input."""
322
+ self.input.clear()
323
+ self.input.focus()
324
+
325
+ @work(exclusive=True)
326
+ async def refresh_paths(self):
327
+ self.loading = True
328
+ root = self.root
329
+
330
+ try:
331
+ path_filter = await asyncio.to_thread(self.get_path_filter, root)
332
+ self.tree_view.path_filter = path_filter
333
+ self.tree_view.clear()
334
+ await self.tree_view.reload()
335
+ paths = await directory.scan(
336
+ root, path_filter=path_filter, add_directories=True
337
+ )
338
+
339
+ paths = [path.absolute() for path in paths]
340
+ self.root = root
341
+ self.paths = paths
342
+ finally:
343
+ self.loading = False
344
+
345
+ def get_loading_widget(self) -> Widget:
346
+ from textual.widgets import LoadingIndicator
347
+
348
+ return LoadingIndicator()
349
+
350
+ def highlight_path(self, path: str) -> Content:
351
+ content = Content.styled(path, "dim $text")
352
+ if os.path.split(path)[-1].startswith("."):
353
+ return content
354
+ content = content.highlight_regex("[^/]*?$", style="not dim $text-primary")
355
+ content = content.highlight_regex(r"\.[^/]*$", style="italic")
356
+ return content
357
+
358
+ def watch_paths(self, paths: list[Path]) -> None:
359
+ self.option_list.highlighted = None
360
+
361
+ def path_display(path: Path) -> str:
362
+ try:
363
+ is_directory = path.is_dir()
364
+ except OSError:
365
+ is_directory = False
366
+ if is_directory:
367
+ return str(path.relative_to(self.root)) + "/"
368
+ else:
369
+ return str(path.relative_to(self.root))
370
+
371
+ display_paths = sorted(map(path_display, paths), key=str.lower)
372
+ self.highlighted_paths = [self.highlight_path(path) for path in display_paths]
373
+ self.option_list.set_options(
374
+ [
375
+ Option(highlighted_path, id=highlighted_path.plain)
376
+ for highlighted_path in self.highlighted_paths
377
+ ][:100]
378
+ )
379
+ with self.option_list.prevent(OptionList.OptionHighlighted):
380
+ self.option_list.highlighted = 0
381
+ self.post_message(PromptSuggestion(""))
toad/widgets/plan.py ADDED
@@ -0,0 +1,180 @@
1
+ from dataclasses import dataclass
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.content import Content
5
+ from textual.layout import Layout
6
+ from textual.reactive import reactive
7
+ from textual import containers
8
+ from textual.widgets import Static
9
+
10
+ from toad.pill import pill
11
+ from toad.widgets.strike_text import StrikeText
12
+
13
+
14
+ class NonSelectableStatic(Static):
15
+ ALLOW_SELECT = False
16
+
17
+
18
+ class Plan(containers.Grid):
19
+ BORDER_TITLE = "Plan"
20
+ DEFAULT_CLASSES = "block"
21
+ DEFAULT_CSS = """
22
+ Plan {
23
+ background: black 20%;
24
+ height: auto;
25
+ padding: 0 1;
26
+ margin: 0 1 1 1;
27
+ border: tall transparent;
28
+
29
+ grid-size: 2;
30
+ grid-columns: auto 1fr;
31
+ grid-rows: auto;
32
+ height: auto;
33
+
34
+ .plan {
35
+ color: $text-secondary;
36
+ }
37
+ .status {
38
+ padding: 0 0 0 0;
39
+ color: $text-secondary;
40
+ }
41
+ .priority {
42
+ padding: 0 0 0 0;
43
+ }
44
+ .status.status-completed {
45
+ color: $text-success;
46
+ text-style: bold;
47
+ }
48
+ .status-pending {
49
+ opacity: 0.8;
50
+ }
51
+ }
52
+
53
+ """
54
+
55
+ @dataclass(frozen=True)
56
+ class Entry:
57
+ """Information about an entry in the Plan."""
58
+
59
+ content: Content
60
+ priority: str
61
+ status: str
62
+
63
+ entries: reactive[list[Entry] | None] = reactive(None, recompose=True)
64
+
65
+ LEFT = Content.styled("▌", "$error-muted on transparent r")
66
+
67
+ PRIORITIES = {
68
+ "high": pill("H", "$error-muted", "$text-error"),
69
+ "medium": pill("M", "$warning-muted", "$text-warning"),
70
+ "low": pill("L", "$primary-muted", "$text-primary"),
71
+ }
72
+
73
+ def __init__(
74
+ self,
75
+ entries: list[Entry],
76
+ name: str | None = None,
77
+ id: str | None = None,
78
+ classes: str | None = None,
79
+ ):
80
+ self.newly_completed: set[Plan.Entry] = set()
81
+ super().__init__(name=name, id=id, classes=classes)
82
+ self.set_reactive(Plan.entries, entries)
83
+
84
+ def watch_entries(self, old_entries: list[Entry], new_entries: list[Entry]) -> None:
85
+ entry_map = {entry.content: entry for entry in old_entries}
86
+ newly_completed: set[Plan.Entry] = set()
87
+ for entry in new_entries:
88
+ old_entry = entry_map.get(entry.content, None)
89
+ if (
90
+ old_entry is not None
91
+ and entry.status == "completed"
92
+ and entry.status != old_entry.status
93
+ ):
94
+ newly_completed.add(entry)
95
+ self.newly_completed = newly_completed
96
+
97
+ def compose(self) -> ComposeResult:
98
+ if not self.entries:
99
+ yield Static("No plan yet", classes="-no-plan")
100
+ return
101
+ for entry in self.entries:
102
+ classes = f"priority-{entry.priority} status-{entry.status}"
103
+ yield NonSelectableStatic(
104
+ self.render_status(entry.status),
105
+ classes=f"status {classes}",
106
+ )
107
+
108
+ yield (
109
+ strike_text := StrikeText(
110
+ entry.content,
111
+ classes=f"plan {classes}",
112
+ )
113
+ )
114
+ if entry in self.newly_completed or (
115
+ not self.is_mounted and entry.status == "completed"
116
+ ):
117
+ self.call_after_refresh(strike_text.strike)
118
+
119
+ def render_status(self, status: str) -> Content:
120
+ if status == "completed":
121
+ return Content.from_markup("✔ ")
122
+ elif status == "pending":
123
+ return Content.styled("⏲ ")
124
+ elif status == "in_progress":
125
+ return Content.from_markup("⮕")
126
+ return Content()
127
+
128
+
129
+ if __name__ == "__main__":
130
+ from textual.app import App
131
+
132
+ entries = [
133
+ Plan.Entry(
134
+ Content.from_markup(
135
+ "Build the best damn UI for agentic coding in the terminal"
136
+ ),
137
+ "high",
138
+ "in_progress",
139
+ ),
140
+ Plan.Entry(Content.from_markup("???"), "medium", "in_progress"),
141
+ Plan.Entry(
142
+ Content.from_markup("[b]Profit[/b]. Retire to Costa Rica"),
143
+ "low",
144
+ "pending",
145
+ ),
146
+ ]
147
+
148
+ new_entries = [
149
+ Plan.Entry(
150
+ Content.from_markup(
151
+ "Build the best damn UI for agentic coding in the terminal"
152
+ ),
153
+ "high",
154
+ "completed",
155
+ ),
156
+ Plan.Entry(Content.from_markup("???"), "medium", "in_progress"),
157
+ Plan.Entry(
158
+ Content.from_markup("[b]Profit[/b]. Retire to Costa Rica"),
159
+ "low",
160
+ "pending",
161
+ ),
162
+ ]
163
+
164
+ class PlanApp(App):
165
+ BINDINGS = [("space", "strike")]
166
+
167
+ CSS = """
168
+ Screen {
169
+ align: center middle;
170
+ }
171
+ """
172
+
173
+ def compose(self) -> ComposeResult:
174
+ yield Plan(entries)
175
+
176
+ def action_strike(self) -> None:
177
+ self.query_one(Plan).entries = new_entries
178
+
179
+ app = PlanApp()
180
+ app.run()
@@ -0,0 +1,74 @@
1
+ from pathlib import Path
2
+ from typing import Iterable
3
+
4
+ import asyncio
5
+
6
+ from textual.binding import Binding
7
+ from textual.widgets import DirectoryTree
8
+ from textual.widgets.directory_tree import DirEntry
9
+
10
+ from toad.path_filter import PathFilter
11
+
12
+
13
+ class ProjectDirectoryTree(DirectoryTree):
14
+ BINDINGS = [
15
+ Binding(
16
+ "ctrl+c",
17
+ "dismiss",
18
+ "Interrupt",
19
+ tooltip="Interrupt running command",
20
+ show=False,
21
+ ),
22
+ ]
23
+
24
+ def __init__(
25
+ self,
26
+ path: str | Path,
27
+ *,
28
+ name: str | None = None,
29
+ id: str | None = None,
30
+ classes: str | None = None,
31
+ disabled: bool = False,
32
+ ) -> None:
33
+ self._path_filter: PathFilter | None = None
34
+ path = Path(path).resolve() if isinstance(path, str) else path.resolve()
35
+ super().__init__(path, name=name, id=id, classes=classes, disabled=disabled)
36
+
37
+ async def watch_path(self) -> None:
38
+ """Watch for changes to the `path` of the directory tree.
39
+
40
+ If the path is changed the directory tree will be repopulated using
41
+ the new value as the root.
42
+ """
43
+ has_cursor = self.cursor_node is not None
44
+ self.reset_node(self.root, str(self.path), DirEntry(self.PATH(self.path)))
45
+ await self.reload()
46
+ if has_cursor:
47
+ self.cursor_line = 0
48
+ self.scroll_to(0, 0, animate=False)
49
+
50
+ async def on_mount(self) -> None:
51
+ path = Path(self.path) if isinstance(self.path, str) else self.path
52
+ path = path.resolve()
53
+ self._path_filter = await asyncio.to_thread(PathFilter.from_git_root, path)
54
+
55
+ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
56
+ """Filter the paths before adding them to the tree.
57
+
58
+ Args:
59
+ paths: The paths to be filtered.
60
+
61
+ Returns:
62
+ The filtered paths.
63
+
64
+ By default this method returns all of the paths provided. To create
65
+ a filtered `DirectoryTree` inherit from it and implement your own
66
+ version of this method.
67
+ """
68
+
69
+ if (path_filter := self._path_filter) is not None:
70
+ for path in paths:
71
+ if not path_filter.match(path):
72
+ yield path
73
+ else:
74
+ yield from paths