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