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.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. 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