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/agent/slash.py ADDED
@@ -0,0 +1,466 @@
1
+ """Slash command handler — extracted from graph.py to keep it focused."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from voidx.agent.slash_components.code_ide import SlashCodeIdeMixin
8
+ from voidx.agent.slash_components.lsp import SlashLspMixin
9
+ from voidx.agent.slash_components.mcp import SlashMcpMixin
10
+ from voidx.agent.slash_components.model import SlashModelMixin
11
+ from voidx.agent.slash_components.skills import SlashSkillsMixin
12
+ from voidx.agent.slash_components.runtime import PROVIDERS, _select_from_list, _w, ui
13
+
14
+ if TYPE_CHECKING:
15
+ from voidx.agent.graph import VoidXGraph
16
+
17
+
18
+ class SlashHandler(SlashCodeIdeMixin, SlashLspMixin, SlashSkillsMixin, SlashMcpMixin, SlashModelMixin):
19
+ """Handles all slash commands (/help, /model, /plan, etc.).
20
+
21
+ Takes a reference to the parent VoidXGraph since commands need access
22
+ to session, config, permission, and model state.
23
+ """
24
+
25
+ def __init__(self, graph: VoidXGraph) -> None:
26
+ self._g = graph
27
+
28
+ async def dispatch(self, inp: str) -> bool:
29
+ from voidx.ui.commands import COMMANDS
30
+
31
+ parts = inp.split(None, 1)
32
+ cmd = parts[0]
33
+ args = parts[1] if len(parts) > 1 else ""
34
+
35
+ known = [n for n, _ in COMMANDS if n == cmd]
36
+ if not known:
37
+ return False
38
+
39
+ ui.print()
40
+
41
+ if cmd in ("/exit", "/quit"):
42
+ return True
43
+
44
+ if cmd == "/clear":
45
+ await self._clear()
46
+ elif cmd == "/code-ide":
47
+ await self._code_ide(args)
48
+ elif cmd == "/list":
49
+ await self._list_sessions()
50
+ elif cmd.startswith("/resume"):
51
+ await self._resume(inp)
52
+ elif cmd.startswith("/title"):
53
+ await self._set_title(inp)
54
+ elif cmd == "/mode":
55
+ await self._mode(args)
56
+ elif cmd == "/goal":
57
+ await self._goal(args)
58
+ elif cmd == "/plan":
59
+ self._set_interaction_mode("plan")
60
+ if hasattr(self._g, "_persist_runtime_state"):
61
+ await self._g._persist_runtime_state()
62
+ elif cmd == "/unplan":
63
+ self._set_interaction_mode("auto")
64
+ if hasattr(self._g, "_persist_runtime_state"):
65
+ await self._g._persist_runtime_state()
66
+ elif cmd.startswith("/allow"):
67
+ tool = args or cmd.removeprefix("/allow").strip()
68
+ if tool:
69
+ self._g._permission.allow(tool)
70
+ elif cmd.startswith("/deny"):
71
+ tool = args or cmd.removeprefix("/deny").strip()
72
+ if tool:
73
+ self._g._permission.deny(tool)
74
+ elif cmd == "/permissions":
75
+ ui.print(self._g._permission.show_rules())
76
+ elif cmd == "/permission-mode":
77
+ await self._permission_mode(args)
78
+ elif cmd == "/sandbox":
79
+ self._sandbox(args)
80
+ elif cmd == "/approval":
81
+ self._approval(args)
82
+ elif cmd == "/usage":
83
+ self._usage()
84
+ elif cmd == "/mcp":
85
+ await self._mcp(args)
86
+ elif cmd == "/lsp":
87
+ await self._lsp(args)
88
+ elif cmd == "/skills":
89
+ await self._skills(args)
90
+ elif cmd == "/paste":
91
+ self._paste_clipboard_image()
92
+ elif cmd.startswith("/debug"):
93
+ self._debug(args)
94
+ elif cmd == "/compact":
95
+ compacted = await self._g._compact_session_history(force=True)
96
+ if compacted:
97
+ ui.print("[dim]Compacted context.[/dim]")
98
+ else:
99
+ ui.print("[dim]Nothing to compact.[/dim]")
100
+ elif cmd == "/diff":
101
+ await self._show_diff()
102
+ elif cmd == "/tavily":
103
+ await self._tavily(args)
104
+ elif cmd == "/model":
105
+ if args == "new":
106
+ await self._model_new()
107
+ elif args == "list":
108
+ await self._model_list()
109
+ elif args == "test" or args.startswith("test "):
110
+ target = args.removeprefix("test").strip()
111
+ await self._model_test(target)
112
+ elif args == "del" or args.startswith("del "):
113
+ target = args.removeprefix("del").strip()
114
+ await self._model_del(target)
115
+ elif args == "switch" or args.startswith("switch "):
116
+ target = args.removeprefix("switch").strip()
117
+ await self._model_switch(target)
118
+ elif args == "reasoning" or args.startswith("reasoning "):
119
+ target = args.removeprefix("reasoning").strip()
120
+ await self._model_reasoning(target)
121
+ elif args:
122
+ await self._switch_model(args)
123
+ else:
124
+ await self._model_switch("")
125
+ elif cmd == "/help":
126
+ ui.print("[bold]Commands:[/bold]")
127
+ for name, desc in COMMANDS:
128
+ ui.print(f" [cyan]{name}[/cyan] — {desc}")
129
+ return True
130
+
131
+ def _set_interaction_mode(self, mode: str) -> None:
132
+ from voidx.agent.runtime_context import InteractionMode
133
+
134
+ parsed = InteractionMode.parse(mode)
135
+ setter = getattr(self._g, "set_interaction_mode", None)
136
+ if callable(setter):
137
+ setter(parsed)
138
+ else:
139
+ self._g._plan_mode = parsed == InteractionMode.PLAN
140
+ self._g._interaction_mode = parsed
141
+ labels = {
142
+ InteractionMode.AUTO: "Auto",
143
+ InteractionMode.PLAN: "Plan",
144
+ InteractionMode.GOAL: "Goal",
145
+ }
146
+ notes = {
147
+ InteractionMode.PLAN: "write/edit/bash/lsp_format blocked",
148
+ InteractionMode.GOAL: "keep work scoped to the current goal",
149
+ }
150
+ suffix = f" — {notes[parsed]}" if parsed in notes else ""
151
+ ui.print(f"[dim]Mode set to [cyan]{labels[parsed]}[/cyan]{suffix}[/dim]")
152
+
153
+ async def _mode(self, arg: str) -> None:
154
+ from voidx.agent.runtime_context import InteractionMode
155
+
156
+ mode = arg.strip().lower()
157
+ choices = [
158
+ ("Auto", InteractionMode.AUTO.value, "Infer the task intent from each turn."),
159
+ ("Plan", InteractionMode.PLAN.value, "Read-only exploration and implementation planning."),
160
+ ("Goal", InteractionMode.GOAL.value, "Keep multi-step work scoped to the current goal."),
161
+ ]
162
+
163
+ if not mode and getattr(self._g, "_app", None):
164
+ mode = await self._g._app.ask_choice("Interaction mode", choices) or ""
165
+
166
+ if not mode:
167
+ current = getattr(getattr(self._g, "_interaction_mode", None), "value", None)
168
+ if current is None:
169
+ current = "plan" if getattr(self._g, "_plan_mode", False) else "auto"
170
+ ui.print(f"Mode: [cyan]{current}[/cyan]")
171
+ ui.print("Usage: /mode [auto|plan|goal]")
172
+ return
173
+
174
+ try:
175
+ parsed = InteractionMode.parse(mode)
176
+ except ValueError:
177
+ ui.error(f"Invalid mode: {mode}. Use: auto, plan, goal")
178
+ return
179
+ self._set_interaction_mode(parsed.value)
180
+ if hasattr(self._g, "_persist_runtime_state"):
181
+ await self._g._persist_runtime_state()
182
+
183
+ async def _goal(self, arg: str) -> None:
184
+ from voidx.agent.runtime_context import InteractionMode
185
+ from voidx.agent.task_state import TaskRun
186
+
187
+ task_run = getattr(self._g, "_task_run", None)
188
+ if task_run is None:
189
+ task_run = TaskRun()
190
+ self._g._task_run = task_run
191
+
192
+ goal = arg.strip()
193
+ if goal.lower() in {"clear", "reset"}:
194
+ task_run.clear()
195
+ task_state = getattr(self._g, "_task_state", None)
196
+ if task_state is not None:
197
+ task_state.awaiting_implementation_approval = False
198
+ task_state.approved_scope = ""
199
+ self._set_interaction_mode(InteractionMode.AUTO.value)
200
+ if hasattr(self._g, "_persist_runtime_state"):
201
+ await self._g._persist_runtime_state()
202
+ ui.print("[dim]Goal cleared.[/dim]")
203
+ return
204
+
205
+ if not goal:
206
+ if task_run.goal:
207
+ ui.print(
208
+ f"Goal: [cyan]{task_run.goal}[/cyan] "
209
+ f"[dim]({task_run.phase.value}, {task_run.status.value}, turns {task_run.turn_count})[/dim]"
210
+ )
211
+ else:
212
+ ui.print("Usage: /goal <goal>|clear")
213
+ return
214
+
215
+ task_run.set_goal(goal)
216
+ self._set_interaction_mode(InteractionMode.GOAL.value)
217
+ if hasattr(self._g, "_persist_runtime_state"):
218
+ await self._g._persist_runtime_state()
219
+ ui.print(f"[dim]Goal set to [cyan]{task_run.goal}[/cyan][/dim]")
220
+
221
+ def _usage(self) -> None:
222
+ from voidx.llm.usage import format_cache_hit_rate, format_token_count
223
+
224
+ stats = getattr(self._g, "_usage_stats", None)
225
+ if stats is None:
226
+ ui.print("[dim]No usage data available.[/dim]")
227
+ return
228
+
229
+ ui.print("[bold]Token Usage[/bold]")
230
+ ui.print(
231
+ f" Context: [cyan]{format_token_count(stats.context_tokens)}[/cyan]"
232
+ f" / {format_token_count(stats.context_limit)}"
233
+ )
234
+ ui.print(
235
+ f" Last call: in [cyan]{format_token_count(stats.last_input_tokens)}[/cyan]"
236
+ f" · out [cyan]{format_token_count(stats.last_output_tokens)}[/cyan]"
237
+ " · cache read "
238
+ f"[cyan]{format_token_count(stats.last_cache_read_tokens or stats.last_estimated_cache_read_tokens)}[/cyan]"
239
+ f" · write [cyan]{format_token_count(stats.last_cache_write_tokens)}[/cyan]"
240
+ )
241
+ ui.print(
242
+ f" Session: in [cyan]{format_token_count(stats.total_input_tokens)}[/cyan]"
243
+ f" · out [cyan]{format_token_count(stats.total_output_tokens)}[/cyan]"
244
+ f" · total [cyan]{format_token_count(stats.total_tokens)}[/cyan]"
245
+ f" · cache {format_cache_hit_rate(stats)}"
246
+ f" · calls {stats.total_calls}"
247
+ )
248
+
249
+ def _sandbox(self, arg: str) -> None:
250
+ mode = arg.strip().lower()
251
+ valid = {"read-only", "workspace-write", "danger-full-access"}
252
+ if not mode:
253
+ ui.print(f"Sandbox mode: [cyan]{self._g._permission.sandbox_mode}[/cyan]")
254
+ ui.print("Usage: /sandbox [read-only|workspace-write|danger-full-access]")
255
+ return
256
+ if mode not in valid:
257
+ ui.error(f"Invalid sandbox mode: {mode}. Use: {', '.join(valid)}")
258
+ return
259
+ self._g._permission.sandbox_mode = mode
260
+ self._g._permission.mark_custom_mode()
261
+ if getattr(self._g, "_settings", None):
262
+ from voidx.config import SandboxMode
263
+ self._g._settings.set_sandbox_mode(SandboxMode(mode))
264
+ ui.print(f"[dim]Sandbox mode set to [cyan]{mode}[/cyan][/dim]")
265
+
266
+ def _approval(self, arg: str) -> None:
267
+ policy = arg.strip().lower()
268
+ valid = {"untrusted", "on-failure", "on-request", "never"}
269
+ if not policy:
270
+ ui.print(f"Approval policy: [cyan]{self._g._permission.approval_policy}[/cyan]")
271
+ ui.print("Usage: /approval [untrusted|on-failure|on-request|never]")
272
+ return
273
+ if policy not in valid:
274
+ ui.error(f"Invalid approval policy: {policy}. Use: {', '.join(valid)}")
275
+ return
276
+ self._g._permission.approval_policy = policy
277
+ self._g._permission.mark_custom_mode()
278
+ if getattr(self._g, "_settings", None):
279
+ from voidx.config import ApprovalPolicy
280
+ self._g._settings.set_approval_policy(ApprovalPolicy(policy))
281
+ ui.print(f"[dim]Approval policy set to [cyan]{policy}[/cyan][/dim]")
282
+
283
+ async def _permission_mode(self, arg: str) -> None:
284
+ from voidx.config import PermissionMode
285
+
286
+ mode = arg.strip().lower()
287
+ labels = {
288
+ PermissionMode.DEFAULT.value: "Default",
289
+ PermissionMode.READ_ONLY.value: "Read only",
290
+ PermissionMode.ACCEPT_EDITS.value: "Accept edits",
291
+ PermissionMode.AUTO_REVIEW.value: "Auto review",
292
+ PermissionMode.FULL_ACCESS.value: "Full access",
293
+ PermissionMode.CUSTOM.value: "Custom (voidx.json)",
294
+ }
295
+ valid = set(labels)
296
+
297
+ if not mode and getattr(self._g, "_app", None):
298
+ choices = [
299
+ (labels[PermissionMode.DEFAULT.value], PermissionMode.DEFAULT.value, "Ask before write/edit/bash."),
300
+ (labels[PermissionMode.READ_ONLY.value], PermissionMode.READ_ONLY.value, "Block all writes and implement delegation."),
301
+ (labels[PermissionMode.ACCEPT_EDITS.value], PermissionMode.ACCEPT_EDITS.value, "Allow workspace file edits; still ask for bash."),
302
+ (labels[PermissionMode.AUTO_REVIEW.value], PermissionMode.AUTO_REVIEW.value, "Use reviewer-assisted approvals where possible."),
303
+ (labels[PermissionMode.FULL_ACCESS.value], PermissionMode.FULL_ACCESS.value, "No sandbox or approval prompts."),
304
+ (labels[PermissionMode.CUSTOM.value], PermissionMode.CUSTOM.value, "Use explicit sandbox/approval config."),
305
+ ]
306
+ mode = await self._g._app.ask_choice("Permission mode", choices) or ""
307
+
308
+ if not mode:
309
+ current = self._g._permission.permission_mode
310
+ ui.print(f"Permission mode: [cyan]{labels.get(current, 'Custom')}[/cyan]")
311
+ ui.print("Usage: /permission-mode [default|read-only|accept-edits|auto-review|full-access|custom]")
312
+ return
313
+ if mode not in valid:
314
+ ui.error(f"Invalid permission mode: {mode}. Use: {', '.join(sorted(valid))}")
315
+ return
316
+
317
+ self._g._permission.set_permission_mode(mode)
318
+ if getattr(self._g, "_settings", None):
319
+ self._g._settings.set_permission_mode(PermissionMode(mode))
320
+ ui.print(f"[dim]Permission mode set to [cyan]{labels[mode]}[/cyan][/dim]")
321
+
322
+ def _debug(self, arg: str) -> None:
323
+ value = arg.strip().lower()
324
+ if value in ("on", "true", "1", "yes"):
325
+ self._g.set_debug(True)
326
+ elif value in ("off", "false", "0", "no"):
327
+ self._g.set_debug(False)
328
+ elif value:
329
+ ui.error("Usage: /debug [on|off]")
330
+ return
331
+ else:
332
+ self._g.set_debug(not self._g._debug)
333
+
334
+ state = "on" if self._g._debug else "off"
335
+ ui.print(f"[dim]debug {state}[/dim]")
336
+
337
+ def _paste_clipboard_image(self) -> None:
338
+ app = getattr(self._g, "_app", None)
339
+ if app is None or not hasattr(app, "paste_clipboard_image"):
340
+ ui.error("/paste requires the interactive UI.")
341
+ return
342
+ result = app.paste_clipboard_image()
343
+ if result.ok:
344
+ ui.print(f"[dim]{result.message}[/dim]")
345
+ return
346
+ ui.error(result.message)
347
+
348
+ async def _show_diff(self) -> None:
349
+ from voidx.ui.diff import git_diff, git_diff_stat
350
+ stat = git_diff_stat(self._g._workspace)
351
+ if stat:
352
+ ui.print(f"[bold]Changes:[/bold]\n{stat}\n")
353
+ diff_text = git_diff(self._g._workspace)
354
+ if diff_text:
355
+ ui.diff(diff_text)
356
+ else:
357
+ ui.print("[dim]No diff content.[/dim]")
358
+ else:
359
+ ui.print("[dim]No changes in working tree.[/dim]")
360
+
361
+ async def _clear(self) -> None:
362
+ if self._g._session:
363
+ from voidx.memory.session import clear_messages, update_title
364
+ await clear_messages(self._g._session.id)
365
+ await update_title(self._g._session.id, "New session")
366
+ if hasattr(self._g, "_clear_runtime_state"):
367
+ await self._g._clear_runtime_state()
368
+ self._g._session = self._g._session.model_copy(update={
369
+ "title": "New session",
370
+ "message_count": 0,
371
+ })
372
+ self._g._tracker._todos = []
373
+ self._g._permission.clear_session_permissions()
374
+ stats = getattr(self._g, "_usage_stats", None)
375
+ if stats is not None:
376
+ stats.reset()
377
+ from voidx.ui.session_changes import session_tracker
378
+ session_tracker.clear()
379
+ from voidx.ui.dock import get_dock
380
+ active_dock = get_dock()
381
+ if active_dock is not None:
382
+ active_dock.reset()
383
+ await self._g._show_startup()
384
+ ui.print("[dim]✓ Session cleared — ready for a new conversation[/dim]")
385
+
386
+ async def _list_sessions(self) -> None:
387
+ from voidx.memory.session import list_sessions
388
+ sessions = await list_sessions()
389
+ if not sessions:
390
+ ui.print("No saved sessions.")
391
+ return
392
+
393
+ ui.print("[bold]Sessions:[/bold]")
394
+ items = []
395
+ for s in sessions:
396
+ title = s.title[:50] + ("..." if len(s.title) > 50 else "")
397
+ items.append(f"{s.id[:8]} | {title} | {getattr(s, 'updated_at', '')[:16]}")
398
+
399
+ idx = None
400
+ if getattr(self._g, "_app", None):
401
+ idx = await _select_from_list(self._g._app, "Resume session?", items)
402
+
403
+ if idx is not None:
404
+ await self._resume(f"/resume {sessions[idx].id}")
405
+
406
+ async def _resume(self, cmd: str) -> None:
407
+ from voidx.memory.session import get_session
408
+ sid = cmd.removeprefix("/resume").strip()
409
+ if not sid:
410
+ ui.error("Usage: /resume <session_id>")
411
+ return
412
+ session = await get_session(sid)
413
+ if not session:
414
+ ui.error(f"Session not found: {sid}")
415
+ return
416
+ self._g._session = session
417
+ self._g._workspace = session.workspace
418
+ self._g.config.workspace = session.workspace
419
+ if hasattr(self._g, "_restore_runtime_state"):
420
+ await self._g._restore_runtime_state()
421
+ from voidx.ui.dock import get_dock
422
+ active_dock = get_dock()
423
+ if active_dock is not None:
424
+ active_dock.reset()
425
+ await self._g._show_startup(append_transcript=True)
426
+ ui.print(f"[dim]Resumed: {session.id} — {session.title} ({session.message_count} msgs)[/dim]")
427
+
428
+ async def _set_title(self, cmd: str) -> None:
429
+ from voidx.memory.session import update_title
430
+ if not self._g._session:
431
+ return
432
+ title = cmd.removeprefix("/title").strip()
433
+ if title:
434
+ await update_title(self._g._session.id, title)
435
+ ui.print(f"[dim]Title set: {title}[/dim]")
436
+
437
+ async def _tavily(self, args: str) -> None:
438
+ """Configure Tavily API key for web search."""
439
+ settings = self._g._settings
440
+ if not settings:
441
+ ui.error("No settings available.")
442
+ return
443
+
444
+ if not args or args.strip() == "show":
445
+ key = settings.get_tavily_api_key()
446
+ if key:
447
+ masked = key[:4] + "****" + key[-4:] if len(key) > 8 else key
448
+ ui.print(f"Tavily API key: [cyan]{masked}[/cyan]")
449
+ else:
450
+ ui.print("[dim]Tavily API key not configured. Using DuckDuckGo fallback.[/dim]")
451
+ ui.print("[dim]Usage: /tavily set <api_key> | /tavily delete[/dim]")
452
+ return
453
+
454
+ if args.startswith("set "):
455
+ api_key = args[4:].strip()
456
+ if api_key:
457
+ settings.set_tavily_api_key(api_key)
458
+ masked = api_key[:4] + "****" + api_key[-4:] if len(api_key) > 8 else api_key
459
+ ui.print(f"Tavily API key saved: [cyan]{masked}[/cyan]")
460
+ else:
461
+ ui.error("Usage: /tavily set <api_key>")
462
+ elif args.strip() == "delete":
463
+ settings.delete_tavily_api_key()
464
+ ui.print("[dim]Tavily API key deleted. Using DuckDuckGo fallback.[/dim]")
465
+ else:
466
+ ui.print("[dim]Usage: /tavily [set <api_key>|delete|show][/dim]")
@@ -0,0 +1 @@
1
+ """Implementation parts for slash commands."""
@@ -0,0 +1,68 @@
1
+ """Slash command support for /code-ide."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from voidx.agent.slash_components.runtime import ui
6
+ from voidx.config import CodeIde
7
+ from voidx.ui.code_ide import code_ide_status, detect_code_ides, normalize_ide
8
+
9
+
10
+ class SlashCodeIdeMixin:
11
+ async def _code_ide(self, args: str) -> None:
12
+ settings = getattr(self._g, "_settings", None)
13
+ if settings is None:
14
+ ui.error("No settings file available.")
15
+ return
16
+
17
+ value = args.strip().lower()
18
+ if value == "status":
19
+ ui.print(code_ide_status(settings))
20
+ return
21
+
22
+ valid = {item.value for item in CodeIde}
23
+ if not value:
24
+ app = getattr(self._g, "_app", None)
25
+ if app is not None:
26
+ detected = detect_code_ides()
27
+ detected_ids = {item.id for item in detected}
28
+ choices = []
29
+ for ide in CodeIde:
30
+ label = _ide_label(ide.value)
31
+ desc = "configured default" if ide.value == settings.get_code_ide().value else ""
32
+ if ide.value in detected_ids:
33
+ desc = (desc + " · " if desc else "") + "detected"
34
+ elif ide.value not in {CodeIde.AUTO.value, CodeIde.SYSTEM.value}:
35
+ desc = (desc + " · " if desc else "") + "not detected"
36
+ choices.append((label, ide.value, desc))
37
+ selected = await app.ask_choice("Code IDE", choices)
38
+ if selected:
39
+ value = selected
40
+ if not value:
41
+ ui.print(code_ide_status(settings))
42
+ ui.print("Usage: /code-ide [auto|trae|cursor|code|windsurf|zed|sublime|jetbrains|ghostty|system|status]")
43
+ return
44
+
45
+ value = normalize_ide(value)
46
+ if value not in valid:
47
+ ui.error(f"Invalid code IDE: {value}. Use: {', '.join(sorted(valid))}")
48
+ return
49
+
50
+ path = settings.set_code_ide(CodeIde(value))
51
+ ui.print(f"[dim]Code IDE set to [cyan]{value}[/cyan]. Saved to {path}[/dim]")
52
+ ui.print(code_ide_status(settings))
53
+
54
+
55
+ def _ide_label(value: str) -> str:
56
+ labels = {
57
+ CodeIde.AUTO.value: "Auto",
58
+ CodeIde.TRAE.value: "Trae",
59
+ CodeIde.CURSOR.value: "Cursor",
60
+ CodeIde.CODE.value: "VS Code",
61
+ CodeIde.WINDSURF.value: "Windsurf",
62
+ CodeIde.ZED.value: "Zed",
63
+ CodeIde.SUBLIME.value: "Sublime Text",
64
+ CodeIde.JETBRAINS.value: "JetBrains",
65
+ CodeIde.GHOSTTY.value: "Ghostty",
66
+ CodeIde.SYSTEM.value: "System default",
67
+ }
68
+ return labels.get(value, value)
@@ -0,0 +1,105 @@
1
+ """Slash command support for /lsp operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from voidx.agent.slash_components.runtime import ui
6
+ from voidx.lsp.config import lsp_config_path
7
+
8
+
9
+ class SlashLspMixin:
10
+ async def _lsp(self, args: str) -> None:
11
+ parts = args.split(None, 1)
12
+ action = parts[0] if parts else "status"
13
+ target = parts[1].strip() if len(parts) > 1 else ""
14
+
15
+ if action in ("", "status"):
16
+ self._lsp_status()
17
+ elif action == "doctor":
18
+ self._lsp_doctor()
19
+ elif action == "restart":
20
+ await self._lsp_restart(target or None)
21
+ elif action == "servers":
22
+ self._lsp_servers()
23
+ else:
24
+ ui.error("Usage: /lsp [status|doctor|restart|servers]")
25
+
26
+ def _lsp_status(self) -> None:
27
+ manager = getattr(self._g, "_lsp_manager", None)
28
+ if manager is None:
29
+ ui.error("No LSP manager available.")
30
+ return
31
+ ui.print("[bold]LSP status:[/bold]")
32
+ for status in manager.statuses():
33
+ label = {
34
+ "connected": "[green]connected[/green]",
35
+ "disconnected": "[dim]disconnected[/dim]",
36
+ "disabled": "[dim]disabled[/dim]",
37
+ "error": "[red]error[/red]",
38
+ }.get(status.status, status.status)
39
+ detail = f" · pid {status.pid}" if status.pid else ""
40
+ docs = f" · {status.open_documents} doc{'s' if status.open_documents != 1 else ''}"
41
+ ui.print(f" [cyan]{status.language}[/cyan] · {label}{detail}{docs}")
42
+ if status.error_message:
43
+ ui.print(f" [red]{status.error_message}[/red]")
44
+ ui.print("[dim]Usage: /lsp status|doctor|restart|servers[/dim]")
45
+
46
+ def _lsp_doctor(self) -> None:
47
+ manager = getattr(self._g, "_lsp_manager", None)
48
+ if manager is None:
49
+ ui.error("No LSP manager available.")
50
+ return
51
+ ui.print("[bold]LSP doctor:[/bold]")
52
+ missing = 0
53
+ disabled = 0
54
+ auto_detected = 0
55
+ for check in manager.doctor():
56
+ if not check.enabled:
57
+ disabled += 1
58
+ ui.print(f" [cyan]{check.language}[/cyan] · [dim]disabled[/dim] · {check.command}")
59
+ continue
60
+ source = f" [dim]({check.detected_source})[/dim]" if check.detected_source else ""
61
+ if check.available:
62
+ if check.detected_source:
63
+ auto_detected += 1
64
+ ui.print(
65
+ f" [cyan]{check.language}[/cyan] · [green]ok[/green] · "
66
+ f"{check.command} [dim]({check.resolved_path})[/dim]{source}"
67
+ )
68
+ continue
69
+ missing += 1
70
+ ui.print(f" [cyan]{check.language}[/cyan] · [red]missing[/red] · {check.command}")
71
+ if check.install_hint:
72
+ ui.print(f" [dim]{check.install_hint}[/dim]")
73
+ if missing:
74
+ ui.print(f"[yellow]{missing} LSP server{'s' if missing != 1 else ''} missing.[/yellow]")
75
+ elif disabled:
76
+ ui.print("[dim]No missing enabled LSP servers.[/dim]")
77
+ else:
78
+ msg = "All enabled LSP servers are available."
79
+ if auto_detected:
80
+ msg += f" ({auto_detected} auto-detected)"
81
+ ui.print(f"[green]{msg}[/green]")
82
+
83
+ async def _lsp_restart(self, language: str | None) -> None:
84
+ manager = getattr(self._g, "_lsp_manager", None)
85
+ if manager is None:
86
+ ui.error("No LSP manager available.")
87
+ return
88
+ await manager.restart(language)
89
+ target = language or "all servers"
90
+ ui.print(f"[green]✓ restarted {target}[/green]")
91
+
92
+ def _lsp_servers(self) -> None:
93
+ manager = getattr(self._g, "_lsp_manager", None)
94
+ workspace = getattr(self._g, "_workspace", ".")
95
+ ui.print("[bold]LSP servers:[/bold]")
96
+ ui.print(f"[dim]{lsp_config_path(workspace)}[/dim]")
97
+ if manager is None:
98
+ ui.error("No LSP manager available.")
99
+ return
100
+ for config in manager.servers.values():
101
+ state = "[green]enabled[/green]" if config.enabled else "[dim]disabled[/dim]"
102
+ exts = ", ".join(config.extensions) or "no extensions"
103
+ command = " ".join([config.command, *config.args]).strip()
104
+ ui.print(f" [cyan]{config.language}[/cyan] · {state} · [dim]{exts}[/dim]")
105
+ ui.print(f" {command}")