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/widgets/question.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual import events, on
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual import containers
|
|
10
|
+
from textual.content import Content
|
|
11
|
+
from textual.reactive import var, reactive
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from textual.widgets import Label
|
|
15
|
+
|
|
16
|
+
from toad.answer import Answer
|
|
17
|
+
|
|
18
|
+
type Options = list[Answer]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Ask:
|
|
23
|
+
"""Data for Question."""
|
|
24
|
+
|
|
25
|
+
question: str
|
|
26
|
+
options: Options
|
|
27
|
+
callback: Callable[[Answer], Any] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NonSelectableLabel(Label):
|
|
31
|
+
ALLOW_SELECT = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Option(containers.HorizontalGroup):
|
|
35
|
+
ALLOW_SELECT = False
|
|
36
|
+
DEFAULT_CSS = """
|
|
37
|
+
Option {
|
|
38
|
+
|
|
39
|
+
&:hover {
|
|
40
|
+
background: $boost;
|
|
41
|
+
}
|
|
42
|
+
color: $text-muted;
|
|
43
|
+
#caret {
|
|
44
|
+
visibility: hidden;
|
|
45
|
+
padding: 0 1;
|
|
46
|
+
}
|
|
47
|
+
#index {
|
|
48
|
+
padding-right: 1;
|
|
49
|
+
}
|
|
50
|
+
#label {
|
|
51
|
+
width: 1fr;
|
|
52
|
+
}
|
|
53
|
+
&.-active {
|
|
54
|
+
color: $text-accent;
|
|
55
|
+
#caret {
|
|
56
|
+
visibility: visible;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
&.-selected {
|
|
60
|
+
opacity: 0.5;
|
|
61
|
+
}
|
|
62
|
+
&.-active.-selected {
|
|
63
|
+
opacity: 1.0;
|
|
64
|
+
background: transparent;
|
|
65
|
+
color: $text-accent;
|
|
66
|
+
#label {
|
|
67
|
+
text-style: underline;
|
|
68
|
+
}
|
|
69
|
+
#caret {
|
|
70
|
+
visibility: hidden;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Selected(Message):
|
|
78
|
+
"""The option was selected."""
|
|
79
|
+
|
|
80
|
+
index: int
|
|
81
|
+
|
|
82
|
+
selected: reactive[bool] = reactive(False, toggle_class="-selected")
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self, index: int, content: Content, key: str | None, classes: str = ""
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__(classes=classes)
|
|
88
|
+
self.index = index
|
|
89
|
+
self.content = content
|
|
90
|
+
self.key = key
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
key = self.key
|
|
94
|
+
yield NonSelectableLabel("❯", id="caret")
|
|
95
|
+
if key:
|
|
96
|
+
yield NonSelectableLabel(Content.styled(f"{key}", "b"), id="index")
|
|
97
|
+
else:
|
|
98
|
+
yield NonSelectableLabel(Content(" "), id="index")
|
|
99
|
+
|
|
100
|
+
yield NonSelectableLabel(self.content, id="label")
|
|
101
|
+
|
|
102
|
+
def on_click(self, event: events.Click) -> None:
|
|
103
|
+
event.stop()
|
|
104
|
+
self.post_message(self.Selected(self.index))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Question(Widget, can_focus=True):
|
|
108
|
+
"""A text question with a menu of responses."""
|
|
109
|
+
|
|
110
|
+
BINDING_GROUP_TITLE = "Question"
|
|
111
|
+
ALLOW_SELECT = False
|
|
112
|
+
CURSOR_GROUP = Binding.Group("Cursor", compact=True)
|
|
113
|
+
ALLOW_GROUP = Binding.Group("Allow once/always", compact=True)
|
|
114
|
+
REJECT_GROUP = Binding.Group("Reject once/always", compact=True)
|
|
115
|
+
BINDINGS = [
|
|
116
|
+
Binding(
|
|
117
|
+
"up",
|
|
118
|
+
"selection_up",
|
|
119
|
+
"Up",
|
|
120
|
+
group=CURSOR_GROUP,
|
|
121
|
+
),
|
|
122
|
+
Binding(
|
|
123
|
+
"down",
|
|
124
|
+
"selection_down",
|
|
125
|
+
"Down",
|
|
126
|
+
group=CURSOR_GROUP,
|
|
127
|
+
),
|
|
128
|
+
Binding(
|
|
129
|
+
"enter",
|
|
130
|
+
"select",
|
|
131
|
+
"Select",
|
|
132
|
+
),
|
|
133
|
+
Binding(
|
|
134
|
+
"a",
|
|
135
|
+
"select_kind(('allow_once', 'allow'))",
|
|
136
|
+
"Allow once",
|
|
137
|
+
group=ALLOW_GROUP,
|
|
138
|
+
),
|
|
139
|
+
Binding(
|
|
140
|
+
"A",
|
|
141
|
+
"select_kind('allow_always')",
|
|
142
|
+
"Allow always",
|
|
143
|
+
group=ALLOW_GROUP,
|
|
144
|
+
),
|
|
145
|
+
Binding(
|
|
146
|
+
"r",
|
|
147
|
+
"select_kind(('reject_once', 'reject'))",
|
|
148
|
+
"Reject once",
|
|
149
|
+
group=REJECT_GROUP,
|
|
150
|
+
),
|
|
151
|
+
Binding(
|
|
152
|
+
"R",
|
|
153
|
+
"select_kind('reject_always')",
|
|
154
|
+
"Reject always",
|
|
155
|
+
group=REJECT_GROUP,
|
|
156
|
+
),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
DEFAULT_CSS = """
|
|
160
|
+
Question {
|
|
161
|
+
width: 1fr;
|
|
162
|
+
height: auto;
|
|
163
|
+
padding: 0 1;
|
|
164
|
+
background: transparent;
|
|
165
|
+
#prompt {
|
|
166
|
+
margin-bottom: 1;
|
|
167
|
+
color: $text-primary;
|
|
168
|
+
}
|
|
169
|
+
&.-blink Option.-active #caret {
|
|
170
|
+
opacity: 0.2;
|
|
171
|
+
}
|
|
172
|
+
&:blur {
|
|
173
|
+
#index {
|
|
174
|
+
opacity: 0.3;
|
|
175
|
+
}
|
|
176
|
+
#caret {
|
|
177
|
+
opacity: 0.3;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
question: var[str] = var("")
|
|
184
|
+
options: var[Options] = var(list)
|
|
185
|
+
|
|
186
|
+
selection: reactive[int] = reactive(0, init=False)
|
|
187
|
+
selected: var[bool] = var(False, toggle_class="-selected")
|
|
188
|
+
blink: var[bool] = var(False)
|
|
189
|
+
|
|
190
|
+
DEFAULT_KINDS = {
|
|
191
|
+
"allow_once": "a",
|
|
192
|
+
"allow_always": "A",
|
|
193
|
+
"reject_once": "r",
|
|
194
|
+
"reject_always": "R",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class Answer(Message):
|
|
199
|
+
"""User selected a response."""
|
|
200
|
+
|
|
201
|
+
index: int
|
|
202
|
+
answer: Answer
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
question: str = "Ask and you will receive",
|
|
207
|
+
options: Options | None = None,
|
|
208
|
+
name: str | None = None,
|
|
209
|
+
id: str | None = None,
|
|
210
|
+
classes: str | None = None,
|
|
211
|
+
disabled: bool = False,
|
|
212
|
+
):
|
|
213
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
214
|
+
self.set_reactive(Question.question, question)
|
|
215
|
+
self.set_reactive(Question.options, options or [])
|
|
216
|
+
|
|
217
|
+
def on_mount(self) -> None:
|
|
218
|
+
def toggle_blink() -> None:
|
|
219
|
+
if self.has_focus:
|
|
220
|
+
self.blink = not self.blink
|
|
221
|
+
else:
|
|
222
|
+
self.blink = False
|
|
223
|
+
|
|
224
|
+
self._blink_timer = self.set_interval(0.5, toggle_blink)
|
|
225
|
+
|
|
226
|
+
def _reset_blink(self) -> None:
|
|
227
|
+
self.blink = False
|
|
228
|
+
self._blink_timer.reset()
|
|
229
|
+
|
|
230
|
+
def update(self, ask: Ask) -> None:
|
|
231
|
+
self.question = ask.question
|
|
232
|
+
self.options = ask.options
|
|
233
|
+
self.selection = 0
|
|
234
|
+
self.selected = False
|
|
235
|
+
self.refresh(recompose=True, layout=True)
|
|
236
|
+
|
|
237
|
+
def compose(self) -> ComposeResult:
|
|
238
|
+
with containers.VerticalGroup():
|
|
239
|
+
if self.question:
|
|
240
|
+
yield Label(self.question, id="prompt")
|
|
241
|
+
|
|
242
|
+
with containers.VerticalGroup(id="option-container"):
|
|
243
|
+
kinds: set[str] = set()
|
|
244
|
+
for index, answer in enumerate(self.options):
|
|
245
|
+
active = index == self.selection
|
|
246
|
+
key = (
|
|
247
|
+
self.DEFAULT_KINDS.get(answer.kind)
|
|
248
|
+
if (answer.kind and answer.kind not in kinds)
|
|
249
|
+
else None
|
|
250
|
+
)
|
|
251
|
+
yield Option(
|
|
252
|
+
index,
|
|
253
|
+
Content(answer.text),
|
|
254
|
+
key,
|
|
255
|
+
classes="-active" if active else "",
|
|
256
|
+
).data_bind(Question.selected)
|
|
257
|
+
if answer.kind is not None:
|
|
258
|
+
kinds.add(answer.kind)
|
|
259
|
+
|
|
260
|
+
def watch_selection(self, old_selection: int, new_selection: int) -> None:
|
|
261
|
+
self.query("#option-container > .-active").remove_class("-active")
|
|
262
|
+
if new_selection >= 0:
|
|
263
|
+
self.query_one("#option-container").children[new_selection].add_class(
|
|
264
|
+
"-active"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
268
|
+
if self.selected and action in ("selection_up", "selection_down"):
|
|
269
|
+
return False
|
|
270
|
+
if action == "select_kind":
|
|
271
|
+
kinds = {answer.kind for answer in self.options if answer.kind is not None}
|
|
272
|
+
check_kinds = set()
|
|
273
|
+
for parameter in parameters:
|
|
274
|
+
if isinstance(parameter, str):
|
|
275
|
+
check_kinds.add(parameter)
|
|
276
|
+
elif isinstance(parameter, tuple):
|
|
277
|
+
check_kinds.update(parameter)
|
|
278
|
+
|
|
279
|
+
return any(kind in kinds for kind in check_kinds)
|
|
280
|
+
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
def watch_blink(self, blink: bool) -> None:
|
|
284
|
+
self.set_class(blink, "-blink")
|
|
285
|
+
|
|
286
|
+
def action_selection_up(self) -> None:
|
|
287
|
+
self._reset_blink()
|
|
288
|
+
self.selection = max(0, self.selection - 1)
|
|
289
|
+
|
|
290
|
+
def action_selection_down(self) -> None:
|
|
291
|
+
self._reset_blink()
|
|
292
|
+
self.selection = min(len(self.options) - 1, self.selection + 1)
|
|
293
|
+
|
|
294
|
+
def action_select(self) -> None:
|
|
295
|
+
self._reset_blink()
|
|
296
|
+
self.post_message(
|
|
297
|
+
self.Answer(
|
|
298
|
+
index=self.selection,
|
|
299
|
+
answer=self.options[self.selection],
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
self.selected = True
|
|
303
|
+
|
|
304
|
+
def action_select_kind(self, kind: str | tuple[str]) -> None:
|
|
305
|
+
kinds = kind if isinstance(kind, tuple) else (kind,)
|
|
306
|
+
for kind in kinds:
|
|
307
|
+
for index, answer in enumerate(self.options):
|
|
308
|
+
if answer.kind == kind:
|
|
309
|
+
self.selection = index
|
|
310
|
+
self.action_select()
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
@on(Option.Selected)
|
|
314
|
+
def on_option_selected(self, event: Option.Selected) -> None:
|
|
315
|
+
event.stop()
|
|
316
|
+
self._reset_blink()
|
|
317
|
+
if not self.selected:
|
|
318
|
+
self.selection = event.index
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
from textual.app import App
|
|
323
|
+
from textual.widgets import Footer
|
|
324
|
+
|
|
325
|
+
OPTIONS = [
|
|
326
|
+
Answer("Yes, allow once", "proceed_always", kind="allow_once"),
|
|
327
|
+
Answer("Yes, allow always", "allow_always", kind="allow_always"),
|
|
328
|
+
Answer("Modify with external editor", "modify", kind="allow_once"),
|
|
329
|
+
Answer("No, suggest changes (esc)", "reject"),
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
class QuestionApp(App):
|
|
333
|
+
def compose(self) -> ComposeResult:
|
|
334
|
+
yield Question("Apply this change?", OPTIONS)
|
|
335
|
+
yield Footer()
|
|
336
|
+
|
|
337
|
+
QuestionApp().run()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual import containers
|
|
6
|
+
from textual.highlight import highlight
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from toad.menus import MenuItem
|
|
11
|
+
from toad.widgets.non_selectable_label import NonSelectableLabel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ShellResult(containers.HorizontalGroup):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
command: str,
|
|
18
|
+
*,
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
id: str | None = None,
|
|
21
|
+
classes: str | None = None,
|
|
22
|
+
disabled: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._command = command
|
|
25
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
yield NonSelectableLabel("$", id="prompt")
|
|
29
|
+
yield Static(highlight(self._command, language="sh"))
|
|
30
|
+
|
|
31
|
+
def get_block_menu(self) -> Iterable[MenuItem]:
|
|
32
|
+
yield from ()
|
|
33
|
+
|
|
34
|
+
def get_block_content(self, destination: str) -> str | None:
|
|
35
|
+
return self._command
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Iterable
|
|
2
|
+
|
|
3
|
+
from toad.menus import MenuItem
|
|
4
|
+
from toad.widgets.terminal import Terminal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ShellTerminal(Terminal):
|
|
8
|
+
"""Subclass of Terminal used in the Shell view."""
|
|
9
|
+
|
|
10
|
+
def get_block_menu(self) -> Iterable[MenuItem]:
|
|
11
|
+
return
|
|
12
|
+
yield
|
|
13
|
+
|
|
14
|
+
def on_mount(self) -> None:
|
|
15
|
+
self.border_title = self.name
|
|
16
|
+
|
|
17
|
+
def get_block_content(self, destination: str) -> str | None:
|
|
18
|
+
return "\n".join(line.content.plain for line in self.state.buffer.lines)
|
toad/widgets/side_bar.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual import containers
|
|
6
|
+
from textual import widgets
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SideBar(containers.Vertical):
|
|
11
|
+
BINDINGS = [("escape", "dismiss", "Dismiss sidebar")]
|
|
12
|
+
|
|
13
|
+
class Dismiss(Message):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Panel:
|
|
18
|
+
title: str
|
|
19
|
+
widget: Widget
|
|
20
|
+
flex: bool = False
|
|
21
|
+
collapsed: bool = False
|
|
22
|
+
id: str | None = None
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*panels: Panel,
|
|
27
|
+
name: str | None = None,
|
|
28
|
+
id: str | None = None,
|
|
29
|
+
classes: str | None = None,
|
|
30
|
+
disabled: bool = False,
|
|
31
|
+
hide: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
34
|
+
self.panels: list[SideBar.Panel] = [*panels]
|
|
35
|
+
self.hide = hide
|
|
36
|
+
|
|
37
|
+
def on_mount(self) -> None:
|
|
38
|
+
self.trap_focus()
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
for panel in self.panels:
|
|
42
|
+
yield widgets.Collapsible(
|
|
43
|
+
panel.widget,
|
|
44
|
+
title=panel.title,
|
|
45
|
+
collapsed=panel.collapsed,
|
|
46
|
+
classes="-flex" if panel.flex else "-fixed",
|
|
47
|
+
id=panel.id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def action_dismiss(self) -> None:
|
|
51
|
+
self.post_message(self.Dismiss())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
from textual.app import App, ComposeResult
|
|
56
|
+
|
|
57
|
+
class SApp(App):
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
yield SideBar(
|
|
60
|
+
SideBar.Panel("Hello", widgets.Label("Hello, World!")),
|
|
61
|
+
SideBar.Panel(
|
|
62
|
+
"Files",
|
|
63
|
+
widgets.DirectoryTree(
|
|
64
|
+
"~/",
|
|
65
|
+
),
|
|
66
|
+
flex=True,
|
|
67
|
+
),
|
|
68
|
+
SideBar.Panel(
|
|
69
|
+
"Hello",
|
|
70
|
+
widgets.Static("Where there is a Will! " * 10),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
SApp().run()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from operator import itemgetter
|
|
3
|
+
from typing import Iterable, Self, Sequence
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.content import Content, Span
|
|
9
|
+
|
|
10
|
+
from textual import getters
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.reactive import var
|
|
13
|
+
from textual import containers
|
|
14
|
+
from textual import widgets
|
|
15
|
+
from textual.widgets.option_list import Option
|
|
16
|
+
|
|
17
|
+
from toad.fuzzy import FuzzySearch
|
|
18
|
+
from toad.messages import Dismiss
|
|
19
|
+
from toad.slash_command import SlashCommand
|
|
20
|
+
from toad.visuals.columns import Columns
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SlashComplete(containers.VerticalGroup):
|
|
24
|
+
"""A widget to auto-complete slash commands."""
|
|
25
|
+
|
|
26
|
+
CURSOR_BINDING_GROUP = Binding.Group(description="Select")
|
|
27
|
+
BINDINGS = [
|
|
28
|
+
Binding(
|
|
29
|
+
"up",
|
|
30
|
+
"cursor_up",
|
|
31
|
+
"Cursor up",
|
|
32
|
+
group=CURSOR_BINDING_GROUP,
|
|
33
|
+
priority=True,
|
|
34
|
+
),
|
|
35
|
+
Binding(
|
|
36
|
+
"down",
|
|
37
|
+
"cursor_down",
|
|
38
|
+
"Cursor down",
|
|
39
|
+
group=CURSOR_BINDING_GROUP,
|
|
40
|
+
priority=True,
|
|
41
|
+
),
|
|
42
|
+
Binding("enter", "submit", "Insert /command", priority=True),
|
|
43
|
+
Binding("escape", "dismiss", "Dismiss", priority=True),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
DEFAULT_CSS = """
|
|
47
|
+
SlashComplete {
|
|
48
|
+
OptionList {
|
|
49
|
+
height: auto;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
input = getters.query_one(widgets.Input)
|
|
55
|
+
option_list = getters.query_one(widgets.OptionList)
|
|
56
|
+
|
|
57
|
+
slash_commands: var[list[SlashCommand]] = var(list)
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Completed(Message):
|
|
61
|
+
command: str
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
slash_commands: Iterable[SlashCommand] | None = None,
|
|
66
|
+
id: str | None = None,
|
|
67
|
+
classes: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__(id=id, classes=classes)
|
|
70
|
+
self.slash_commands = list(slash_commands) if slash_commands else []
|
|
71
|
+
self.fuzzy_search = FuzzySearch(case_sensitive=False)
|
|
72
|
+
|
|
73
|
+
def compose(self) -> ComposeResult:
|
|
74
|
+
yield widgets.Input(compact=True, placeholder="fuzzy search")
|
|
75
|
+
yield widgets.OptionList()
|
|
76
|
+
|
|
77
|
+
def focus(self, scroll_visible: bool = False) -> Self:
|
|
78
|
+
self.filter_slash_commands("")
|
|
79
|
+
self.input.focus(scroll_visible)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def on_mount(self) -> None:
|
|
83
|
+
self.filter_slash_commands("")
|
|
84
|
+
|
|
85
|
+
def on_descendant_blur(self) -> None:
|
|
86
|
+
self.post_message(Dismiss(self))
|
|
87
|
+
|
|
88
|
+
@on(widgets.Input.Changed)
|
|
89
|
+
def on_input_changed(self, event: widgets.Input.Changed) -> None:
|
|
90
|
+
event.stop()
|
|
91
|
+
self.filter_slash_commands(event.value)
|
|
92
|
+
|
|
93
|
+
async def watch_slash_commands(self) -> None:
|
|
94
|
+
self.filter_slash_commands(self.input.value)
|
|
95
|
+
|
|
96
|
+
def filter_slash_commands(self, prompt: str) -> None:
|
|
97
|
+
"""Filter slash commands by the given prompt.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
prompt: Text prompt.
|
|
101
|
+
"""
|
|
102
|
+
prompt = prompt.lstrip("/").casefold()
|
|
103
|
+
columns = self.columns = Columns("auto", "flex")
|
|
104
|
+
|
|
105
|
+
slash_commands = sorted(
|
|
106
|
+
self.slash_commands,
|
|
107
|
+
key=lambda slash_command: slash_command.command.casefold(),
|
|
108
|
+
)
|
|
109
|
+
deduplicated_slash_commands = {
|
|
110
|
+
slash_command.command: slash_command for slash_command in slash_commands
|
|
111
|
+
}
|
|
112
|
+
self.fuzzy_search.cache.grow(len(deduplicated_slash_commands))
|
|
113
|
+
|
|
114
|
+
if prompt:
|
|
115
|
+
slash_prompt = f"/{prompt}"
|
|
116
|
+
scores: list[tuple[float, Sequence[int], SlashCommand]] = [
|
|
117
|
+
(
|
|
118
|
+
*self.fuzzy_search.match(prompt, slash_command.command[1:]),
|
|
119
|
+
slash_command,
|
|
120
|
+
)
|
|
121
|
+
for slash_command in slash_commands
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
scores = sorted(
|
|
125
|
+
[
|
|
126
|
+
(
|
|
127
|
+
(
|
|
128
|
+
score * 2
|
|
129
|
+
if slash_command.command.casefold().startswith(slash_prompt)
|
|
130
|
+
else score
|
|
131
|
+
),
|
|
132
|
+
highlights,
|
|
133
|
+
slash_command,
|
|
134
|
+
)
|
|
135
|
+
for score, highlights, slash_command in scores
|
|
136
|
+
if score
|
|
137
|
+
],
|
|
138
|
+
key=itemgetter(0),
|
|
139
|
+
reverse=True,
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
scores = [(1.0, [], slash_command) for slash_command in slash_commands]
|
|
143
|
+
|
|
144
|
+
def make_row(
|
|
145
|
+
slash_command: SlashCommand, indices: Iterable[int]
|
|
146
|
+
) -> tuple[Content, ...]:
|
|
147
|
+
"""Make a row for the Columns display.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
slash_command: The slash command instance.
|
|
151
|
+
indices: Indices of matching characters.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
A tuple of `Content` instances for use as a column row.
|
|
155
|
+
"""
|
|
156
|
+
command = Content.styled(slash_command.command, "$text-success")
|
|
157
|
+
command = command.add_spans(
|
|
158
|
+
[Span(index + 1, index + 2, "underline not dim") for index in indices]
|
|
159
|
+
)
|
|
160
|
+
return (command, Content.styled(slash_command.help, "dim"))
|
|
161
|
+
|
|
162
|
+
rows = [
|
|
163
|
+
(
|
|
164
|
+
columns.add_row(
|
|
165
|
+
*make_row(slash_command, indices),
|
|
166
|
+
),
|
|
167
|
+
slash_command.command,
|
|
168
|
+
)
|
|
169
|
+
for _, indices, slash_command in scores
|
|
170
|
+
]
|
|
171
|
+
self.option_list.set_options(
|
|
172
|
+
Option(row, id=command_name) for row, command_name in rows
|
|
173
|
+
)
|
|
174
|
+
if self.display:
|
|
175
|
+
self.option_list.highlighted = 0
|
|
176
|
+
else:
|
|
177
|
+
with self.option_list.prevent(widgets.OptionList.OptionHighlighted):
|
|
178
|
+
self.option_list.highlighted = 0
|
|
179
|
+
|
|
180
|
+
def action_cursor_down(self) -> None:
|
|
181
|
+
self.option_list.action_cursor_down()
|
|
182
|
+
|
|
183
|
+
def action_cursor_up(self) -> None:
|
|
184
|
+
self.option_list.action_cursor_up()
|
|
185
|
+
|
|
186
|
+
def action_dismiss(self) -> None:
|
|
187
|
+
self.post_message(Dismiss(self))
|
|
188
|
+
|
|
189
|
+
def action_submit(self) -> None:
|
|
190
|
+
if (option := self.option_list.highlighted_option) is not None:
|
|
191
|
+
with self.input.prevent(widgets.Input.Changed):
|
|
192
|
+
self.input.clear()
|
|
193
|
+
self.post_message(Dismiss(self))
|
|
194
|
+
self.post_message(self.Completed(option.id or ""))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
from textual.app import App, ComposeResult
|
|
199
|
+
|
|
200
|
+
COMMANDS = [
|
|
201
|
+
SlashCommand("/help", "Help with slash commands"),
|
|
202
|
+
SlashCommand("/foo", "This is FOO"),
|
|
203
|
+
SlashCommand("/bar", "This is BAR"),
|
|
204
|
+
SlashCommand("/baz", "This is BAZ"),
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
class SlashApp(App):
|
|
208
|
+
def compose(self) -> ComposeResult:
|
|
209
|
+
yield SlashComplete(COMMANDS)
|
|
210
|
+
|
|
211
|
+
SlashApp().run()
|