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/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())]
|