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,1169 @@
|
|
|
1
|
+
"""Panel and transcript rendering mixin for PromptToolkitTui."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
8
|
+
from prompt_toolkit.formatted_text import AnyFormattedText
|
|
9
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
10
|
+
from rich.cells import cell_len
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
from voidx.llm.usage import format_cache_hit_rate, format_token_count
|
|
14
|
+
from voidx.ui.code_ide import open_file_in_code_ide
|
|
15
|
+
from voidx.ui.app_components.formatting import (
|
|
16
|
+
_args_preview,
|
|
17
|
+
_clip,
|
|
18
|
+
_continuation_prefix,
|
|
19
|
+
_friendly_choice_label,
|
|
20
|
+
_lines_to_formatted_text,
|
|
21
|
+
_mcp_status_label,
|
|
22
|
+
_permission_target,
|
|
23
|
+
_visible_text,
|
|
24
|
+
)
|
|
25
|
+
from voidx.ui.app_components.file_picker import (
|
|
26
|
+
AttachmentToken,
|
|
27
|
+
FileCandidate,
|
|
28
|
+
find_attachment_token,
|
|
29
|
+
format_size,
|
|
30
|
+
list_file_candidates,
|
|
31
|
+
)
|
|
32
|
+
from voidx.ui.dock import dock
|
|
33
|
+
from voidx.ui.session_changes import session_tracker
|
|
34
|
+
|
|
35
|
+
COMMAND_VISIBLE_ITEMS = 5
|
|
36
|
+
CHOICE_VISIBLE_ITEMS = 8
|
|
37
|
+
_EFFORT_LABELS = {
|
|
38
|
+
"off": "关",
|
|
39
|
+
"none": "关",
|
|
40
|
+
"low": "低",
|
|
41
|
+
"medium": "中",
|
|
42
|
+
"high": "高",
|
|
43
|
+
"xhigh": "超高",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PromptToolkitRenderMixin:
|
|
48
|
+
COMMAND_OUTPUT_WIDE_MIN = 110
|
|
49
|
+
BOTTOM_BAR_HEIGHT = 4
|
|
50
|
+
|
|
51
|
+
def _command_panel_active(self) -> bool:
|
|
52
|
+
text = self.input.text
|
|
53
|
+
return (
|
|
54
|
+
self._active_choice is None
|
|
55
|
+
and self._active_text_prompt is None
|
|
56
|
+
and text.startswith("/")
|
|
57
|
+
and text != self._command_panel_suppressed_text
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _attachment_panel_active(self) -> bool:
|
|
61
|
+
text = self.input.text
|
|
62
|
+
return (
|
|
63
|
+
self._active_choice is None
|
|
64
|
+
and self._active_text_prompt is None
|
|
65
|
+
and not text.startswith("/")
|
|
66
|
+
and text != self._attachment_panel_suppressed_text
|
|
67
|
+
and self._attachment_token() is not None
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _attachment_token(self) -> AttachmentToken | None:
|
|
71
|
+
return find_attachment_token(self.input.text, self.input.buffer.cursor_position)
|
|
72
|
+
|
|
73
|
+
def _attachment_matches(self) -> list[FileCandidate]:
|
|
74
|
+
token = self._attachment_token()
|
|
75
|
+
if token is None:
|
|
76
|
+
return []
|
|
77
|
+
return list_file_candidates(self.status.workspace, token.query, limit=8)
|
|
78
|
+
|
|
79
|
+
def _mcp_panel_active(self) -> bool:
|
|
80
|
+
raw = self.input.text
|
|
81
|
+
text = raw.strip().lower()
|
|
82
|
+
if text == "/mcp":
|
|
83
|
+
return True
|
|
84
|
+
if not raw.lower().startswith("/mcp "):
|
|
85
|
+
return False
|
|
86
|
+
if text == "/mcp":
|
|
87
|
+
return True
|
|
88
|
+
mcp_cmds = [(n, d) for n, d in self.commands if n.lower().startswith("/mcp")]
|
|
89
|
+
if any(name.lower() == text for name, _ in mcp_cmds):
|
|
90
|
+
return False
|
|
91
|
+
parts = text.split(None, 1)
|
|
92
|
+
if len(parts) > 1 and not any(name.lower().startswith(text) for name, _ in mcp_cmds if " " in name):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def _slash_matches(self) -> list[tuple[str, str]]:
|
|
97
|
+
text = self.input.text.strip().lower()
|
|
98
|
+
if not text or text == "/":
|
|
99
|
+
return self.commands
|
|
100
|
+
matched = [(name, desc) for name, desc in self.commands if name.lower().startswith(text)]
|
|
101
|
+
if not matched:
|
|
102
|
+
token = text.split(None, 1)[0]
|
|
103
|
+
matched = [(name, desc) for name, desc in self.commands if name.lower().startswith(token)]
|
|
104
|
+
return sorted(matched, key=lambda m: (" " in m[0], m[0]))
|
|
105
|
+
|
|
106
|
+
def _mcp_servers(self) -> list[McpServerStatus]:
|
|
107
|
+
try:
|
|
108
|
+
return self.status.mcp_servers()
|
|
109
|
+
except Exception:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
def _command_selectable_count(self) -> int:
|
|
113
|
+
if self._mcp_panel_active():
|
|
114
|
+
return min(len(self._mcp_servers()), 8)
|
|
115
|
+
return min(len(self._slash_matches()), 8)
|
|
116
|
+
|
|
117
|
+
def _attachment_selectable_count(self) -> int:
|
|
118
|
+
return min(len(self._attachment_matches()), 8)
|
|
119
|
+
|
|
120
|
+
def _clamp_command_selection(self) -> None:
|
|
121
|
+
count = self._command_selectable_count()
|
|
122
|
+
if count <= 0:
|
|
123
|
+
self._command_selected = 0
|
|
124
|
+
return
|
|
125
|
+
self._command_selected = max(0, min(self._command_selected, count - 1))
|
|
126
|
+
|
|
127
|
+
def _clamp_attachment_selection(self) -> None:
|
|
128
|
+
count = self._attachment_selectable_count()
|
|
129
|
+
if count <= 0:
|
|
130
|
+
self._attachment_selected = 0
|
|
131
|
+
return
|
|
132
|
+
self._attachment_selected = max(0, min(self._attachment_selected, count - 1))
|
|
133
|
+
|
|
134
|
+
def _move_command_selection(self, amount: int) -> None:
|
|
135
|
+
count = self._command_selectable_count()
|
|
136
|
+
if count <= 0:
|
|
137
|
+
return
|
|
138
|
+
self._command_selected = max(0, min(self._command_selected + amount, count - 1))
|
|
139
|
+
|
|
140
|
+
def _move_command_selection_visual(self, direction: int) -> None:
|
|
141
|
+
self._move_command_selection(-direction)
|
|
142
|
+
|
|
143
|
+
def _move_attachment_selection(self, amount: int) -> None:
|
|
144
|
+
count = self._attachment_selectable_count()
|
|
145
|
+
if count <= 0:
|
|
146
|
+
return
|
|
147
|
+
self._attachment_selected = max(0, min(self._attachment_selected + amount, count - 1))
|
|
148
|
+
|
|
149
|
+
def _accept_command_panel_selection(self) -> bool:
|
|
150
|
+
if self._mcp_panel_active():
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
matches = self._slash_matches()
|
|
154
|
+
if not matches:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
selected = matches[min(self._command_selected, len(matches) - 1)][0]
|
|
158
|
+
text = self.input.text.strip()
|
|
159
|
+
if text == selected or text.startswith(selected + " "):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
self.input.text = selected
|
|
163
|
+
self.input.buffer.cursor_position = len(selected)
|
|
164
|
+
self._command_panel_suppressed_text = ""
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def _render_footer(self) -> AnyFormattedText:
|
|
168
|
+
width = max(self._input_panel_width() - 3, 1)
|
|
169
|
+
|
|
170
|
+
if self._active_choice is not None and self._choice_details:
|
|
171
|
+
text = " ↑/↓ select Enter confirm Esc cancel a/y/n quick choose"
|
|
172
|
+
return [("class:hints", text[:width])]
|
|
173
|
+
|
|
174
|
+
if self._active_text_prompt is not None:
|
|
175
|
+
detail = "input hidden" if self._active_text_secret else "text input"
|
|
176
|
+
text = f" Enter submit Esc cancel {detail}"
|
|
177
|
+
return [("class:hints", text[:width])]
|
|
178
|
+
|
|
179
|
+
if self._command_panel_active():
|
|
180
|
+
text = " ↑/↓ select Enter confirm Esc hide panel"
|
|
181
|
+
return [("class:hints", text[:width])]
|
|
182
|
+
|
|
183
|
+
if self._attachment_panel_active():
|
|
184
|
+
text = " ↑/↓ select Enter attach Esc hide panel"
|
|
185
|
+
return [("class:hints", text[:width])]
|
|
186
|
+
|
|
187
|
+
left = self._footer_left_fragment(width)
|
|
188
|
+
left_text = left[1]
|
|
189
|
+
status_fragments = self._status_fragments(max(width - len(left_text), 1))
|
|
190
|
+
status_len = _fragment_text_len(status_fragments)
|
|
191
|
+
available = max(width - len(left_text) - status_len, 0)
|
|
192
|
+
|
|
193
|
+
positions: dict[str, int] = {"permission": 2}
|
|
194
|
+
cursor = len(left_text) + available
|
|
195
|
+
for text, _, anchor in self._status_segment_data(max(width - len(left_text), 1)):
|
|
196
|
+
positions[anchor] = cursor
|
|
197
|
+
cursor += len(text) + 2
|
|
198
|
+
self._footer_anchor_positions = positions
|
|
199
|
+
|
|
200
|
+
return [
|
|
201
|
+
left,
|
|
202
|
+
("class:hints", " " * available),
|
|
203
|
+
*status_fragments,
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
def _status_text(self, width: int | None = None) -> str:
|
|
207
|
+
provider = _safe_status_value(self.status.provider, "-")
|
|
208
|
+
model = _safe_status_value(self.status.model, "-")
|
|
209
|
+
effort = _safe_status_value(self.status.reasoning_effort, "xhigh")
|
|
210
|
+
busy = " busy" if self._busy else ""
|
|
211
|
+
error = f" error:{self._last_error[:32]}" if self._last_error else ""
|
|
212
|
+
variants = [
|
|
213
|
+
[f"{provider}/{model}", effort, f"{busy}{error}".strip()],
|
|
214
|
+
[model, effort, f"{busy}{error}".strip()],
|
|
215
|
+
[provider, f"{busy}{error}".strip()],
|
|
216
|
+
]
|
|
217
|
+
if width is None:
|
|
218
|
+
return _join_status_segments(variants[0])
|
|
219
|
+
for segments in variants:
|
|
220
|
+
text = _join_status_segments(segments)
|
|
221
|
+
if len(text) <= width:
|
|
222
|
+
return text
|
|
223
|
+
return _clip(_join_status_segments(variants[-1]), width)
|
|
224
|
+
|
|
225
|
+
def _footer_left_fragment(self, width: int) -> tuple:
|
|
226
|
+
text = self.input.text
|
|
227
|
+
if self._notice and not text:
|
|
228
|
+
return ("class:hints", _clip(" " + self._notice, width))
|
|
229
|
+
permission = _safe_status_value(self.status.permission_label(), "default")
|
|
230
|
+
return (
|
|
231
|
+
"class:footer.permission",
|
|
232
|
+
_clip(f" {permission}", width),
|
|
233
|
+
self._footer_click_handler("/permission-mode", choice_anchor="permission"),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _status_segment_data(self, width: int) -> list[tuple[str, str, str]]:
|
|
237
|
+
provider = _safe_status_value(self.status.provider, "-")
|
|
238
|
+
model = _safe_status_value(self.status.model, "-")
|
|
239
|
+
effort = _safe_status_value(self.status.reasoning_effort, "xhigh")
|
|
240
|
+
variants = [
|
|
241
|
+
[
|
|
242
|
+
(f"{provider}/{model}", "/model", "model"),
|
|
243
|
+
(effort, "/model reasoning", "reasoning"),
|
|
244
|
+
],
|
|
245
|
+
[
|
|
246
|
+
(model, "/model", "model"),
|
|
247
|
+
(effort, "/model reasoning", "reasoning"),
|
|
248
|
+
],
|
|
249
|
+
[
|
|
250
|
+
(provider, "/model", "provider"),
|
|
251
|
+
],
|
|
252
|
+
]
|
|
253
|
+
for segments in variants:
|
|
254
|
+
text = _join_status_segments([segment for segment, _, _ in segments])
|
|
255
|
+
if len(text) <= width:
|
|
256
|
+
return segments
|
|
257
|
+
clipped = _clip(_join_status_segments([segment for segment, _, _ in variants[-1]]), width)
|
|
258
|
+
return [(clipped, "/model", "status")]
|
|
259
|
+
|
|
260
|
+
def _status_fragments(self, width: int) -> list[tuple]:
|
|
261
|
+
return self._status_segment_fragments(self._status_segment_data(width))
|
|
262
|
+
|
|
263
|
+
def _status_segment_fragments(self, segments: list[tuple[str, str, str]]) -> list[tuple]:
|
|
264
|
+
fragments: list[tuple] = []
|
|
265
|
+
for text, command, anchor in segments:
|
|
266
|
+
if not text:
|
|
267
|
+
continue
|
|
268
|
+
if fragments:
|
|
269
|
+
fragments.append(("class:hints", " "))
|
|
270
|
+
if command:
|
|
271
|
+
fragments.append((
|
|
272
|
+
self._footer_status_style(anchor),
|
|
273
|
+
text,
|
|
274
|
+
self._footer_click_handler(command, choice_anchor=anchor),
|
|
275
|
+
))
|
|
276
|
+
else:
|
|
277
|
+
fragments.append(("class:hints", text))
|
|
278
|
+
return fragments
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _footer_status_style(anchor: str) -> str:
|
|
282
|
+
if anchor == "reasoning":
|
|
283
|
+
return "class:footer.reasoning"
|
|
284
|
+
return "class:footer.model"
|
|
285
|
+
|
|
286
|
+
def _footer_click_handler(self, command: str, *, quiet: bool = True, choice_anchor: str = ""):
|
|
287
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
288
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
289
|
+
return None
|
|
290
|
+
if self._active_choice is not None:
|
|
291
|
+
if self._choice_anchor == choice_anchor:
|
|
292
|
+
self._finish_choice(None)
|
|
293
|
+
self.invalidate()
|
|
294
|
+
return None
|
|
295
|
+
self._finish_choice(None)
|
|
296
|
+
self._command_panel_suppressed_text = ""
|
|
297
|
+
self._attachment_panel_suppressed_text = ""
|
|
298
|
+
self._choice_anchor = choice_anchor
|
|
299
|
+
if choice_anchor == "permission":
|
|
300
|
+
self._choice_current_value = _safe_status_value(self.status.permission_label(), "default")
|
|
301
|
+
elif choice_anchor == "reasoning":
|
|
302
|
+
self._choice_current_value = _safe_status_value(self.status.reasoning_effort, "xhigh")
|
|
303
|
+
elif choice_anchor == "model":
|
|
304
|
+
self._choice_current_value = _safe_status_value(self.status.model, "")
|
|
305
|
+
if quiet:
|
|
306
|
+
self.queue_quiet_command(command)
|
|
307
|
+
else:
|
|
308
|
+
self._queue.put_nowait(command)
|
|
309
|
+
self.invalidate()
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
return _handler
|
|
313
|
+
|
|
314
|
+
def _render_detail_status_panel(self) -> AnyFormattedText:
|
|
315
|
+
width = max(self._detail_status_width(), 20)
|
|
316
|
+
rows: list[tuple[str, str]] = []
|
|
317
|
+
stats = self.status.usage_stats
|
|
318
|
+
context_limit = stats.context_limit or self.status.context_limit
|
|
319
|
+
busy = "busy" if self._busy else "idle"
|
|
320
|
+
if self._last_error:
|
|
321
|
+
busy = f"error:{self._last_error[:20]}"
|
|
322
|
+
|
|
323
|
+
state = busy
|
|
324
|
+
detail_rows = [
|
|
325
|
+
[
|
|
326
|
+
("class:status.label", "ctx "),
|
|
327
|
+
(
|
|
328
|
+
"class:status.value",
|
|
329
|
+
f"{format_token_count(stats.context_tokens)}/{format_token_count(context_limit)}",
|
|
330
|
+
),
|
|
331
|
+
("class:status.dim", " cache "),
|
|
332
|
+
("class:status.value", format_cache_hit_rate(stats)),
|
|
333
|
+
],
|
|
334
|
+
[
|
|
335
|
+
("class:status.label", "calls "),
|
|
336
|
+
("class:status.value", format_token_count(stats.total_calls)),
|
|
337
|
+
("class:status.dim", " "),
|
|
338
|
+
("class:status.label", "in "),
|
|
339
|
+
("class:status.value", format_token_count(stats.last_input_tokens)),
|
|
340
|
+
("class:status.dim", " out "),
|
|
341
|
+
("class:status.value", format_token_count(stats.last_output_tokens)),
|
|
342
|
+
("class:status.dim", " total "),
|
|
343
|
+
("class:status.value", format_token_count(stats.total_tokens)),
|
|
344
|
+
],
|
|
345
|
+
[
|
|
346
|
+
("class:status.label", "s:"),
|
|
347
|
+
("class:status.value", _safe_status_value(self.status.sandbox_label(), "w-write")),
|
|
348
|
+
("class:status.dim", " a:"),
|
|
349
|
+
("class:status.value", _safe_status_value(self.status.approval_label(), "on-fail")),
|
|
350
|
+
("class:status.dim", " r:"),
|
|
351
|
+
("class:status.value", _safe_status_value(self.status.approval_reviewer_label(), "user")),
|
|
352
|
+
("class:status.dim", " dbg:"),
|
|
353
|
+
("class:status.value", "on" if self.status.debug() else "off"),
|
|
354
|
+
],
|
|
355
|
+
[
|
|
356
|
+
("class:status.label", "state:"),
|
|
357
|
+
("class:status.value", state),
|
|
358
|
+
("class:status.dim", " mode:"),
|
|
359
|
+
("class:status.value", _safe_status_value(self.status.interaction_mode(), "auto")),
|
|
360
|
+
("class:status.dim", " plan:"),
|
|
361
|
+
("class:status.value", "on" if self.status.plan_mode() else "off"),
|
|
362
|
+
],
|
|
363
|
+
]
|
|
364
|
+
goal_label = _safe_status_value(self.status.goal_label(), "")
|
|
365
|
+
goal_status = _safe_status_value(self.status.goal_status(), "idle")
|
|
366
|
+
if goal_label or goal_status != "idle":
|
|
367
|
+
approval = "waiting" if self.status.goal_awaiting_approval() else "none"
|
|
368
|
+
detail_rows.append([
|
|
369
|
+
("class:status.label", "goal:"),
|
|
370
|
+
("class:status.value", _clip(goal_status, 12)),
|
|
371
|
+
("class:status.dim", "/"),
|
|
372
|
+
("class:status.value", _clip(_safe_status_value(self.status.goal_phase(), "clarify"), 14)),
|
|
373
|
+
("class:status.dim", " turns "),
|
|
374
|
+
("class:status.value", str(self.status.goal_turn_count())),
|
|
375
|
+
("class:status.dim", " approval:"),
|
|
376
|
+
("class:status.value", approval),
|
|
377
|
+
("class:status.dim", " "),
|
|
378
|
+
("class:status.value", _clip(goal_label, max(12, width - 48))),
|
|
379
|
+
])
|
|
380
|
+
for index, row in enumerate(detail_rows):
|
|
381
|
+
self._append_status_line(rows, row, width, newline=index < len(detail_rows) - 1)
|
|
382
|
+
return rows
|
|
383
|
+
|
|
384
|
+
def _append_status_line(
|
|
385
|
+
self,
|
|
386
|
+
rows: list[tuple[str, str]],
|
|
387
|
+
parts: list[tuple[str, str]],
|
|
388
|
+
width: int,
|
|
389
|
+
*,
|
|
390
|
+
newline: bool = True,
|
|
391
|
+
) -> None:
|
|
392
|
+
rows.append(("class:status", " "))
|
|
393
|
+
used = 2
|
|
394
|
+
for style, text in parts:
|
|
395
|
+
clipped = _clip(text, max(width - used, 0))
|
|
396
|
+
rows.append((style, clipped))
|
|
397
|
+
used += len(clipped)
|
|
398
|
+
if used < width:
|
|
399
|
+
rows.append(("class:status", " " * (width - used)))
|
|
400
|
+
if newline:
|
|
401
|
+
rows.append(("class:status", "\n"))
|
|
402
|
+
|
|
403
|
+
def _render_body(self) -> AnyFormattedText:
|
|
404
|
+
width = max(self._main_width() - 1, 20)
|
|
405
|
+
lines, line_map = dock.tree.render_with_line_map(width)
|
|
406
|
+
if not lines:
|
|
407
|
+
lines = ["[dim]No conversation yet.[/]"]
|
|
408
|
+
line_map = {}
|
|
409
|
+
height = self._body_height()
|
|
410
|
+
offset = min(self._scroll_offset, self._max_scroll(len(lines), height))
|
|
411
|
+
end = len(lines) - offset
|
|
412
|
+
start = max(0, end - height)
|
|
413
|
+
visible = lines[start:end]
|
|
414
|
+
visible_node_ids = [line_map.get(index) for index in range(start, end)]
|
|
415
|
+
self._visible_body_lines = visible
|
|
416
|
+
self._visible_body_node_ids = visible_node_ids
|
|
417
|
+
|
|
418
|
+
# Build visual-row -> node_id mapping (accounts for line wrapping)
|
|
419
|
+
# Uses width for first visual line, (width - prefix_w) for continuation
|
|
420
|
+
# lines, matching prompt_toolkit's actual wrapping behaviour.
|
|
421
|
+
row_map: dict[int, str | None] = {}
|
|
422
|
+
visual_row = 0
|
|
423
|
+
for i, line in enumerate(visible):
|
|
424
|
+
vis_w = cell_len(_visible_text(line))
|
|
425
|
+
prefix = _continuation_prefix(line)
|
|
426
|
+
prefix_w = len(prefix)
|
|
427
|
+
if vis_w <= width or prefix_w <= 0:
|
|
428
|
+
wraps = max(1, (vis_w + width - 1) // width) if width > 0 else 1
|
|
429
|
+
else:
|
|
430
|
+
cont_width = max(width - prefix_w, 1)
|
|
431
|
+
wraps = 1 + max(0, (vis_w - width + cont_width - 1) // cont_width)
|
|
432
|
+
node_id = line_map.get(start + i)
|
|
433
|
+
for row in range(visual_row, visual_row + wraps):
|
|
434
|
+
row_map[row] = node_id
|
|
435
|
+
visual_row += wraps
|
|
436
|
+
self._visible_row_to_node = row_map
|
|
437
|
+
|
|
438
|
+
return _lines_to_formatted_text(visible, width, follow_tail=offset == 0)
|
|
439
|
+
|
|
440
|
+
def _render_choice_panel(self) -> AnyFormattedText:
|
|
441
|
+
width = max(self._main_width() - 1, 32)
|
|
442
|
+
if not self._choice_details:
|
|
443
|
+
return self._render_compact_choice_panel()
|
|
444
|
+
|
|
445
|
+
rows: list[tuple[str, str]] = []
|
|
446
|
+
|
|
447
|
+
title = " Permission "
|
|
448
|
+
top_fill = max(width - len(title) - 3, 0)
|
|
449
|
+
rows.append(("class:permission.border", "╭─"))
|
|
450
|
+
rows.append(("class:permission.title", title))
|
|
451
|
+
rows.append(("class:permission.border", "─" * top_fill + "╮\n"))
|
|
452
|
+
|
|
453
|
+
self._append_panel_line(rows, [("class:permission.prompt", self._choice_prompt)], width)
|
|
454
|
+
|
|
455
|
+
details = self._choice_detail_lines()
|
|
456
|
+
for line in details[:4]:
|
|
457
|
+
self._append_panel_line(rows, line, width)
|
|
458
|
+
if len(details) > 4:
|
|
459
|
+
self._append_panel_line(rows, [("class:permission.dim", f"... +{len(details) - 4} more")], width)
|
|
460
|
+
|
|
461
|
+
rows.append(("class:permission.border", "├" + "─" * (width - 2) + "┤\n"))
|
|
462
|
+
choices = self._active_choice or []
|
|
463
|
+
selected = max(0, min(self._choice_selected, len(choices) - 1))
|
|
464
|
+
visible_count = min(len(choices), self._choice_visible_items())
|
|
465
|
+
start, visible = _selected_window(choices, selected, visible_count)
|
|
466
|
+
if start > 0:
|
|
467
|
+
self._append_panel_line(rows, [("class:permission.dim", f"... {start} above")], width)
|
|
468
|
+
for offset, (label, value, desc) in enumerate(visible):
|
|
469
|
+
index = start + offset
|
|
470
|
+
selected = index == self._choice_selected
|
|
471
|
+
marker = "❯" if selected else " "
|
|
472
|
+
text = _friendly_choice_label(label, value, desc)
|
|
473
|
+
key = value if len(value) == 1 else ""
|
|
474
|
+
style = "class:permission.choice.selected" if selected else "class:permission.choice"
|
|
475
|
+
parts = [
|
|
476
|
+
("class:permission.marker", marker),
|
|
477
|
+
(style, f" {text}"),
|
|
478
|
+
]
|
|
479
|
+
if key:
|
|
480
|
+
parts.append(("class:permission.key", f" {key}"))
|
|
481
|
+
self._append_panel_line(rows, parts, width)
|
|
482
|
+
hidden_below = len(choices) - start - len(visible)
|
|
483
|
+
if hidden_below > 0:
|
|
484
|
+
self._append_panel_line(rows, [("class:permission.dim", f"... {hidden_below} below")], width)
|
|
485
|
+
|
|
486
|
+
rows.append(("class:permission.border", "╰" + "─" * (width - 2) + "╯"))
|
|
487
|
+
return rows
|
|
488
|
+
|
|
489
|
+
def _render_compact_choice_panel(self) -> AnyFormattedText:
|
|
490
|
+
menu_width = self._choice_float_width()
|
|
491
|
+
rows: list[tuple[str, str]] = []
|
|
492
|
+
choices = self._active_choice or []
|
|
493
|
+
selected = max(0, min(self._choice_selected, len(choices) - 1))
|
|
494
|
+
visible_count = min(len(choices), self._choice_visible_items())
|
|
495
|
+
start, visible = _selected_window(choices, selected, visible_count)
|
|
496
|
+
detail_lines = self._choice_detail_lines()
|
|
497
|
+
left_pad = 0
|
|
498
|
+
|
|
499
|
+
if self._choice_details:
|
|
500
|
+
self._append_compact_choice_parts_row(
|
|
501
|
+
rows,
|
|
502
|
+
left_pad,
|
|
503
|
+
menu_width,
|
|
504
|
+
[("class:choice.prompt", self._choice_prompt)],
|
|
505
|
+
)
|
|
506
|
+
for line in detail_lines[:3]:
|
|
507
|
+
self._append_compact_choice_parts_row(rows, left_pad, menu_width, line)
|
|
508
|
+
if len(detail_lines) > 3:
|
|
509
|
+
self._append_compact_choice_row(rows, left_pad, menu_width, f"... +{len(detail_lines) - 3} more", False, None)
|
|
510
|
+
|
|
511
|
+
if start > 0:
|
|
512
|
+
self._append_compact_choice_row(rows, left_pad, menu_width, f"... {start} above", False, None)
|
|
513
|
+
|
|
514
|
+
for offset, (label, value, desc) in enumerate(visible):
|
|
515
|
+
index = start + offset
|
|
516
|
+
selected = index == self._choice_selected
|
|
517
|
+
self._append_compact_choice_row(
|
|
518
|
+
rows,
|
|
519
|
+
left_pad,
|
|
520
|
+
menu_width,
|
|
521
|
+
_compact_choice_label(label, value, desc, self._choice_current_value),
|
|
522
|
+
selected,
|
|
523
|
+
index,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
hidden_below = len(choices) - start - len(visible)
|
|
527
|
+
if hidden_below > 0:
|
|
528
|
+
self._append_compact_choice_row(rows, left_pad, menu_width, f"... {hidden_below} below", False, None)
|
|
529
|
+
return rows
|
|
530
|
+
|
|
531
|
+
def _append_compact_choice_row(
|
|
532
|
+
self,
|
|
533
|
+
rows: list[tuple[str, str]],
|
|
534
|
+
left_pad: int,
|
|
535
|
+
menu_width: int,
|
|
536
|
+
label: str,
|
|
537
|
+
selected: bool,
|
|
538
|
+
index: int | None,
|
|
539
|
+
) -> None:
|
|
540
|
+
rows.append(("class:choice.pad", " " * left_pad))
|
|
541
|
+
text = " " + _clip(label, max(menu_width - 3, 1))
|
|
542
|
+
text += " " * max(menu_width - len(text), 0)
|
|
543
|
+
style = "class:choice.selected" if selected else "class:choice"
|
|
544
|
+
if index is None:
|
|
545
|
+
rows.append((style, text))
|
|
546
|
+
else:
|
|
547
|
+
rows.append((style, text, self._compact_choice_click_handler(index)))
|
|
548
|
+
rows.append(("class:choice.pad", "\n"))
|
|
549
|
+
|
|
550
|
+
def _append_compact_choice_parts_row(
|
|
551
|
+
self,
|
|
552
|
+
rows: list[tuple[str, str]],
|
|
553
|
+
left_pad: int,
|
|
554
|
+
menu_width: int,
|
|
555
|
+
parts: list[tuple[str, str]],
|
|
556
|
+
) -> None:
|
|
557
|
+
rows.append(("class:choice.pad", " " * left_pad))
|
|
558
|
+
rows.append(("class:choice", " "))
|
|
559
|
+
used = 2
|
|
560
|
+
for style, text in parts:
|
|
561
|
+
clipped = _clip(text, max(menu_width - used - 1, 0))
|
|
562
|
+
rows.append((style, clipped))
|
|
563
|
+
used += len(clipped)
|
|
564
|
+
rows.append(("class:choice", " " * max(menu_width - used, 0)))
|
|
565
|
+
rows.append(("class:choice.pad", "\n"))
|
|
566
|
+
|
|
567
|
+
def _compact_choice_left_pad(self, width: int, menu_width: int) -> int:
|
|
568
|
+
max_left = max(width - menu_width, 0)
|
|
569
|
+
anchor = self._choice_anchor
|
|
570
|
+
pos = self._footer_anchor_positions.get(anchor)
|
|
571
|
+
if pos is not None:
|
|
572
|
+
return max(0, min(pos, max_left))
|
|
573
|
+
return max(width - menu_width - 2, 0)
|
|
574
|
+
|
|
575
|
+
def _choice_float_width(self) -> int:
|
|
576
|
+
width = self._choice_menu_width()
|
|
577
|
+
choice_float = getattr(self, "_compact_choice_float", None)
|
|
578
|
+
if choice_float is not None:
|
|
579
|
+
choice_float.left = self._compact_choice_left_pad(
|
|
580
|
+
max(self._input_panel_width() - 3, 1),
|
|
581
|
+
width,
|
|
582
|
+
)
|
|
583
|
+
perm_float = getattr(self, "_permission_choice_float", None)
|
|
584
|
+
if perm_float is not None:
|
|
585
|
+
perm_float.left = max(self._input_panel_width() // 3, 2)
|
|
586
|
+
return width
|
|
587
|
+
|
|
588
|
+
def _choice_float_available_width(self) -> int:
|
|
589
|
+
return max(self._input_panel_width() - 1, 32)
|
|
590
|
+
|
|
591
|
+
def _choice_menu_width(self) -> int:
|
|
592
|
+
width = self._choice_float_available_width()
|
|
593
|
+
choices = self._active_choice or []
|
|
594
|
+
selected = max(0, min(self._choice_selected, len(choices) - 1))
|
|
595
|
+
visible_count = min(len(choices), self._choice_visible_items())
|
|
596
|
+
_, visible = _selected_window(choices, selected, visible_count)
|
|
597
|
+
labels = [_compact_choice_label(label, value, desc, self._choice_current_value) for label, value, desc in visible]
|
|
598
|
+
content_width = max(
|
|
599
|
+
[cell_len(label) for label in labels]
|
|
600
|
+
+ [cell_len(self._choice_prompt)]
|
|
601
|
+
+ [4]
|
|
602
|
+
)
|
|
603
|
+
result = min(content_width + 4, width)
|
|
604
|
+
permission_float = getattr(self, "_permission_choice_float", None)
|
|
605
|
+
if permission_float is not None and self._choice_details:
|
|
606
|
+
permission_float.left = max(self._input_panel_width() // 3, 2)
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
def _choice_anchor_for_prompt(self, prompt: str) -> str:
|
|
610
|
+
normalized = prompt.strip().lower()
|
|
611
|
+
if "permission" in normalized or "allow tool" in normalized:
|
|
612
|
+
return "permission"
|
|
613
|
+
if "effort" in normalized or "reasoning" in normalized:
|
|
614
|
+
return "reasoning"
|
|
615
|
+
if "provider" in normalized:
|
|
616
|
+
return "provider"
|
|
617
|
+
if "model" in normalized or "switch" in normalized:
|
|
618
|
+
return "model"
|
|
619
|
+
return ""
|
|
620
|
+
|
|
621
|
+
def _compact_choice_click_handler(self, index: int):
|
|
622
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
623
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
624
|
+
return None
|
|
625
|
+
choices = self._active_choice or []
|
|
626
|
+
if index < 0 or index >= len(choices):
|
|
627
|
+
return None
|
|
628
|
+
self._choice_selected = index
|
|
629
|
+
self._finish_choice(choices[index][1])
|
|
630
|
+
self.invalidate()
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
return _handler
|
|
634
|
+
|
|
635
|
+
def _render_command_panel(self) -> AnyFormattedText:
|
|
636
|
+
width = max(self._main_width() - 1, 32)
|
|
637
|
+
if self._mcp_panel_active():
|
|
638
|
+
return self._render_mcp_panel(width)
|
|
639
|
+
return self._render_slash_panel(width)
|
|
640
|
+
|
|
641
|
+
def _render_attachment_panel(self) -> AnyFormattedText:
|
|
642
|
+
width = max(self._main_width() - 1, 32)
|
|
643
|
+
rows: list[tuple[str, str]] = [("class:command.divider", "─" * width + "\n")]
|
|
644
|
+
matches = self._attachment_matches()
|
|
645
|
+
self._append_command_line(rows, [("class:command.title", "Attach files")], width)
|
|
646
|
+
token = self._attachment_token()
|
|
647
|
+
query = token.query if token is not None else ""
|
|
648
|
+
detail = f"{len(matches)} match{'es' if len(matches) != 1 else ''}"
|
|
649
|
+
if query:
|
|
650
|
+
detail += f" for @{query}"
|
|
651
|
+
self._append_command_line(rows, [("class:command.dim", detail)], width)
|
|
652
|
+
|
|
653
|
+
if not matches:
|
|
654
|
+
self._append_command_line(rows, [("class:command.dim", "No matching files")], width, indent=" ")
|
|
655
|
+
return rows
|
|
656
|
+
|
|
657
|
+
selected = min(self._attachment_selected, len(matches) - 1)
|
|
658
|
+
visible_count = min(len(matches), COMMAND_VISIBLE_ITEMS)
|
|
659
|
+
start, visible = _selected_window(matches, selected, visible_count)
|
|
660
|
+
|
|
661
|
+
if start > 0:
|
|
662
|
+
self._append_command_line(rows, [("class:command.dim", f" ... {start} above")], width, indent=" ")
|
|
663
|
+
|
|
664
|
+
for offset, candidate in enumerate(visible):
|
|
665
|
+
index = start + offset
|
|
666
|
+
marker = "❯" if index == selected else " "
|
|
667
|
+
name_style = "class:command.selected" if index == selected else "class:command.name"
|
|
668
|
+
meta = f" {candidate.kind} · {format_size(candidate.size)}"
|
|
669
|
+
self._append_command_line(
|
|
670
|
+
rows,
|
|
671
|
+
[
|
|
672
|
+
("class:command.marker", marker),
|
|
673
|
+
(name_style, f" {candidate.rel_path}"),
|
|
674
|
+
("class:command.dim", meta),
|
|
675
|
+
],
|
|
676
|
+
width,
|
|
677
|
+
indent=" ",
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
hidden_below = len(matches) - start - len(visible)
|
|
681
|
+
if hidden_below > 0:
|
|
682
|
+
self._append_command_line(rows, [("class:command.dim", f" ... {hidden_below} below")], width, indent=" ")
|
|
683
|
+
|
|
684
|
+
return rows
|
|
685
|
+
|
|
686
|
+
def _render_command_output_panel(self) -> AnyFormattedText:
|
|
687
|
+
width = self.command_output_width()
|
|
688
|
+
lines = self._command_output_lines if self._command_output_lines else []
|
|
689
|
+
return _lines_to_formatted_text(lines, width, follow_tail=False)
|
|
690
|
+
|
|
691
|
+
def _render_slash_panel(self, width: int) -> AnyFormattedText:
|
|
692
|
+
rows: list[tuple[str, str]] = [("class:command.divider", "─" * width + "\n")]
|
|
693
|
+
matches = self._slash_matches()
|
|
694
|
+
self._append_command_line(rows, [("class:command.title", "Slash commands")], width)
|
|
695
|
+
count = f"{len(matches)} command{'s' if len(matches) != 1 else ''}"
|
|
696
|
+
self._append_command_line(rows, [("class:command.dim", count)], width)
|
|
697
|
+
|
|
698
|
+
if not matches:
|
|
699
|
+
self._append_command_line(rows, [("class:command.dim", "No matching commands")], width, indent=" ")
|
|
700
|
+
return rows
|
|
701
|
+
|
|
702
|
+
selected = self._command_selected
|
|
703
|
+
visible_count = min(len(matches), COMMAND_VISIBLE_ITEMS)
|
|
704
|
+
start, visible = _selected_window(matches, selected, visible_count)
|
|
705
|
+
|
|
706
|
+
if start > 0:
|
|
707
|
+
self._append_command_line(rows, [("class:command.dim", f" ... {start} above")], width, indent=" ")
|
|
708
|
+
|
|
709
|
+
for offset, (name, desc) in enumerate(reversed(visible)):
|
|
710
|
+
original_index = start + len(visible) - 1 - offset
|
|
711
|
+
marker = "❯" if original_index == selected else " "
|
|
712
|
+
command_style = "class:command.selected" if original_index == selected else "class:command.name"
|
|
713
|
+
self._append_command_line(
|
|
714
|
+
rows,
|
|
715
|
+
[
|
|
716
|
+
("class:command.marker", marker),
|
|
717
|
+
(command_style, f" {name}"),
|
|
718
|
+
("class:command.dim", f" {desc}"),
|
|
719
|
+
],
|
|
720
|
+
width,
|
|
721
|
+
indent=" ",
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
hidden_below = len(matches) - start - len(visible)
|
|
725
|
+
if hidden_below > 0:
|
|
726
|
+
self._append_command_line(rows, [("class:command.dim", f" ... {hidden_below} below")], width, indent=" ")
|
|
727
|
+
|
|
728
|
+
return rows
|
|
729
|
+
|
|
730
|
+
def _render_mcp_panel(self, width: int) -> AnyFormattedText:
|
|
731
|
+
rows: list[tuple[str, str]] = [("class:command.divider", "─" * width + "\n")]
|
|
732
|
+
servers = self._mcp_servers()
|
|
733
|
+
self._append_command_line(rows, [("class:command.title", "Manage MCP servers")], width)
|
|
734
|
+
count = f"{len(servers)} server{'s' if len(servers) != 1 else ''}"
|
|
735
|
+
self._append_command_line(rows, [("class:command.dim", count)], width)
|
|
736
|
+
self._append_command_line(rows, [], width)
|
|
737
|
+
|
|
738
|
+
if not servers:
|
|
739
|
+
self._append_command_line(
|
|
740
|
+
rows,
|
|
741
|
+
[("class:command.dim", "No MCP servers configured")],
|
|
742
|
+
width,
|
|
743
|
+
indent=" ",
|
|
744
|
+
)
|
|
745
|
+
return rows
|
|
746
|
+
|
|
747
|
+
source = servers[0].source if servers else "Project MCPs"
|
|
748
|
+
full = self.status.mcp_config_path or f"{self.status.workspace}/voidx.json"
|
|
749
|
+
try:
|
|
750
|
+
path = str(Path(full).resolve().relative_to(Path(self.status.workspace).resolve()))
|
|
751
|
+
except ValueError:
|
|
752
|
+
path = full
|
|
753
|
+
self._append_command_line(
|
|
754
|
+
rows,
|
|
755
|
+
[
|
|
756
|
+
("class:command.group", source),
|
|
757
|
+
("class:command.dim", f" ({path})"),
|
|
758
|
+
],
|
|
759
|
+
width,
|
|
760
|
+
indent=" ",
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
selected = self._command_selected
|
|
764
|
+
visible_count = min(len(servers), COMMAND_VISIBLE_ITEMS)
|
|
765
|
+
start, visible = _selected_window(servers, selected, visible_count)
|
|
766
|
+
|
|
767
|
+
if start > 0:
|
|
768
|
+
self._append_command_line(rows, [("class:command.dim", f" ... {start} above")], width, indent=" ")
|
|
769
|
+
|
|
770
|
+
for offset, server in enumerate(reversed(visible)):
|
|
771
|
+
original_index = start + len(visible) - 1 - offset
|
|
772
|
+
marker = "❯" if original_index == selected else " "
|
|
773
|
+
status = _mcp_status_label(server.status)
|
|
774
|
+
tools = f"{server.tool_count} tool{'s' if server.tool_count != 1 else ''}"
|
|
775
|
+
name_style = "class:command.selected" if original_index == selected else "class:command.name"
|
|
776
|
+
self._append_command_line(
|
|
777
|
+
rows,
|
|
778
|
+
[
|
|
779
|
+
("class:command.marker", marker),
|
|
780
|
+
(name_style, f" {server.name}"),
|
|
781
|
+
("class:command.dim", " · "),
|
|
782
|
+
(status[0], status[1]),
|
|
783
|
+
("class:command.dim", f" · {tools}"),
|
|
784
|
+
],
|
|
785
|
+
width,
|
|
786
|
+
indent=" ",
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
hidden_below = len(servers) - start - len(visible)
|
|
790
|
+
if hidden_below > 0:
|
|
791
|
+
self._append_command_line(rows, [("class:command.dim", f" ... {hidden_below} below")], width, indent=" ")
|
|
792
|
+
|
|
793
|
+
return rows
|
|
794
|
+
|
|
795
|
+
def _append_command_line(
|
|
796
|
+
self,
|
|
797
|
+
rows: list[tuple[str, str]],
|
|
798
|
+
parts: list[tuple[str, str]],
|
|
799
|
+
width: int,
|
|
800
|
+
*,
|
|
801
|
+
indent: str = " ",
|
|
802
|
+
) -> None:
|
|
803
|
+
rows.append(("class:command", indent))
|
|
804
|
+
used = len(indent)
|
|
805
|
+
for style, text in parts:
|
|
806
|
+
clipped = _clip(text, max(width - used, 0))
|
|
807
|
+
rows.append((style, clipped))
|
|
808
|
+
used += len(clipped)
|
|
809
|
+
rows.append(("class:command", "\n"))
|
|
810
|
+
|
|
811
|
+
def _choice_detail_lines(self) -> list[list[tuple[str, str]]]:
|
|
812
|
+
lines: list[list[tuple[str, str]]] = []
|
|
813
|
+
for detail in self._choice_details:
|
|
814
|
+
name = str(detail.get("name") or "tool")
|
|
815
|
+
pattern = str(detail.get("pattern") or "")
|
|
816
|
+
args = detail.get("args") if isinstance(detail.get("args"), dict) else {}
|
|
817
|
+
target = pattern if pattern and pattern != "*" else _permission_target(args)
|
|
818
|
+
lines.append([
|
|
819
|
+
("class:choice.tool", name),
|
|
820
|
+
("class:choice.dim", f" {target}" if target else ""),
|
|
821
|
+
])
|
|
822
|
+
preview = _args_preview(args)
|
|
823
|
+
if preview:
|
|
824
|
+
lines.append([("class:choice.dim", f" {preview}")])
|
|
825
|
+
return lines
|
|
826
|
+
|
|
827
|
+
def _append_panel_line(
|
|
828
|
+
self,
|
|
829
|
+
rows: list[tuple[str, str]],
|
|
830
|
+
parts: list[tuple[str, str]],
|
|
831
|
+
width: int,
|
|
832
|
+
) -> None:
|
|
833
|
+
rows.append(("class:permission.border", "│ "))
|
|
834
|
+
used = 2
|
|
835
|
+
for style, text in parts:
|
|
836
|
+
clipped = _clip(text, max(width - used - 2, 0))
|
|
837
|
+
rows.append((style, clipped))
|
|
838
|
+
used += len(clipped)
|
|
839
|
+
rows.append(("class:permission", " " * max(width - used - 1, 0)))
|
|
840
|
+
rows.append(("class:permission.border", "│\n"))
|
|
841
|
+
|
|
842
|
+
def _body_line_prefix(self, line_number: int, wrap_count: int) -> AnyFormattedText:
|
|
843
|
+
if wrap_count <= 0 or line_number >= len(self._visible_body_lines):
|
|
844
|
+
return []
|
|
845
|
+
prefix = _continuation_prefix(self._visible_body_lines[line_number])
|
|
846
|
+
return [("", prefix)] if prefix else []
|
|
847
|
+
|
|
848
|
+
def _hint_text(self) -> str:
|
|
849
|
+
text = self.input.text
|
|
850
|
+
if self._notice and not text:
|
|
851
|
+
return self._notice
|
|
852
|
+
if not text.startswith("/"):
|
|
853
|
+
return "wheel/click transcript · @ attach · ^V image"
|
|
854
|
+
p = text.lower()
|
|
855
|
+
matches = [(name, desc) for name, desc in self.commands if name.lower().startswith(p)]
|
|
856
|
+
if not matches:
|
|
857
|
+
return "no matching commands"
|
|
858
|
+
shown = " ".join(f"{name} {desc}" for name, desc in matches[:4])
|
|
859
|
+
return shown
|
|
860
|
+
|
|
861
|
+
def _body_height(self) -> int:
|
|
862
|
+
rows = 24
|
|
863
|
+
app = get_app_or_none()
|
|
864
|
+
if app is not None:
|
|
865
|
+
rows = app.output.get_size().rows
|
|
866
|
+
bottom_rows = self._bottom_bar_height()
|
|
867
|
+
choice_rows = 0
|
|
868
|
+
command_rows = self._command_panel_height() if self._command_panel_active() else 0
|
|
869
|
+
attachment_rows = self._attachment_panel_height() if self._attachment_panel_active() else 0
|
|
870
|
+
gap_rows = self._transcript_bottom_gap_height()
|
|
871
|
+
return max(rows - 1 - choice_rows - command_rows - attachment_rows - bottom_rows - gap_rows - 1, 1)
|
|
872
|
+
|
|
873
|
+
def _bottom_bar_height(self) -> int:
|
|
874
|
+
return self.BOTTOM_BAR_HEIGHT
|
|
875
|
+
|
|
876
|
+
def _transcript_bottom_gap_height(self) -> int:
|
|
877
|
+
return 1
|
|
878
|
+
|
|
879
|
+
def _input_panel_width(self) -> int:
|
|
880
|
+
available = max(self._main_width() - 1, 1)
|
|
881
|
+
return max(1, available - self._detail_status_width())
|
|
882
|
+
|
|
883
|
+
def _detail_status_width(self) -> int:
|
|
884
|
+
available = max(self._main_width() - 1, 1)
|
|
885
|
+
input_width = (available * 3) // 5
|
|
886
|
+
detail_width = available - input_width
|
|
887
|
+
if available >= 150:
|
|
888
|
+
detail_width = max(detail_width, 72)
|
|
889
|
+
return max(1, min(detail_width, available - 1))
|
|
890
|
+
|
|
891
|
+
def _choice_visible_items(self) -> int:
|
|
892
|
+
return 3 if self._choice_details else CHOICE_VISIBLE_ITEMS
|
|
893
|
+
|
|
894
|
+
def _choice_panel_height(self) -> int:
|
|
895
|
+
if self._active_choice is None:
|
|
896
|
+
return 0
|
|
897
|
+
choices = self._active_choice or []
|
|
898
|
+
visible_count = min(len(choices), self._choice_visible_items())
|
|
899
|
+
selected = max(0, min(self._choice_selected, len(choices) - 1))
|
|
900
|
+
start, visible = _selected_window(choices, selected, visible_count)
|
|
901
|
+
indicator_rows = int(start > 0) + int(len(choices) - start - len(visible) > 0)
|
|
902
|
+
detail_rows = 0
|
|
903
|
+
if self._choice_details:
|
|
904
|
+
details = self._choice_detail_lines()
|
|
905
|
+
detail_rows = 1 + min(len(details), 3) + int(len(details) > 3)
|
|
906
|
+
return min(16, max(1, detail_rows + visible_count + indicator_rows))
|
|
907
|
+
|
|
908
|
+
def _command_panel_height(self) -> int:
|
|
909
|
+
if self._mcp_panel_active():
|
|
910
|
+
servers = self._mcp_servers()
|
|
911
|
+
visible = min(len(servers), COMMAND_VISIBLE_ITEMS)
|
|
912
|
+
return 1 + visible + (2 if len(servers) > visible else 0)
|
|
913
|
+
matches = self._slash_matches()
|
|
914
|
+
visible = min(len(matches), COMMAND_VISIBLE_ITEMS)
|
|
915
|
+
return 1 + visible + (2 if len(matches) > visible else 0)
|
|
916
|
+
|
|
917
|
+
def _attachment_panel_height(self) -> int:
|
|
918
|
+
matches = self._attachment_matches()
|
|
919
|
+
visible = min(len(matches), COMMAND_VISIBLE_ITEMS)
|
|
920
|
+
return 3 + visible + (2 if len(matches) > visible else 0)
|
|
921
|
+
|
|
922
|
+
def _line_count(self) -> int:
|
|
923
|
+
return len(dock.tree.render(self._main_width())) or 1
|
|
924
|
+
|
|
925
|
+
def _max_scroll(self, line_count: int | None = None, height: int | None = None) -> int:
|
|
926
|
+
line_count = self._line_count() if line_count is None else line_count
|
|
927
|
+
height = self._body_height() if height is None else height
|
|
928
|
+
return max(line_count - height, 0)
|
|
929
|
+
|
|
930
|
+
def _scroll_by(self, amount: int) -> None:
|
|
931
|
+
self._scroll_offset = max(0, min(self._scroll_offset + amount, self._max_scroll()))
|
|
932
|
+
|
|
933
|
+
def _toggle_body_node_at(self, row: int) -> None:
|
|
934
|
+
node_id = self._visible_row_to_node.get(row)
|
|
935
|
+
if not node_id:
|
|
936
|
+
return
|
|
937
|
+
node = dock.tree.get(node_id)
|
|
938
|
+
if node is None or not (node.body_lines or node.children):
|
|
939
|
+
return
|
|
940
|
+
node.collapsed = not node.collapsed
|
|
941
|
+
dock.tree.mark_dirty()
|
|
942
|
+
|
|
943
|
+
def _scroll_to_top(self) -> None:
|
|
944
|
+
self._scroll_offset = self._max_scroll()
|
|
945
|
+
|
|
946
|
+
def _scroll_to_bottom(self) -> None:
|
|
947
|
+
self._scroll_offset = 0
|
|
948
|
+
|
|
949
|
+
def _render_scrollbar_margin(self, height: int) -> list[tuple[str, str]]:
|
|
950
|
+
line_count = self._line_count()
|
|
951
|
+
max_scroll = max(line_count - height, 0)
|
|
952
|
+
if line_count <= height or height <= 0:
|
|
953
|
+
return [("class:scrollbar.background", " \n") for _ in range(height)]
|
|
954
|
+
|
|
955
|
+
thumb_height = max(1, min(height, int(height * height / line_count)))
|
|
956
|
+
max_top = max(height - thumb_height, 0)
|
|
957
|
+
if max_scroll:
|
|
958
|
+
position = 1 - (self._scroll_offset / max_scroll)
|
|
959
|
+
thumb_top = round(max_top * position)
|
|
960
|
+
else:
|
|
961
|
+
thumb_top = max_top
|
|
962
|
+
|
|
963
|
+
result: list[tuple[str, str]] = []
|
|
964
|
+
for row in range(height):
|
|
965
|
+
in_thumb = thumb_top <= row < thumb_top + thumb_height
|
|
966
|
+
style = "class:scrollbar.button" if in_thumb else "class:scrollbar.background"
|
|
967
|
+
result.append((style, " "))
|
|
968
|
+
if row < height - 1:
|
|
969
|
+
result.append(("", "\n"))
|
|
970
|
+
return result
|
|
971
|
+
|
|
972
|
+
def _width(self) -> int:
|
|
973
|
+
app = get_app_or_none()
|
|
974
|
+
if app is not None:
|
|
975
|
+
return max(app.output.get_size().columns, 20)
|
|
976
|
+
return 80
|
|
977
|
+
|
|
978
|
+
def _main_width(self) -> int:
|
|
979
|
+
return self._width()
|
|
980
|
+
|
|
981
|
+
def _command_output_float_width(self) -> int:
|
|
982
|
+
available = max(self._width() - 4, 20)
|
|
983
|
+
return min(max(36, self._width() // 3), available)
|
|
984
|
+
|
|
985
|
+
def _command_output_float_height(self) -> int:
|
|
986
|
+
return max(3, min(18, self._body_height()))
|
|
987
|
+
|
|
988
|
+
def _command_output_active(self) -> bool:
|
|
989
|
+
return bool(self._command_output_visible and self._command_output_lines)
|
|
990
|
+
|
|
991
|
+
def _command_output_wide_active(self) -> bool:
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
def _command_output_bottom_active(self) -> bool:
|
|
995
|
+
return False
|
|
996
|
+
|
|
997
|
+
def _review_panel_active(self) -> bool:
|
|
998
|
+
return getattr(self, '_review_active', False)
|
|
999
|
+
|
|
1000
|
+
def _render_changes_bar(self) -> AnyFormattedText:
|
|
1001
|
+
full_width = max(self._main_width() - 4, 20)
|
|
1002
|
+
count = session_tracker.file_count
|
|
1003
|
+
added = session_tracker.total_added
|
|
1004
|
+
removed = session_tracker.total_removed
|
|
1005
|
+
|
|
1006
|
+
label = f"{count} file{'s' if count != 1 else ''} changed this turn"
|
|
1007
|
+
added_str = f"+{added}"
|
|
1008
|
+
removed_str = f"\u2212{removed}"
|
|
1009
|
+
review_btn = " Review "
|
|
1010
|
+
|
|
1011
|
+
full_text = f" {label} {added_str} {removed_str} {review_btn} "
|
|
1012
|
+
content_len = len(full_text)
|
|
1013
|
+
pad_left = max((full_width - content_len) // 2, 0)
|
|
1014
|
+
pad_right = max(full_width - pad_left - content_len, 0)
|
|
1015
|
+
|
|
1016
|
+
result: list = [("class:body", " " * pad_left)]
|
|
1017
|
+
result.append(("class:changes", " "))
|
|
1018
|
+
result.append(("class:changes.label", label))
|
|
1019
|
+
result.append(("class:changes.dim", " "))
|
|
1020
|
+
result.append(("class:changes.added", added_str))
|
|
1021
|
+
result.append(("class:changes.dim", " "))
|
|
1022
|
+
result.append(("class:changes.removed", removed_str))
|
|
1023
|
+
result.append(("class:changes.dim", " "))
|
|
1024
|
+
result.append(("class:changes.review", review_btn, self._review_click_handler()))
|
|
1025
|
+
result.append(("class:changes", " "))
|
|
1026
|
+
result.append(("class:body", " " * pad_right))
|
|
1027
|
+
return result
|
|
1028
|
+
|
|
1029
|
+
def _render_review_panel(self) -> AnyFormattedText:
|
|
1030
|
+
full_width = max(self._main_width() - 4, 20)
|
|
1031
|
+
files = session_tracker.files
|
|
1032
|
+
added = session_tracker.total_added
|
|
1033
|
+
removed = session_tracker.total_removed
|
|
1034
|
+
count = len(files)
|
|
1035
|
+
|
|
1036
|
+
header = f"Turn changes: {count} file{'s' if count != 1 else ''} +{added} \u2212{removed}"
|
|
1037
|
+
rollback_btn = " Rollback all "
|
|
1038
|
+
hint = "Esc to close | Click file to open"
|
|
1039
|
+
|
|
1040
|
+
lines: list[list[tuple]] = []
|
|
1041
|
+
lines.append([
|
|
1042
|
+
("class:changes.label", f" {header} "),
|
|
1043
|
+
("class:changes.rollback", rollback_btn, self._rollback_click_handler()),
|
|
1044
|
+
])
|
|
1045
|
+
|
|
1046
|
+
if not files:
|
|
1047
|
+
lines.append([("class:changes.dim", " No changes ")])
|
|
1048
|
+
else:
|
|
1049
|
+
for f in files[:12]:
|
|
1050
|
+
a_str = f"+{f.added}"
|
|
1051
|
+
d_str = f"\u2212{f.removed}"
|
|
1052
|
+
lines.append([
|
|
1053
|
+
("class:changes", " "),
|
|
1054
|
+
("class:review.file", f.path, self._file_click_handler(f.path)),
|
|
1055
|
+
("class:review.dim", " "),
|
|
1056
|
+
("class:review.added", a_str),
|
|
1057
|
+
("class:review.dim", " "),
|
|
1058
|
+
("class:review.removed", d_str),
|
|
1059
|
+
("class:changes", " "),
|
|
1060
|
+
])
|
|
1061
|
+
|
|
1062
|
+
lines.append([("class:changes.dim", f" {hint} ")])
|
|
1063
|
+
|
|
1064
|
+
block_width = max(_fragment_text_len(line) for line in lines)
|
|
1065
|
+
pad = max((full_width - block_width) // 2, 0)
|
|
1066
|
+
|
|
1067
|
+
result: list = [("class:body", "\n")]
|
|
1068
|
+
for line in lines:
|
|
1069
|
+
line_width = _fragment_text_len(line)
|
|
1070
|
+
right_pad = max(block_width - line_width, 0)
|
|
1071
|
+
result.append(("class:body", " " * pad))
|
|
1072
|
+
result.extend(line)
|
|
1073
|
+
if right_pad:
|
|
1074
|
+
result.append(("class:changes", " " * right_pad))
|
|
1075
|
+
result.append(("class:body", "\n"))
|
|
1076
|
+
return result
|
|
1077
|
+
|
|
1078
|
+
def _review_panel_height(self) -> int:
|
|
1079
|
+
files = session_tracker.files
|
|
1080
|
+
return min(len(files) + 4, 15)
|
|
1081
|
+
|
|
1082
|
+
def _review_click_handler(self):
|
|
1083
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
1084
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
1085
|
+
return None
|
|
1086
|
+
self._review_active = not self._review_active
|
|
1087
|
+
self.invalidate()
|
|
1088
|
+
return None
|
|
1089
|
+
|
|
1090
|
+
return _handler
|
|
1091
|
+
|
|
1092
|
+
def _file_click_handler(self, file_path: str):
|
|
1093
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
1094
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
1095
|
+
return None
|
|
1096
|
+
workspace = self.status.workspace
|
|
1097
|
+
full = Path(workspace) / file_path
|
|
1098
|
+
opened = open_file_in_code_ide(
|
|
1099
|
+
full,
|
|
1100
|
+
line=1,
|
|
1101
|
+
preferred=self.status.code_ide(),
|
|
1102
|
+
)
|
|
1103
|
+
self._notice = f"Opened {file_path}" if opened else f"Could not open {file_path}. Use /code-ide status."
|
|
1104
|
+
self._review_active = False
|
|
1105
|
+
self.invalidate()
|
|
1106
|
+
return None
|
|
1107
|
+
|
|
1108
|
+
return _handler
|
|
1109
|
+
|
|
1110
|
+
def _rollback_click_handler(self):
|
|
1111
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
1112
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
1113
|
+
return None
|
|
1114
|
+
result = session_tracker.rollback_current()
|
|
1115
|
+
self._review_active = False
|
|
1116
|
+
if result.ok:
|
|
1117
|
+
restored = len(result.restored)
|
|
1118
|
+
removed = len(result.removed)
|
|
1119
|
+
parts = []
|
|
1120
|
+
if restored:
|
|
1121
|
+
parts.append(f"restored {restored}")
|
|
1122
|
+
if removed:
|
|
1123
|
+
parts.append(f"removed {removed}")
|
|
1124
|
+
self._notice = "Rollback complete" + (f": {', '.join(parts)}" if parts else "")
|
|
1125
|
+
else:
|
|
1126
|
+
self._notice = "Rollback failed: " + "; ".join(result.errors[:2])
|
|
1127
|
+
self.invalidate()
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
return _handler
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _selected_window(items: list, selected: int, size: int) -> tuple[int, list]:
|
|
1134
|
+
if size <= 0 or not items:
|
|
1135
|
+
return 0, []
|
|
1136
|
+
selected = max(0, min(selected, len(items) - 1))
|
|
1137
|
+
half = size // 2
|
|
1138
|
+
start = max(0, selected - half)
|
|
1139
|
+
start = min(start, max(len(items) - size, 0))
|
|
1140
|
+
end = min(start + size, len(items))
|
|
1141
|
+
return start, items[start:end]
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _join_status_segments(segments: list[str]) -> str:
|
|
1145
|
+
return " ".join(segment for segment in segments if segment)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _fragment_text(parts: list[tuple[str, str]]) -> str:
|
|
1149
|
+
return "".join(text for _, text in parts)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _safe_status_value(value: object, fallback: str) -> str:
|
|
1153
|
+
text = str(value or "").strip()
|
|
1154
|
+
return text if text else fallback
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _fragment_text_len(fragments: list[tuple]) -> int:
|
|
1158
|
+
return sum(len(fragment[1]) for fragment in fragments)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _effort_label(value: str) -> str:
|
|
1162
|
+
return value
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _compact_choice_label(label: str, value: str, desc: str, current: str = "") -> str:
|
|
1166
|
+
if value in ("a", "y", "n"):
|
|
1167
|
+
return _friendly_choice_label(label, value, desc)
|
|
1168
|
+
prefix = "✓ " if current and (current == value or current == label) else ""
|
|
1169
|
+
return prefix + label
|