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/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())
|
toad/visuals/columns.py
ADDED
|
@@ -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()
|