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
toad/version.py ADDED
@@ -0,0 +1,80 @@
1
+ from typing import NamedTuple
2
+
3
+
4
+ VERSION_TOML_URL = "https://www.batrachian.ai/toad.toml"
5
+
6
+
7
+ class VersionMeta(NamedTuple):
8
+ """Information about the current version of Toad."""
9
+
10
+ version: str
11
+ upgrade_message: str
12
+ visit_url: str
13
+
14
+
15
+ class VersionCheckFailed(Exception):
16
+ """Something went wrong in the version check."""
17
+
18
+
19
+ async def check_version() -> tuple[bool, VersionMeta]:
20
+ """Check for a new version of Toad.
21
+
22
+ Returns:
23
+ A tuple containing a boolean that indicates if there is a newer version,
24
+ and a `VersionMeta` structure with meta information.
25
+ """
26
+ import httpx
27
+ import packaging.version
28
+ import tomllib
29
+
30
+ from toad import get_version
31
+
32
+ try:
33
+ current_version = packaging.version.parse(get_version())
34
+ except packaging.version.InvalidVersion as error:
35
+ raise VersionCheckFailed(f"Invalid version;{error}")
36
+
37
+ try:
38
+ async with httpx.AsyncClient() as client:
39
+ response = await client.get(VERSION_TOML_URL)
40
+ version_toml_bytes = await response.aread()
41
+ except Exception as error:
42
+ raise VersionCheckFailed(f"Failed to retrieve version;{error}")
43
+
44
+ try:
45
+ version_toml = version_toml_bytes.decode("utf-8", "replace")
46
+ version_meta = tomllib.loads(version_toml)
47
+ except Exception as error:
48
+ raise VersionCheckFailed(f"Failed to decode version TOML;{error}")
49
+
50
+ if not isinstance(version_meta, dict):
51
+ raise VersionCheckFailed("Response isn't TOML")
52
+
53
+ toad_version = str(version_meta.get("version", "0"))
54
+ version_message = str(version_meta.get("upgrade_message", ""))
55
+ version_message = version_message.replace("$VERSION", toad_version)
56
+ verison_meta = VersionMeta(
57
+ version=toad_version,
58
+ upgrade_message=version_message,
59
+ visit_url=str(version_meta.get("visit_url", "")),
60
+ )
61
+
62
+ try:
63
+ new_version = packaging.version.parse(verison_meta.version)
64
+ except packaging.version.InvalidVersion as error:
65
+ raise VersionCheckFailed(f"Invalid remote version;{error}")
66
+
67
+ return new_version > current_version, verison_meta
68
+
69
+
70
+ if __name__ == "__main__":
71
+
72
+ async def run() -> None:
73
+ result = await check_version()
74
+ from rich import print
75
+
76
+ print(result)
77
+
78
+ import asyncio
79
+
80
+ asyncio.run(run())
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, Iterator, Literal
4
+ from fractions import Fraction
5
+
6
+ import rich.repr
7
+ from rich.segment import Segment
8
+
9
+ from textual.cache import LRUCache
10
+ from textual.content import Content
11
+ from textual.css.styles import RulesMap
12
+ from textual.visual import Visual, RenderOptions
13
+ from textual.strip import Strip
14
+ from textual.style import Style
15
+
16
+ from toad._loop import loop_last
17
+
18
+
19
+ from textual._profile import timer
20
+
21
+
22
+ @rich.repr.auto
23
+ class Row(Visual):
24
+ """A visual for a row produced by `columns`.
25
+
26
+ No need to construct these manually, they are returned from the Columns `__getindex__`
27
+
28
+ """
29
+
30
+ def __init__(self, columns: Columns, row_index: int) -> None:
31
+ """
32
+
33
+ Args:
34
+ columns: The parent Columns instance.
35
+ row_index: Index of the row within columns.
36
+ """
37
+ self.columns = columns
38
+ self.row_index = row_index
39
+
40
+ def __rich_repr__(self) -> rich.repr.Result:
41
+ yield self.columns
42
+ yield self.row_index
43
+
44
+ def render_strips(
45
+ self, width: int, height: int | None, style: Style, options: RenderOptions
46
+ ) -> list[Strip]:
47
+ strips = self.columns.render(self.row_index, width, style)
48
+ return strips
49
+
50
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
51
+ return min(container_width, self.columns.get_optimal_width())
52
+
53
+ def get_height(self, rules: RulesMap, width: int) -> int:
54
+ return self.columns.get_row_height(width, self.row_index)
55
+
56
+
57
+ @rich.repr.auto
58
+ class Columns:
59
+ """Renders columns of Content."""
60
+
61
+ def __init__(
62
+ self,
63
+ *columns: Literal["auto", "flex"],
64
+ gutter: int = 1,
65
+ style: Style | str = "",
66
+ ) -> None:
67
+ """
68
+
69
+ Args:
70
+ *columns: "auto" to use the maximum width of the cells in a column,
71
+ or "flex" to use the remaining space.
72
+ gutter: Space between columns in cells.
73
+ style: Base style for the columns.
74
+ """
75
+ self.columns = columns
76
+ self.gutter = gutter
77
+ self.style = style
78
+ self.rows: list[list[Content]] = []
79
+ self._render_cache: LRUCache[tuple, list[list[Strip]]] = LRUCache(maxsize=64)
80
+ self._optimal_width_cache: int | None = None
81
+
82
+ def __rich_repr__(self) -> rich.repr.Result:
83
+ for column in self.columns:
84
+ yield column
85
+ yield "gutter", self.gutter, 1
86
+ yield "style", self.style, ""
87
+
88
+ def __getitem__(self, row_index: int) -> Row:
89
+ if row_index < 0:
90
+ row_index = len(self.rows) - row_index
91
+ if row_index >= len(self.rows):
92
+ raise IndexError(f"No row with index {row_index}")
93
+ return Row(self, row_index)
94
+
95
+ def __len__(self) -> int:
96
+ return len(self.rows)
97
+
98
+ def __iter__(self) -> Iterator[Row]:
99
+ return iter([self[row_index] for row_index in range(len(self))])
100
+
101
+ def get_optimal_width(self) -> int:
102
+ """Get optional width (Visual protocol).
103
+
104
+ Returns:
105
+ Width in cells.
106
+ """
107
+ if self._optimal_width_cache is not None:
108
+ return self._optimal_width_cache
109
+ gutter_width = (len(self.columns) - 1) * self.gutter
110
+ optimal_width = max(
111
+ sum(content.cell_length for content in row) + gutter_width
112
+ for row in self.rows
113
+ )
114
+ self._optimal_width_cache = optimal_width
115
+ return optimal_width
116
+
117
+ def get_row_height(self, width: int, row_index: int) -> int:
118
+ """Get the height of a row when rendered with the given width.
119
+
120
+ Args:
121
+ width: Available width.
122
+ row_index: Index of the row.
123
+
124
+ Returns:
125
+ Height in lines of the row.
126
+ """
127
+ if self._last_render is None:
128
+ row_strips = self._render(width, Style.null())
129
+ else:
130
+ row_strips = self._last_render
131
+ return len(row_strips[row_index])
132
+
133
+ def add_row(self, *cells: Content | str) -> Row:
134
+ """Add a row.
135
+
136
+ Args:
137
+ *cells: Cell content.
138
+
139
+ Returns:
140
+ A Row renderable.
141
+
142
+ """
143
+ assert len(cells) == len(self.columns)
144
+ new_cells = [
145
+ cell if isinstance(cell, Content) else Content(cell) for cell in cells
146
+ ]
147
+ self.rows.append(new_cells)
148
+ self._optimal_width_cache = None
149
+ self._last_render = None
150
+ self._render_cache.clear()
151
+ return Row(self, len(self.rows) - 1)
152
+
153
+ def render(
154
+ self, row_index: int, render_width: int, style: Style = Style.null()
155
+ ) -> list[Strip]:
156
+ """render a row given by its index.
157
+
158
+ Args:
159
+ row_index: Index of the row.
160
+ render_width: Width of the render.
161
+ style: Base style to render.
162
+
163
+ Returns:
164
+ A list of strips, which may be returned from a visual.
165
+ """
166
+ row_strips = self._render(render_width, style)
167
+ return row_strips[row_index]
168
+
169
+ def _render(self, render_width: int, style: Style) -> list[list[Strip]]:
170
+ """Render a row.
171
+
172
+ Args:
173
+ render_width: Width of render.
174
+ style: Base Style.
175
+
176
+ Returns:
177
+ A list of list of Strips (one list of strips per row).
178
+ """
179
+
180
+ cache_key = (render_width, style)
181
+ if (cached_render := self._render_cache.get(cache_key)) is not None:
182
+ return cached_render
183
+
184
+ gutter_width = (len(self.columns) - 1) * self.gutter
185
+ widths: list[int | None] = []
186
+
187
+ for index, column in enumerate(self.columns):
188
+ if column == "auto":
189
+ widths.append(max(row[index].cell_length for row in self.rows))
190
+ else:
191
+ widths.append(None)
192
+
193
+ if any(width is None for width in widths):
194
+ used_width = sum(width for width in widths if width is not None)
195
+ remaining_width = Fraction(render_width - gutter_width - used_width)
196
+ if remaining_width <= 0:
197
+ widths = [width or 0 for width in widths]
198
+ else:
199
+ remaining_count = sum(1 for width in widths if width is None)
200
+ cell_width = remaining_width / remaining_count
201
+
202
+ distribute: list[int] = []
203
+ previous_width = 0
204
+ total = Fraction(0)
205
+ for _ in range(remaining_count):
206
+ total += cell_width
207
+ distribute.append(int(total) - previous_width)
208
+ previous_width = int(total)
209
+
210
+ iter_distribute = iter(distribute)
211
+ for index, column_width in enumerate(widths.copy()):
212
+ if column_width is None:
213
+ widths[index] = int(next(iter_distribute))
214
+
215
+ row_strips: list[list[Strip]] = []
216
+
217
+ for row in self.rows:
218
+ column_renders: list[list[list[Segment]]] = []
219
+ for content_width, content in zip(widths, row):
220
+ assert content_width is not None
221
+ segments = [
222
+ line.truncate(content_width, pad=True).render_segments(style)
223
+ for line in content.wrap(content_width)
224
+ ]
225
+
226
+ column_renders.append(segments)
227
+
228
+ height = max(len(lines) for lines in column_renders)
229
+ rich_style = style.rich_style
230
+ for width, lines in zip(widths, column_renders):
231
+ assert width is not None
232
+ while len(lines) < height:
233
+ lines.append([Segment(" " * width, rich_style)])
234
+
235
+ gutter = Segment(" " * self.gutter, rich_style)
236
+ strips: list[Strip] = []
237
+ for line_no in range(height):
238
+ strip_segments: list[Segment] = []
239
+ for last, column in loop_last(column_renders):
240
+ strip_segments.extend(column[line_no])
241
+ if not last and gutter:
242
+ strip_segments.append(gutter)
243
+ strips.append(Strip(strip_segments, render_width))
244
+
245
+ row_strips.append(strips)
246
+
247
+ self._render_cache[cache_key] = row_strips
248
+ return row_strips
249
+
250
+
251
+ if __name__ == "__main__":
252
+ from rich import traceback
253
+
254
+ traceback.install(show_locals=True)
255
+
256
+ from textual.app import App, ComposeResult
257
+ from textual.widgets import Static
258
+
259
+ columns = Columns("auto", "flex")
260
+ columns.add_row("Foo", "Hello, World! " * 20)
261
+
262
+ class CApp(App):
263
+ DEFAULT_CSS = """
264
+ .row1 {
265
+ background: blue;
266
+
267
+ }
268
+ """
269
+
270
+ def compose(self) -> ComposeResult:
271
+ yield Static(columns[0], classes="row1")
272
+
273
+ CApp().run()
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+
3
+ from textual.reactive import var
4
+ from textual import work
5
+ from textual.widget import Widget
6
+ from textual.widgets import Markdown
7
+ from textual.widgets.markdown import MarkdownStream
8
+
9
+ from toad import messages
10
+
11
+
12
+ SYSTEM = """\
13
+ If asked to output code add inline documentation in the google style format, and always use type hinting where appropriate.
14
+ Avoid using external libraries where possible, and favor code that writes output to the terminal.
15
+ When asked for a table do not wrap it in a code fence.
16
+ """
17
+
18
+
19
+ class AgentResponse(Markdown):
20
+ block_cursor_offset = var(-1)
21
+
22
+ def __init__(self, markdown: str | None = None) -> None:
23
+ super().__init__(markdown)
24
+ self._stream: MarkdownStream | None = None
25
+
26
+ def block_cursor_clear(self) -> None:
27
+ self.block_cursor_offset = -1
28
+
29
+ def block_cursor_up(self) -> Widget | None:
30
+ if self.block_cursor_offset == -1:
31
+ if self.children:
32
+ self.block_cursor_offset = len(self.children) - 1
33
+ else:
34
+ return None
35
+ else:
36
+ self.block_cursor_offset -= 1
37
+
38
+ if self.block_cursor_offset == -1:
39
+ return None
40
+ try:
41
+ return self.children[self.block_cursor_offset]
42
+ except IndexError:
43
+ self.block_cursor_offset = -1
44
+ return None
45
+
46
+ def block_cursor_down(self) -> Widget | None:
47
+ if self.block_cursor_offset == -1:
48
+ if self.children:
49
+ self.block_cursor_offset = 0
50
+ else:
51
+ return None
52
+ else:
53
+ self.block_cursor_offset += 1
54
+ if self.block_cursor_offset >= len(self.children):
55
+ self.block_cursor_offset = -1
56
+ return None
57
+ try:
58
+ return self.children[self.block_cursor_offset]
59
+ except IndexError:
60
+ self.block_cursor_offset = -1
61
+ return None
62
+
63
+ def get_cursor_block(self) -> Widget | None:
64
+ if self.block_cursor_offset == -1:
65
+ return None
66
+ return self.children[self.block_cursor_offset]
67
+
68
+ def block_select(self, widget: Widget) -> None:
69
+ self.block_cursor_offset = self.children.index(widget)
70
+
71
+ @property
72
+ def stream(self) -> MarkdownStream:
73
+ if self._stream is None:
74
+ self._stream = self.get_stream(self)
75
+ return self._stream
76
+
77
+ async def append_fragment(self, fragment: str) -> None:
78
+ self.loading = False
79
+ await self.stream.write(fragment)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+ from typing import ClassVar
3
+
4
+ from textual.binding import Binding, BindingType
5
+ from textual.reactive import var
6
+ from textual.widgets import Markdown
7
+ from textual.widgets.markdown import MarkdownStream
8
+
9
+
10
+ class AgentThought(Markdown, can_focus=True):
11
+ """The agent's 'thoughts'."""
12
+
13
+ BINDINGS: ClassVar[list[BindingType]] = [
14
+ Binding("up", "scroll_up", "Scroll Up", show=False),
15
+ Binding("down", "scroll_down", "Scroll Down", show=False),
16
+ Binding("left", "scroll_left", "Scroll Left", show=False),
17
+ Binding("right", "scroll_right", "Scroll Right", show=False),
18
+ Binding("home", "scroll_home", "Scroll Home", show=False),
19
+ Binding("end", "scroll_end", "Scroll End", show=False),
20
+ Binding("pageup", "page_up", "Page Up", show=False),
21
+ Binding("pagedown", "page_down", "Page Down", show=False),
22
+ Binding("ctrl+pageup", "page_left", "Page Left", show=False),
23
+ Binding("ctrl+pagedown", "page_right", "Page Right", show=False),
24
+ ]
25
+
26
+ ALLOW_MAXIMIZE = True
27
+ _stream: var[MarkdownStream | None] = var(None)
28
+
29
+ def watch_loading(self, loading: bool) -> None:
30
+ self.set_class(loading, "-loading")
31
+
32
+ @property
33
+ def stream(self) -> MarkdownStream:
34
+ if self._stream is None:
35
+ self._stream = self.get_stream(self)
36
+ return self._stream
37
+
38
+ async def append_fragment(self, fragment: str) -> None:
39
+ self.loading = False
40
+ await self.stream.write(fragment)
41
+ self.scroll_end()
@@ -0,0 +1,224 @@
1
+ import asyncio
2
+ import codecs
3
+ from dataclasses import dataclass
4
+
5
+ import os
6
+ import fcntl
7
+ import pty
8
+ import struct
9
+ import termios
10
+
11
+
12
+ from textual import events
13
+ from textual.message import Message
14
+
15
+ from toad.shell_read import shell_read
16
+
17
+ from toad.widgets.terminal import Terminal
18
+
19
+
20
+ class CommandError(Exception):
21
+ """An error occurred running the command."""
22
+
23
+
24
+ class CommandPane(Terminal):
25
+ DEFAULT_CSS = """
26
+ CommandPane {
27
+ scrollbar-size: 0 0;
28
+
29
+ }
30
+
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ name: str | None = None,
36
+ id: str | None = None,
37
+ classes: str | None = None,
38
+ ):
39
+ self._execute_task: asyncio.Task | None = None
40
+ self._return_code: int | None = None
41
+ self._master: int | None = None
42
+ super().__init__(name=name, id=id, classes=classes)
43
+
44
+ @property
45
+ def return_code(self) -> int | None:
46
+ return self._return_code
47
+
48
+ @dataclass
49
+ class CommandComplete(Message):
50
+ return_code: int
51
+
52
+ def execute(self, command: str, *, final: bool = True) -> asyncio.Task:
53
+ self._execute_task = asyncio.create_task(self._execute(command, final=final))
54
+ self.anchor()
55
+ return self._execute_task
56
+
57
+ def on_resize(self, event: events.Resize):
58
+ event.prevent_default()
59
+ if self._master is None:
60
+ return
61
+ self._size_changed()
62
+
63
+ def _size_changed(self):
64
+ if self._master is None:
65
+ return
66
+ width, height = self.scrollable_content_region.size
67
+ try:
68
+ size = struct.pack("HHHH", height, width, 0, 0)
69
+ fcntl.ioctl(self._master, termios.TIOCSWINSZ, size)
70
+ except OSError:
71
+ pass
72
+ self.update_size(width, height)
73
+
74
+ @property
75
+ def is_cooked(self) -> bool:
76
+ """Is the terminal in 'cooked' mode?"""
77
+ if self._master is None:
78
+ return True
79
+ attrs = termios.tcgetattr(self._master)
80
+ lflag = attrs[3]
81
+ return bool(lflag & termios.ICANON)
82
+
83
+ async def write_stdin(self, text: str | bytes, hide_echo: bool = False) -> int:
84
+ if self._master is None:
85
+ return 0
86
+ text_bytes = text.encode("utf-8", "ignore") if isinstance(text, str) else text
87
+ try:
88
+ return await asyncio.to_thread(os.write, self._master, text_bytes)
89
+ except OSError:
90
+ return 0
91
+
92
+ async def _execute(self, command: str, *, final: bool = True) -> None:
93
+ # width, height = self.scrollable_content_region.size
94
+
95
+ await self.wait_for_refresh()
96
+
97
+ master, slave = pty.openpty()
98
+ self._master = master
99
+
100
+ flags = fcntl.fcntl(master, fcntl.F_GETFL)
101
+ fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
102
+
103
+ # # Get terminal attributes
104
+ # attrs = termios.tcgetattr(slave)
105
+
106
+ # # Apply the changes
107
+ # termios.tcsetattr(slave, termios.TCSANOW, attrs)
108
+
109
+ env = os.environ.copy()
110
+ env["FORCE_COLOR"] = "1"
111
+ env["TTY_COMPATIBLE"] = "1"
112
+ env["TERM"] = "xterm-256color"
113
+ env["COLORTERM"] = "truecolor"
114
+ env["TOAD"] = "1"
115
+ env["CLICOLOR"] = "1"
116
+
117
+ try:
118
+ process = await asyncio.create_subprocess_shell(
119
+ command,
120
+ stdin=slave,
121
+ stdout=slave,
122
+ stderr=slave,
123
+ env=env,
124
+ start_new_session=True, # Linux / macOS only
125
+ )
126
+ except Exception as error:
127
+ raise CommandError(f"Failed to execute {command!r}; {error}")
128
+
129
+ os.close(slave)
130
+
131
+ self._size_changed()
132
+
133
+ self.set_write_to_stdin(self.write_stdin)
134
+
135
+ BUFFER_SIZE = 64 * 1024
136
+ reader = asyncio.StreamReader(BUFFER_SIZE)
137
+ protocol = asyncio.StreamReaderProtocol(reader)
138
+
139
+ loop = asyncio.get_event_loop()
140
+ transport, _ = await loop.connect_read_pipe(
141
+ lambda: protocol, os.fdopen(master, "rb", 0)
142
+ )
143
+
144
+ # Create write transport
145
+ writer_protocol = asyncio.BaseProtocol()
146
+ self.write_transport, _ = await loop.connect_write_pipe(
147
+ lambda: writer_protocol,
148
+ os.fdopen(os.dup(master), "wb", 0),
149
+ )
150
+ unicode_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
151
+ try:
152
+ while True:
153
+ data = await shell_read(reader, BUFFER_SIZE)
154
+ if line := unicode_decoder.decode(data, final=not data):
155
+ try:
156
+ await self.write(line)
157
+ except Exception as error:
158
+ print(repr(line))
159
+ print(error)
160
+ from traceback import print_exc
161
+
162
+ print_exc()
163
+
164
+ if not data:
165
+ break
166
+ finally:
167
+ transport.close()
168
+
169
+ await process.wait()
170
+ return_code = self._return_code = process.returncode
171
+ if final:
172
+ self.set_class(return_code == 0, "-success")
173
+ self.set_class(return_code != 0, "-fail")
174
+ self.post_message(self.CommandComplete(return_code or 0))
175
+ self.hide_cursor = True
176
+
177
+
178
+ if __name__ == "__main__":
179
+ from textual.app import App, ComposeResult
180
+
181
+ COMMAND = os.environ["SHELL"]
182
+ # COMMAND = "python test_input.py"
183
+
184
+ # COMMAND = "htop"
185
+ # COMMAND = "python test_scroll_margins.py"
186
+
187
+ # COMMAND = "python cpr.py"
188
+
189
+ COMMAND = "python test_input.py"
190
+
191
+ class CommandApp(App):
192
+ CSS = """
193
+ Screen {
194
+ align: center middle;
195
+ }
196
+ CommandPane {
197
+ # background: blue 20%;
198
+ scrollbar-gutter: stable;
199
+ background: black 10%;
200
+ max-height: 40;
201
+ # border: green;
202
+ border: tab $text-primary;
203
+ margin: 0 2;
204
+ }
205
+ # CommandPane {
206
+ # width: 1fr;
207
+ # height: 1fr;
208
+ # # background: black 10%;
209
+ # # color: white;
210
+ # background: ansi_default;
211
+ # # color: ansi_default;
212
+ # }
213
+ """
214
+
215
+ def compose(self) -> ComposeResult:
216
+ yield CommandPane()
217
+
218
+ def on_mount(self) -> None:
219
+ command_pane = self.query_one(CommandPane)
220
+ command_pane.border_title = COMMAND
221
+ command_pane.execute(COMMAND)
222
+
223
+ app = CommandApp()
224
+ app.run()