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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Implementation parts for PromptToolkitTui."""
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Clipboard image capture and compression helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import secrets
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from voidx.agent.attachments import MAX_IMAGE_ATTACHMENT_BYTES
|
|
14
|
+
|
|
15
|
+
CLIPBOARD_ATTACHMENT_DIR = ".voidx/attachments"
|
|
16
|
+
KEEP_ORIGINAL_BYTES = 3_000_000
|
|
17
|
+
TARGET_IMAGE_BYTES = 4_000_000
|
|
18
|
+
MAX_IMAGE_EDGE = 2048
|
|
19
|
+
JPEG_QUALITIES = (85, 75, 65, 55)
|
|
20
|
+
|
|
21
|
+
CaptureClipboardPng = Callable[[Path], str]
|
|
22
|
+
CompressImage = Callable[[Path, Path], bool]
|
|
23
|
+
NameFactory = Callable[[], str]
|
|
24
|
+
|
|
25
|
+
_CAPTURE_SCRIPT = r"""
|
|
26
|
+
ObjC.import('AppKit');
|
|
27
|
+
ObjC.import('Foundation');
|
|
28
|
+
|
|
29
|
+
function run(argv) {
|
|
30
|
+
const outPath = argv[0];
|
|
31
|
+
const pasteboard = $.NSPasteboard.generalPasteboard;
|
|
32
|
+
let data = pasteboard.dataForType('public.png');
|
|
33
|
+
|
|
34
|
+
if (!data) {
|
|
35
|
+
const image = $.NSImage.alloc.initWithPasteboard(pasteboard);
|
|
36
|
+
if (!image) {
|
|
37
|
+
return 'no_image';
|
|
38
|
+
}
|
|
39
|
+
const tiff = image.TIFFRepresentation;
|
|
40
|
+
if (!tiff) {
|
|
41
|
+
return 'no_image';
|
|
42
|
+
}
|
|
43
|
+
const rep = $.NSBitmapImageRep.imageRepWithData(tiff);
|
|
44
|
+
if (!rep) {
|
|
45
|
+
return 'no_image';
|
|
46
|
+
}
|
|
47
|
+
data = rep.representationUsingTypeProperties($.NSPNGFileType, $({}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!data) {
|
|
51
|
+
return 'no_image';
|
|
52
|
+
}
|
|
53
|
+
return data.writeToFileAtomically(outPath, true) ? 'ok' : 'write_failed';
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ClipboardImageResult:
|
|
60
|
+
status: str
|
|
61
|
+
message: str
|
|
62
|
+
rel_path: str = ""
|
|
63
|
+
size: int = 0
|
|
64
|
+
compressed: bool = False
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def ok(self) -> bool:
|
|
68
|
+
return self.status == "ok"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def paste_clipboard_image(
|
|
72
|
+
workspace: str,
|
|
73
|
+
*,
|
|
74
|
+
capture_clipboard_png: CaptureClipboardPng | None = None,
|
|
75
|
+
compress_image: CompressImage | None = None,
|
|
76
|
+
name_factory: NameFactory | None = None,
|
|
77
|
+
) -> ClipboardImageResult:
|
|
78
|
+
root = Path(workspace).resolve()
|
|
79
|
+
target_dir = root / CLIPBOARD_ATTACHMENT_DIR
|
|
80
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
stem = name_factory() if name_factory else _attachment_stem()
|
|
83
|
+
png_path = target_dir / f"{stem}.png"
|
|
84
|
+
jpg_path = target_dir / f"{stem}.jpg"
|
|
85
|
+
|
|
86
|
+
capture = capture_clipboard_png or _capture_clipboard_png
|
|
87
|
+
status = capture(png_path)
|
|
88
|
+
if status != "ok":
|
|
89
|
+
_safe_unlink(png_path)
|
|
90
|
+
return ClipboardImageResult(status=_result_status(status), message=_capture_message(status))
|
|
91
|
+
|
|
92
|
+
if not png_path.exists() or png_path.stat().st_size == 0:
|
|
93
|
+
_safe_unlink(png_path)
|
|
94
|
+
return ClipboardImageResult(status="error", message="Clipboard image could not be saved.")
|
|
95
|
+
|
|
96
|
+
original_size = png_path.stat().st_size
|
|
97
|
+
if original_size <= KEEP_ORIGINAL_BYTES:
|
|
98
|
+
return _ok_result(root, png_path, compressed=False)
|
|
99
|
+
|
|
100
|
+
compressor = compress_image or _compress_image_to_jpeg
|
|
101
|
+
compressed = compressor(png_path, jpg_path)
|
|
102
|
+
choice = _best_usable_image(png_path, jpg_path if compressed else None)
|
|
103
|
+
if choice is not None:
|
|
104
|
+
chosen_path, chosen_compressed = choice
|
|
105
|
+
for path in (png_path, jpg_path):
|
|
106
|
+
if path != chosen_path:
|
|
107
|
+
_safe_unlink(path)
|
|
108
|
+
return _ok_result(root, chosen_path, compressed=chosen_compressed)
|
|
109
|
+
|
|
110
|
+
smallest = _smallest_existing_size(png_path, jpg_path)
|
|
111
|
+
_safe_unlink(png_path)
|
|
112
|
+
_safe_unlink(jpg_path)
|
|
113
|
+
size_text = _format_size(smallest) if smallest else "unknown size"
|
|
114
|
+
return ClipboardImageResult(
|
|
115
|
+
status="too_large",
|
|
116
|
+
message=f"Clipboard image too large after compression: {size_text}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _capture_clipboard_png(output_path: Path) -> str:
|
|
121
|
+
if platform.system() != "Darwin":
|
|
122
|
+
return "unsupported"
|
|
123
|
+
try:
|
|
124
|
+
result = subprocess.run(
|
|
125
|
+
["osascript", "-l", "JavaScript", "-e", _CAPTURE_SCRIPT, str(output_path)],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
timeout=8,
|
|
129
|
+
check=False,
|
|
130
|
+
)
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
return "unsupported"
|
|
133
|
+
except subprocess.TimeoutExpired:
|
|
134
|
+
return "error: clipboard read timed out"
|
|
135
|
+
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
detail = (result.stderr or result.stdout).strip()
|
|
138
|
+
return f"error: {detail or 'clipboard read failed'}"
|
|
139
|
+
return (result.stdout or "").strip() or "error: clipboard read failed"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _compress_image_to_jpeg(source: Path, destination: Path) -> bool:
|
|
143
|
+
if platform.system() != "Darwin":
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
wrote_file = False
|
|
147
|
+
for quality in JPEG_QUALITIES:
|
|
148
|
+
try:
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
[
|
|
151
|
+
"sips",
|
|
152
|
+
"-s",
|
|
153
|
+
"format",
|
|
154
|
+
"jpeg",
|
|
155
|
+
"-s",
|
|
156
|
+
"formatOptions",
|
|
157
|
+
str(quality),
|
|
158
|
+
"--resampleHeightWidthMax",
|
|
159
|
+
str(MAX_IMAGE_EDGE),
|
|
160
|
+
str(source),
|
|
161
|
+
"--out",
|
|
162
|
+
str(destination),
|
|
163
|
+
],
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
timeout=20,
|
|
167
|
+
check=False,
|
|
168
|
+
)
|
|
169
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
170
|
+
return wrote_file
|
|
171
|
+
if result.returncode != 0 or not destination.exists():
|
|
172
|
+
continue
|
|
173
|
+
wrote_file = True
|
|
174
|
+
if destination.stat().st_size <= TARGET_IMAGE_BYTES:
|
|
175
|
+
return True
|
|
176
|
+
return wrote_file
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _best_usable_image(png_path: Path, jpg_path: Path | None) -> tuple[Path, bool] | None:
|
|
180
|
+
candidates: list[tuple[int, Path, bool]] = []
|
|
181
|
+
if png_path.exists():
|
|
182
|
+
candidates.append((png_path.stat().st_size, png_path, False))
|
|
183
|
+
if jpg_path is not None and jpg_path.exists():
|
|
184
|
+
candidates.append((jpg_path.stat().st_size, jpg_path, True))
|
|
185
|
+
usable = [item for item in candidates if item[0] <= MAX_IMAGE_ATTACHMENT_BYTES]
|
|
186
|
+
if not usable:
|
|
187
|
+
return None
|
|
188
|
+
_, path, compressed = min(usable, key=lambda item: item[0])
|
|
189
|
+
return path, compressed
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _ok_result(root: Path, path: Path, *, compressed: bool) -> ClipboardImageResult:
|
|
193
|
+
rel_path = path.resolve().relative_to(root).as_posix()
|
|
194
|
+
size = path.stat().st_size
|
|
195
|
+
suffix = " (compressed)" if compressed else ""
|
|
196
|
+
return ClipboardImageResult(
|
|
197
|
+
status="ok",
|
|
198
|
+
message=f"Pasted image: {rel_path} ({_format_size(size)}){suffix}",
|
|
199
|
+
rel_path=rel_path,
|
|
200
|
+
size=size,
|
|
201
|
+
compressed=compressed,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _capture_message(status: str) -> str:
|
|
206
|
+
if status == "no_image":
|
|
207
|
+
return "Clipboard does not contain an image."
|
|
208
|
+
if status == "unsupported":
|
|
209
|
+
return "Clipboard image paste is only supported on macOS right now."
|
|
210
|
+
if status == "write_failed":
|
|
211
|
+
return "Clipboard image could not be written."
|
|
212
|
+
if status.startswith("error:"):
|
|
213
|
+
return status.removeprefix("error:").strip() or "Clipboard image paste failed."
|
|
214
|
+
return "Clipboard image paste failed."
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _result_status(status: str) -> str:
|
|
218
|
+
if status in {"no_image", "unsupported"}:
|
|
219
|
+
return status
|
|
220
|
+
return "error"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _smallest_existing_size(*paths: Path) -> int:
|
|
224
|
+
sizes = [path.stat().st_size for path in paths if path.exists()]
|
|
225
|
+
return min(sizes) if sizes else 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _attachment_stem() -> str:
|
|
229
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
230
|
+
return f"clipboard-{timestamp}-{secrets.token_hex(4)}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _safe_unlink(path: Path) -> None:
|
|
234
|
+
try:
|
|
235
|
+
path.unlink()
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _format_size(size: int) -> str:
|
|
241
|
+
if size < 1024:
|
|
242
|
+
return f"{size} B"
|
|
243
|
+
if size < 1024 * 1024:
|
|
244
|
+
return f"{size / 1024:.1f} KB"
|
|
245
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Prompt completions for the TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SlashCommandCompleter(Completer):
|
|
9
|
+
def __init__(self, commands: list[tuple[str, str]]):
|
|
10
|
+
self.commands = commands
|
|
11
|
+
|
|
12
|
+
def get_completions(self, document, complete_event):
|
|
13
|
+
text = document.text
|
|
14
|
+
if text.startswith("/"):
|
|
15
|
+
p = text.lower()
|
|
16
|
+
for name, desc in self.commands:
|
|
17
|
+
if name.lower().startswith(p):
|
|
18
|
+
yield Completion(name, start_position=-len(text), display_meta=desc)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Prompt-toolkit controls used by the TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
8
|
+
from prompt_toolkit.layout.margins import Margin
|
|
9
|
+
from prompt_toolkit.mouse_events import MouseEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TranscriptControl(FormattedTextControl):
|
|
13
|
+
def __init__(self, tui) -> None:
|
|
14
|
+
self._tui = tui
|
|
15
|
+
super().__init__(tui._render_body, focusable=False, show_cursor=False)
|
|
16
|
+
|
|
17
|
+
def mouse_handler(self, mouse_event: MouseEvent) -> None:
|
|
18
|
+
return self._tui._handle_body_mouse(mouse_event)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TranscriptScrollbarMargin(Margin):
|
|
22
|
+
def __init__(self, tui) -> None:
|
|
23
|
+
self._tui = tui
|
|
24
|
+
|
|
25
|
+
def get_width(self, get_ui_content: Callable[[], object]) -> int:
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
def create_margin(self, window_render_info: object, width: int, height: int) -> list[tuple[str, str]]:
|
|
29
|
+
return self._tui._render_scrollbar_margin(height)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Workspace file picker helpers for @ attachments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
|
10
|
+
SKIP_DIRS = {
|
|
11
|
+
".git",
|
|
12
|
+
".hg",
|
|
13
|
+
".svn",
|
|
14
|
+
".venv",
|
|
15
|
+
"venv",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
}
|
|
23
|
+
MAX_SCAN_FILES = 5_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AttachmentToken:
|
|
28
|
+
start: int
|
|
29
|
+
end: int
|
|
30
|
+
query: str
|
|
31
|
+
quoted: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class FileCandidate:
|
|
36
|
+
rel_path: str
|
|
37
|
+
kind: str
|
|
38
|
+
size: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_attachment_token(text: str, cursor: int) -> AttachmentToken | None:
|
|
42
|
+
cursor = max(0, min(cursor, len(text)))
|
|
43
|
+
start = text.rfind("@", 0, cursor)
|
|
44
|
+
while start != -1:
|
|
45
|
+
if start == 0 or text[start - 1].isspace():
|
|
46
|
+
break
|
|
47
|
+
start = text.rfind("@", 0, start)
|
|
48
|
+
if start == -1:
|
|
49
|
+
return None
|
|
50
|
+
if start + 1 < len(text) and text[start + 1] == '"':
|
|
51
|
+
closing = text.find('"', start + 2)
|
|
52
|
+
if closing != -1 and closing < cursor:
|
|
53
|
+
return None
|
|
54
|
+
return AttachmentToken(start=start, end=cursor, query=text[start + 2:cursor], quoted=True)
|
|
55
|
+
token = text[start + 1:cursor]
|
|
56
|
+
if any(ch.isspace() for ch in token):
|
|
57
|
+
return None
|
|
58
|
+
return AttachmentToken(start=start, end=cursor, query=token, quoted=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def list_file_candidates(workspace: str, query: str, limit: int = 8) -> list[FileCandidate]:
|
|
62
|
+
root = Path(workspace).resolve()
|
|
63
|
+
if not root.exists() or not root.is_dir():
|
|
64
|
+
return []
|
|
65
|
+
normalized_query = query.strip().lower().replace("\\", "/")
|
|
66
|
+
candidates: list[FileCandidate] = []
|
|
67
|
+
scanned = 0
|
|
68
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
69
|
+
dirnames[:] = [name for name in dirnames if name not in SKIP_DIRS and not name.startswith(".")]
|
|
70
|
+
rel_dir = Path(dirpath).resolve().relative_to(root).as_posix()
|
|
71
|
+
for filename in filenames:
|
|
72
|
+
if filename.startswith("."):
|
|
73
|
+
continue
|
|
74
|
+
path = Path(dirpath) / filename
|
|
75
|
+
try:
|
|
76
|
+
rel_path = path.resolve().relative_to(root).as_posix()
|
|
77
|
+
except ValueError:
|
|
78
|
+
continue
|
|
79
|
+
scanned += 1
|
|
80
|
+
if scanned > MAX_SCAN_FILES:
|
|
81
|
+
break
|
|
82
|
+
rel_lower = rel_path.lower()
|
|
83
|
+
if normalized_query and normalized_query not in rel_lower:
|
|
84
|
+
continue
|
|
85
|
+
candidates.append(FileCandidate(
|
|
86
|
+
rel_path=rel_path,
|
|
87
|
+
kind="image" if is_image_file(rel_path) else "file",
|
|
88
|
+
size=path.stat().st_size,
|
|
89
|
+
))
|
|
90
|
+
if scanned > MAX_SCAN_FILES:
|
|
91
|
+
break
|
|
92
|
+
candidates.sort(key=lambda item: (
|
|
93
|
+
not item.rel_path.lower().startswith(normalized_query),
|
|
94
|
+
len(item.rel_path),
|
|
95
|
+
item.rel_path,
|
|
96
|
+
))
|
|
97
|
+
return candidates[:limit]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def attachment_token_text(rel_path: str) -> str:
|
|
101
|
+
if any(ch.isspace() for ch in rel_path):
|
|
102
|
+
return f'@"{rel_path}"'
|
|
103
|
+
return f"@{rel_path}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_image_file(path: str | Path) -> bool:
|
|
107
|
+
return Path(path).suffix.lower() in IMAGE_EXTENSIONS
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_size(size: int) -> str:
|
|
111
|
+
if size < 1024:
|
|
112
|
+
return f"{size} B"
|
|
113
|
+
if size < 1024 * 1024:
|
|
114
|
+
return f"{size / 1024:.1f} KB"
|
|
115
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Formatting helpers for prompt_toolkit TUI rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from prompt_toolkit.formatted_text import FormattedText, to_formatted_text
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from voidx.ui.dock_components.formatting import ANSI_LINE_PREFIX
|
|
14
|
+
|
|
15
|
+
_ANSI_CONSOLE: Console | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_ansi_console(width: int) -> Console:
|
|
19
|
+
global _ANSI_CONSOLE
|
|
20
|
+
if _ANSI_CONSOLE is None or _ANSI_CONSOLE.width != width:
|
|
21
|
+
_ANSI_CONSOLE = Console(
|
|
22
|
+
file=StringIO(),
|
|
23
|
+
force_terminal=True,
|
|
24
|
+
color_system="truecolor",
|
|
25
|
+
width=width,
|
|
26
|
+
)
|
|
27
|
+
return _ANSI_CONSOLE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rich_to_ansi(markup: str, width: int) -> str:
|
|
31
|
+
console = _get_ansi_console(width)
|
|
32
|
+
buffer = console.file
|
|
33
|
+
buffer.seek(0)
|
|
34
|
+
buffer.truncate()
|
|
35
|
+
console.print(markup, end="")
|
|
36
|
+
return buffer.getvalue()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _lines_to_formatted_text(lines: list[str], width: int, *, follow_tail: bool = True) -> FormattedText:
|
|
40
|
+
console = _get_ansi_console(width)
|
|
41
|
+
result = []
|
|
42
|
+
|
|
43
|
+
for line in lines:
|
|
44
|
+
marker = line.find(ANSI_LINE_PREFIX)
|
|
45
|
+
if marker == -1:
|
|
46
|
+
markup_part = line
|
|
47
|
+
ansi_part = ""
|
|
48
|
+
else:
|
|
49
|
+
markup_part = line[:marker]
|
|
50
|
+
ansi_part = line[marker + len(ANSI_LINE_PREFIX):]
|
|
51
|
+
|
|
52
|
+
if markup_part:
|
|
53
|
+
try:
|
|
54
|
+
text = Text.from_markup(markup_part)
|
|
55
|
+
segments = console.render(text)
|
|
56
|
+
for segment in segments:
|
|
57
|
+
style = segment.style
|
|
58
|
+
pt_style = ""
|
|
59
|
+
if style:
|
|
60
|
+
if style.color:
|
|
61
|
+
if style.color.is_system_defined:
|
|
62
|
+
pt_style += f"fg:{style.color.name} "
|
|
63
|
+
else:
|
|
64
|
+
pt_style += f"fg:{style.color.get_truecolor().hex} "
|
|
65
|
+
if style.bgcolor:
|
|
66
|
+
if style.bgcolor.is_system_defined:
|
|
67
|
+
pt_style += f"bg:{style.bgcolor.name} "
|
|
68
|
+
else:
|
|
69
|
+
pt_style += f"bg:{style.bgcolor.get_truecolor().hex} "
|
|
70
|
+
if style.bold:
|
|
71
|
+
pt_style += "bold "
|
|
72
|
+
if style.italic:
|
|
73
|
+
pt_style += "italic "
|
|
74
|
+
if style.underline:
|
|
75
|
+
pt_style += "underline "
|
|
76
|
+
if style.dim:
|
|
77
|
+
pt_style += "class:dim "
|
|
78
|
+
result.append((pt_style.strip(), segment.text))
|
|
79
|
+
except Exception:
|
|
80
|
+
result.append(("", markup_part))
|
|
81
|
+
|
|
82
|
+
if ansi_part:
|
|
83
|
+
from prompt_toolkit.formatted_text.ansi import ANSI
|
|
84
|
+
|
|
85
|
+
parsed = ANSI(ansi_part + "\x1b[0m")
|
|
86
|
+
result.extend(to_formatted_text(parsed))
|
|
87
|
+
|
|
88
|
+
result.append(("", "\n"))
|
|
89
|
+
|
|
90
|
+
if result and result[-1] == ("", "\n"):
|
|
91
|
+
result.pop()
|
|
92
|
+
if result and follow_tail:
|
|
93
|
+
result.append(("[SetCursorPosition]", ""))
|
|
94
|
+
|
|
95
|
+
return FormattedText(result)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _continuation_prefix(line: str) -> str:
|
|
99
|
+
text = _visible_text(line)
|
|
100
|
+
leading = len(text) - len(text.lstrip(" "))
|
|
101
|
+
stripped = text[leading:]
|
|
102
|
+
extra = 0
|
|
103
|
+
if stripped.startswith(("• ", "- ", "* ")):
|
|
104
|
+
extra = 2
|
|
105
|
+
else:
|
|
106
|
+
ordered = re.match(r"\d+[.)]\s+", stripped)
|
|
107
|
+
if ordered:
|
|
108
|
+
extra = len(ordered.group(0))
|
|
109
|
+
elif stripped.startswith(("├─ ", "└─ ")):
|
|
110
|
+
extra = 3
|
|
111
|
+
return " " * (leading + extra)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _visible_text(line: str) -> str:
|
|
115
|
+
marker = line.find(ANSI_LINE_PREFIX)
|
|
116
|
+
if marker == -1:
|
|
117
|
+
markup_part = line
|
|
118
|
+
ansi_part = ""
|
|
119
|
+
else:
|
|
120
|
+
markup_part = line[:marker]
|
|
121
|
+
ansi_part = line[marker + len(ANSI_LINE_PREFIX):]
|
|
122
|
+
|
|
123
|
+
parts: list[str] = []
|
|
124
|
+
if markup_part:
|
|
125
|
+
try:
|
|
126
|
+
parts.append(Text.from_markup(markup_part).plain)
|
|
127
|
+
except Exception:
|
|
128
|
+
parts.append(markup_part)
|
|
129
|
+
if ansi_part:
|
|
130
|
+
parts.append(Text.from_ansi(ansi_part).plain)
|
|
131
|
+
return "".join(parts)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _clip(text: str, width: int) -> str:
|
|
135
|
+
if width <= 0:
|
|
136
|
+
return ""
|
|
137
|
+
if len(text) <= width:
|
|
138
|
+
return text
|
|
139
|
+
if width <= 1:
|
|
140
|
+
return "…"
|
|
141
|
+
prefix = text[: width - 1].rstrip()
|
|
142
|
+
last_space = prefix.rfind(" ")
|
|
143
|
+
if last_space > width // 3:
|
|
144
|
+
prefix = prefix[:last_space].rstrip()
|
|
145
|
+
return prefix + "…"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _friendly_choice_label(label: str, value: str, desc: str) -> str:
|
|
149
|
+
if value == "a":
|
|
150
|
+
return "Yes, and don't ask again this session"
|
|
151
|
+
if value == "y":
|
|
152
|
+
return "Yes, allow once"
|
|
153
|
+
if value == "n":
|
|
154
|
+
return "No, deny"
|
|
155
|
+
return desc or label
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _permission_target(args: dict[str, Any]) -> str:
|
|
159
|
+
for key in ("command", "file_path", "path", "pattern", "url", "query", "agent", "role"):
|
|
160
|
+
value = args.get(key)
|
|
161
|
+
if value:
|
|
162
|
+
return str(value).replace("\n", " ")
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _args_preview(args: dict[str, Any]) -> str:
|
|
167
|
+
parts: list[str] = []
|
|
168
|
+
for key, value in args.items():
|
|
169
|
+
if key in {"command", "file_path", "path", "pattern", "url", "query", "agent", "role"}:
|
|
170
|
+
continue
|
|
171
|
+
text = str(value).replace("\n", " ")
|
|
172
|
+
parts.append(f"{key}={text}")
|
|
173
|
+
if len(parts) >= 3:
|
|
174
|
+
break
|
|
175
|
+
return ", ".join(parts)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _mcp_status_label(status: str) -> tuple[str, str]:
|
|
179
|
+
normalized = status.strip().lower()
|
|
180
|
+
if normalized in {"connected", "configured", "ready"}:
|
|
181
|
+
label = "connected" if normalized == "connected" else normalized
|
|
182
|
+
return ("class:command.ok", f"✓ {label}")
|
|
183
|
+
if normalized in {"disabled", "off"}:
|
|
184
|
+
return ("class:command.dim", "disabled")
|
|
185
|
+
if normalized in {"error", "failed"}:
|
|
186
|
+
return ("class:command.error", "✗ error")
|
|
187
|
+
return ("class:command.dim", normalized or "unknown")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Git change stats for the input bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ChangeStats:
|
|
11
|
+
files_changed: int = 0
|
|
12
|
+
insertions: int = 0
|
|
13
|
+
deletions: int = 0
|
|
14
|
+
has_changes: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_change_stats(workspace: str = ".") -> ChangeStats:
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", "diff", "--shortstat", "--cached"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
timeout=3,
|
|
24
|
+
cwd=workspace,
|
|
25
|
+
)
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
return ChangeStats()
|
|
28
|
+
line = result.stdout.strip()
|
|
29
|
+
if not line:
|
|
30
|
+
return ChangeStats()
|
|
31
|
+
return _parse_shortstat(line)
|
|
32
|
+
except Exception:
|
|
33
|
+
return ChangeStats()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_shortstat(line: str) -> ChangeStats:
|
|
37
|
+
stats = ChangeStats()
|
|
38
|
+
files_part = line.split(",")[0] if "," in line else line
|
|
39
|
+
if "file" in files_part:
|
|
40
|
+
num = "".join(c for c in files_part.split()[0] if c.isdigit())
|
|
41
|
+
stats.files_changed = int(num) if num else 0
|
|
42
|
+
for part in line.split(","):
|
|
43
|
+
part = part.strip()
|
|
44
|
+
if "insertion" in part:
|
|
45
|
+
num = "".join(c for c in part.split()[0] if c.isdigit())
|
|
46
|
+
stats.insertions = int(num) if num else 0
|
|
47
|
+
elif "deletion" in part:
|
|
48
|
+
num = "".join(c for c in part.split()[0] if c.isdigit())
|
|
49
|
+
stats.deletions = int(num) if num else 0
|
|
50
|
+
stats.has_changes = stats.files_changed > 0
|
|
51
|
+
return stats
|