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/browse.py ADDED
@@ -0,0 +1,160 @@
1
+ """Browse mode — mouse-driven tree expand/collapse after agent output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from rich.console import Console
7
+ from voidx.ui.tree import OutputTree
8
+
9
+
10
+ def browse(tree: OutputTree, console: Console) -> None:
11
+ """Enter mouse browse mode. Click nodes to expand/collapse, any key to exit."""
12
+
13
+ # Terminal escape sequences
14
+ ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h"
15
+ DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l"
16
+ HINT = "[dim]点击节点展开/折叠,按任意键开始输入[/dim]"
17
+
18
+ # Initial render — track line count for in-place updates
19
+ width = console.width or 80
20
+ lines, line_map = tree.render_with_line_map(width)
21
+ for line in lines:
22
+ console.print(line)
23
+ console.print(HINT)
24
+ total_lines = len(lines) + 1 # +1 for hint
25
+
26
+ # Enable mouse tracking
27
+ sys.stdout.write(ENABLE_MOUSE)
28
+ sys.stdout.flush()
29
+
30
+ try:
31
+ if sys.platform == "win32":
32
+ _browse_windows(tree, console, line_map, total_lines, width)
33
+ else:
34
+ _browse_unix(tree, console, line_map, total_lines, width)
35
+ finally:
36
+ sys.stdout.write(DISABLE_MOUSE)
37
+ sys.stdout.flush()
38
+
39
+
40
+ def _rerender(tree, console, total_lines, width):
41
+ """Move cursor up, clear, re-render tree + hint."""
42
+ # Move up to start of tree
43
+ sys.stdout.write(f"\x1b[{total_lines}A")
44
+ sys.stdout.write("\x1b[J") # Clear from cursor to end
45
+ sys.stdout.flush()
46
+
47
+ lines, line_map = tree.render_with_line_map(width)
48
+ for line in lines:
49
+ console.print(line)
50
+ console.print("[dim]点击节点展开/折叠,按任意键开始输入[/dim]")
51
+ sys.stdout.flush()
52
+ return len(lines) + 1, line_map
53
+
54
+
55
+ def _browse_windows(tree, console, line_map, total_lines, width):
56
+ import msvcrt
57
+
58
+ while True:
59
+ ch = msvcrt.getwch()
60
+ if ch == '\x1b':
61
+ # Check if mouse event follows: \x1b[<...
62
+ if msvcrt.kbhit():
63
+ ch2 = msvcrt.getwch()
64
+ if ch2 == '[':
65
+ if msvcrt.kbhit():
66
+ ch3 = msvcrt.getwch()
67
+ if ch3 == '<':
68
+ # Parse SGR mouse: <button;col;row{M|m}
69
+ buf = ''
70
+ while True:
71
+ c = msvcrt.getwch()
72
+ if c in ('M', 'm'):
73
+ break
74
+ buf += c
75
+ parts = buf.split(';')
76
+ if len(parts) >= 3 and parts[0] == '0':
77
+ # Left button press
78
+ try:
79
+ row = int(parts[2])
80
+ node_id = line_map.get(row - 1) # 0-based
81
+ if node_id:
82
+ node = tree.get(node_id)
83
+ if node:
84
+ node.collapsed = not node.collapsed
85
+ total_lines, line_map = _rerender(
86
+ tree, console, total_lines, width)
87
+ except (ValueError, IndexError):
88
+ pass
89
+ else:
90
+ # Arrow key or other sequence — exit
91
+ return
92
+ else:
93
+ return
94
+ else:
95
+ return
96
+ else:
97
+ # Lone ESC — exit
98
+ return
99
+ else:
100
+ # Any other key — exit
101
+ return
102
+
103
+
104
+ def _browse_unix(tree, console, line_map, total_lines, width):
105
+ import termios
106
+ import tty
107
+ import select
108
+
109
+ fd = sys.stdin.fileno()
110
+ old = termios.tcgetattr(fd)
111
+ try:
112
+ tty.setraw(fd)
113
+ while True:
114
+ # Use select with short timeout to handle partial reads
115
+ if not select.select([sys.stdin], [], [], 0.1)[0]:
116
+ continue
117
+
118
+ ch = sys.stdin.buffer.read(1)
119
+ if ch == b'\x1b':
120
+ # Check for mouse/arrow sequence
121
+ ready, _, _ = select.select([sys.stdin], [], [], 0.05)
122
+ if ready:
123
+ ch2 = sys.stdin.buffer.read(1)
124
+ if ch2 == b'[':
125
+ ready2, _, _ = select.select([sys.stdin], [], [], 0.05)
126
+ if ready2:
127
+ ch3 = sys.stdin.buffer.read(1)
128
+ if ch3 == b'<':
129
+ # Mouse event
130
+ buf = b''
131
+ while True:
132
+ c = sys.stdin.buffer.read(1)
133
+ if c in (b'M', b'm'):
134
+ break
135
+ buf += c
136
+ parts = buf.decode().split(';')
137
+ if len(parts) >= 3 and parts[0] == '0':
138
+ try:
139
+ row = int(parts[2])
140
+ node_id = line_map.get(row - 1)
141
+ if node_id:
142
+ node = tree.get(node_id)
143
+ if node:
144
+ node.collapsed = not node.collapsed
145
+ total_lines, line_map = _rerender(
146
+ tree, console, total_lines, width)
147
+ except (ValueError, IndexError):
148
+ pass
149
+ else:
150
+ return # Arrow key → exit
151
+ else:
152
+ return # [ with nothing → exit
153
+ else:
154
+ return # not [ → ESC → exit
155
+ else:
156
+ return # Lone ESC → exit
157
+ else:
158
+ return # Any other key → exit
159
+ finally:
160
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
voidx/ui/capture.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+ import time
3
+
4
+ from rich.markup import escape
5
+
6
+ from voidx.ui.tree import OutputTree, OutputNode
7
+ from voidx.ui.console import _fmt_args, _title, VoidConsole
8
+ from voidx.ui.dock import dock
9
+ from voidx.ui.dock_components.nodes import _bash_markdown_lines
10
+ from voidx.ui.events import (
11
+ ErrorAppended,
12
+ SubagentStepStarted,
13
+ ToolFinished,
14
+ ToolResultAppended,
15
+ ToolStarted,
16
+ WarningAppended,
17
+ ui_events,
18
+ )
19
+
20
+ class _DummyConsole:
21
+ width: int = 80
22
+
23
+ class CaptureConsole:
24
+ def __init__(self, tree: OutputTree, parent_node: OutputNode, *, agent_id: int = -1):
25
+ self._tree = tree
26
+ self._parent = parent_node
27
+ self._agent_id = agent_id
28
+ self._current_tool: OutputNode | None = None
29
+ self._current_tool_id: str = ""
30
+ self._tool_nodes: dict[str, OutputNode] = {}
31
+ self._dummy = _DummyConsole()
32
+
33
+ @property
34
+ def console(self) -> _DummyConsole:
35
+ return self._dummy
36
+
37
+ def step_header(self, n: int, max_n: int, agent: str = "") -> None:
38
+ if dock.active and ui_events.is_running:
39
+ ui_events.emit_nowait(SubagentStepStarted(
40
+ agent_id=self._agent_id,
41
+ subagent_id=agent or "subagent",
42
+ name=_title(VoidConsole._AGENT_GERUND.get(agent, agent)),
43
+ step=n,
44
+ max_steps=max_n,
45
+ ))
46
+ return
47
+ gerund = _title(VoidConsole._AGENT_GERUND.get(agent, agent))
48
+ self._tree.new_node(
49
+ parent=self._parent, node_type="turn",
50
+ header=f"⟳ {gerund} ({n}/{max_n})",
51
+ step_info=f"step {n}/{max_n}",
52
+ agent_name=agent if agent else None,
53
+ collapsed=False,
54
+ )
55
+ dock.refresh()
56
+
57
+ def tool_call(self, tool_name: str, args: dict[str, object], tool_call_id: str | None = None) -> None:
58
+ gerund = _title(VoidConsole._TOOL_GERUND.get(tool_name, tool_name + "ing"))
59
+ call_id = tool_call_id or f"capture:{tool_name}:{time.time_ns()}"
60
+ self._current_tool_id = call_id
61
+ if dock.active and ui_events.is_running:
62
+ ui_events.emit_nowait(ToolStarted(
63
+ agent_id=self._agent_id,
64
+ tool_call_id=call_id,
65
+ tool_name=tool_name,
66
+ label=gerund,
67
+ args=_fmt_args(args),
68
+ raw_args=args,
69
+ ))
70
+ return
71
+ body_lines = []
72
+ detail = f"({_fmt_args(args)})"
73
+ if tool_name == "bash":
74
+ command = str(args.get("command") or "")
75
+ detail = ""
76
+ body_lines = _bash_markdown_lines(command, self._dummy.width)
77
+ self._current_tool = self._tree.new_node(
78
+ parent=self._parent, node_type="tool_call",
79
+ header=f"● {gerund}{detail}",
80
+ body_lines=body_lines,
81
+ status="running", collapsed=True,
82
+ )
83
+ self._tool_nodes[call_id] = self._current_tool
84
+ dock.refresh()
85
+
86
+ def tool_done(self, tool_name: str, elapsed: float, ok: bool = True, tool_call_id: str | None = None) -> None:
87
+ call_id = tool_call_id or self._current_tool_id
88
+ if dock.active and ui_events.is_running and call_id:
89
+ ui_events.emit_nowait(ToolFinished(
90
+ agent_id=self._agent_id,
91
+ tool_call_id=call_id,
92
+ label=_title(tool_name),
93
+ elapsed=elapsed,
94
+ ok=ok,
95
+ ))
96
+ return
97
+ tool_node = self._tool_nodes.get(call_id) or self._current_tool
98
+ if not tool_node: return
99
+ icon = "●" if ok else "✗"
100
+ tool_node.header += f" {icon} {_title(tool_name)} ({elapsed:.1f}s)"
101
+ tool_node.elapsed = elapsed
102
+ tool_node.status = "done" if ok else "error"
103
+ self._tree.mark_dirty()
104
+ dock.refresh()
105
+
106
+ def tool_result(self, text: str, tool_call_id: str | None = None) -> None:
107
+ call_id = tool_call_id or self._current_tool_id
108
+ if dock.active and ui_events.is_running:
109
+ ui_events.emit_nowait(ToolResultAppended(
110
+ agent_id=self._agent_id,
111
+ tool_call_id=call_id,
112
+ text=text,
113
+ ))
114
+ return
115
+ parent = self._tool_nodes.get(call_id) or self._current_tool or self._parent
116
+ lines = text.split("\n")
117
+ self._tree.new_node(
118
+ parent=parent, node_type="tool_result",
119
+ header=escape(lines[0]) if lines else "",
120
+ body_lines=[escape(line) for line in lines[1:]], collapsed=False,
121
+ )
122
+ dock.refresh()
123
+
124
+ def diff(self, diff_text: str, title: str = "") -> None:
125
+ if dock.active and ui_events.is_running:
126
+ text = f"{title}\n{diff_text}" if title else diff_text
127
+ ui_events.emit_nowait(ToolResultAppended(
128
+ agent_id=self._agent_id,
129
+ tool_call_id=self._current_tool_id,
130
+ text=text,
131
+ collapsed=True,
132
+ ))
133
+ return
134
+ parent = self._current_tool or self._parent
135
+ lines = diff_text.split("\n")
136
+ self._tree.new_node(
137
+ parent=parent, node_type="diff",
138
+ header=escape((title or "diff")[:80]),
139
+ body_lines=[escape(line) for line in lines[:20]], collapsed=True,
140
+ )
141
+ dock.refresh()
142
+
143
+ def print(self, *args, **kwargs) -> None:
144
+ pass
145
+
146
+ def markdown(self, content: str) -> None:
147
+ pass
148
+
149
+ def thinking(self, text: str) -> None:
150
+ pass
151
+
152
+ def error(self, message: str) -> None:
153
+ if dock.active and ui_events.is_running:
154
+ ui_events.emit_nowait(ErrorAppended(agent_id=self._agent_id, message=message))
155
+ return
156
+ self._tree.new_node(parent=self._parent, node_type="error",
157
+ header=f"[red]✗ {escape(message)}[/]", header_style="red", collapsed=False)
158
+ dock.refresh()
159
+
160
+ def warn(self, message: str) -> None:
161
+ if dock.active and ui_events.is_running:
162
+ ui_events.emit_nowait(WarningAppended(agent_id=self._agent_id, message=message))
163
+ return
164
+ self._tree.new_node(parent=self._parent, node_type="warn",
165
+ header=f"[yellow]! {escape(message)}[/]", header_style="yellow", collapsed=False)
166
+ dock.refresh()
167
+
168
+ def sep(self) -> None:
169
+ pass
voidx/ui/code_ide.py ADDED
@@ -0,0 +1,251 @@
1
+ """Open changed files in the user's preferred code IDE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from voidx.config import CodeIde, Settings
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class IdeCandidate:
17
+ id: str
18
+ label: str
19
+ command: list[str] | None
20
+ source: str
21
+ available: bool
22
+
23
+
24
+ CLI_IDES: dict[str, tuple[str, list[str]]] = {
25
+ CodeIde.TRAE.value: ("Trae", ["trae"]),
26
+ CodeIde.CURSOR.value: ("Cursor", ["cursor"]),
27
+ CodeIde.CODE.value: ("VS Code", ["code"]),
28
+ CodeIde.WINDSURF.value: ("Windsurf", ["windsurf"]),
29
+ CodeIde.ZED.value: ("Zed", ["zed"]),
30
+ CodeIde.SUBLIME.value: ("Sublime Text", ["subl"]),
31
+ }
32
+
33
+ APP_IDES: dict[str, tuple[str, list[str]]] = {
34
+ CodeIde.TRAE.value: ("Trae", ["Trae.app"]),
35
+ CodeIde.CURSOR.value: ("Cursor", ["Cursor.app"]),
36
+ CodeIde.CODE.value: ("Visual Studio Code", ["Visual Studio Code.app", "Visual Studio Code - Insiders.app"]),
37
+ CodeIde.WINDSURF.value: ("Windsurf", ["Windsurf.app"]),
38
+ CodeIde.ZED.value: ("Zed", ["Zed.app"]),
39
+ CodeIde.SUBLIME.value: ("Sublime Text", ["Sublime Text.app"]),
40
+ CodeIde.JETBRAINS.value: ("JetBrains Toolbox", [
41
+ "IntelliJ IDEA.app",
42
+ "PyCharm.app",
43
+ "WebStorm.app",
44
+ "PhpStorm.app",
45
+ "GoLand.app",
46
+ "RustRover.app",
47
+ "CLion.app",
48
+ ]),
49
+ CodeIde.GHOSTTY.value: ("Ghostty", ["Ghostty.app"]),
50
+ }
51
+
52
+ TERMINAL_IDS = {CodeIde.GHOSTTY.value}
53
+
54
+
55
+ def normalize_ide(value: str | CodeIde | None) -> str:
56
+ raw = str(value.value if isinstance(value, CodeIde) else value or "").strip().lower()
57
+ return raw or CodeIde.TRAE.value
58
+
59
+
60
+ def detect_code_ides() -> list[IdeCandidate]:
61
+ candidates: list[IdeCandidate] = []
62
+ seen: set[tuple[str, str]] = set()
63
+
64
+ for ide_id, (label, commands) in CLI_IDES.items():
65
+ for command in commands:
66
+ resolved = shutil.which(command)
67
+ if resolved:
68
+ _append_candidate(candidates, seen, IdeCandidate(
69
+ id=ide_id,
70
+ label=label,
71
+ command=[resolved],
72
+ source=f"cli:{command}",
73
+ available=True,
74
+ ))
75
+ break
76
+
77
+ ghostty = shutil.which("ghostty")
78
+ if ghostty:
79
+ _append_candidate(candidates, seen, IdeCandidate(
80
+ id=CodeIde.GHOSTTY.value,
81
+ label="Ghostty",
82
+ command=[ghostty],
83
+ source="cli:ghostty",
84
+ available=True,
85
+ ))
86
+
87
+ for ide_id, (label, app_names) in APP_IDES.items():
88
+ for app_name in app_names:
89
+ app_path = _find_app(app_name)
90
+ if app_path is not None:
91
+ _append_candidate(candidates, seen, IdeCandidate(
92
+ id=ide_id,
93
+ label=label,
94
+ command=["open", "-a", app_path.stem],
95
+ source=f"app:{app_path}",
96
+ available=True,
97
+ ))
98
+ break
99
+
100
+ _append_candidate(candidates, seen, IdeCandidate(
101
+ id=CodeIde.SYSTEM.value,
102
+ label="System default",
103
+ command=["open"],
104
+ source="macOS open",
105
+ available=shutil.which("open") is not None,
106
+ ))
107
+ return [candidate for candidate in candidates if candidate.available]
108
+
109
+
110
+ def preferred_ide(settings: Settings | None = None) -> str:
111
+ if settings is None:
112
+ return CodeIde.TRAE.value
113
+ return normalize_ide(settings.get_code_ide())
114
+
115
+
116
+ def choose_ide(
117
+ settings: Settings | None = None,
118
+ detected: list[IdeCandidate] | None = None,
119
+ preferred: str | CodeIde | None = None,
120
+ ) -> IdeCandidate | None:
121
+ detected = detected if detected is not None else detect_code_ides()
122
+ if not detected:
123
+ return None
124
+ preferred_id = normalize_ide(preferred) if preferred is not None else preferred_ide(settings)
125
+ if preferred_id != CodeIde.AUTO.value:
126
+ for candidate in detected:
127
+ if candidate.id == preferred_id:
128
+ return candidate
129
+ priority = [
130
+ CodeIde.TRAE.value,
131
+ CodeIde.CURSOR.value,
132
+ CodeIde.CODE.value,
133
+ CodeIde.WINDSURF.value,
134
+ CodeIde.ZED.value,
135
+ CodeIde.SUBLIME.value,
136
+ CodeIde.JETBRAINS.value,
137
+ CodeIde.GHOSTTY.value,
138
+ CodeIde.SYSTEM.value,
139
+ ]
140
+ for ide_id in priority:
141
+ for candidate in detected:
142
+ if candidate.id == ide_id:
143
+ return candidate
144
+ return detected[0]
145
+
146
+
147
+ def code_ide_status(settings: Settings | None = None) -> str:
148
+ configured = preferred_ide(settings)
149
+ detected = detect_code_ides()
150
+ selected = choose_ide(settings, detected)
151
+ lines = [
152
+ "[bold]Code IDE[/bold]",
153
+ f" Configured: [cyan]{configured}[/cyan]",
154
+ f" Selected: [cyan]{selected.label if selected else 'none'}[/cyan]",
155
+ "",
156
+ " Detected:",
157
+ ]
158
+ if not detected:
159
+ lines.append(" [dim]none[/dim]")
160
+ else:
161
+ for candidate in detected:
162
+ marker = "✓" if selected and candidate.id == selected.id else " "
163
+ lines.append(f" {marker} [cyan]{candidate.id}[/cyan] — {candidate.label} [dim]({candidate.source})[/dim]")
164
+ return "\n".join(lines)
165
+
166
+
167
+ def open_file_in_code_ide(
168
+ file_path: str | Path,
169
+ *,
170
+ line: int = 1,
171
+ settings: Settings | None = None,
172
+ preferred: str | CodeIde | None = None,
173
+ ) -> bool:
174
+ path = Path(file_path).expanduser().resolve()
175
+ candidate = choose_ide(settings, preferred=preferred)
176
+ if candidate is None:
177
+ return False
178
+ command = build_open_command(candidate, path, line=line)
179
+ if command is None:
180
+ return False
181
+ try:
182
+ subprocess.Popen(command)
183
+ return True
184
+ except Exception:
185
+ return False
186
+
187
+
188
+ def build_open_command(candidate: IdeCandidate, path: Path, *, line: int = 1) -> list[str] | None:
189
+ if candidate.command is None:
190
+ return None
191
+ location = f"{path}:{line}"
192
+ if _is_app_launcher(candidate.command):
193
+ return _build_app_open_command(candidate, path, location, line=line)
194
+ if candidate.id in {CodeIde.TRAE.value, CodeIde.CURSOR.value, CodeIde.CODE.value, CodeIde.WINDSURF.value}:
195
+ return [*candidate.command, "--goto", location]
196
+ if candidate.id == CodeIde.ZED.value:
197
+ return [*candidate.command, str(path)]
198
+ if candidate.id == CodeIde.SUBLIME.value:
199
+ return [*candidate.command, location]
200
+ if candidate.id == CodeIde.JETBRAINS.value:
201
+ return [*candidate.command, str(path)]
202
+ if candidate.id in TERMINAL_IDS:
203
+ editor = _terminal_editor_command()
204
+ if not editor:
205
+ return [*candidate.command, str(path)]
206
+ return [*candidate.command, "-e", *editor, f"+{line}", str(path)]
207
+ if candidate.id == CodeIde.SYSTEM.value:
208
+ return [*candidate.command, str(path)]
209
+ return [*candidate.command, str(path)]
210
+
211
+
212
+ def _build_app_open_command(candidate: IdeCandidate, path: Path, location: str, *, line: int) -> list[str]:
213
+ if candidate.id in {CodeIde.TRAE.value, CodeIde.CURSOR.value, CodeIde.CODE.value, CodeIde.WINDSURF.value}:
214
+ return [*candidate.command, "--args", "--goto", location]
215
+ if candidate.id in TERMINAL_IDS:
216
+ editor = _terminal_editor_command()
217
+ if editor:
218
+ return [*candidate.command, "--args", "-e", *editor, f"+{line}", str(path)]
219
+ return [*candidate.command, str(path)]
220
+
221
+
222
+ def _is_app_launcher(command: list[str]) -> bool:
223
+ return len(command) >= 3 and Path(command[0]).name == "open" and command[1] == "-a"
224
+
225
+
226
+ def _append_candidate(candidates: list[IdeCandidate], seen: set[tuple[str, str]], candidate: IdeCandidate) -> None:
227
+ key = (candidate.id, candidate.source)
228
+ if key in seen:
229
+ return
230
+ seen.add(key)
231
+ candidates.append(candidate)
232
+
233
+
234
+ def _find_app(app_name: str) -> Path | None:
235
+ for base in (Path("/Applications"), Path.home() / "Applications"):
236
+ path = base / app_name
237
+ if path.exists():
238
+ return path
239
+ return None
240
+
241
+
242
+ def _terminal_editor_command() -> list[str]:
243
+ for env_name in ("EDITOR", "VISUAL"):
244
+ value = os.environ.get(env_name)
245
+ if value:
246
+ return shlex.split(value)
247
+ for command in ("nvim", "vim", "nano"):
248
+ resolved = shutil.which(command)
249
+ if resolved:
250
+ return [resolved]
251
+ return []
voidx/ui/commands.py ADDED
@@ -0,0 +1,83 @@
1
+ """Slash command palette — Claude Code style. / triggers selectable command list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ COMMANDS: list[tuple[str, str]] = [
6
+ ("/allow", "Allow a tool for this session"),
7
+ ("/approval", "Set approval policy: untrusted|on-failure|on-request|never"),
8
+ ("/approval never", "Never ask for approval"),
9
+ ("/approval on-failure", "Auto-allow non-bash tools and report failures"),
10
+ ("/approval on-request", "Auto-allow unless an explicit request is needed"),
11
+ ("/approval untrusted", "Ask for write/edit/write-capable bash tools"),
12
+ ("/clear", "Start a new session with empty context"),
13
+ ("/code-ide", "Choose app for opening changed files"),
14
+ ("/code-ide status", "Show detected code IDEs"),
15
+ ("/compact", "Manually trigger context compaction"),
16
+ ("/debug", "Toggle verbose step/tool output"),
17
+ ("/debug off", "Disable verbose step/tool output"),
18
+ ("/debug on", "Enable verbose step/tool output"),
19
+ ("/deny", "Deny a tool for this session"),
20
+ ("/diff", "Show git working tree diff with syntax highlighting"),
21
+ ("/exit", "Exit voidx"),
22
+ ("/help", "Show all commands"),
23
+ ("/goal", "Set or show current goal"),
24
+ ("/goal clear", "Clear current goal"),
25
+ ("/list", "List saved sessions"),
26
+ ("/lsp", "Manage language servers"),
27
+ ("/lsp doctor", "Check installed language servers"),
28
+ ("/lsp restart", "Restart language servers"),
29
+ ("/lsp servers", "List configured LSP servers"),
30
+ ("/lsp status", "Show LSP server status"),
31
+ ("/mcp", "Manage MCP servers"),
32
+ ("/mcp del", "Remove an MCP server"),
33
+ ("/mcp list", "List configured MCP servers"),
34
+ ("/mcp new", "Configure a new MCP server"),
35
+ ("/mcp restart", "Restart an MCP server"),
36
+ ("/mcp test", "Test an MCP server connection"),
37
+ ("/mcp tools", "Show MCP server tools"),
38
+ ("/mode", "Choose interaction mode: auto|plan|goal"),
39
+ ("/mode auto", "Auto-detect task intent per turn"),
40
+ ("/mode goal", "Keep multi-step work scoped"),
41
+ ("/mode plan", "Read-only plan mode"),
42
+ ("/model", "Switch configured model"),
43
+ ("/model del", "Remove a profile"),
44
+ ("/model list", "Show configured model details"),
45
+ ("/model new", "Create or update a model profile"),
46
+ ("/model reasoning", "Set reasoning effort level"),
47
+ ("/model switch", "Switch to a configured provider"),
48
+ ("/model test", "Test a provider's connectivity"),
49
+ ("/paste", "Paste an image from the clipboard"),
50
+ ("/permission-mode", "Choose permission mode"),
51
+ ("/permission-mode accept-edits", "Allow file edits, ask for bash"),
52
+ ("/permission-mode auto-review", "Reviewer-assisted approvals"),
53
+ ("/permission-mode custom", "Use voidx.json config"),
54
+ ("/permission-mode default", "Ask before write/edit/bash"),
55
+ ("/permission-mode full-access", "No sandbox or approval prompts"),
56
+ ("/permission-mode read-only", "Block all writes"),
57
+ ("/permissions", "Show current permission rules"),
58
+ ("/plan", "Enter plan mode (writes and write-capable bash blocked)"),
59
+ ("/resume", "Resume a session by ID"),
60
+ ("/sandbox", "Set sandbox mode: read-only|workspace-write|danger-full-access"),
61
+ ("/sandbox danger-full-access", "No sandbox restrictions"),
62
+ ("/sandbox read-only", "Read-only sandbox"),
63
+ ("/sandbox workspace-write", "Allow workspace writes"),
64
+ ("/skills", "Manage local skills"),
65
+ ("/tavily", "Configure Tavily API key for web search"),
66
+ ("/tavily delete", "Delete Tavily API key"),
67
+ ("/tavily set", "Set Tavily API key for web search"),
68
+ ("/tavily show", "Show Tavily API key status"),
69
+ ("/title", "Set session title"),
70
+ ("/unplan", "Return to auto mode"),
71
+ ("/usage", "Show token usage for this session"),
72
+ ]
73
+
74
+ def filter_commands(prefix: str) -> list[tuple[str, str]]:
75
+ """Filter commands by prefix. Returns (name, description) pairs.
76
+
77
+ Matches both when the input *is* a prefix of a command (e.g. ``/res``
78
+ matches ``/resume``) and when the input *starts with* a command
79
+ (e.g. ``/resume abc123`` matches ``/resume``).
80
+ """
81
+ p = prefix.lower()
82
+ return [(n, d) for n, d in COMMANDS
83
+ if n.lower().startswith(p) or p.startswith(n.lower())]