voidx 1.0.0__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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/ui/dock.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Rich Live/Layout backed bottom input dock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from rich.console import Console, Group
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from voidx.ui.dock_components.formatting import (
|
|
14
|
+
ANSI_LINE_PREFIX,
|
|
15
|
+
_ansi_line,
|
|
16
|
+
_ansi_rgb,
|
|
17
|
+
_clean,
|
|
18
|
+
_markdown_lines,
|
|
19
|
+
_text_from_line,
|
|
20
|
+
)
|
|
21
|
+
from voidx.ui.dock_components.nodes import DockNodeMixin
|
|
22
|
+
from voidx.ui.dock_components.state import dock, get_dock, set_dock
|
|
23
|
+
from voidx.ui.tree import OutputNode, OutputTree
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BottomInputDock(DockNodeMixin):
|
|
27
|
+
"""Render agent output above a fixed input box with Rich Live/Layout."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._console = Console()
|
|
31
|
+
self._active = False
|
|
32
|
+
self._live: Live | None = None
|
|
33
|
+
self._stopping = False
|
|
34
|
+
self._tree = OutputTree()
|
|
35
|
+
self._current_turn: OutputNode | None = None
|
|
36
|
+
self._current_agent: OutputNode | None = None
|
|
37
|
+
self._current_tool: OutputNode | None = None
|
|
38
|
+
self._stream_node: OutputNode | None = None
|
|
39
|
+
self._stream_text = ""
|
|
40
|
+
self._status_nodes: dict[str, OutputNode] = {}
|
|
41
|
+
self._status_ticks: dict[str, int] = {}
|
|
42
|
+
self._permission_node: OutputNode | None = None
|
|
43
|
+
self._input_text = ""
|
|
44
|
+
self._cursor_pos = 0
|
|
45
|
+
self._hints: list[tuple[str, str, bool]] = []
|
|
46
|
+
self._refresh_callback: Callable[[], None] | None = None
|
|
47
|
+
self._width_provider: Callable[[], int] | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def active(self) -> bool:
|
|
51
|
+
return self._active
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def tree(self) -> OutputTree:
|
|
55
|
+
return self._tree
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def current_turn(self) -> OutputNode | None:
|
|
59
|
+
return self._current_turn
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def current_agent(self) -> OutputNode | None:
|
|
63
|
+
return self._current_agent
|
|
64
|
+
|
|
65
|
+
def set_refresh_callback(self, callback: Callable[[], None] | None) -> None:
|
|
66
|
+
self._refresh_callback = callback
|
|
67
|
+
|
|
68
|
+
def set_width_provider(self, callback: Callable[[], int] | None) -> None:
|
|
69
|
+
self._width_provider = callback
|
|
70
|
+
|
|
71
|
+
def activate(self) -> None:
|
|
72
|
+
if self._live:
|
|
73
|
+
self.refresh()
|
|
74
|
+
return
|
|
75
|
+
self._active = True
|
|
76
|
+
self._stopping = False
|
|
77
|
+
self._stream_text = ""
|
|
78
|
+
self._live = Live(
|
|
79
|
+
console=self._console,
|
|
80
|
+
auto_refresh=False,
|
|
81
|
+
refresh_per_second=20,
|
|
82
|
+
transient=True,
|
|
83
|
+
redirect_stdout=False,
|
|
84
|
+
redirect_stderr=False,
|
|
85
|
+
get_renderable=self._render,
|
|
86
|
+
)
|
|
87
|
+
self._live.start(refresh=True)
|
|
88
|
+
|
|
89
|
+
def begin_capture(self) -> None:
|
|
90
|
+
if self._active:
|
|
91
|
+
return
|
|
92
|
+
self._active = True
|
|
93
|
+
self._stream_text = ""
|
|
94
|
+
|
|
95
|
+
def deactivate(self) -> None:
|
|
96
|
+
if not self._active:
|
|
97
|
+
return
|
|
98
|
+
self._stream_node = None
|
|
99
|
+
self._stream_text = ""
|
|
100
|
+
if self._live:
|
|
101
|
+
self._stopping = True
|
|
102
|
+
self._live.stop()
|
|
103
|
+
self._live = None
|
|
104
|
+
self._stopping = False
|
|
105
|
+
self._active = False
|
|
106
|
+
|
|
107
|
+
def reset(self) -> None:
|
|
108
|
+
self._tree = OutputTree()
|
|
109
|
+
self._reset_runtime_nodes()
|
|
110
|
+
self._input_text = ""
|
|
111
|
+
self._cursor_pos = 0
|
|
112
|
+
self._hints = []
|
|
113
|
+
self.refresh()
|
|
114
|
+
|
|
115
|
+
def restore_tree(self, tree: OutputTree, *, append: bool = False) -> None:
|
|
116
|
+
if append:
|
|
117
|
+
self._tree.extend_from(tree)
|
|
118
|
+
else:
|
|
119
|
+
self._tree = tree
|
|
120
|
+
self._reset_runtime_nodes()
|
|
121
|
+
self.refresh()
|
|
122
|
+
|
|
123
|
+
def _reset_runtime_nodes(self) -> None:
|
|
124
|
+
self._current_turn = None
|
|
125
|
+
self._current_agent = None
|
|
126
|
+
self._current_tool = None
|
|
127
|
+
self._stream_node = None
|
|
128
|
+
self._stream_text = ""
|
|
129
|
+
self._status_nodes = {}
|
|
130
|
+
self._status_ticks = {}
|
|
131
|
+
self._permission_node = None
|
|
132
|
+
|
|
133
|
+
def start_turn(self, text: str) -> OutputNode:
|
|
134
|
+
self.commit_stream()
|
|
135
|
+
self._current_tool = None
|
|
136
|
+
self._current_agent = None
|
|
137
|
+
if self._tree.root.children:
|
|
138
|
+
self._tree.new_node(
|
|
139
|
+
parent=self._tree.root,
|
|
140
|
+
node_type="message",
|
|
141
|
+
header="",
|
|
142
|
+
collapsed=False,
|
|
143
|
+
)
|
|
144
|
+
self._current_turn = self._tree.new_node(
|
|
145
|
+
parent=self._tree.root,
|
|
146
|
+
node_type="turn",
|
|
147
|
+
header=f"[bold white]❯[/] {escape(text[:160])}",
|
|
148
|
+
collapsed=False,
|
|
149
|
+
)
|
|
150
|
+
self.refresh()
|
|
151
|
+
return self._current_turn
|
|
152
|
+
|
|
153
|
+
def ensure_agent(self) -> OutputNode:
|
|
154
|
+
if self._current_agent is None:
|
|
155
|
+
self._current_agent = self._tree.new_node(
|
|
156
|
+
parent=self._tree.root,
|
|
157
|
+
node_type="assistant",
|
|
158
|
+
header="[#EBCB8B]●[/#EBCB8B] Working",
|
|
159
|
+
collapsed=False,
|
|
160
|
+
)
|
|
161
|
+
self.refresh()
|
|
162
|
+
return self._current_agent
|
|
163
|
+
|
|
164
|
+
def print(self, *args, **kwargs) -> bool:
|
|
165
|
+
if not self._active:
|
|
166
|
+
return False
|
|
167
|
+
self.capture(lambda console: console.print(*args, **kwargs))
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def capture(self, render: Callable[[Console], None]) -> bool:
|
|
171
|
+
if not self._active:
|
|
172
|
+
return False
|
|
173
|
+
buffer = StringIO()
|
|
174
|
+
console = Console(
|
|
175
|
+
file=buffer,
|
|
176
|
+
force_terminal=True,
|
|
177
|
+
color_system="truecolor",
|
|
178
|
+
width=self._width(),
|
|
179
|
+
)
|
|
180
|
+
render(console)
|
|
181
|
+
text = buffer.getvalue().rstrip("\n")
|
|
182
|
+
if text:
|
|
183
|
+
self.append_ansi(text)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def set_stream(self, text: str, *, parent: OutputNode | None = None) -> bool:
|
|
187
|
+
if not self._active:
|
|
188
|
+
return False
|
|
189
|
+
self._stream_text = text
|
|
190
|
+
self._update_stream_node(parent=parent)
|
|
191
|
+
self.refresh()
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def commit_stream(self) -> bool:
|
|
195
|
+
if not self._active:
|
|
196
|
+
return False
|
|
197
|
+
self._stream_node = None
|
|
198
|
+
self._stream_text = ""
|
|
199
|
+
self.refresh()
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def discard_stream(self) -> bool:
|
|
203
|
+
if not self._active:
|
|
204
|
+
return False
|
|
205
|
+
if self._stream_node and not self._stream_text.strip():
|
|
206
|
+
self._remove_node(self._stream_node)
|
|
207
|
+
if self._current_agent is self._stream_node:
|
|
208
|
+
self._current_agent = None
|
|
209
|
+
elif self._stream_node:
|
|
210
|
+
self._remove_node(self._stream_node)
|
|
211
|
+
if self._current_agent is self._stream_node:
|
|
212
|
+
self._current_agent = None
|
|
213
|
+
self._stream_node = None
|
|
214
|
+
self._stream_text = ""
|
|
215
|
+
self.refresh()
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def set_input(
|
|
219
|
+
self,
|
|
220
|
+
text: str,
|
|
221
|
+
hints: list[tuple[str, str, bool]] | None = None,
|
|
222
|
+
cursor_pos: int | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
self._input_text = text
|
|
225
|
+
self._cursor_pos = max(0, min(len(text), len(text) if cursor_pos is None else cursor_pos))
|
|
226
|
+
self._hints = hints or []
|
|
227
|
+
self.refresh()
|
|
228
|
+
|
|
229
|
+
def after_output(self) -> None:
|
|
230
|
+
self.refresh()
|
|
231
|
+
|
|
232
|
+
def render(self) -> None:
|
|
233
|
+
self.refresh()
|
|
234
|
+
|
|
235
|
+
def refresh(self) -> None:
|
|
236
|
+
if self._refresh_callback:
|
|
237
|
+
self._refresh_callback()
|
|
238
|
+
if self._live:
|
|
239
|
+
self._live.update(self._render(), refresh=True)
|
|
240
|
+
|
|
241
|
+
def _render(self) -> Group:
|
|
242
|
+
if self._stopping:
|
|
243
|
+
return Group(Text(""))
|
|
244
|
+
|
|
245
|
+
width = max(self._width() - 1, 3)
|
|
246
|
+
hint_lines = []
|
|
247
|
+
for name, desc, selected in self._hints[:6]:
|
|
248
|
+
style = "bold blue" if selected else "dim"
|
|
249
|
+
hint_lines.append(Text.assemble((" " + name, style), (" " + desc, style)))
|
|
250
|
+
|
|
251
|
+
input_height = 3 + len(hint_lines)
|
|
252
|
+
body_limit = max((self._console.height or 24) - input_height - 1, 1)
|
|
253
|
+
lines = self._tree.render(self._width())
|
|
254
|
+
body = Group(*[_text_from_line(line) for line in lines[-body_limit:]]) if lines else Text("")
|
|
255
|
+
|
|
256
|
+
border = "─" * width
|
|
257
|
+
input_box = Text.assemble(
|
|
258
|
+
(border + "\n", "white"),
|
|
259
|
+
("❯ ", "bold white"),
|
|
260
|
+
*self._render_input_text(),
|
|
261
|
+
("\n" + border, "white"),
|
|
262
|
+
)
|
|
263
|
+
input_renderable = Group(input_box, *hint_lines)
|
|
264
|
+
return Group(body, input_renderable)
|
|
265
|
+
|
|
266
|
+
def _render_input_text(self) -> list[tuple[str, str]]:
|
|
267
|
+
text = self._input_text
|
|
268
|
+
cursor = self._cursor_pos
|
|
269
|
+
before = text[:cursor]
|
|
270
|
+
at = text[cursor:cursor + 1]
|
|
271
|
+
after = text[cursor + 1:]
|
|
272
|
+
parts: list[tuple[str, str]] = []
|
|
273
|
+
if before:
|
|
274
|
+
parts.append((before, "white"))
|
|
275
|
+
if at and at != "\n":
|
|
276
|
+
parts.append((at, "reverse white"))
|
|
277
|
+
else:
|
|
278
|
+
parts.append((" ", "reverse white"))
|
|
279
|
+
if at == "\n":
|
|
280
|
+
parts.append(("\n", "white"))
|
|
281
|
+
if after:
|
|
282
|
+
parts.append((after, "white"))
|
|
283
|
+
return parts
|
|
284
|
+
|
|
285
|
+
def _update_stream_node(self, *, parent: OutputNode | None = None) -> None:
|
|
286
|
+
clean = _clean(self._stream_text).strip("\n")
|
|
287
|
+
if not clean:
|
|
288
|
+
return
|
|
289
|
+
if self._stream_node is None or (
|
|
290
|
+
parent is not None and self._stream_node.parent is not parent
|
|
291
|
+
):
|
|
292
|
+
self._stream_node = self._new_stream_node(parent=parent)
|
|
293
|
+
if clean.startswith("● "):
|
|
294
|
+
clean = clean[2:]
|
|
295
|
+
lines = _markdown_lines(clean, self._markdown_width())
|
|
296
|
+
if not lines:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# Render the bullet and first line as one ANSI run so prompt_toolkit
|
|
300
|
+
# keeps them on the same visual baseline.
|
|
301
|
+
bullet = _ansi_rgb("●", (163, 190, 140))
|
|
302
|
+
self._stream_node.header = _ansi_line(f"{bullet} {lines[0]}")
|
|
303
|
+
self._stream_node.body_lines = [_ansi_line(f" {line}") for line in lines[1:]]
|
|
304
|
+
self._tree.mark_dirty()
|
|
305
|
+
|
|
306
|
+
def _new_stream_node(self, *, parent: OutputNode | None = None) -> OutputNode:
|
|
307
|
+
if parent is not None:
|
|
308
|
+
return self._tree.new_node(
|
|
309
|
+
parent=parent,
|
|
310
|
+
node_type="assistant",
|
|
311
|
+
header="",
|
|
312
|
+
collapsed=False,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if (
|
|
316
|
+
self._current_agent is not None
|
|
317
|
+
and self._current_agent.node_type == "assistant"
|
|
318
|
+
and self._current_agent.header == "[#EBCB8B]●[/#EBCB8B] Working"
|
|
319
|
+
and not self._current_agent.children
|
|
320
|
+
):
|
|
321
|
+
return self._current_agent
|
|
322
|
+
|
|
323
|
+
if self._current_agent is not None:
|
|
324
|
+
if self._current_agent.header == "[#EBCB8B]●[/#EBCB8B] Working":
|
|
325
|
+
self._current_agent.header = "[dim]●[/dim] voidx"
|
|
326
|
+
self._tree.mark_dirty()
|
|
327
|
+
self._current_agent = self._tree.new_node(
|
|
328
|
+
parent=self._tree.root,
|
|
329
|
+
node_type="assistant",
|
|
330
|
+
header="",
|
|
331
|
+
collapsed=False,
|
|
332
|
+
)
|
|
333
|
+
return self._current_agent
|
|
334
|
+
|
|
335
|
+
self._current_agent = self._tree.new_node(
|
|
336
|
+
parent=self._tree.root,
|
|
337
|
+
node_type="assistant",
|
|
338
|
+
header="",
|
|
339
|
+
collapsed=False,
|
|
340
|
+
)
|
|
341
|
+
return self._current_agent
|
|
342
|
+
|
|
343
|
+
def _settle_stream_for_tool(self) -> None:
|
|
344
|
+
if (
|
|
345
|
+
self._stream_node is not None
|
|
346
|
+
and self._stream_node is self._current_agent
|
|
347
|
+
and not self._stream_node.children
|
|
348
|
+
and self._stream_text.strip()
|
|
349
|
+
):
|
|
350
|
+
self._stream_node.header = "[#EBCB8B]●[/#EBCB8B] Working"
|
|
351
|
+
self._stream_node.body_lines = []
|
|
352
|
+
self._tree.mark_dirty()
|
|
353
|
+
self.commit_stream()
|
|
354
|
+
|
|
355
|
+
def _remove_node(self, node: OutputNode) -> None:
|
|
356
|
+
parent = node.parent
|
|
357
|
+
if parent and node in parent.children:
|
|
358
|
+
parent.children.remove(node)
|
|
359
|
+
for index, child in enumerate(parent.children):
|
|
360
|
+
child._is_last_sibling = index == len(parent.children) - 1
|
|
361
|
+
for child in list(node.children):
|
|
362
|
+
self._remove_node(child)
|
|
363
|
+
self._tree._all.pop(node.id, None)
|
|
364
|
+
self._tree.mark_dirty()
|
|
365
|
+
|
|
366
|
+
def _width(self) -> int:
|
|
367
|
+
if self._width_provider is not None:
|
|
368
|
+
return max(self._width_provider(), 20)
|
|
369
|
+
return max(self._console.width or 80, 20)
|
|
370
|
+
|
|
371
|
+
def _markdown_width(self) -> int:
|
|
372
|
+
return max(self._width() - 4, 20)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Implementation parts for BottomInputDock."""
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Formatting helpers for dock rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from io import StringIO
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.markdown import Markdown
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
|
|
13
|
+
_ANSI_SGR_RE = re.compile(r"\x1b\[([0-9;]*)m")
|
|
14
|
+
ANSI_LINE_PREFIX = "\x00voidx-ansi\x00"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _clean(text: str) -> str:
|
|
18
|
+
return _ANSI_RE.sub("", text).rstrip("\n")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ansi_line(text: str) -> str:
|
|
22
|
+
return ANSI_LINE_PREFIX + text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ansi_rgb(text: str, rgb: tuple[int, int, int]) -> str:
|
|
26
|
+
r, g, b = rgb
|
|
27
|
+
return f"\x1b[38;2;{r};{g};{b}m{text}\x1b[0m"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _text_from_line(line: str) -> Text:
|
|
31
|
+
marker = line.find(ANSI_LINE_PREFIX)
|
|
32
|
+
if marker == -1:
|
|
33
|
+
return Text.from_markup(line)
|
|
34
|
+
text = Text.from_markup(line[:marker])
|
|
35
|
+
text.append_text(Text.from_ansi(line[marker + len(ANSI_LINE_PREFIX):]))
|
|
36
|
+
return text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _markdown_lines(text: str, width: int) -> list[str]:
|
|
40
|
+
buffer = StringIO()
|
|
41
|
+
console = Console(
|
|
42
|
+
file=buffer,
|
|
43
|
+
force_terminal=True,
|
|
44
|
+
color_system="truecolor",
|
|
45
|
+
width=width or 80,
|
|
46
|
+
)
|
|
47
|
+
console.print(Markdown(text), end="")
|
|
48
|
+
|
|
49
|
+
lines: list[str] = []
|
|
50
|
+
for raw_line in buffer.getvalue().rstrip("\n").splitlines():
|
|
51
|
+
stripped = _strip_ansi_trailing_space(raw_line)
|
|
52
|
+
parts = stripped.splitlines() or [stripped]
|
|
53
|
+
lines.extend(parts)
|
|
54
|
+
|
|
55
|
+
return [line for line in lines if _clean(line).strip()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _strip_ansi_trailing_space(line: str) -> str:
|
|
59
|
+
text = Text.from_ansi(_strip_ansi_backgrounds(line))
|
|
60
|
+
text.rstrip()
|
|
61
|
+
buffer = StringIO()
|
|
62
|
+
console = Console(
|
|
63
|
+
file=buffer,
|
|
64
|
+
force_terminal=True,
|
|
65
|
+
color_system="truecolor",
|
|
66
|
+
width=10_000,
|
|
67
|
+
)
|
|
68
|
+
console.print(text, end="")
|
|
69
|
+
return buffer.getvalue()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _short_value(value: object) -> str:
|
|
73
|
+
text = str(value).replace("\n", "\\n")
|
|
74
|
+
return text[:157] + "..." if len(text) > 160 else text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _short_path(path: str, limit: int = 96) -> str:
|
|
78
|
+
if len(path) <= limit:
|
|
79
|
+
return path
|
|
80
|
+
keep = max((limit - 1) // 2, 1)
|
|
81
|
+
return f"{path[:keep]}…{path[-keep:]}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _tail_lines(text: str, limit: int) -> list[str]:
|
|
85
|
+
if not text.strip():
|
|
86
|
+
return []
|
|
87
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
88
|
+
return lines[-limit:]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _strip_ansi_backgrounds(text: str) -> str:
|
|
92
|
+
def replace(match: re.Match[str]) -> str:
|
|
93
|
+
raw = match.group(1)
|
|
94
|
+
if raw == "":
|
|
95
|
+
return match.group(0)
|
|
96
|
+
parts = raw.split(";")
|
|
97
|
+
kept: list[str] = []
|
|
98
|
+
i = 0
|
|
99
|
+
while i < len(parts):
|
|
100
|
+
part = parts[i]
|
|
101
|
+
if part == "48":
|
|
102
|
+
mode = parts[i + 1] if i + 1 < len(parts) else ""
|
|
103
|
+
if mode == "5":
|
|
104
|
+
i += 3
|
|
105
|
+
elif mode == "2":
|
|
106
|
+
i += 5
|
|
107
|
+
else:
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
value = int(part)
|
|
112
|
+
except ValueError:
|
|
113
|
+
kept.append(part)
|
|
114
|
+
i += 1
|
|
115
|
+
continue
|
|
116
|
+
if 40 <= value <= 49 or 100 <= value <= 107:
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
kept.append(part)
|
|
120
|
+
i += 1
|
|
121
|
+
return f"\x1b[{';'.join(kept)}m" if kept else ""
|
|
122
|
+
|
|
123
|
+
return _ANSI_SGR_RE.sub(replace, text)
|