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,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)
@@ -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()