voidx 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
@@ -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