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/settings_schema.py
ADDED
|
@@ -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
|
+
)
|