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,318 @@
1
+ from toad.settings import SchemaDict
2
+
3
+ SCHEMA: list[SchemaDict] = [
4
+ {
5
+ "key": "ui",
6
+ "title": "User interface settings",
7
+ "help": "The following settings allow you to customize the look and feel of the User Interface.",
8
+ "type": "object",
9
+ "fields": [
10
+ {
11
+ "key": "theme",
12
+ "title": "Theme",
13
+ "help": "One of the builtin Textual themes.",
14
+ "type": "choices",
15
+ "default": "dracula",
16
+ "choices": [
17
+ "atom-one-dark",
18
+ "atom-one-light",
19
+ "catppuccin-latte",
20
+ "catppuccin-mocha",
21
+ "dracula",
22
+ "flexoki",
23
+ "gruvbox",
24
+ "monokai",
25
+ "nord",
26
+ "solarized-light",
27
+ "solarized-dark",
28
+ "textual-dark",
29
+ "textual-light",
30
+ "tokyo-night",
31
+ "rose-pine",
32
+ "rose-pine-moon",
33
+ "rose-pine-dawn",
34
+ ],
35
+ },
36
+ {
37
+ "key": "compact-input",
38
+ "title": "Compact text input?",
39
+ "help": "Remove border and margin around the text area for additional space",
40
+ "type": "boolean",
41
+ "default": False,
42
+ },
43
+ {
44
+ "key": "footer",
45
+ "title": "Enable footer?",
46
+ "help": "Disable the footer if you want additional room.",
47
+ "type": "boolean",
48
+ "default": True,
49
+ },
50
+ {
51
+ "key": "info-bar",
52
+ "title": "Enable info bar?",
53
+ "help": "The info bar is the text below the prompt text area. Disable for more space.",
54
+ "type": "boolean",
55
+ "default": True,
56
+ },
57
+ {
58
+ "key": "status-line",
59
+ "title": "Show status line in the info bar?",
60
+ "help": "The status line shows tokens and cost (not available in all agents).",
61
+ "type": "boolean",
62
+ "default": True,
63
+ },
64
+ {
65
+ "key": "agent-title",
66
+ "title": "Show agent title the info bar?",
67
+ "help": "Disable for a little extras space.",
68
+ "type": "boolean",
69
+ "default": True,
70
+ },
71
+ {
72
+ "key": "column",
73
+ "title": "Enable column?",
74
+ "help": "Enable for a fixed column size. Disable to use the full screen width.",
75
+ "type": "boolean",
76
+ "default": True,
77
+ },
78
+ {
79
+ "key": "column-width",
80
+ "title": "Width of the column",
81
+ "help": "Width of the column if enabled. Minimum 40 characters.",
82
+ "type": "integer",
83
+ "default": 100,
84
+ "validate": [{"type": "minimum", "value": 40}],
85
+ },
86
+ {
87
+ "key": "scrollbar",
88
+ "title": "Scrollbar size",
89
+ "type": "choices",
90
+ "default": "normal",
91
+ "choices": [
92
+ ("Normal", "normal"),
93
+ ("Thin", "thin"),
94
+ ("Hidden", "hidden"),
95
+ ],
96
+ },
97
+ {
98
+ "key": "throbber",
99
+ "title": "Thinking animation",
100
+ "help": "Animation to show while the agent is busy",
101
+ "type": "choices",
102
+ "default": "quotes",
103
+ "choices": [
104
+ ("Pulse", "pulse"),
105
+ ("Quotes", "quotes"),
106
+ ],
107
+ },
108
+ {
109
+ "key": "flash_duration",
110
+ "title": "Flash duration",
111
+ "help": "Default duration of flash messages (in seconds)",
112
+ "type": "number",
113
+ "default": 3.0,
114
+ "validate": [{"type": "minimum", "value": 0.5}],
115
+ },
116
+ {
117
+ "key": "auto_copy",
118
+ "title": "Automatic copy",
119
+ "help": "Automatically copy text on selection?\nDoesn't apply to text areas (use ctrl+c to copy).",
120
+ "type": "boolean",
121
+ "default": True,
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ "key": "notifications",
127
+ "title": "Notification (toasts) settings",
128
+ "help": "Customize how Toad displays notifications",
129
+ "type": "object",
130
+ "fields": [
131
+ {
132
+ "key": "system",
133
+ "title": "Show Toad notifications on your desktop?",
134
+ "type": "choices",
135
+ "default": "blur",
136
+ "choices": [
137
+ ("Never", "never"),
138
+ ("When app is not focused", "blur"),
139
+ ("Always", "always"),
140
+ ],
141
+ },
142
+ {
143
+ "key": "enable_sounds",
144
+ "title": "Allow sound in notifications?",
145
+ "type": "boolean",
146
+ "default": True,
147
+ },
148
+ {
149
+ "key": "turn_over",
150
+ "title": "Desktop notification when agent has finished?",
151
+ "type": "boolean",
152
+ "default": True,
153
+ },
154
+ {
155
+ "key": "hide_low_severity",
156
+ "title": "Limit desktop notifications to warning and errors?",
157
+ "type": "boolean",
158
+ "default": True,
159
+ },
160
+ ],
161
+ },
162
+ {
163
+ "key": "sidebar",
164
+ "title": "Sidebar settings",
165
+ "help": "Customize how the sidebar is displayed.",
166
+ "type": "object",
167
+ "fields": [
168
+ {
169
+ "key": "hide",
170
+ "title": "Hide the sidebar when not in use?",
171
+ "type": "boolean",
172
+ "default": False,
173
+ }
174
+ ],
175
+ },
176
+ {
177
+ "key": "agent",
178
+ "title": "Agent settings",
179
+ "help": "Customize how you interact with agents",
180
+ "type": "object",
181
+ "fields": [
182
+ {
183
+ "key": "thoughts",
184
+ "title": "Agent thoughts",
185
+ "help": "Show agent's 'thoughts' in the conversation?",
186
+ "type": "boolean",
187
+ },
188
+ # {
189
+ # "key": "warn",
190
+ # "title": "Warning against dangerous commands?",
191
+ # "help": "Please note that this can produce false positive [i]and[/i] false negatives. If you get a warning, examine the command more closely. But do not assume a command is safe if you get no warning.\n\nThis setting will have no effect if you have given the agent permissions to execute all commands.",
192
+ # "type": "boolean",
193
+ # "default": True,
194
+ # },
195
+ ],
196
+ },
197
+ {
198
+ "key": "tools",
199
+ "title": "Tool call settings",
200
+ "help": "Customize how Toad displays agent tool calls",
201
+ "type": "object",
202
+ "fields": [
203
+ {
204
+ "key": "expand",
205
+ "title": "Tool call expand",
206
+ "help": "When should Toad expand tool calls?",
207
+ "type": "choices",
208
+ "default": "fail",
209
+ "choices": [
210
+ ("Never", "never"),
211
+ ("Always", "always"),
212
+ ("Success only", "success"),
213
+ ("Fail only", "fail"),
214
+ ("Fail and success", "both"),
215
+ ],
216
+ }
217
+ ],
218
+ },
219
+ {
220
+ "key": "shell",
221
+ "title": "Shell settings",
222
+ "help": "Customize shell interactions.",
223
+ "type": "object",
224
+ "fields": [
225
+ {
226
+ "key": "command",
227
+ "title": "Shell command",
228
+ "type": "string",
229
+ "help": "Command used to launch your shell on macOS.\n[bold]Note:[/] Requires restart.",
230
+ "default": "/bin/sh",
231
+ },
232
+ {
233
+ "key": "command_start",
234
+ "title": "Startup commands",
235
+ "type": "text",
236
+ "help": "Command(s) to run on shell start.",
237
+ "default": 'PS1=""',
238
+ },
239
+ {
240
+ "key": "warn_dangerous",
241
+ "title": "Warn against potentially destructive commands?",
242
+ "help": "If enabled, Toad will highlight potentially destructive commands that may modify the filesystem outside of the project directory.\n\nNote that false positive [i]and[/] false negatives are possible.",
243
+ "type": "boolean",
244
+ "default": True,
245
+ },
246
+ {
247
+ "key": "allow_commands",
248
+ "title": "Allow commands",
249
+ "help": "List of commands (one per line) which should be considered shell commands by default, rather than a part of a prompt.",
250
+ "type": "text",
251
+ "default": "python\ngit\nls\ncat\ncd\nmv\ncp\ntree\nrm\necho\nrmdir\nmkdir\ntouch\nopen\npwd\nnano",
252
+ },
253
+ {
254
+ "key": "directory_commands",
255
+ "title": "Directory commands",
256
+ "help": "List of commands (one per line) which accept only a directory as their first argument (used in tab completion).",
257
+ "type": "text",
258
+ "default": "cd\nrmdir",
259
+ },
260
+ {
261
+ "key": "file_commands",
262
+ "title": "File commands",
263
+ "help": "List of commands (one per line) which accept only a non-directory as their first argument (used in tab completion).",
264
+ "type": "text",
265
+ "default": "cat",
266
+ },
267
+ ],
268
+ },
269
+ {
270
+ "key": "diff",
271
+ "title": "Diff view settings",
272
+ "help": "Customize how diffs are displayed.",
273
+ "type": "object",
274
+ "fields": [
275
+ {
276
+ "key": "view",
277
+ "title": "Display preference",
278
+ "default": "auto",
279
+ "type": "choices",
280
+ "choices": [
281
+ ("Unified", "unified"),
282
+ ("Split", "split"),
283
+ ("Best fit", "auto"),
284
+ ],
285
+ }
286
+ ],
287
+ },
288
+ {
289
+ "key": "launcher",
290
+ "title": "Launcher settings",
291
+ "help": "Customize the launcher",
292
+ "type": "object",
293
+ "editable": False,
294
+ "fields": [
295
+ {
296
+ "key": "agents",
297
+ "title": "Agents to show in the launcher",
298
+ "type": "text",
299
+ "default": "",
300
+ }
301
+ ],
302
+ },
303
+ {
304
+ "key": "statistics",
305
+ "title": "Data collection",
306
+ "help": "Preferences regarding data collection.",
307
+ "type": "object",
308
+ "fields": [
309
+ {
310
+ "key": "allow_collect",
311
+ "title": "Allow collection of anonymous usage data?",
312
+ "help": "Toad can collect basic usage data (number of installs, OS version, agents used, session length etc). This information is associated with a randomly generated UUID (see it in /about-toad) and contains no personal information.\n\nCollecting this information will help me (Will McGugan) convince big tech to take this project seriously. I would appreciate if you left this on, but it is entirely up to you.",
313
+ "type": "boolean",
314
+ "default": True,
315
+ },
316
+ ],
317
+ },
318
+ ]
toad/shell.py ADDED
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ from contextlib import suppress
5
+ import os
6
+ import asyncio
7
+ import codecs
8
+ import fcntl
9
+ import platform
10
+ import pty
11
+ import struct
12
+ import termios
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ from textual import log
17
+ from textual.message import Message
18
+
19
+ from toad.shell_read import shell_read
20
+
21
+ from toad.widgets.terminal import Terminal
22
+
23
+ if TYPE_CHECKING:
24
+ from toad.widgets.conversation import Conversation
25
+
26
+ IS_MACOS = platform.system() == "Darwin"
27
+
28
+
29
+ def resize_pty(fd, cols, rows):
30
+ """Resize the pseudo-terminal"""
31
+ # Pack the dimensions into the format expected by TIOCSWINSZ
32
+ try:
33
+ size = struct.pack("HHHH", rows, cols, 0, 0)
34
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, size)
35
+ except OSError:
36
+ # Possibly file descriptor closed
37
+ pass
38
+
39
+
40
+ @dataclass
41
+ class CurrentWorkingDirectoryChanged(Message):
42
+ """Current working directory has changed in shell."""
43
+
44
+ path: str
45
+
46
+
47
+ @dataclass
48
+ class ShellFinished(Message):
49
+ """The shell finished."""
50
+
51
+
52
+ class Shell:
53
+ """Responsible for shell interactions in Conversation."""
54
+
55
+ def __init__(
56
+ self,
57
+ conversation: Conversation,
58
+ working_directory: str,
59
+ shell="",
60
+ start="",
61
+ hide_start: bool = True,
62
+ ) -> None:
63
+ self.conversation = conversation
64
+ self.working_directory = working_directory
65
+
66
+ self.terminal: Terminal | None = None
67
+ self.new_log: bool = False
68
+ self.shell = shell or os.environ.get("SHELL", "sh")
69
+ self.shell_start = start
70
+ self.hide_start = hide_start
71
+ self.master: int | None = None
72
+ self._task: asyncio.Task | None = None
73
+ self._process: asyncio.subprocess.Process | None = None
74
+
75
+ self._finished: bool = False
76
+ self._ready_event: asyncio.Event = asyncio.Event()
77
+
78
+ self._hide_echo: set[bytes] = set()
79
+ """A set of byte strings to remove from output."""
80
+
81
+ self._hide_output = hide_start
82
+ """Hide all output."""
83
+
84
+ @property
85
+ def is_finished(self) -> bool:
86
+ return self._finished
87
+
88
+ async def wait_for_ready(self) -> None:
89
+ await self._ready_event.wait()
90
+
91
+ async def send(self, command: str, width: int, height: int) -> None:
92
+ await self._ready_event.wait()
93
+ if self.master is None:
94
+ print("TTY FD not set")
95
+ return
96
+
97
+ if self.terminal is not None:
98
+ self.terminal.finalize()
99
+ self.terminal = None
100
+
101
+ try:
102
+ await asyncio.to_thread(resize_pty, self.master, width, max(height, 1))
103
+ except OSError:
104
+ pass
105
+
106
+ get_pwd_command = f"{command};" + r'printf "\e]2025;$(pwd);\e\\"' + "\n"
107
+ await self.write(get_pwd_command, hide_echo=True)
108
+
109
+ def start(self) -> None:
110
+ assert self._task is None
111
+ self._task = asyncio.create_task(self.run(), name=repr(self))
112
+ log("shell starting")
113
+
114
+ async def interrupt(self) -> None:
115
+ """Interrupt the running command."""
116
+ await self.write(b"\x03")
117
+
118
+ def update_size(self, width: int, height: int) -> None:
119
+ """Update the size of the shell pty.
120
+
121
+ Args:
122
+ width: Desired width.
123
+ height: Desired height.
124
+ """
125
+ if self.master is None:
126
+ return
127
+ with suppress(OSError):
128
+ resize_pty(self.master, width, max(height, 1))
129
+
130
+ async def write(
131
+ self, text: str | bytes, hide_echo: bool = False, hide_output: bool = False
132
+ ) -> int:
133
+ if self.master is None:
134
+ return 0
135
+ text_bytes = text.encode("utf-8", "ignore") if isinstance(text, str) else text
136
+
137
+ if hide_echo:
138
+ for line in text_bytes.split(b"\n"):
139
+ if line:
140
+ self._hide_echo.add(line)
141
+ try:
142
+ result = await asyncio.to_thread(os.write, self.master, text_bytes)
143
+ except OSError:
144
+ return 0
145
+ self._hide_output = hide_output
146
+ return result
147
+
148
+ async def run(self) -> None:
149
+ current_directory = self.working_directory
150
+
151
+ master, slave = pty.openpty()
152
+ self.master = master
153
+
154
+ flags = fcntl.fcntl(master, fcntl.F_GETFL)
155
+ fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
156
+
157
+ env = os.environ.copy()
158
+ env["FORCE_COLOR"] = "1"
159
+ env["TTY_COMPATIBLE"] = "1"
160
+ env["TERM"] = "xterm-256color"
161
+ env["COLORTERM"] = "truecolor"
162
+ env["TOAD"] = "1"
163
+ env["CLICOLOR"] = "1"
164
+
165
+ shell = self.shell
166
+
167
+ def setup_pty():
168
+ os.setsid()
169
+ fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
170
+
171
+ try:
172
+ _process = await asyncio.create_subprocess_shell(
173
+ shell,
174
+ stdin=slave,
175
+ stdout=slave,
176
+ stderr=slave,
177
+ env=env,
178
+ cwd=current_directory,
179
+ preexec_fn=setup_pty,
180
+ )
181
+ except Exception as error:
182
+ self.conversation.notify(
183
+ f"Unable to start shell: {error}\n\nCheck your settings.",
184
+ title="Shell",
185
+ severity="error",
186
+ )
187
+ return
188
+
189
+ os.close(slave)
190
+ BUFFER_SIZE = 64 * 1024
191
+ reader = asyncio.StreamReader(BUFFER_SIZE)
192
+ protocol = asyncio.StreamReaderProtocol(reader)
193
+
194
+ loop = asyncio.get_event_loop()
195
+ transport, _ = await loop.connect_read_pipe(
196
+ lambda: protocol, os.fdopen(master, "rb", 0)
197
+ )
198
+
199
+ self._ready_event.set()
200
+
201
+ if shell_start := self.shell_start.strip():
202
+ shell_start = self.shell_start.strip()
203
+ if not shell_start.endswith("\n"):
204
+ shell_start += "\n"
205
+ await self.write(shell_start, hide_echo=False, hide_output=self.hide_start)
206
+
207
+ unicode_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
208
+
209
+ while True:
210
+ data = await shell_read(reader, BUFFER_SIZE)
211
+
212
+ for string_bytes in list(self._hide_echo):
213
+ remove_bytes = string_bytes
214
+ if remove_bytes in data:
215
+ remove_start = data.index(remove_bytes)
216
+ try:
217
+ next_line = data.index(b"\n", remove_start + len(remove_bytes))
218
+ except ValueError:
219
+ data = data.replace(remove_bytes, b"\x1b[2K")
220
+ else:
221
+ data = data[:remove_start] + b"\x1b[2K" + data[next_line + 1 :]
222
+
223
+ self._hide_echo.discard(string_bytes)
224
+
225
+ if line := unicode_decoder.decode(data, final=not data):
226
+ if self.terminal is None or self.terminal.is_finalized:
227
+ previous_state = (
228
+ None if self.terminal is None else self.terminal.state
229
+ )
230
+ self.terminal = await self.conversation.new_terminal()
231
+ # if previous_state is not None:
232
+ # self.terminal.set_state(previous_state)
233
+ self.terminal.set_write_to_stdin(self.write)
234
+
235
+ terminal_updated = await self.terminal.write(
236
+ line, hide_output=self._hide_output
237
+ )
238
+ if terminal_updated and not self.terminal.display:
239
+ if (
240
+ self.terminal.alternate_screen
241
+ or not self.terminal.state.scrollback_buffer.is_blank
242
+ ):
243
+ self.terminal.display = True
244
+ new_directory = self.terminal.current_directory
245
+ if new_directory and new_directory != current_directory:
246
+ current_directory = new_directory
247
+ self.conversation.post_message(
248
+ CurrentWorkingDirectoryChanged(current_directory)
249
+ )
250
+ if (
251
+ self.terminal is not None
252
+ and self.terminal.is_finalized
253
+ and self.terminal.state.scrollback_buffer.is_blank
254
+ ):
255
+ self.terminal.finalize()
256
+ self.terminal = None
257
+
258
+ if not data:
259
+ break
260
+
261
+ self.master = None
262
+ self._finished = True
263
+ self.conversation.post_message(ShellFinished())
toad/shell_read.py ADDED
@@ -0,0 +1,42 @@
1
+ import asyncio
2
+ from contextlib import suppress
3
+ from time import monotonic
4
+
5
+
6
+ async def shell_read(
7
+ reader: asyncio.StreamReader,
8
+ buffer_size: int,
9
+ *,
10
+ buffer_period: float | None = 1 / 100,
11
+ max_buffer_duration: float = 1 / 60,
12
+ ) -> bytes:
13
+ """Read data from a stream reader, with buffer logic to reduce the number of chunks.
14
+
15
+ Args:
16
+ reader: A reader instance.
17
+ buffer_size: Maximum buffer size.
18
+ buffer_period: Time in seconds where reads are batched, or `None` for no batching.
19
+ max_buffer_duration: Maximum time in seconds to buffer.
20
+
21
+ Returns:
22
+ Bytes read. May be empty on the last read.
23
+ """
24
+ try:
25
+ data = await reader.read(buffer_size)
26
+ except OSError:
27
+ data = b""
28
+ if data and buffer_period is not None:
29
+ buffer_time = monotonic() + max_buffer_duration
30
+ with suppress(asyncio.TimeoutError):
31
+ while len(data) < buffer_size and (time := monotonic()) < buffer_time:
32
+ async with asyncio.timeout(min(buffer_time - time, buffer_period)):
33
+ try:
34
+ if chunk := await reader.read(buffer_size - len(data)):
35
+ data += chunk
36
+ else:
37
+ break
38
+ except OSError as error:
39
+ print(repr(error))
40
+
41
+ break
42
+ return data
toad/slash_command.py ADDED
@@ -0,0 +1,34 @@
1
+ import rich.repr
2
+
3
+ from textual.content import Content
4
+
5
+
6
+ @rich.repr.auto
7
+ class SlashCommand:
8
+ """A record of a slash command."""
9
+
10
+ def __init__(self, command: str, help: str, hint: str | None = None) -> None:
11
+ """
12
+
13
+ Args:
14
+ command: The command name.
15
+ help: Description of command.
16
+ hint: Hint text (displayed as suggestion)
17
+ """
18
+ self.command = command
19
+ self.help = help
20
+ self.hint: str | None = hint
21
+
22
+ def __rich_repr__(self) -> rich.repr.Result:
23
+ yield self.command
24
+ yield "help", self.help
25
+ yield "hint", self.hint, None
26
+
27
+ def __str__(self) -> str:
28
+ return self.command
29
+
30
+ @property
31
+ def content(self) -> Content:
32
+ return Content.assemble(
33
+ (self.command, "$text-success"), "\t", (self.help, "dim")
34
+ )