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,338 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from asyncio.subprocess import Process
5
+ import codecs
6
+ import fcntl
7
+ import os
8
+ import pty
9
+ import shlex
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+ import struct
13
+ import termios
14
+ from typing import Mapping
15
+
16
+ from textual.content import Content
17
+ from textual.reactive import var
18
+
19
+ from toad.shell_read import shell_read
20
+ from toad.widgets.terminal import Terminal
21
+
22
+
23
+ @dataclass
24
+ class Command:
25
+ """A command and corresponding environment."""
26
+
27
+ command: str
28
+ """Command to run."""
29
+ args: list[str]
30
+ """List of arguments."""
31
+ env: Mapping[str, str]
32
+ """Environment variables."""
33
+ cwd: str
34
+ """Current working directory."""
35
+
36
+ def __str__(self) -> str:
37
+ command_str = shlex.join([self.command, *self.args]).strip("'")
38
+ return command_str
39
+
40
+
41
+ @dataclass
42
+ class ToolState:
43
+ """Current state of the terminal."""
44
+
45
+ output: str
46
+ truncated: bool
47
+ return_code: int | None = None
48
+ signal: str | None = None
49
+
50
+
51
+ class TerminalTool(Terminal):
52
+ DEFAULT_CSS = """
53
+ TerminalTool {
54
+ height: auto;
55
+ border: panel $text-primary;
56
+ }
57
+ """
58
+
59
+ _command: var[Command | None] = var(None)
60
+
61
+ def __init__(
62
+ self,
63
+ command: Command,
64
+ *,
65
+ output_byte_limit: int | None = None,
66
+ name: str | None = None,
67
+ id: str | None = None,
68
+ classes: str | None = None,
69
+ disabled: bool = False,
70
+ minimum_terminal_width: int = -1,
71
+ ):
72
+ super().__init__(
73
+ name=name,
74
+ id=id,
75
+ classes=classes,
76
+ disabled=disabled,
77
+ minimum_terminal_width=minimum_terminal_width,
78
+ )
79
+ self._command = command
80
+ self._output_byte_limit = output_byte_limit
81
+ self._command_task: asyncio.Task | None = None
82
+ self._output: deque[bytes] = deque()
83
+
84
+ self._process: Process | None = None
85
+ self._bytes_read = 0
86
+ self._output_bytes_count = 0
87
+ self._shell_fd: int | None = None
88
+ self._return_code: int | None = None
89
+ self._released: bool = False
90
+ self._ready_event = asyncio.Event()
91
+ self._exit_event = asyncio.Event()
92
+
93
+ @property
94
+ def return_code(self) -> int | None:
95
+ """The command return code, or `None` if not yet set."""
96
+ return self._return_code
97
+
98
+ @property
99
+ def released(self) -> bool:
100
+ """Has the terminal been released?"""
101
+ return self._released
102
+
103
+ @property
104
+ def tool_state(self) -> ToolState:
105
+ """Get the current terminal state."""
106
+ output, truncated = self.get_output()
107
+ # TODO: report signal
108
+ return ToolState(
109
+ output=output, truncated=truncated, return_code=self.return_code
110
+ )
111
+
112
+ @staticmethod
113
+ def resize_pty(fd: int, columns: int, rows: int) -> None:
114
+ """Resize the pseudo terminal.
115
+
116
+ Args:
117
+ fd: File descriptor.
118
+ columns: Columns (width).
119
+ rows: Rows (height).
120
+ """
121
+ # Pack the dimensions into the format expected by TIOCSWINSZ
122
+ size = struct.pack("HHHH", rows, columns, 0, 0)
123
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, size)
124
+
125
+ async def wait_for_exit(self) -> tuple[int | None, str | None]:
126
+ """Wait for the terminal process to exit."""
127
+ if self._process is None or self._command_task is None:
128
+ return None, None
129
+ # await self._task
130
+ await self._exit_event.wait()
131
+ return (self.return_code or 0, None)
132
+
133
+ def kill(self) -> bool:
134
+ """Kill the terminal process.
135
+
136
+ Returns:
137
+ Returns `True` if the process was killed, or `False` if there
138
+ was no running process.
139
+ """
140
+ if self.return_code is not None:
141
+ return False
142
+ if self._process is None:
143
+ return False
144
+ try:
145
+ self._process.kill()
146
+ except Exception:
147
+ return False
148
+ return True
149
+
150
+ def release(self) -> None:
151
+ """Release the terminal (may no longer be used from ACP)."""
152
+ self._released = True
153
+
154
+ def watch__command(self, command: Command) -> None:
155
+ self.border_title = str(command)
156
+
157
+ async def start(self, width: int = 0, height: int = 0) -> None:
158
+ assert self._command is not None
159
+
160
+ self.update_size(width, height)
161
+ self._command_task = asyncio.create_task(
162
+ self.run(), name=f"Terminal {self._command}"
163
+ )
164
+ await self._ready_event.wait()
165
+
166
+ async def run(self) -> None:
167
+ try:
168
+ await self._run()
169
+ except Exception:
170
+ from traceback import print_exc
171
+
172
+ print_exc()
173
+ finally:
174
+ self._exit_event.set()
175
+
176
+ async def _run(self) -> None:
177
+ self._command_task = asyncio.current_task()
178
+
179
+ assert self._command is not None
180
+ master, slave = pty.openpty()
181
+ self._shell_fd = master
182
+
183
+ flags = fcntl.fcntl(master, fcntl.F_GETFL)
184
+ fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
185
+
186
+ command = self._command
187
+ environment = os.environ | command.env
188
+
189
+ if " " in command.command:
190
+ run_command = command.command
191
+ else:
192
+ run_command = f"{command.command} {shlex.join(command.args)}"
193
+
194
+ shell = os.environ.get("SHELL", "sh")
195
+ run_command = shlex.join([shell, "-c", run_command])
196
+
197
+ try:
198
+ process = self._process = await asyncio.create_subprocess_shell(
199
+ run_command,
200
+ stdin=slave,
201
+ stdout=slave,
202
+ stderr=slave,
203
+ env=environment,
204
+ cwd=command.cwd,
205
+ )
206
+ except Exception as error:
207
+ self._ready_event.set()
208
+ print(error)
209
+ raise
210
+
211
+ self._ready_event.set()
212
+
213
+ self.resize_pty(
214
+ master,
215
+ self._width or 80,
216
+ self._height or 24,
217
+ )
218
+
219
+ os.close(slave)
220
+ BUFFER_SIZE = 64 * 1024 * 2
221
+ reader = asyncio.StreamReader(BUFFER_SIZE)
222
+ protocol = asyncio.StreamReaderProtocol(reader)
223
+
224
+ loop = asyncio.get_event_loop()
225
+ transport, _ = await loop.connect_read_pipe(
226
+ lambda: protocol, os.fdopen(master, "rb", 0)
227
+ )
228
+ # Create write transport
229
+ writer_protocol = asyncio.BaseProtocol()
230
+ write_transport, _ = await loop.connect_write_pipe(
231
+ lambda: writer_protocol,
232
+ os.fdopen(os.dup(master), "wb", 0),
233
+ )
234
+ self.writer = write_transport
235
+
236
+ unicode_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
237
+ try:
238
+ while True:
239
+ data = await shell_read(reader, BUFFER_SIZE)
240
+ if process_data := unicode_decoder.decode(data, final=not data):
241
+ self._record_output(data)
242
+ if await self.write(process_data):
243
+ self.display = True
244
+ if not data:
245
+ break
246
+ finally:
247
+ transport.close()
248
+
249
+ self.finalize()
250
+ return_code = self._return_code = await process.wait()
251
+
252
+ if return_code == 0:
253
+ self.add_class("-success")
254
+ else:
255
+ self.add_class("-error")
256
+ self.border_title = Content.assemble(
257
+ f"{command} [{return_code}]",
258
+ )
259
+
260
+ def _record_output(self, data: bytes) -> None:
261
+ """Keep a record of the bytes left.
262
+
263
+ Store at most the limit set in self._output_byte_limit (if set).
264
+
265
+ """
266
+
267
+ self._output.append(data)
268
+ self._output_bytes_count += len(data)
269
+ self._bytes_read += len(data)
270
+
271
+ if self._output_byte_limit is None:
272
+ return
273
+
274
+ while self._output_bytes_count > self._output_byte_limit and self._output:
275
+ oldest_bytes = self._output[0]
276
+ oldest_bytes_count = len(oldest_bytes)
277
+ if self._output_bytes_count - oldest_bytes_count < self._output_byte_limit:
278
+ break
279
+ self._output.popleft()
280
+ self._output_bytes_count -= oldest_bytes_count
281
+
282
+ def get_output(self) -> tuple[str, bool]:
283
+ """Get the output.
284
+
285
+ Returns:
286
+ A tuple of the output and a bool to indicate if the output was truncated.
287
+ """
288
+ output_bytes = b"".join(self._output)
289
+
290
+ def is_continuation(byte_value: int) -> bool:
291
+ """Check if the given byte is a utf-8 continuation byte.
292
+
293
+ Args:
294
+ byte_value: Ordinal of the byte.
295
+
296
+ Returns:
297
+ `True` if the byte is a continuation, or `False` if it is the start of a character.
298
+ """
299
+ return (byte_value & 0b11000000) == 0b10000000
300
+
301
+ truncated = False
302
+ if (
303
+ self._output_byte_limit is not None
304
+ and len(output_bytes) > self._output_byte_limit
305
+ ):
306
+ truncated = True
307
+ output_bytes = output_bytes[-self._output_byte_limit :]
308
+ # Must start on a utf-8 boundary
309
+ # Discard initial bytes that aren't a utf-8 continuation byte.
310
+ for offset, byte_value in enumerate(output_bytes):
311
+ if not is_continuation(byte_value):
312
+ if offset:
313
+ output_bytes = output_bytes[offset:]
314
+ break
315
+
316
+ output = output_bytes.decode("utf-8", "replace")
317
+ return output, truncated
318
+
319
+
320
+ if __name__ == "__main__":
321
+ from textual.app import App, ComposeResult
322
+
323
+ command = Command("python", ["mandelbrot.py"], os.environ.copy(), os.curdir)
324
+
325
+ class TApp(App):
326
+ CSS = """
327
+ Terminal.-success {
328
+ border: panel $text-success 90%;
329
+ }
330
+ """
331
+
332
+ def compose(self) -> ComposeResult:
333
+ yield TerminalTool(command)
334
+
335
+ def on_mount(self) -> None:
336
+ self.query_one(TerminalTool).start()
337
+
338
+ TApp().run()
@@ -0,0 +1,90 @@
1
+ from time import monotonic
2
+ from typing import Callable
3
+
4
+ from rich.segment import Segment
5
+ from rich.style import Style as RichStyle
6
+
7
+ from textual.visual import Visual
8
+ from textual.color import Color, Gradient
9
+
10
+ from textual.style import Style
11
+ from textual.strip import Strip
12
+ from textual.visual import RenderOptions
13
+ from textual.widget import Widget
14
+ from textual.css.styles import RulesMap
15
+
16
+
17
+ COLORS = [
18
+ "#881177",
19
+ "#aa3355",
20
+ "#cc6666",
21
+ "#ee9944",
22
+ "#eedd00",
23
+ "#99dd55",
24
+ "#44dd88",
25
+ "#22ccbb",
26
+ "#00bbcc",
27
+ "#0099cc",
28
+ "#3366bb",
29
+ "#663399",
30
+ ]
31
+
32
+
33
+ class ThrobberVisual(Visual):
34
+ """A Textual 'Visual' object.
35
+
36
+ Analogous to a Rich renderable, but with support for transparency.
37
+
38
+ """
39
+
40
+ gradient = Gradient.from_colors(*[Color.parse(color) for color in COLORS])
41
+
42
+ def render_strips(
43
+ self, width: int, height: int | None, style: Style, options: RenderOptions
44
+ ) -> list[Strip]:
45
+ """Render the Visual into an iterable of strips.
46
+
47
+ Args:
48
+ width: Width of desired render.
49
+ height: Height of desired render or `None` for any height.
50
+ style: The base style to render on top of.
51
+ options: Additional render options.
52
+
53
+ Returns:
54
+ An list of Strips.
55
+ """
56
+
57
+ time = monotonic()
58
+ gradient = self.gradient
59
+ background = style.rich_style.bgcolor
60
+
61
+ strips = [
62
+ Strip(
63
+ [
64
+ Segment(
65
+ "━",
66
+ RichStyle.from_color(
67
+ gradient.get_rich_color((offset / width - time) % 1.0),
68
+ background,
69
+ ),
70
+ )
71
+ for offset in range(width)
72
+ ],
73
+ width,
74
+ )
75
+ ]
76
+ return strips
77
+
78
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
79
+ return container_width
80
+
81
+ def get_height(self, rules: RulesMap, width: int) -> int:
82
+ return 1
83
+
84
+
85
+ class Throbber(Widget):
86
+ def on_mount(self) -> None:
87
+ self.auto_refresh = 1 / 15
88
+
89
+ def render(self) -> ThrobberVisual:
90
+ return ThrobberVisual()