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
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Output node mutation mixin for BottomInputDock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
|
|
10
|
+
from voidx.ui.dock_components.formatting import (
|
|
11
|
+
_ansi_line,
|
|
12
|
+
_clean,
|
|
13
|
+
_markdown_lines,
|
|
14
|
+
_short_path,
|
|
15
|
+
_short_value,
|
|
16
|
+
_strip_ansi_trailing_space,
|
|
17
|
+
_tail_lines,
|
|
18
|
+
)
|
|
19
|
+
from voidx.ui.tree import OutputNode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DockNodeMixin:
|
|
23
|
+
def append_startup(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
model: str,
|
|
27
|
+
provider: str,
|
|
28
|
+
workspace: str,
|
|
29
|
+
session_title: str,
|
|
30
|
+
is_new: bool,
|
|
31
|
+
profile_configured: bool = True,
|
|
32
|
+
) -> OutputNode | None:
|
|
33
|
+
from voidx.ui.startup import render_startup_lines
|
|
34
|
+
|
|
35
|
+
lines = render_startup_lines(
|
|
36
|
+
self._width(),
|
|
37
|
+
model=model,
|
|
38
|
+
provider=provider,
|
|
39
|
+
workspace=workspace,
|
|
40
|
+
session_title=session_title,
|
|
41
|
+
is_new=is_new,
|
|
42
|
+
)
|
|
43
|
+
if not profile_configured:
|
|
44
|
+
lines.extend([
|
|
45
|
+
"[yellow]No profile configured — chat is disabled until you set one up.[/yellow]",
|
|
46
|
+
"[dim] Use [cyan]/model new[/cyan] to create a profile interactively[/dim]",
|
|
47
|
+
])
|
|
48
|
+
if not lines:
|
|
49
|
+
return None
|
|
50
|
+
node = self._tree.new_node(
|
|
51
|
+
parent=self._tree.root,
|
|
52
|
+
node_type="startup",
|
|
53
|
+
header=lines[0],
|
|
54
|
+
body_lines=lines[1:],
|
|
55
|
+
collapsed=False,
|
|
56
|
+
)
|
|
57
|
+
self.refresh()
|
|
58
|
+
return node
|
|
59
|
+
|
|
60
|
+
def append_message(self, text: str, *, style: str = "", parent: OutputNode | None = None) -> OutputNode | None:
|
|
61
|
+
clean = _clean(text)
|
|
62
|
+
if not clean.strip():
|
|
63
|
+
return None
|
|
64
|
+
target = parent or self._tree.root
|
|
65
|
+
lines = [_strip_ansi_trailing_space(line) for line in (clean.splitlines() or [clean])]
|
|
66
|
+
header = escape(lines[0])
|
|
67
|
+
if style:
|
|
68
|
+
header = f"[{style}]{header}[/]"
|
|
69
|
+
body_lines = [escape(line) for line in lines[1:]]
|
|
70
|
+
if style:
|
|
71
|
+
body_lines = [f"[{style}]{line}[/]" for line in body_lines]
|
|
72
|
+
node = self._tree.new_node(
|
|
73
|
+
parent=target,
|
|
74
|
+
node_type="message",
|
|
75
|
+
header=header,
|
|
76
|
+
body_lines=body_lines,
|
|
77
|
+
collapsed=False,
|
|
78
|
+
)
|
|
79
|
+
self.refresh()
|
|
80
|
+
return node
|
|
81
|
+
|
|
82
|
+
def append_error(self, message: str, *, parent: OutputNode | None = None) -> OutputNode | None:
|
|
83
|
+
clean = _clean(message)
|
|
84
|
+
if not clean.strip():
|
|
85
|
+
return None
|
|
86
|
+
lines = [_strip_ansi_trailing_space(line) for line in (clean.splitlines() or [clean])]
|
|
87
|
+
node = self._tree.new_node(
|
|
88
|
+
parent=parent or self._tree.root,
|
|
89
|
+
node_type="error",
|
|
90
|
+
header=f"[red]✗ {escape(lines[0])}[/red]",
|
|
91
|
+
body_lines=[f"[red] {escape(line)}[/red]" for line in lines[1:]],
|
|
92
|
+
collapsed=False,
|
|
93
|
+
status="error",
|
|
94
|
+
)
|
|
95
|
+
self.refresh()
|
|
96
|
+
return node
|
|
97
|
+
|
|
98
|
+
def append_ansi(self, text: str, *, parent: OutputNode | None = None) -> OutputNode | None:
|
|
99
|
+
clean = text.rstrip("\n")
|
|
100
|
+
if not clean.strip():
|
|
101
|
+
return None
|
|
102
|
+
lines = [_strip_ansi_trailing_space(line) for line in (clean.splitlines() or [clean])]
|
|
103
|
+
node = self._tree.new_node(
|
|
104
|
+
parent=parent or self._tree.root,
|
|
105
|
+
node_type="message",
|
|
106
|
+
header=_ansi_line(lines[0]),
|
|
107
|
+
body_lines=[_ansi_line(line) for line in lines[1:]],
|
|
108
|
+
collapsed=False,
|
|
109
|
+
)
|
|
110
|
+
self.refresh()
|
|
111
|
+
return node
|
|
112
|
+
|
|
113
|
+
def append_thought(self, text: str, elapsed: float | None = None) -> OutputNode | None:
|
|
114
|
+
clean = _clean(text).strip()
|
|
115
|
+
if not clean:
|
|
116
|
+
return None
|
|
117
|
+
lines = clean.splitlines()
|
|
118
|
+
summary = f"Thinking for {elapsed:.0f}s" if elapsed is not None else "Thinking"
|
|
119
|
+
if lines:
|
|
120
|
+
summary += f", {len(lines)} line{'s' if len(lines) != 1 else ''}"
|
|
121
|
+
node = self._tree.new_node(
|
|
122
|
+
parent=self.ensure_agent(),
|
|
123
|
+
node_type="thought",
|
|
124
|
+
header=f"[dim]●[/dim] [dim]{escape(summary)}[/dim]",
|
|
125
|
+
body_lines=[f"[dim]{escape(line)}[/dim]" for line in lines[-5:]],
|
|
126
|
+
collapsed=True,
|
|
127
|
+
meta=summary,
|
|
128
|
+
)
|
|
129
|
+
self.refresh()
|
|
130
|
+
return node
|
|
131
|
+
|
|
132
|
+
def start_tool(
|
|
133
|
+
self,
|
|
134
|
+
label: str,
|
|
135
|
+
args: str = "",
|
|
136
|
+
*,
|
|
137
|
+
parent: OutputNode | None = None,
|
|
138
|
+
tool_call_id: str | None = None,
|
|
139
|
+
tool_name: str = "",
|
|
140
|
+
raw_args: dict[str, Any] | None = None,
|
|
141
|
+
) -> OutputNode:
|
|
142
|
+
if parent is None:
|
|
143
|
+
self._settle_stream_for_tool()
|
|
144
|
+
raw_args = raw_args or {}
|
|
145
|
+
body_lines: list[str] = []
|
|
146
|
+
header = f"[bold]{escape(label)}[/]"
|
|
147
|
+
if tool_name == "bash":
|
|
148
|
+
command = str(raw_args.get("command") or "")
|
|
149
|
+
if command:
|
|
150
|
+
body_lines = _bash_markdown_lines(command, self._markdown_width())
|
|
151
|
+
elif args:
|
|
152
|
+
header += f"({args})"
|
|
153
|
+
parent = parent or self.ensure_agent()
|
|
154
|
+
tool_body = header
|
|
155
|
+
self._current_tool = self._tree.new_node(
|
|
156
|
+
parent=parent,
|
|
157
|
+
node_type="tool_call",
|
|
158
|
+
header=f"[#A3BE8C]●[/#A3BE8C] {tool_body}",
|
|
159
|
+
body_lines=body_lines,
|
|
160
|
+
status="running",
|
|
161
|
+
collapsed=True,
|
|
162
|
+
meta=tool_body,
|
|
163
|
+
tool_call_id=tool_call_id,
|
|
164
|
+
payload={"tool_name": tool_name, "args": args, "raw_args": raw_args},
|
|
165
|
+
)
|
|
166
|
+
self.refresh()
|
|
167
|
+
return self._current_tool
|
|
168
|
+
|
|
169
|
+
def finish_tool(self, label: str, elapsed: float, ok: bool = True, detail: str = "") -> None:
|
|
170
|
+
if not self._current_tool:
|
|
171
|
+
return
|
|
172
|
+
self.finish_tool_node(self._current_tool, label, elapsed, ok, detail)
|
|
173
|
+
|
|
174
|
+
def finish_tool_node(
|
|
175
|
+
self,
|
|
176
|
+
node: OutputNode,
|
|
177
|
+
label: str,
|
|
178
|
+
elapsed: float,
|
|
179
|
+
ok: bool = True,
|
|
180
|
+
detail: str = "",
|
|
181
|
+
) -> None:
|
|
182
|
+
color = "dim" if ok else "red"
|
|
183
|
+
icon = "●" if ok else "✗"
|
|
184
|
+
tool_body = node.meta or node.header
|
|
185
|
+
suffix = f" [dim]({elapsed:.1f}s)[/dim]"
|
|
186
|
+
if detail:
|
|
187
|
+
suffix += f" [dim]{detail}[/dim]"
|
|
188
|
+
node.header = f"[{color}]{icon}[/{color}] {tool_body}{suffix}"
|
|
189
|
+
node.elapsed = elapsed
|
|
190
|
+
node.status = "done" if ok else "error"
|
|
191
|
+
self._tree.mark_dirty()
|
|
192
|
+
self.refresh()
|
|
193
|
+
|
|
194
|
+
def append_tool_result(
|
|
195
|
+
self,
|
|
196
|
+
text: str,
|
|
197
|
+
*,
|
|
198
|
+
parent: OutputNode | None = None,
|
|
199
|
+
collapsed: bool = False,
|
|
200
|
+
tool_call_id: str | None = None,
|
|
201
|
+
) -> OutputNode | None:
|
|
202
|
+
clean = _clean(text)
|
|
203
|
+
if not clean.strip():
|
|
204
|
+
return None
|
|
205
|
+
lines = [_strip_ansi_trailing_space(line) for line in (clean.splitlines() or [clean])]
|
|
206
|
+
while lines and not _clean(lines[0]).strip():
|
|
207
|
+
lines.pop(0)
|
|
208
|
+
while lines and not _clean(lines[-1]).strip():
|
|
209
|
+
lines.pop()
|
|
210
|
+
if not lines:
|
|
211
|
+
return None
|
|
212
|
+
node = self._tree.new_node(
|
|
213
|
+
parent=parent or self._current_tool or self._current_agent or self._tree.root,
|
|
214
|
+
node_type="tool_result",
|
|
215
|
+
header=escape(lines[0]) if lines else "",
|
|
216
|
+
body_lines=[escape(line) for line in lines[1:]],
|
|
217
|
+
collapsed=collapsed,
|
|
218
|
+
tool_call_id=tool_call_id,
|
|
219
|
+
)
|
|
220
|
+
self.refresh()
|
|
221
|
+
return node
|
|
222
|
+
|
|
223
|
+
def append_file_change(
|
|
224
|
+
self,
|
|
225
|
+
diff_text: str,
|
|
226
|
+
*,
|
|
227
|
+
parent: OutputNode | None = None,
|
|
228
|
+
tool_call_id: str | None = None,
|
|
229
|
+
preview_hunks: int = 1,
|
|
230
|
+
preview_lines: int = 8,
|
|
231
|
+
) -> OutputNode | None:
|
|
232
|
+
from voidx.ui.diff import (
|
|
233
|
+
parse_unified_diff,
|
|
234
|
+
render_file_change_lines,
|
|
235
|
+
render_full_file_diff_lines,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
parsed = parse_unified_diff(diff_text)
|
|
239
|
+
if not parsed.files:
|
|
240
|
+
return self.append_tool_result(
|
|
241
|
+
diff_text,
|
|
242
|
+
parent=parent,
|
|
243
|
+
collapsed=True,
|
|
244
|
+
tool_call_id=tool_call_id,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
target = parent or self._current_tool or self._current_agent or self._tree.root
|
|
248
|
+
first_node: OutputNode | None = None
|
|
249
|
+
for index, file_diff in enumerate(parsed.files):
|
|
250
|
+
body_lines, omitted = render_file_change_lines(file_diff, preview_hunks, preview_lines)
|
|
251
|
+
header = (
|
|
252
|
+
f"[#A3BE8C]●[/#A3BE8C] "
|
|
253
|
+
f"[bold]{file_diff.operation}[/bold]({escape(_short_path(file_diff.path))})"
|
|
254
|
+
)
|
|
255
|
+
if index == 0 and target.node_type == "tool_call":
|
|
256
|
+
node = target
|
|
257
|
+
node.header = header
|
|
258
|
+
node.body_lines = body_lines
|
|
259
|
+
node.collapsed = True
|
|
260
|
+
node.status = "done"
|
|
261
|
+
node.meta = header
|
|
262
|
+
if tool_call_id:
|
|
263
|
+
node.tool_call_id = tool_call_id
|
|
264
|
+
node.payload["diff_text"] = diff_text
|
|
265
|
+
else:
|
|
266
|
+
node = self._tree.new_node(
|
|
267
|
+
parent=target,
|
|
268
|
+
node_type="tool_call",
|
|
269
|
+
header=header,
|
|
270
|
+
body_lines=body_lines,
|
|
271
|
+
collapsed=True,
|
|
272
|
+
status="done",
|
|
273
|
+
meta=header,
|
|
274
|
+
tool_call_id=tool_call_id,
|
|
275
|
+
payload={"diff_text": diff_text},
|
|
276
|
+
)
|
|
277
|
+
full_lines = render_full_file_diff_lines(file_diff)
|
|
278
|
+
if omitted and full_lines:
|
|
279
|
+
self._tree.new_node(
|
|
280
|
+
parent=node,
|
|
281
|
+
node_type="tool_result",
|
|
282
|
+
header="[dim]Full diff[/dim]",
|
|
283
|
+
body_lines=full_lines,
|
|
284
|
+
collapsed=True,
|
|
285
|
+
)
|
|
286
|
+
if first_node is None:
|
|
287
|
+
first_node = node
|
|
288
|
+
self._tree.mark_dirty()
|
|
289
|
+
self.refresh()
|
|
290
|
+
return first_node
|
|
291
|
+
|
|
292
|
+
def set_status(
|
|
293
|
+
self,
|
|
294
|
+
status_id: str,
|
|
295
|
+
label: str,
|
|
296
|
+
detail: str = "",
|
|
297
|
+
*,
|
|
298
|
+
parent: OutputNode | None = None,
|
|
299
|
+
stage: str = "working",
|
|
300
|
+
) -> OutputNode:
|
|
301
|
+
node = self._status_nodes.get(status_id)
|
|
302
|
+
if node is None:
|
|
303
|
+
node = self._tree.new_node(
|
|
304
|
+
parent=parent or self._tree.root,
|
|
305
|
+
node_type="status",
|
|
306
|
+
header="",
|
|
307
|
+
collapsed=False,
|
|
308
|
+
)
|
|
309
|
+
self._status_nodes[status_id] = node
|
|
310
|
+
tick = self._status_ticks.get(status_id, 0)
|
|
311
|
+
self._status_ticks[status_id] = tick + 1
|
|
312
|
+
color = "#EBCB8B" if tick % 2 == 0 else "#F6D365"
|
|
313
|
+
node.header = f"[{color}]●[/{color}] {escape(label)}"
|
|
314
|
+
clean_detail = _clean(detail).strip()
|
|
315
|
+
node.body_lines = [f"[dim]{escape(line)}[/dim]" for line in _tail_lines(clean_detail, 5)]
|
|
316
|
+
node.collapsed = False
|
|
317
|
+
node.status = "running"
|
|
318
|
+
node.meta = label
|
|
319
|
+
self._tree.mark_dirty()
|
|
320
|
+
self.refresh()
|
|
321
|
+
return node
|
|
322
|
+
|
|
323
|
+
def finish_status(
|
|
324
|
+
self,
|
|
325
|
+
status_id: str,
|
|
326
|
+
*,
|
|
327
|
+
label: str = "",
|
|
328
|
+
detail: str = "",
|
|
329
|
+
ok: bool = True,
|
|
330
|
+
remove: bool = True,
|
|
331
|
+
) -> None:
|
|
332
|
+
node = self._status_nodes.pop(status_id, None)
|
|
333
|
+
if node is None:
|
|
334
|
+
return
|
|
335
|
+
self._status_ticks.pop(status_id, None)
|
|
336
|
+
if remove:
|
|
337
|
+
self._remove_node(node)
|
|
338
|
+
self.refresh()
|
|
339
|
+
return
|
|
340
|
+
color = "dim" if ok else "red"
|
|
341
|
+
icon = "●" if ok else "✗"
|
|
342
|
+
text = label or _clean(node.header).strip() or "Done"
|
|
343
|
+
node.header = f"[{color}]{icon}[/{color}] [dim]{escape(text)}[/dim]"
|
|
344
|
+
clean_detail = _clean(detail).strip()
|
|
345
|
+
if clean_detail:
|
|
346
|
+
node.body_lines = [f"[dim]{escape(line)}[/dim]" for line in _tail_lines(clean_detail, 5)]
|
|
347
|
+
node.status = "done" if ok else "error"
|
|
348
|
+
node.collapsed = True
|
|
349
|
+
node.meta = text
|
|
350
|
+
self._tree.mark_dirty()
|
|
351
|
+
self.refresh()
|
|
352
|
+
|
|
353
|
+
def show_permission(
|
|
354
|
+
self,
|
|
355
|
+
prompt: str,
|
|
356
|
+
tools: list[dict[str, Any]],
|
|
357
|
+
*,
|
|
358
|
+
parent: OutputNode | None = None,
|
|
359
|
+
) -> OutputNode:
|
|
360
|
+
self.clear_permission()
|
|
361
|
+
body: list[str] = []
|
|
362
|
+
for index, tool in enumerate(tools, 1):
|
|
363
|
+
name = str(tool.get("name") or "tool")
|
|
364
|
+
pattern = str(tool.get("pattern") or "")
|
|
365
|
+
body.append(escape(f"{index}. {name}"))
|
|
366
|
+
if pattern and pattern != "*":
|
|
367
|
+
body.append(escape(f" target: {pattern}"))
|
|
368
|
+
args = tool.get("args")
|
|
369
|
+
if isinstance(args, dict):
|
|
370
|
+
for key, value in args.items():
|
|
371
|
+
body.append(escape(f" {key}: {_short_value(value)}"))
|
|
372
|
+
self._permission_node = self._tree.new_node(
|
|
373
|
+
parent=parent or self._tree.root,
|
|
374
|
+
node_type="permission",
|
|
375
|
+
header=f"[yellow]Permission required[/yellow] {escape(prompt)}",
|
|
376
|
+
body_lines=body,
|
|
377
|
+
collapsed=False,
|
|
378
|
+
)
|
|
379
|
+
self.refresh()
|
|
380
|
+
return self._permission_node
|
|
381
|
+
|
|
382
|
+
def clear_permission(self) -> None:
|
|
383
|
+
if self._permission_node is None:
|
|
384
|
+
return
|
|
385
|
+
self._remove_node(self._permission_node)
|
|
386
|
+
self._permission_node = None
|
|
387
|
+
self.refresh()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _bash_markdown_lines(command: str, width: int) -> list[str]:
|
|
391
|
+
command = command.rstrip("\n")
|
|
392
|
+
if not command:
|
|
393
|
+
return []
|
|
394
|
+
fence = _markdown_fence(command)
|
|
395
|
+
markdown = f"{fence}bash\n{command}\n{fence}"
|
|
396
|
+
return [_ansi_line(line) for line in _markdown_lines(markdown, width)]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _markdown_fence(text: str) -> str:
|
|
400
|
+
runs = [len(match.group(0)) for match in re.finditer(r"`+", text)]
|
|
401
|
+
return "`" * max(3, max(runs, default=0) + 1)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Context-local dock proxy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_current_dock: ContextVar[Any | None] = ContextVar("current_dock", default=None)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_dock() -> Any | None:
|
|
12
|
+
return _current_dock.get()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_dock(dock: Any | None) -> None:
|
|
16
|
+
_current_dock.set(dock)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _DockProxy:
|
|
20
|
+
@property
|
|
21
|
+
def active(self) -> bool:
|
|
22
|
+
d = get_dock()
|
|
23
|
+
return d.active if d is not None else False
|
|
24
|
+
|
|
25
|
+
def __getattr__(self, name):
|
|
26
|
+
d = get_dock()
|
|
27
|
+
if d is None:
|
|
28
|
+
if name in ("active",):
|
|
29
|
+
return False
|
|
30
|
+
if name in (
|
|
31
|
+
"begin_capture",
|
|
32
|
+
"deactivate",
|
|
33
|
+
"print",
|
|
34
|
+
"capture",
|
|
35
|
+
"start_turn",
|
|
36
|
+
"set_stream",
|
|
37
|
+
"start_tool",
|
|
38
|
+
"tool_output",
|
|
39
|
+
"append_tool_result",
|
|
40
|
+
"refresh",
|
|
41
|
+
"set_mode",
|
|
42
|
+
):
|
|
43
|
+
return lambda *args, **kwargs: None
|
|
44
|
+
raise RuntimeError(f"No active dock in this context. Cannot access '{name}'.")
|
|
45
|
+
return getattr(d, name)
|
|
46
|
+
|
|
47
|
+
def __bool__(self):
|
|
48
|
+
return get_dock() is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
dock = _DockProxy()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Implementation parts for UI events."""
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Typed UI event schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, TypeAlias
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UiEventBase(BaseModel):
|
|
11
|
+
model_config = ConfigDict(frozen=True)
|
|
12
|
+
agent_id: int = -1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CaptureStarted(UiEventBase):
|
|
16
|
+
kind: Literal["capture.started"] = "capture.started"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CaptureStopped(UiEventBase):
|
|
20
|
+
kind: Literal["capture.stopped"] = "capture.stopped"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RefreshRequested(UiEventBase):
|
|
24
|
+
kind: Literal["refresh.requested"] = "refresh.requested"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResetRequested(UiEventBase):
|
|
28
|
+
kind: Literal["reset.requested"] = "reset.requested"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TurnStarted(UiEventBase):
|
|
32
|
+
kind: Literal["turn.started"] = "turn.started"
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StartupShown(UiEventBase):
|
|
37
|
+
kind: Literal["startup.shown"] = "startup.shown"
|
|
38
|
+
model: str
|
|
39
|
+
provider: str
|
|
40
|
+
workspace: str
|
|
41
|
+
session_title: str
|
|
42
|
+
is_new: bool
|
|
43
|
+
profile_configured: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MessageAppended(UiEventBase):
|
|
47
|
+
kind: Literal["message.appended"] = "message.appended"
|
|
48
|
+
text: str
|
|
49
|
+
style: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AnsiAppended(UiEventBase):
|
|
53
|
+
kind: Literal["ansi.appended"] = "ansi.appended"
|
|
54
|
+
text: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MarkdownAppended(UiEventBase):
|
|
58
|
+
kind: Literal["markdown.appended"] = "markdown.appended"
|
|
59
|
+
content: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ThoughtAppended(UiEventBase):
|
|
63
|
+
kind: Literal["thought.appended"] = "thought.appended"
|
|
64
|
+
text: str
|
|
65
|
+
elapsed: float | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WarningAppended(UiEventBase):
|
|
69
|
+
kind: Literal["warning.appended"] = "warning.appended"
|
|
70
|
+
message: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ErrorAppended(UiEventBase):
|
|
74
|
+
kind: Literal["error.appended"] = "error.appended"
|
|
75
|
+
message: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DiffAppended(UiEventBase):
|
|
79
|
+
kind: Literal["diff.appended"] = "diff.appended"
|
|
80
|
+
diff_text: str
|
|
81
|
+
title: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StatusUpdated(UiEventBase):
|
|
85
|
+
kind: Literal["status.updated"] = "status.updated"
|
|
86
|
+
status_id: str
|
|
87
|
+
label: str
|
|
88
|
+
detail: str = ""
|
|
89
|
+
stage: Literal[
|
|
90
|
+
"analyzing",
|
|
91
|
+
"thinking",
|
|
92
|
+
"streaming",
|
|
93
|
+
"agent_step",
|
|
94
|
+
"compacting",
|
|
95
|
+
"waiting_permission",
|
|
96
|
+
"working",
|
|
97
|
+
] = "working"
|
|
98
|
+
parent_tool_call_id: str = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class StatusFinished(UiEventBase):
|
|
102
|
+
kind: Literal["status.finished"] = "status.finished"
|
|
103
|
+
status_id: str
|
|
104
|
+
label: str = ""
|
|
105
|
+
detail: str = ""
|
|
106
|
+
ok: bool = True
|
|
107
|
+
remove: bool = True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AssistantStreamStarted(UiEventBase):
|
|
111
|
+
kind: Literal["assistant_stream.started"] = "assistant_stream.started"
|
|
112
|
+
stream_id: str = "default"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AssistantStreamUpdated(UiEventBase):
|
|
116
|
+
kind: Literal["assistant_stream.updated"] = "assistant_stream.updated"
|
|
117
|
+
text: str
|
|
118
|
+
stream_id: str = "default"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AssistantStreamCommitted(UiEventBase):
|
|
122
|
+
kind: Literal["assistant_stream.committed"] = "assistant_stream.committed"
|
|
123
|
+
stream_id: str = "default"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AssistantStreamDiscarded(UiEventBase):
|
|
127
|
+
kind: Literal["assistant_stream.discarded"] = "assistant_stream.discarded"
|
|
128
|
+
stream_id: str = "default"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ToolStarted(UiEventBase):
|
|
132
|
+
kind: Literal["tool.started"] = "tool.started"
|
|
133
|
+
tool_call_id: str
|
|
134
|
+
label: str
|
|
135
|
+
args: str = ""
|
|
136
|
+
tool_name: str = ""
|
|
137
|
+
raw_args: dict[str, Any] = Field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ToolFinished(UiEventBase):
|
|
141
|
+
kind: Literal["tool.finished"] = "tool.finished"
|
|
142
|
+
tool_call_id: str
|
|
143
|
+
label: str
|
|
144
|
+
elapsed: float
|
|
145
|
+
ok: bool = True
|
|
146
|
+
detail: str = ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ToolResultAppended(UiEventBase):
|
|
150
|
+
kind: Literal["tool_result.appended"] = "tool_result.appended"
|
|
151
|
+
tool_call_id: str = ""
|
|
152
|
+
text: str
|
|
153
|
+
collapsed: bool = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class FileChangeAppended(UiEventBase):
|
|
157
|
+
kind: Literal["file_change.appended"] = "file_change.appended"
|
|
158
|
+
tool_call_id: str = ""
|
|
159
|
+
diff_text: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SubagentStarted(UiEventBase):
|
|
163
|
+
kind: Literal["subagent.started"] = "subagent.started"
|
|
164
|
+
agent_id: int
|
|
165
|
+
subagent_id: str
|
|
166
|
+
name: str
|
|
167
|
+
description: str = ""
|
|
168
|
+
parent_agent_id: int = -1
|
|
169
|
+
parent_tool_call_id: str = ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SubagentStepStarted(UiEventBase):
|
|
173
|
+
kind: Literal["subagent_step.started"] = "subagent_step.started"
|
|
174
|
+
agent_id: int
|
|
175
|
+
subagent_id: str
|
|
176
|
+
name: str
|
|
177
|
+
step: int
|
|
178
|
+
max_steps: int
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SubagentFinished(UiEventBase):
|
|
182
|
+
kind: Literal["subagent.finished"] = "subagent.finished"
|
|
183
|
+
agent_id: int
|
|
184
|
+
subagent_id: str
|
|
185
|
+
ok: bool = True
|
|
186
|
+
elapsed: float | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PermissionToolDetail(BaseModel):
|
|
190
|
+
name: str
|
|
191
|
+
pattern: str = ""
|
|
192
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class PermissionPromptShown(UiEventBase):
|
|
196
|
+
kind: Literal["permission_prompt.shown"] = "permission_prompt.shown"
|
|
197
|
+
prompt: str
|
|
198
|
+
choices: list[tuple[str, str, str]]
|
|
199
|
+
tools: list[PermissionToolDetail] = Field(default_factory=list)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class PermissionPromptCleared(UiEventBase):
|
|
203
|
+
kind: Literal["permission_prompt.cleared"] = "permission_prompt.cleared"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class InputSet(UiEventBase):
|
|
207
|
+
kind: Literal["input.set"] = "input.set"
|
|
208
|
+
text: str
|
|
209
|
+
hints: list[tuple[str, str, bool]] = Field(default_factory=list)
|
|
210
|
+
cursor_pos: int | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class NoticeSet(UiEventBase):
|
|
214
|
+
kind: Literal["notice.set"] = "notice.set"
|
|
215
|
+
text: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
UiEvent: TypeAlias = (
|
|
219
|
+
CaptureStarted
|
|
220
|
+
| CaptureStopped
|
|
221
|
+
| RefreshRequested
|
|
222
|
+
| ResetRequested
|
|
223
|
+
| TurnStarted
|
|
224
|
+
| StartupShown
|
|
225
|
+
| MessageAppended
|
|
226
|
+
| AnsiAppended
|
|
227
|
+
| MarkdownAppended
|
|
228
|
+
| ThoughtAppended
|
|
229
|
+
| WarningAppended
|
|
230
|
+
| ErrorAppended
|
|
231
|
+
| DiffAppended
|
|
232
|
+
| StatusUpdated
|
|
233
|
+
| StatusFinished
|
|
234
|
+
| AssistantStreamStarted
|
|
235
|
+
| AssistantStreamUpdated
|
|
236
|
+
| AssistantStreamCommitted
|
|
237
|
+
| AssistantStreamDiscarded
|
|
238
|
+
| ToolStarted
|
|
239
|
+
| ToolFinished
|
|
240
|
+
| ToolResultAppended
|
|
241
|
+
| FileChangeAppended
|
|
242
|
+
| SubagentStarted
|
|
243
|
+
| SubagentStepStarted
|
|
244
|
+
| SubagentFinished
|
|
245
|
+
| PermissionPromptShown
|
|
246
|
+
| PermissionPromptCleared
|
|
247
|
+
| InputSet
|
|
248
|
+
| NoticeSet
|
|
249
|
+
)
|