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,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 ""
|