voidx 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/ui/events.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Typed UI event bus for serializing terminal rendering updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
from voidx.ui.dock import BottomInputDock
|
|
14
|
+
from voidx.ui.event_components.schema import (
|
|
15
|
+
AnsiAppended,
|
|
16
|
+
AssistantStreamCommitted,
|
|
17
|
+
AssistantStreamDiscarded,
|
|
18
|
+
AssistantStreamStarted,
|
|
19
|
+
AssistantStreamUpdated,
|
|
20
|
+
CaptureStarted,
|
|
21
|
+
CaptureStopped,
|
|
22
|
+
DiffAppended,
|
|
23
|
+
ErrorAppended,
|
|
24
|
+
FileChangeAppended,
|
|
25
|
+
InputSet,
|
|
26
|
+
MarkdownAppended,
|
|
27
|
+
MessageAppended,
|
|
28
|
+
NoticeSet,
|
|
29
|
+
PermissionPromptCleared,
|
|
30
|
+
PermissionPromptShown,
|
|
31
|
+
PermissionToolDetail,
|
|
32
|
+
RefreshRequested,
|
|
33
|
+
ResetRequested,
|
|
34
|
+
StartupShown,
|
|
35
|
+
StatusFinished,
|
|
36
|
+
StatusUpdated,
|
|
37
|
+
SubagentFinished,
|
|
38
|
+
SubagentStarted,
|
|
39
|
+
SubagentStepStarted,
|
|
40
|
+
ThoughtAppended,
|
|
41
|
+
ToolFinished,
|
|
42
|
+
ToolResultAppended,
|
|
43
|
+
ToolStarted,
|
|
44
|
+
TurnStarted,
|
|
45
|
+
UiEvent,
|
|
46
|
+
UiEventBase,
|
|
47
|
+
WarningAppended,
|
|
48
|
+
)
|
|
49
|
+
from voidx.ui.tree import OutputNode
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _QueuedEvent:
|
|
54
|
+
event: UiEvent
|
|
55
|
+
future: asyncio.Future[Any] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UiEventBus:
|
|
59
|
+
"""Single-consumer async queue for all UI mutations."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._queue: asyncio.Queue[_QueuedEvent | None] | None = None
|
|
63
|
+
self._task: asyncio.Task[None] | None = None
|
|
64
|
+
self._consumer: DockEventConsumer | None = None
|
|
65
|
+
self._last_error: BaseException | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_running(self) -> bool:
|
|
69
|
+
return self._task is not None and not self._task.done() and self._queue is not None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def last_error(self) -> BaseException | None:
|
|
73
|
+
return self._last_error
|
|
74
|
+
|
|
75
|
+
def start(self, consumer: DockEventConsumer) -> None:
|
|
76
|
+
if self.is_running:
|
|
77
|
+
self._consumer = consumer
|
|
78
|
+
return
|
|
79
|
+
self._queue = asyncio.Queue()
|
|
80
|
+
self._consumer = consumer
|
|
81
|
+
self._last_error = None
|
|
82
|
+
self._task = asyncio.create_task(self._run(), name="voidx-ui-event-bus")
|
|
83
|
+
|
|
84
|
+
async def emit(self, event: UiEvent) -> bool:
|
|
85
|
+
if not self.is_running or self._queue is None:
|
|
86
|
+
return False
|
|
87
|
+
await self._queue.put(_QueuedEvent(event))
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def emit_nowait(self, event: UiEvent) -> bool:
|
|
91
|
+
if not self.is_running or self._queue is None:
|
|
92
|
+
return False
|
|
93
|
+
self._queue.put_nowait(_QueuedEvent(event))
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
async def request(self, event: UiEvent) -> Any:
|
|
97
|
+
if not self.is_running or self._queue is None:
|
|
98
|
+
raise RuntimeError("UI event bus is not running")
|
|
99
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
100
|
+
await self._queue.put(_QueuedEvent(event, future))
|
|
101
|
+
return await future
|
|
102
|
+
|
|
103
|
+
async def drain(self) -> None:
|
|
104
|
+
if self._queue is not None:
|
|
105
|
+
await self._queue.join()
|
|
106
|
+
if self._last_error is not None:
|
|
107
|
+
raise self._last_error
|
|
108
|
+
|
|
109
|
+
async def stop(self) -> None:
|
|
110
|
+
if self._queue is None or self._task is None:
|
|
111
|
+
return
|
|
112
|
+
await self._queue.join()
|
|
113
|
+
await self._queue.put(None)
|
|
114
|
+
await self._task
|
|
115
|
+
self._queue = None
|
|
116
|
+
self._task = None
|
|
117
|
+
self._consumer = None
|
|
118
|
+
|
|
119
|
+
async def _run(self) -> None:
|
|
120
|
+
assert self._queue is not None
|
|
121
|
+
while True:
|
|
122
|
+
item = await self._queue.get()
|
|
123
|
+
try:
|
|
124
|
+
if item is None:
|
|
125
|
+
return
|
|
126
|
+
result = None
|
|
127
|
+
try:
|
|
128
|
+
if self._consumer is None:
|
|
129
|
+
raise RuntimeError("UI event bus has no consumer")
|
|
130
|
+
result = self._consumer.handle(item.event)
|
|
131
|
+
if inspect.isawaitable(result):
|
|
132
|
+
result = await result
|
|
133
|
+
except BaseException as exc:
|
|
134
|
+
self._last_error = exc
|
|
135
|
+
if item.future is not None and not item.future.done():
|
|
136
|
+
item.future.set_exception(exc)
|
|
137
|
+
else:
|
|
138
|
+
if item.future is not None and not item.future.done():
|
|
139
|
+
item.future.set_result(result)
|
|
140
|
+
finally:
|
|
141
|
+
self._queue.task_done()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DockEventConsumer:
|
|
145
|
+
"""Apply typed events to BottomInputDock in queue order."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, target: BottomInputDock) -> None:
|
|
148
|
+
self._dock = target
|
|
149
|
+
self._tool_nodes: dict[str, OutputNode] = {}
|
|
150
|
+
self._agent_nodes: dict[int, OutputNode] = {}
|
|
151
|
+
|
|
152
|
+
def handle(self, event: UiEvent) -> Any:
|
|
153
|
+
if isinstance(event, CaptureStarted):
|
|
154
|
+
return self._dock.begin_capture()
|
|
155
|
+
if isinstance(event, CaptureStopped):
|
|
156
|
+
return self._dock.deactivate()
|
|
157
|
+
if isinstance(event, RefreshRequested):
|
|
158
|
+
return self._dock.refresh()
|
|
159
|
+
if isinstance(event, ResetRequested):
|
|
160
|
+
self._tool_nodes.clear()
|
|
161
|
+
self._agent_nodes.clear()
|
|
162
|
+
return self._dock.reset()
|
|
163
|
+
if isinstance(event, TurnStarted):
|
|
164
|
+
self._tool_nodes.clear()
|
|
165
|
+
self._agent_nodes.clear()
|
|
166
|
+
return self._dock.start_turn(event.text)
|
|
167
|
+
if isinstance(event, StartupShown):
|
|
168
|
+
return self._dock.append_startup(
|
|
169
|
+
model=event.model,
|
|
170
|
+
provider=event.provider,
|
|
171
|
+
workspace=event.workspace,
|
|
172
|
+
session_title=event.session_title,
|
|
173
|
+
is_new=event.is_new,
|
|
174
|
+
profile_configured=event.profile_configured,
|
|
175
|
+
)
|
|
176
|
+
if isinstance(event, MessageAppended):
|
|
177
|
+
return self._dock.append_message(event.text, style=event.style)
|
|
178
|
+
if isinstance(event, AnsiAppended):
|
|
179
|
+
return self._dock.append_ansi(event.text)
|
|
180
|
+
if isinstance(event, MarkdownAppended):
|
|
181
|
+
return self._dock.capture(lambda console: console.print(Markdown(event.content)))
|
|
182
|
+
if isinstance(event, ThoughtAppended):
|
|
183
|
+
return self._dock.append_thought(event.text, event.elapsed)
|
|
184
|
+
if isinstance(event, WarningAppended):
|
|
185
|
+
return self._dock.append_message(f"! {event.message}", style="yellow")
|
|
186
|
+
if isinstance(event, ErrorAppended):
|
|
187
|
+
return self._dock.append_error(event.message, parent=self._agent_parent(event.agent_id))
|
|
188
|
+
if isinstance(event, DiffAppended):
|
|
189
|
+
from voidx.ui.diff import render_diff
|
|
190
|
+
|
|
191
|
+
return self._dock.capture(lambda console: render_diff(console, event.diff_text, event.title))
|
|
192
|
+
if isinstance(event, StatusUpdated):
|
|
193
|
+
return self._dock.set_status(
|
|
194
|
+
event.status_id,
|
|
195
|
+
event.label,
|
|
196
|
+
event.detail,
|
|
197
|
+
parent=self._status_parent(event),
|
|
198
|
+
stage=event.stage.replace("_", " "),
|
|
199
|
+
)
|
|
200
|
+
if isinstance(event, StatusFinished):
|
|
201
|
+
return self._dock.finish_status(
|
|
202
|
+
event.status_id,
|
|
203
|
+
label=event.label,
|
|
204
|
+
detail=event.detail,
|
|
205
|
+
ok=event.ok,
|
|
206
|
+
remove=event.remove,
|
|
207
|
+
)
|
|
208
|
+
if isinstance(event, AssistantStreamStarted):
|
|
209
|
+
return self._dock.set_stream("", parent=self._stream_parent(event.agent_id))
|
|
210
|
+
if isinstance(event, AssistantStreamUpdated):
|
|
211
|
+
return self._dock.set_stream(event.text, parent=self._stream_parent(event.agent_id))
|
|
212
|
+
if isinstance(event, AssistantStreamCommitted):
|
|
213
|
+
return self._dock.commit_stream()
|
|
214
|
+
if isinstance(event, AssistantStreamDiscarded):
|
|
215
|
+
return self._dock.discard_stream()
|
|
216
|
+
if isinstance(event, ToolStarted):
|
|
217
|
+
parent = self._agent_parent(event.agent_id)
|
|
218
|
+
node = self._dock.start_tool(
|
|
219
|
+
event.label,
|
|
220
|
+
event.args,
|
|
221
|
+
parent=parent,
|
|
222
|
+
tool_call_id=event.tool_call_id,
|
|
223
|
+
tool_name=event.tool_name,
|
|
224
|
+
raw_args=event.raw_args,
|
|
225
|
+
)
|
|
226
|
+
self._tool_nodes[event.tool_call_id] = node
|
|
227
|
+
return node
|
|
228
|
+
if isinstance(event, ToolFinished):
|
|
229
|
+
node = self._tool_nodes.get(event.tool_call_id)
|
|
230
|
+
if node is None:
|
|
231
|
+
self._dock.finish_tool(event.label, event.elapsed, event.ok, event.detail)
|
|
232
|
+
return None
|
|
233
|
+
return self._dock.finish_tool_node(node, event.label, event.elapsed, event.ok, event.detail)
|
|
234
|
+
if isinstance(event, ToolResultAppended):
|
|
235
|
+
parent = self._tool_nodes.get(event.tool_call_id) if event.tool_call_id else None
|
|
236
|
+
if parent is None:
|
|
237
|
+
parent = self._stream_parent(event.agent_id)
|
|
238
|
+
return self._dock.append_tool_result(
|
|
239
|
+
event.text,
|
|
240
|
+
parent=parent,
|
|
241
|
+
collapsed=event.collapsed,
|
|
242
|
+
tool_call_id=event.tool_call_id or None,
|
|
243
|
+
)
|
|
244
|
+
if isinstance(event, FileChangeAppended):
|
|
245
|
+
parent = self._tool_nodes.get(event.tool_call_id) if event.tool_call_id else None
|
|
246
|
+
if parent is None:
|
|
247
|
+
parent = self._stream_parent(event.agent_id)
|
|
248
|
+
return self._dock.append_file_change(
|
|
249
|
+
event.diff_text,
|
|
250
|
+
parent=parent,
|
|
251
|
+
tool_call_id=event.tool_call_id or None,
|
|
252
|
+
)
|
|
253
|
+
if isinstance(event, SubagentStepStarted):
|
|
254
|
+
parent = self._agent_parent(event.agent_id)
|
|
255
|
+
return self._dock.set_status(
|
|
256
|
+
f"agent:{event.agent_id}:progress",
|
|
257
|
+
f"{event.name} ({event.step}/{event.max_steps})",
|
|
258
|
+
parent=parent,
|
|
259
|
+
stage="agent step",
|
|
260
|
+
)
|
|
261
|
+
if isinstance(event, SubagentStarted):
|
|
262
|
+
parent = self._tool_nodes.get(event.parent_tool_call_id)
|
|
263
|
+
if parent is not None:
|
|
264
|
+
parent.collapsed = False
|
|
265
|
+
if parent is None and event.parent_agent_id >= 0:
|
|
266
|
+
parent = self._agent_parent(event.parent_agent_id)
|
|
267
|
+
if parent is None:
|
|
268
|
+
parent = self._dock.ensure_agent()
|
|
269
|
+
body_lines = []
|
|
270
|
+
if event.description:
|
|
271
|
+
body_lines.append(f"[dim]Task:[/dim] {escape(event.description)}")
|
|
272
|
+
body_lines.append(f"[dim]Agent ID:[/dim] {event.agent_id}")
|
|
273
|
+
node = self._dock.tree.new_node(
|
|
274
|
+
parent=parent,
|
|
275
|
+
node_type="subagent",
|
|
276
|
+
header=f"[#B48EAD]●[/#B48EAD] [bold]{escape(event.name)}[/bold] agent",
|
|
277
|
+
body_lines=body_lines,
|
|
278
|
+
collapsed=False,
|
|
279
|
+
agent_name=event.name,
|
|
280
|
+
agent_run_id=event.subagent_id,
|
|
281
|
+
payload={"role_name": event.name, "description": event.description},
|
|
282
|
+
)
|
|
283
|
+
self._agent_nodes[event.agent_id] = node
|
|
284
|
+
self._dock.refresh()
|
|
285
|
+
return node
|
|
286
|
+
if isinstance(event, SubagentFinished):
|
|
287
|
+
node = self._agent_parent(event.agent_id)
|
|
288
|
+
label = "completed" if event.ok else "failed"
|
|
289
|
+
elapsed = f" ({event.elapsed:.1f}s)" if event.elapsed is not None else ""
|
|
290
|
+
self._dock.finish_status(f"agent:{event.agent_id}:progress")
|
|
291
|
+
if node is None:
|
|
292
|
+
return None
|
|
293
|
+
color = "dim" if event.ok else "red"
|
|
294
|
+
icon = "●" if event.ok else "✗"
|
|
295
|
+
role_name = str(node.payload.get("role_name") or node.agent_name or event.subagent_id)
|
|
296
|
+
node.header = f"[{color}]{icon}[/{color}] [{color}]{escape(role_name)} agent {label}{elapsed}[/{color}]"
|
|
297
|
+
node.status = "done" if event.ok else "error"
|
|
298
|
+
node.elapsed = event.elapsed
|
|
299
|
+
node.collapsed = False
|
|
300
|
+
self._dock.tree.mark_dirty()
|
|
301
|
+
self._dock.refresh()
|
|
302
|
+
return node
|
|
303
|
+
if isinstance(event, InputSet):
|
|
304
|
+
return self._dock.set_input(event.text, event.hints, event.cursor_pos)
|
|
305
|
+
if isinstance(event, (PermissionPromptShown, PermissionPromptCleared, NoticeSet)):
|
|
306
|
+
return None
|
|
307
|
+
raise TypeError(f"Unsupported UI event: {event!r}")
|
|
308
|
+
|
|
309
|
+
def _status_parent(self, event: StatusUpdated) -> OutputNode | None:
|
|
310
|
+
if event.parent_tool_call_id:
|
|
311
|
+
node = self._tool_nodes.get(event.parent_tool_call_id)
|
|
312
|
+
if node is not None:
|
|
313
|
+
return node
|
|
314
|
+
if event.agent_id >= 0:
|
|
315
|
+
return self._agent_parent(event.agent_id)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
def _stream_parent(self, agent_id: int) -> OutputNode | None:
|
|
319
|
+
if agent_id >= 0:
|
|
320
|
+
return self._agent_parent(agent_id)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
def _agent_parent(self, agent_id: int) -> OutputNode | None:
|
|
324
|
+
if agent_id < 0:
|
|
325
|
+
return None
|
|
326
|
+
node = self._agent_nodes.get(agent_id)
|
|
327
|
+
if node is not None:
|
|
328
|
+
return node
|
|
329
|
+
node = self._dock.tree.new_node(
|
|
330
|
+
parent=self._dock.ensure_agent(),
|
|
331
|
+
node_type="subagent",
|
|
332
|
+
header=f"[#B48EAD]●[/#B48EAD] [bold]child agent {agent_id}[/bold]",
|
|
333
|
+
collapsed=False,
|
|
334
|
+
agent_name=f"agent {agent_id}",
|
|
335
|
+
)
|
|
336
|
+
self._agent_nodes[agent_id] = node
|
|
337
|
+
self._dock.refresh()
|
|
338
|
+
return node
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
ui_events = UiEventBus()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Turn-level file change tracker for the review bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from voidx.tools.base import resolve_safe
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FileChangeRecord:
|
|
13
|
+
path: str
|
|
14
|
+
added: int
|
|
15
|
+
removed: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FileSnapshot:
|
|
20
|
+
path: str
|
|
21
|
+
resolved_path: Path
|
|
22
|
+
existed: bool
|
|
23
|
+
content: bytes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RollbackResult:
|
|
28
|
+
restored: list[str]
|
|
29
|
+
removed: list[str]
|
|
30
|
+
errors: list[str]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ok(self) -> bool:
|
|
34
|
+
return not self.errors
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionChangeTracker:
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._files: dict[str, FileChangeRecord] = {}
|
|
40
|
+
self._snapshots: dict[str, FileSnapshot] = {}
|
|
41
|
+
self._workspace = "."
|
|
42
|
+
self._visible = False
|
|
43
|
+
|
|
44
|
+
def begin_turn(self, workspace: str) -> None:
|
|
45
|
+
self._workspace = workspace
|
|
46
|
+
self._files.clear()
|
|
47
|
+
self._snapshots.clear()
|
|
48
|
+
self._visible = False
|
|
49
|
+
|
|
50
|
+
def finish_turn(self) -> None:
|
|
51
|
+
self._visible = True
|
|
52
|
+
|
|
53
|
+
def capture_tool_call(
|
|
54
|
+
self,
|
|
55
|
+
tool_name: str,
|
|
56
|
+
args: dict,
|
|
57
|
+
workspace: str,
|
|
58
|
+
extra_paths: list[str] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
if tool_name not in {"write", "edit", "lsp_format"}:
|
|
61
|
+
return
|
|
62
|
+
file_path = args.get("file_path")
|
|
63
|
+
if not isinstance(file_path, str) or not file_path:
|
|
64
|
+
return
|
|
65
|
+
self.capture_file(file_path, workspace, extra_paths)
|
|
66
|
+
|
|
67
|
+
def capture_file(
|
|
68
|
+
self,
|
|
69
|
+
file_path: str,
|
|
70
|
+
workspace: str | None = None,
|
|
71
|
+
extra_paths: list[str] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
workspace = workspace or self._workspace
|
|
74
|
+
resolved = resolve_safe(workspace, file_path, extra_paths)
|
|
75
|
+
if resolved is None:
|
|
76
|
+
return
|
|
77
|
+
key = str(resolved)
|
|
78
|
+
if key in self._snapshots:
|
|
79
|
+
return
|
|
80
|
+
existed = resolved.exists() and resolved.is_file()
|
|
81
|
+
content = resolved.read_bytes() if existed else b""
|
|
82
|
+
self._snapshots[key] = FileSnapshot(
|
|
83
|
+
path=self._display_path(resolved, file_path, workspace),
|
|
84
|
+
resolved_path=resolved,
|
|
85
|
+
existed=existed,
|
|
86
|
+
content=content,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def record_diff(self, diff_text: str) -> None:
|
|
90
|
+
from voidx.ui.diff import parse_unified_diff
|
|
91
|
+
|
|
92
|
+
parsed = parse_unified_diff(diff_text)
|
|
93
|
+
for fd in parsed.files:
|
|
94
|
+
key = fd.path
|
|
95
|
+
if key in self._files:
|
|
96
|
+
existing = self._files[key]
|
|
97
|
+
existing.added += fd.added
|
|
98
|
+
existing.removed += fd.removed
|
|
99
|
+
else:
|
|
100
|
+
self._files[key] = FileChangeRecord(
|
|
101
|
+
path=fd.path,
|
|
102
|
+
added=fd.added,
|
|
103
|
+
removed=fd.removed,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def rollback_current(self) -> RollbackResult:
|
|
107
|
+
restored: list[str] = []
|
|
108
|
+
removed: list[str] = []
|
|
109
|
+
errors: list[str] = []
|
|
110
|
+
|
|
111
|
+
for snapshot in self._snapshots.values():
|
|
112
|
+
try:
|
|
113
|
+
if snapshot.existed:
|
|
114
|
+
snapshot.resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
snapshot.resolved_path.write_bytes(snapshot.content)
|
|
116
|
+
restored.append(snapshot.path)
|
|
117
|
+
elif snapshot.resolved_path.exists():
|
|
118
|
+
if snapshot.resolved_path.is_file():
|
|
119
|
+
snapshot.resolved_path.unlink()
|
|
120
|
+
removed.append(snapshot.path)
|
|
121
|
+
else:
|
|
122
|
+
errors.append(f"{snapshot.path}: path exists but is not a file")
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
errors.append(f"{snapshot.path}: {exc}")
|
|
125
|
+
|
|
126
|
+
if not errors:
|
|
127
|
+
self.clear()
|
|
128
|
+
return RollbackResult(restored=restored, removed=removed, errors=errors)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def files(self) -> list[FileChangeRecord]:
|
|
132
|
+
return list(self._files.values())
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def file_count(self) -> int:
|
|
136
|
+
return len(self._files)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def total_added(self) -> int:
|
|
140
|
+
return sum(f.added for f in self._files.values())
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def total_removed(self) -> int:
|
|
144
|
+
return sum(f.removed for f in self._files.values())
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def has_changes(self) -> bool:
|
|
148
|
+
return self._visible and len(self._files) > 0
|
|
149
|
+
|
|
150
|
+
def clear(self) -> None:
|
|
151
|
+
self._files.clear()
|
|
152
|
+
self._snapshots.clear()
|
|
153
|
+
self._visible = False
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _display_path(resolved: Path, original: str, workspace: str) -> str:
|
|
157
|
+
try:
|
|
158
|
+
return str(resolved.relative_to(Path(workspace).resolve()))
|
|
159
|
+
except ValueError:
|
|
160
|
+
return original
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
session_tracker = SessionChangeTracker()
|
voidx/ui/startup.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Startup screen — Claude Code style terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from rich.cells import cell_len
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StartupConsole(Protocol):
|
|
12
|
+
width: int
|
|
13
|
+
|
|
14
|
+
def print(self, *args, **kwargs) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def show_startup(
|
|
18
|
+
console: StartupConsole,
|
|
19
|
+
model: str,
|
|
20
|
+
provider: str,
|
|
21
|
+
workspace: str,
|
|
22
|
+
session_title: str,
|
|
23
|
+
is_new: bool,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Render the Claude Code style startup banner."""
|
|
26
|
+
|
|
27
|
+
console.print("\n".join(render_startup_lines(
|
|
28
|
+
console.width,
|
|
29
|
+
model=model,
|
|
30
|
+
provider=provider,
|
|
31
|
+
workspace=workspace,
|
|
32
|
+
session_title=session_title,
|
|
33
|
+
is_new=is_new,
|
|
34
|
+
)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_startup_lines(
|
|
38
|
+
console_width: int,
|
|
39
|
+
*,
|
|
40
|
+
model: str,
|
|
41
|
+
provider: str,
|
|
42
|
+
workspace: str,
|
|
43
|
+
session_title: str,
|
|
44
|
+
is_new: bool,
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
"""Build Rich-markup startup banner lines."""
|
|
47
|
+
|
|
48
|
+
from voidx import __version__
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
folder_name = os.path.basename(workspace) or workspace
|
|
52
|
+
|
|
53
|
+
greeting = "Welcome back!" if not is_new else "Welcome to voidx!"
|
|
54
|
+
session_line = "New session" if is_new else f"Resumed: {session_title or 'previous session'}"
|
|
55
|
+
info: list[tuple[str, str]] = [
|
|
56
|
+
("greeting", greeting),
|
|
57
|
+
("model", f"● Model {provider}/{model}"),
|
|
58
|
+
("folder", f"▣ Workspace {folder_name}"),
|
|
59
|
+
("session", f"↳ {session_line}"),
|
|
60
|
+
("hint", "Ask anything · / commands · /model switch · Ctrl+J newline"),
|
|
61
|
+
("hint", "wheel/click transcript · @ attach · Ctrl+V image"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
title = f"voidx v{__version__}"
|
|
65
|
+
logo = _cat_logo()
|
|
66
|
+
logo_plain = [plain for plain, _ in logo]
|
|
67
|
+
width = _banner_width(console_width, title, logo_plain, [line for _, line in info])
|
|
68
|
+
logo_width = max(cell_len(line) for line in logo_plain)
|
|
69
|
+
info_width = max(width - logo_width - 4, 16)
|
|
70
|
+
|
|
71
|
+
rendered: list[str] = []
|
|
72
|
+
title_gap = max(width - cell_len(title) - 1, 0)
|
|
73
|
+
rendered.append(f"[dim]╭─ {escape(title)} {'─' * title_gap}╮[/dim]")
|
|
74
|
+
for idx in range(max(len(logo), len(info))):
|
|
75
|
+
left, styled_left = logo[idx] if idx < len(logo) else ("", "")
|
|
76
|
+
item = info[idx] if idx < len(info) else ("", "")
|
|
77
|
+
right = _fit_cell(item[1], info_width)
|
|
78
|
+
plain = f"{left}{' ' * (logo_width - cell_len(left) + 4)}{right}"
|
|
79
|
+
pad = max(width - cell_len(plain), 0)
|
|
80
|
+
rendered.append(
|
|
81
|
+
f"[dim]│[/dim] {styled_left}"
|
|
82
|
+
f"{' ' * (logo_width - cell_len(left) + 4)}"
|
|
83
|
+
f"{_style_info(item[0], right)}"
|
|
84
|
+
f"{' ' * pad} [dim]│[/dim]"
|
|
85
|
+
)
|
|
86
|
+
rendered.append(f"[dim]╰{'─' * (width + 2)}╯[/dim]")
|
|
87
|
+
|
|
88
|
+
return rendered
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cat_logo() -> list[tuple[str, str]]:
|
|
92
|
+
outline = "#8B6F62"
|
|
93
|
+
eye = "#F2D6A2"
|
|
94
|
+
bubble = "#C9B79A"
|
|
95
|
+
return [
|
|
96
|
+
(
|
|
97
|
+
" o O ",
|
|
98
|
+
f"[{bubble}] o O [/]",
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
" /\\________/\\ ╭╮",
|
|
102
|
+
f"[{outline}] /\\________/\\ ╭╮[/]",
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
" / ◒ ◒ \\___││",
|
|
106
|
+
f"[{outline}] / [/][{eye}]◒ ◒[/][{outline}] \\___││[/]",
|
|
107
|
+
),
|
|
108
|
+
(
|
|
109
|
+
" | ▔ ▔ \\││",
|
|
110
|
+
f"[{outline}] | [/][{eye}]▔ ▔[/][{outline}] \\││[/]",
|
|
111
|
+
),
|
|
112
|
+
(
|
|
113
|
+
" | __╰╯",
|
|
114
|
+
f"[{outline}] | __╰╯[/]",
|
|
115
|
+
),
|
|
116
|
+
(
|
|
117
|
+
" \\______________/ ",
|
|
118
|
+
f"[{outline}] \\______________/ [/]",
|
|
119
|
+
),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _banner_width(console_width: int, title: str, logo: list[str], info: list[str]) -> int:
|
|
124
|
+
logo_width = max(cell_len(line) for line in logo)
|
|
125
|
+
info_width = max(cell_len(line) for line in info)
|
|
126
|
+
content_width = max(logo_width + 4 + info_width, cell_len(title) + 4)
|
|
127
|
+
return min(content_width, max(console_width - 4, 44))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fit_cell(text: str, width: int) -> str:
|
|
131
|
+
if cell_len(text) <= width:
|
|
132
|
+
return text
|
|
133
|
+
if width <= 3:
|
|
134
|
+
return "." * max(width, 0)
|
|
135
|
+
out = ""
|
|
136
|
+
used = 0
|
|
137
|
+
for char in text:
|
|
138
|
+
char_width = cell_len(char)
|
|
139
|
+
if used + char_width > width - 3:
|
|
140
|
+
break
|
|
141
|
+
out += char
|
|
142
|
+
used += char_width
|
|
143
|
+
return out + "..."
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _style_info(kind: str, text: str) -> str:
|
|
147
|
+
escaped = escape(text)
|
|
148
|
+
if kind == "greeting":
|
|
149
|
+
return f"[bold white]{escaped}[/bold white]"
|
|
150
|
+
if kind == "model":
|
|
151
|
+
if text.startswith("● Model "):
|
|
152
|
+
return f"[#A3BE8C]●[/#A3BE8C] [dim]Model [/dim][bold]{escape(text[9:])}[/bold]"
|
|
153
|
+
return f"[bold]{escaped}[/bold]"
|
|
154
|
+
if kind == "folder":
|
|
155
|
+
prefix = "▣ Workspace "
|
|
156
|
+
if text.startswith(prefix):
|
|
157
|
+
return f"[dim]▣ Workspace [/dim][cyan]{escape(text[len(prefix):])}[/cyan]"
|
|
158
|
+
if kind == "session":
|
|
159
|
+
if text.startswith("↳ "):
|
|
160
|
+
return f"[dim]↳ [/dim][cyan]{escape(text[2:])}[/cyan]"
|
|
161
|
+
return f"[dim]{escaped}[/dim]"
|