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