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,253 @@
1
+ """Streaming assistant renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from types import TracebackType
7
+
8
+ from rich.console import Console
9
+ from rich.live import Live
10
+ from rich.markdown import Markdown
11
+ from rich.text import Text
12
+
13
+ from voidx.ui.console_components.formatting import _next_spin
14
+ from voidx.ui.dock import dock
15
+ from voidx.ui.events import (
16
+ AssistantStreamStarted,
17
+ AssistantStreamCommitted,
18
+ AssistantStreamDiscarded,
19
+ AssistantStreamUpdated,
20
+ StatusFinished,
21
+ StatusUpdated,
22
+ ui_events,
23
+ )
24
+
25
+
26
+ class StreamingRenderer:
27
+ """Smooth streaming with Rich Live + Markdown rendering."""
28
+
29
+ FLUSH_INTERVAL = 0.05
30
+
31
+ def __init__(
32
+ self,
33
+ console: Console,
34
+ debug: bool = True,
35
+ stream_to_dock: bool = True,
36
+ agent_id: int = -1,
37
+ ) -> None:
38
+ self._console = console
39
+ self._debug = debug
40
+ self._stream_to_dock = stream_to_dock
41
+ self._agent_id = agent_id
42
+ self._thinking: list[str] = []
43
+ self._thinking_full: list[str] = []
44
+ self._accumulated: str = ""
45
+ self._phase: str = "thinking"
46
+ self._last_flush: float = 0.0
47
+ self._live: Live | None = None
48
+ self._start_time: float = time.monotonic()
49
+ self._first_text: bool = True
50
+ self._discard: bool = False
51
+ stamp = time.time_ns()
52
+ self._thinking_status_id = f"agent:{agent_id}:thinking:{stamp}"
53
+ self._streaming_status_id = f"agent:{agent_id}:streaming:{stamp}"
54
+ self._status_started = False
55
+ self._streaming_status_started = False
56
+
57
+ async def __aenter__(self):
58
+ self.start()
59
+ return self
60
+
61
+ async def __aexit__(
62
+ self, exc_type: type[BaseException] | None,
63
+ exc_val: BaseException | None, exc_tb: TracebackType | None,
64
+ ) -> None:
65
+ self.done()
66
+
67
+ def start(self) -> None:
68
+ if self._status_started:
69
+ return
70
+ self._status_started = True
71
+ if dock.active and self._stream_to_dock:
72
+ ui_events.emit_nowait(AssistantStreamStarted(agent_id=self._agent_id))
73
+ ui_events.emit_nowait(StatusUpdated(
74
+ agent_id=self._agent_id,
75
+ status_id=self._thinking_status_id,
76
+ label="Thinking",
77
+ detail="",
78
+ stage="thinking",
79
+ ))
80
+
81
+ def feed_thinking(self, text: str) -> None:
82
+ self._thinking.append(text)
83
+ self._thinking_full.append(text)
84
+ if dock.active and self._stream_to_dock:
85
+ ui_events.emit_nowait(StatusUpdated(
86
+ agent_id=self._agent_id,
87
+ status_id=self._thinking_status_id,
88
+ label="Thinking",
89
+ detail=self.get_thinking_text(),
90
+ stage="thinking",
91
+ ))
92
+
93
+ def feed_text(self, text: str) -> None:
94
+ if not self._status_started:
95
+ self.start()
96
+ if self._thinking and self._phase == "thinking":
97
+ self._flush_thinking()
98
+
99
+ self._phase = "text"
100
+
101
+ if self._first_text:
102
+ self._first_text = False
103
+ self._start_streaming_status()
104
+ text = "● " + text.lstrip()
105
+
106
+ self._accumulated += text
107
+
108
+ if dock.active and self._stream_to_dock:
109
+ now = time.monotonic()
110
+ if now - self._last_flush >= self.FLUSH_INTERVAL:
111
+ if not ui_events.emit_nowait(AssistantStreamUpdated(
112
+ agent_id=self._agent_id,
113
+ text=self._accumulated,
114
+ )):
115
+ dock.set_stream(self._accumulated)
116
+ self._last_flush = now
117
+ return
118
+
119
+ if self._live is None:
120
+ self._live = Live(
121
+ Markdown(""), console=self._console,
122
+ refresh_per_second=20, transient=False,
123
+ )
124
+ self._live.start()
125
+
126
+ now = time.monotonic()
127
+ if now - self._last_flush >= self.FLUSH_INTERVAL:
128
+ self._live.update(Markdown(self._accumulated))
129
+ dock.after_output()
130
+ self._last_flush = now
131
+
132
+ def elapsed(self) -> float:
133
+ return time.monotonic() - self._start_time
134
+
135
+ def discard(self) -> None:
136
+ """Mark this renderer's output as discarded — don't commit to dock."""
137
+ self._discard = True
138
+
139
+ def done(self) -> str:
140
+ if self._thinking and self._phase == "thinking":
141
+ self._flush_thinking()
142
+
143
+ self._finish_live_status()
144
+
145
+ if self._live:
146
+ if self._accumulated:
147
+ self._live.update(Markdown(self._accumulated))
148
+ self._live.stop()
149
+ self._live = None
150
+ elif dock.active:
151
+ if self._discard:
152
+ if not ui_events.emit_nowait(AssistantStreamDiscarded(agent_id=self._agent_id)):
153
+ dock.discard_stream()
154
+ else:
155
+ if self._accumulated:
156
+ if not ui_events.emit_nowait(AssistantStreamUpdated(
157
+ agent_id=self._agent_id,
158
+ text=self._accumulated,
159
+ )):
160
+ dock.set_stream(self._accumulated)
161
+ if not ui_events.emit_nowait(AssistantStreamCommitted(agent_id=self._agent_id)):
162
+ dock.commit_stream()
163
+
164
+ full = self._accumulated
165
+ if full.startswith("● "):
166
+ full = full[2:]
167
+ self._accumulated = ""
168
+ self._thinking = []
169
+ self._thinking_full = []
170
+ self._first_text = True
171
+ self._status_started = False
172
+ self._streaming_status_started = False
173
+ return full
174
+
175
+ def get_thinking_text(self) -> str:
176
+ return "".join(self._thinking_full)
177
+
178
+ def get_body_text(self) -> str:
179
+ return self._accumulated
180
+
181
+ THINKING_MAX_LINES = 5
182
+
183
+ def _flush_thinking(self) -> None:
184
+ thinking_text = self.get_thinking_text()
185
+ if thinking_text.strip():
186
+ if dock.active:
187
+ if not ui_events.emit_nowait(StatusUpdated(
188
+ agent_id=self._agent_id,
189
+ status_id=self._thinking_status_id,
190
+ label="Thinking",
191
+ detail=thinking_text,
192
+ stage="thinking",
193
+ )):
194
+ node = dock.set_status(self._thinking_status_id, "Thinking", thinking_text, stage="thinking")
195
+ node.collapsed = False
196
+ self._thinking = []
197
+ return
198
+ lines = thinking_text.split("\n")
199
+ total = len(lines)
200
+ def render(console: Console) -> None:
201
+ if total > self.THINKING_MAX_LINES:
202
+ skipped = total - self.THINKING_MAX_LINES
203
+ visible = "\n".join(lines[-self.THINKING_MAX_LINES:])
204
+ console.print(f" {_next_spin()} [dim]Thinking… [/dim]", end="")
205
+ console.print(f"[dim][{skipped} earlier lines folded][/dim]")
206
+ else:
207
+ visible = thinking_text
208
+ console.print(f" {_next_spin()} [dim]Thinking... [/dim]", end="")
209
+ console.print(Text(visible, style="dim italic"))
210
+
211
+ if not dock.capture(render):
212
+ render(self._console)
213
+ self._thinking = []
214
+
215
+ def _start_streaming_status(self) -> None:
216
+ if self._streaming_status_started or not dock.active or not self._stream_to_dock:
217
+ return
218
+ self._streaming_status_started = True
219
+ self._finish_thinking_status()
220
+ ui_events.emit_nowait(StatusUpdated(
221
+ agent_id=self._agent_id,
222
+ status_id=self._streaming_status_id,
223
+ label="Streaming",
224
+ detail="",
225
+ stage="streaming",
226
+ ))
227
+
228
+ def _finish_live_status(self) -> None:
229
+ if not dock.active or not self._stream_to_dock or not self._status_started:
230
+ return
231
+ if self._streaming_status_started:
232
+ ui_events.emit_nowait(StatusFinished(
233
+ agent_id=self._agent_id,
234
+ status_id=self._streaming_status_id,
235
+ ))
236
+ else:
237
+ self._finish_thinking_status()
238
+
239
+ def _finish_thinking_status(self) -> None:
240
+ thinking_text = self.get_thinking_text()
241
+ if thinking_text.strip():
242
+ ui_events.emit_nowait(StatusFinished(
243
+ agent_id=self._agent_id,
244
+ status_id=self._thinking_status_id,
245
+ label=f"Thinking for {self.elapsed():.0f}s",
246
+ detail=thinking_text,
247
+ remove=False,
248
+ ))
249
+ return
250
+ ui_events.emit_nowait(StatusFinished(
251
+ agent_id=self._agent_id,
252
+ status_id=self._thinking_status_id,
253
+ ))
voidx/ui/diff.py ADDED
@@ -0,0 +1,331 @@
1
+ """Diff parsing and rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import re
7
+ import subprocess
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, Field
11
+ from rich.console import Console
12
+ from rich.markup import escape
13
+ from rich.syntax import Syntax
14
+
15
+
16
+ class DiffLine(BaseModel):
17
+ kind: Literal["context", "add", "remove"]
18
+ old_lineno: int | None = None
19
+ new_lineno: int | None = None
20
+ text: str = ""
21
+
22
+
23
+ class DiffHunk(BaseModel):
24
+ old_start: int
25
+ old_count: int
26
+ new_start: int
27
+ new_count: int
28
+ section: str = ""
29
+ lines: list[DiffLine] = Field(default_factory=list)
30
+
31
+
32
+ class FileDiff(BaseModel):
33
+ old_path: str = ""
34
+ new_path: str = ""
35
+ path: str = ""
36
+ operation: Literal["Create", "Update", "Delete"] = "Update"
37
+ added: int = 0
38
+ removed: int = 0
39
+ hunks: list[DiffHunk] = Field(default_factory=list)
40
+ raw: str = ""
41
+
42
+
43
+ class StructuredDiff(BaseModel):
44
+ files: list[FileDiff] = Field(default_factory=list)
45
+
46
+
47
+ def render_diff(console: Console, diff_text: str, title: str = "") -> None:
48
+ """Render a unified diff with syntax highlighting."""
49
+ if not diff_text.strip():
50
+ return
51
+ if title:
52
+ console.print(f"[bold]{title}[/bold]")
53
+ console.print(Syntax(diff_text, "diff", theme="monokai", line_numbers=False))
54
+
55
+
56
+ _HUNK_RE = re.compile(r"@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? \+(?P<new_start>\d+)(?:,(?P<new_count>\d+))? @@(?P<section>.*)")
57
+
58
+
59
+ def parse_unified_diff(diff_text: str) -> StructuredDiff:
60
+ files: list[FileDiff] = []
61
+ current: FileDiff | None = None
62
+ hunk: DiffHunk | None = None
63
+ old_lineno = 0
64
+ new_lineno = 0
65
+
66
+ for raw in diff_text.splitlines():
67
+ if raw.startswith("--- "):
68
+ if current is not None:
69
+ files.append(current)
70
+ old_path = _clean_diff_path(raw[4:].strip())
71
+ current = FileDiff(old_path=old_path, raw=raw)
72
+ hunk = None
73
+ continue
74
+
75
+ if current is not None:
76
+ current.raw = f"{current.raw}\n{raw}" if current.raw else raw
77
+
78
+ if raw.startswith("+++ ") and current is not None:
79
+ new_path = _clean_diff_path(raw[4:].strip())
80
+ current.new_path = new_path
81
+ current.path = _display_path(current.old_path, new_path)
82
+ current.operation = _operation(current.old_path, new_path)
83
+ continue
84
+
85
+ match = _HUNK_RE.match(raw)
86
+ if match and current is not None:
87
+ hunk = DiffHunk(
88
+ old_start=int(match.group("old_start")),
89
+ old_count=int(match.group("old_count") or "1"),
90
+ new_start=int(match.group("new_start")),
91
+ new_count=int(match.group("new_count") or "1"),
92
+ section=match.group("section").strip(),
93
+ )
94
+ current.hunks.append(hunk)
95
+ old_lineno = hunk.old_start
96
+ new_lineno = hunk.new_start
97
+ continue
98
+
99
+ if hunk is None or current is None:
100
+ continue
101
+
102
+ if raw.startswith("+") and not raw.startswith("+++"):
103
+ hunk.lines.append(DiffLine(kind="add", new_lineno=new_lineno, text=raw[1:]))
104
+ current.added += 1
105
+ new_lineno += 1
106
+ elif raw.startswith("-") and not raw.startswith("---"):
107
+ hunk.lines.append(DiffLine(kind="remove", old_lineno=old_lineno, text=raw[1:]))
108
+ current.removed += 1
109
+ old_lineno += 1
110
+ elif raw.startswith(" "):
111
+ hunk.lines.append(DiffLine(
112
+ kind="context",
113
+ old_lineno=old_lineno,
114
+ new_lineno=new_lineno,
115
+ text=raw[1:],
116
+ ))
117
+ old_lineno += 1
118
+ new_lineno += 1
119
+ elif raw.startswith("\\"):
120
+ continue
121
+
122
+ if current is not None:
123
+ files.append(current)
124
+ return StructuredDiff(files=files)
125
+
126
+
127
+ def render_file_change_lines(file_diff: FileDiff, max_hunks: int = 1, max_lines: int = 8) -> tuple[list[str], bool]:
128
+ lines = [_summary_line(file_diff)]
129
+ omitted = False
130
+ shown_hunks = 0
131
+ shown_lines = 0
132
+ language = language_from_path(file_diff.path)
133
+
134
+ for hunk in file_diff.hunks:
135
+ if shown_hunks >= max_hunks:
136
+ omitted = True
137
+ break
138
+ for line in hunk.lines:
139
+ if shown_lines >= max_lines:
140
+ omitted = True
141
+ break
142
+ lines.append(_render_diff_line(line, language))
143
+ shown_lines += 1
144
+ shown_hunks += 1
145
+ if omitted:
146
+ break
147
+
148
+ return lines, omitted
149
+
150
+
151
+ def render_full_file_diff_lines(file_diff: FileDiff) -> list[str]:
152
+ lines: list[str] = []
153
+ language = language_from_path(file_diff.path)
154
+ for hunk in file_diff.hunks:
155
+ if hunk.section:
156
+ lines.append(f"[dim]@@ {escape(hunk.section)}[/dim]")
157
+ for line in hunk.lines:
158
+ lines.append(_render_diff_line(line, language))
159
+ return lines
160
+
161
+
162
+ def language_from_path(path: str) -> str:
163
+ suffix = path.rsplit(".", 1)[-1].lower() if "." in path else ""
164
+ mapping = {
165
+ "py": "python",
166
+ "js": "javascript",
167
+ "jsx": "javascript",
168
+ "ts": "typescript",
169
+ "tsx": "typescript",
170
+ "cpp": "cpp",
171
+ "cc": "cpp",
172
+ "cxx": "cpp",
173
+ "c": "c",
174
+ "h": "cpp",
175
+ "hpp": "cpp",
176
+ "rs": "rust",
177
+ "go": "go",
178
+ "java": "java",
179
+ "json": "json",
180
+ "toml": "toml",
181
+ "yaml": "yaml",
182
+ "yml": "yaml",
183
+ "md": "markdown",
184
+ "css": "css",
185
+ "html": "html",
186
+ }
187
+ return mapping.get(suffix, "")
188
+
189
+
190
+ def _summary_line(file_diff: FileDiff) -> str:
191
+ added = f"Added {file_diff.added} line{'s' if file_diff.added != 1 else ''}"
192
+ removed = f"removed {file_diff.removed} line{'s' if file_diff.removed != 1 else ''}"
193
+ return f"[dim]└[/dim] {added}, {removed}"
194
+
195
+
196
+ def _render_diff_line(line: DiffLine, language: str = "") -> str:
197
+ lineno = line.new_lineno if line.kind == "add" else line.old_lineno
198
+ number = "" if lineno is None else str(lineno)
199
+ prefix = f"{number:>5} "
200
+ text = _highlight_code(line.text, language)
201
+ if line.kind == "add":
202
+ return f"[on #003b0a][#A3BE8C]{prefix}+[/#A3BE8C] {text}[/on #003b0a]"
203
+ if line.kind == "remove":
204
+ return f"[on #4a0000][#BF616A]{prefix}-[/#BF616A] {text}[/on #4a0000]"
205
+ return f"[dim]{prefix}[/dim] {text}"
206
+
207
+
208
+ _TOKEN_RE = re.compile(
209
+ r"(?P<comment>//.*|#.*)"
210
+ r"|(?P<string>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')"
211
+ r"|(?P<number>\b\d+(?:\.\d+)?\b)"
212
+ r"|(?P<word>\b[A-Za-z_][A-Za-z0-9_]*\b)"
213
+ )
214
+
215
+ _COMMON_KEYWORDS = {
216
+ "and", "as", "async", "await", "break", "case", "catch", "class", "const",
217
+ "continue", "def", "default", "delete", "do", "else", "enum", "except",
218
+ "export", "false", "final", "finally", "for", "from", "func", "function",
219
+ "if", "impl", "import", "in", "interface", "let", "match", "namespace",
220
+ "new", "none", "nullptr", "package", "private", "protected", "public",
221
+ "return", "self", "static", "struct", "switch", "this", "throw", "true",
222
+ "try", "type", "using", "var", "void", "while",
223
+ }
224
+
225
+
226
+ def _highlight_code(text: str, language: str) -> str:
227
+ if not text:
228
+ return ""
229
+ if language == "markdown":
230
+ return escape(text)
231
+
232
+ rendered: list[str] = []
233
+ last = 0
234
+ for match in _TOKEN_RE.finditer(text):
235
+ if match.start() > last:
236
+ rendered.append(escape(text[last:match.start()]))
237
+ token = match.group(0)
238
+ kind = match.lastgroup or ""
239
+ if kind == "comment":
240
+ rendered.append(f"[#7A7F8A]{escape(token)}[/#7A7F8A]")
241
+ elif kind == "string":
242
+ rendered.append(f"[#EBCB8B]{escape(token)}[/#EBCB8B]")
243
+ elif kind == "number":
244
+ rendered.append(f"[#B48EFD]{escape(token)}[/#B48EFD]")
245
+ elif kind == "word" and token.lower() in _COMMON_KEYWORDS:
246
+ rendered.append(f"[#ff5caa]{escape(token)}[/#ff5caa]")
247
+ else:
248
+ rendered.append(escape(token))
249
+ last = match.end()
250
+ if last < len(text):
251
+ rendered.append(escape(text[last:]))
252
+ return "".join(rendered)
253
+
254
+
255
+ def _clean_diff_path(value: str) -> str:
256
+ path = value.split("\t", 1)[0]
257
+ if path in {"/dev/null", "dev/null"}:
258
+ return "/dev/null"
259
+ if path.startswith("a/") or path.startswith("b/"):
260
+ return path[2:]
261
+ return path
262
+
263
+
264
+ def _display_path(old_path: str, new_path: str) -> str:
265
+ if new_path and new_path != "/dev/null":
266
+ return new_path
267
+ return old_path
268
+
269
+
270
+ def _operation(old_path: str, new_path: str) -> Literal["Create", "Update", "Delete"]:
271
+ if old_path == "/dev/null":
272
+ return "Create"
273
+ if new_path == "/dev/null":
274
+ return "Delete"
275
+ return "Update"
276
+
277
+
278
+ def make_file_diff(
279
+ filepath: str,
280
+ old_content: str,
281
+ new_content: str,
282
+ old_label: str = "",
283
+ new_label: str = "",
284
+ ) -> str:
285
+ """Generate a unified diff between old and new content."""
286
+ old = old_content.splitlines(keepends=True)
287
+ new = new_content.splitlines(keepends=True)
288
+ a = old_label or f"a/{filepath}"
289
+ b = new_label or f"b/{filepath}"
290
+ diff = difflib.unified_diff(old, new, fromfile=a, tofile=b)
291
+ return "".join(diff)
292
+
293
+
294
+ def diff_stat(diff_text: str) -> tuple[int, int]:
295
+ """Return (added, removed) line counts from a unified diff."""
296
+ added = 0
297
+ removed = 0
298
+ for line in diff_text.split("\n"):
299
+ if line.startswith("+") and not line.startswith("+++"):
300
+ added += 1
301
+ elif line.startswith("-") and not line.startswith("---"):
302
+ removed += 1
303
+ return added, removed
304
+
305
+
306
+ def git_diff(workspace: str, staged: bool = False) -> str:
307
+ """Get working tree diff via git."""
308
+ try:
309
+ args = ["git", "diff"]
310
+ if staged:
311
+ args.append("--staged")
312
+ result = subprocess.run(
313
+ args, capture_output=True, text=True,
314
+ cwd=workspace, timeout=10,
315
+ )
316
+ return result.stdout
317
+ except Exception:
318
+ return ""
319
+
320
+
321
+ def git_diff_stat(workspace: str) -> str:
322
+ """Get git diff --stat summary."""
323
+ try:
324
+ result = subprocess.run(
325
+ ["git", "diff", "--stat"],
326
+ capture_output=True, text=True,
327
+ cwd=workspace, timeout=10,
328
+ )
329
+ return result.stdout.strip()
330
+ except Exception:
331
+ return ""