voidx 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/ui/app.py ADDED
@@ -0,0 +1,1033 @@
1
+ """prompt_toolkit based full-screen UI for voidx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Awaitable, Callable
11
+
12
+ from prompt_toolkit.application import Application
13
+ from prompt_toolkit.application.current import get_app_or_none
14
+ from prompt_toolkit.clipboard import ClipboardData
15
+ from prompt_toolkit.data_structures import Point
16
+ from prompt_toolkit.filters import Condition
17
+ from prompt_toolkit.formatted_text import AnyFormattedText
18
+ from prompt_toolkit.key_binding import KeyBindings
19
+ from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
20
+ from prompt_toolkit.layout.containers import ConditionalContainer, Float, FloatContainer
21
+ from prompt_toolkit.layout.controls import FormattedTextControl
22
+ from prompt_toolkit.layout.dimension import Dimension
23
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
24
+ from prompt_toolkit.selection import SelectionType
25
+ from prompt_toolkit.styles import Style
26
+ from prompt_toolkit.widgets import TextArea
27
+
28
+ from voidx.ui.app_components.commands import SlashCommandCompleter
29
+ from voidx.ui.app_components.controls import TranscriptControl, TranscriptScrollbarMargin
30
+ from voidx.ui.app_components.formatting import (
31
+ _args_preview,
32
+ _clip,
33
+ _continuation_prefix,
34
+ _friendly_choice_label,
35
+ _get_ansi_console,
36
+ _lines_to_formatted_text,
37
+ _mcp_status_label,
38
+ _permission_target,
39
+ _rich_to_ansi,
40
+ _visible_text,
41
+ )
42
+ from voidx.ui.app_components.file_picker import attachment_token_text
43
+ from voidx.ui.app_components.clipboard_image import (
44
+ ClipboardImageResult,
45
+ paste_clipboard_image as paste_clipboard_image_from_system,
46
+ )
47
+ from voidx.ui.app_components.rendering import PromptToolkitRenderMixin
48
+ from voidx.ui.dock import dock
49
+ from voidx.ui.dock_components.formatting import _ansi_line, _strip_ansi_trailing_space
50
+ from voidx.ui.events import ErrorAppended, ui_events
51
+ from voidx.ui.session_changes import session_tracker
52
+ from voidx.llm.usage import UsageStats
53
+
54
+
55
+ SubmitHandler = Callable[[str], Awaitable[bool]]
56
+
57
+
58
+ @dataclass
59
+ class McpServerStatus:
60
+ name: str
61
+ status: str = "configured"
62
+ tool_count: int = 0
63
+ source: str = "Project MCPs"
64
+
65
+
66
+ @dataclass
67
+ class UiStatus:
68
+ provider: str
69
+ model: str
70
+ workspace: str
71
+ session_title: str
72
+ context_limit: int
73
+ debug: Callable[[], bool]
74
+ plan_mode: Callable[[], bool]
75
+ interaction_mode: Callable[[], str] = field(default_factory=lambda: lambda: "auto")
76
+ goal_label: Callable[[], str] = field(default_factory=lambda: lambda: "")
77
+ goal_phase: Callable[[], str] = field(default_factory=lambda: lambda: "clarify")
78
+ goal_status: Callable[[], str] = field(default_factory=lambda: lambda: "idle")
79
+ goal_turn_count: Callable[[], int] = field(default_factory=lambda: lambda: 0)
80
+ goal_awaiting_approval: Callable[[], bool] = field(default_factory=lambda: lambda: False)
81
+ reasoning_effort: str = "xhigh"
82
+ permission_label: Callable[[], str] = field(default_factory=lambda: lambda: "default")
83
+ sandbox_label: Callable[[], str] = field(default_factory=lambda: lambda: "w-write")
84
+ approval_label: Callable[[], str] = field(default_factory=lambda: lambda: "on-fail")
85
+ approval_reviewer_label: Callable[[], str] = field(default_factory=lambda: lambda: "user")
86
+ usage_stats: UsageStats = field(default_factory=UsageStats)
87
+ mcp_servers: Callable[[], list[McpServerStatus]] = field(default_factory=lambda: lambda: [])
88
+ mcp_config_path: str = ""
89
+ code_ide: Callable[[], str] = field(default_factory=lambda: lambda: "trae")
90
+
91
+
92
+ class PromptToolkitTui(PromptToolkitRenderMixin):
93
+ """Scrollable transcript with a fixed bottom input."""
94
+
95
+ COMMAND_OUTPUT_TTL_SECONDS = 5.0
96
+
97
+ def __init__(self, status: UiStatus, commands: list[tuple[str, str]]) -> None:
98
+ self.status = status
99
+ self.commands = commands
100
+ self._queue: asyncio.Queue[str | None] = asyncio.Queue()
101
+ self._quiet_commands: list[str] = []
102
+ self._choice_queue: asyncio.Queue[str | None] = asyncio.Queue()
103
+ self._text_queue: asyncio.Queue[str | None] = asyncio.Queue()
104
+ self._active_choice: list[tuple[str, str, str]] | None = None
105
+ self._active_text_prompt: str | None = None
106
+ self._active_text_default = ""
107
+ self._active_text_secret = False
108
+ self._saved_input_text = ""
109
+ self._saved_input_cursor = 0
110
+ self._choice_prompt: str = ""
111
+ self._choice_selected: int = 0
112
+ self._choice_details: list[dict[str, Any]] = []
113
+ self._choice_anchor = ""
114
+ self._choice_current_value: str = ""
115
+ self._footer_anchor_positions: dict[str, int] = {}
116
+ self._scroll_offset = 0
117
+ self._busy = False
118
+ self._last_error = ""
119
+ self._notice = ""
120
+ self._ctrl_c_armed = False
121
+ self._ctrl_c_deadline = 0.0
122
+ self._exit_requested = False
123
+ self._visible_body_lines: list[str] = []
124
+ self._visible_body_node_ids: list[str | None] = []
125
+ self._visible_row_to_node: dict[int, str | None] = {}
126
+ self._last_body_click: Point | None = None
127
+ self._body_mouse_down: Point | None = None
128
+ self._command_selected = 0
129
+ self._command_panel_suppressed_text = ""
130
+ self._attachment_selected = 0
131
+ self._attachment_panel_suppressed_text = ""
132
+ self._command_output_title = ""
133
+ self._command_output_lines: list[str] = []
134
+ self._command_output_visible = False
135
+ self._command_output_clear_handle: asyncio.TimerHandle | None = None
136
+ self._input_history: list[str] = []
137
+ self._input_history_index: int | None = None
138
+ self._input_history_draft = ""
139
+ self._loading_input_history = False
140
+ self._review_active = False
141
+ self._current_submit_task: asyncio.Task[bool] | None = None
142
+ self._current_submitted_text = ""
143
+ self._submit_cancel_requested = False
144
+
145
+ self.input = TextArea(
146
+ height=Dimension(min=3, preferred=3, max=3),
147
+ multiline=True,
148
+ password=Condition(lambda: self._active_text_secret),
149
+ wrap_lines=True,
150
+ prompt=self._input_prompt,
151
+ style="class:input",
152
+ )
153
+ self.input.buffer.on_text_changed += self._on_input_changed
154
+ self.input.control.mouse_handler = self._ignore_input_mouse
155
+ self.body_control = TranscriptControl(self)
156
+
157
+ kb = KeyBindings()
158
+
159
+ choice_mode = Condition(lambda: self._active_choice is not None)
160
+ text_mode = Condition(lambda: self._active_text_prompt is not None)
161
+ command_mode = Condition(lambda: self._command_panel_active())
162
+ attachment_mode = Condition(lambda: self._attachment_panel_active())
163
+ command_output_mode = Condition(lambda: self._command_output_active())
164
+ compact_choice_mode = Condition(
165
+ lambda: self._active_choice is not None
166
+ )
167
+ has_choice_details = Condition(lambda: bool(self._choice_details))
168
+ footer_choice_mode = compact_choice_mode & ~has_choice_details
169
+ permission_choice_mode = compact_choice_mode & has_choice_details
170
+ has_changes = Condition(lambda: session_tracker.has_changes)
171
+ review_mode = Condition(lambda: self._review_active)
172
+
173
+ @kb.add("escape", filter=choice_mode)
174
+ def _(event) -> None:
175
+ self._finish_choice(None)
176
+ event.app.invalidate()
177
+
178
+ @kb.add("enter", filter=choice_mode)
179
+ def _(event) -> None:
180
+ self._submit_choice_selection()
181
+ event.app.invalidate()
182
+
183
+ @kb.add("up", filter=choice_mode)
184
+ def _(event) -> None:
185
+ self._move_choice_selection(-1)
186
+ event.app.invalidate()
187
+
188
+ @kb.add("down", filter=choice_mode)
189
+ def _(event) -> None:
190
+ self._move_choice_selection(1)
191
+ event.app.invalidate()
192
+
193
+ @kb.add("<any>", filter=choice_mode)
194
+ def _(event) -> None:
195
+ char = event.key_sequence[0].data
196
+ quick_keys = {value: value for _, value, _ in self._active_choice or [] if len(value) == 1}
197
+ if char in quick_keys:
198
+ self._finish_choice(quick_keys[char])
199
+ event.app.invalidate()
200
+
201
+ @kb.add("enter", filter=text_mode)
202
+ def _(event) -> None:
203
+ self._submit_text_prompt()
204
+ event.app.invalidate()
205
+
206
+ @kb.add("escape", filter=text_mode)
207
+ def _(event) -> None:
208
+ self._cancel_text_prompt()
209
+ event.app.invalidate()
210
+
211
+ @kb.add("enter", filter=command_mode)
212
+ def _(event) -> None:
213
+ if self._accept_command_panel_selection():
214
+ event.app.invalidate()
215
+ return
216
+ self._submit_input()
217
+ event.app.invalidate()
218
+
219
+ @kb.add("enter", filter=attachment_mode)
220
+ def _(event) -> None:
221
+ if self._accept_attachment_panel_selection():
222
+ event.app.invalidate()
223
+ return
224
+ self._submit_input()
225
+ event.app.invalidate()
226
+
227
+ @kb.add("up", filter=command_mode)
228
+ def _(event) -> None:
229
+ self._move_command_selection_visual(-1)
230
+ event.app.invalidate()
231
+
232
+ @kb.add("up", filter=attachment_mode)
233
+ def _(event) -> None:
234
+ self._move_attachment_selection(-1)
235
+ event.app.invalidate()
236
+
237
+ @kb.add("down", filter=command_mode)
238
+ def _(event) -> None:
239
+ self._move_command_selection_visual(1)
240
+ event.app.invalidate()
241
+
242
+ @kb.add("down", filter=attachment_mode)
243
+ def _(event) -> None:
244
+ self._move_attachment_selection(1)
245
+ event.app.invalidate()
246
+
247
+ @kb.add("escape", filter=command_mode)
248
+ def _(event) -> None:
249
+ self._command_panel_suppressed_text = self.input.text
250
+ event.app.invalidate()
251
+
252
+ @kb.add("escape", filter=attachment_mode)
253
+ def _(event) -> None:
254
+ self._attachment_panel_suppressed_text = self.input.text
255
+ event.app.invalidate()
256
+
257
+ @kb.add("escape", filter=command_output_mode & ~choice_mode & ~command_mode & ~attachment_mode)
258
+ def _(event) -> None:
259
+ self.hide_command_output()
260
+ event.app.invalidate()
261
+
262
+ @kb.add("escape", filter=review_mode)
263
+ def _(event) -> None:
264
+ self._review_active = False
265
+ event.app.invalidate()
266
+
267
+ @kb.add("enter", filter=~choice_mode & ~text_mode & ~command_mode & ~attachment_mode & ~review_mode)
268
+ def _(event) -> None:
269
+ self._submit_input()
270
+ event.app.invalidate()
271
+
272
+ @kb.add("escape", "enter", filter=~choice_mode & ~text_mode)
273
+ def _(event) -> None:
274
+ self._insert_input_newline()
275
+ event.app.invalidate()
276
+
277
+ @kb.add("c-j")
278
+ def _(event) -> None:
279
+ self._insert_input_newline()
280
+ event.app.invalidate()
281
+
282
+ @kb.add("s-left", filter=~choice_mode)
283
+ def _(event) -> None:
284
+ self._extend_input_selection(-1)
285
+ event.app.invalidate()
286
+
287
+ @kb.add("s-right", filter=~choice_mode)
288
+ def _(event) -> None:
289
+ self._extend_input_selection(1)
290
+ event.app.invalidate()
291
+
292
+ @kb.add("up", filter=~choice_mode & ~text_mode & ~command_mode & ~attachment_mode)
293
+ def _(event) -> None:
294
+ self._previous_input_history()
295
+ event.app.invalidate()
296
+
297
+ @kb.add("down", filter=~choice_mode & ~text_mode & ~command_mode & ~attachment_mode)
298
+ def _(event) -> None:
299
+ self._next_input_history()
300
+ event.app.invalidate()
301
+
302
+ @kb.add("c-c")
303
+ def _(event) -> None:
304
+ if self._copy_input_selection(event):
305
+ event.app.invalidate()
306
+ return
307
+ self._handle_ctrl_c()
308
+ event.app.invalidate()
309
+
310
+ @kb.add("escape", "c", filter=~choice_mode)
311
+ def _(event) -> None:
312
+ self._copy_input_selection(event)
313
+ event.app.invalidate()
314
+
315
+ @kb.add("c-d")
316
+ def _(event) -> None:
317
+ if not self.input.text:
318
+ self._request_exit()
319
+
320
+ @kb.add("c-v", filter=~choice_mode)
321
+ def _(event) -> None:
322
+ result = self.paste_clipboard_image(quiet_no_image=True)
323
+ if not result.ok:
324
+ self.input.buffer.paste_clipboard_data(event.app.clipboard.get_data())
325
+ event.app.invalidate()
326
+
327
+ compact_choice_overlay = ConditionalContainer(
328
+ Window(
329
+ FormattedTextControl(self._render_compact_choice_panel),
330
+ width=self._choice_float_width,
331
+ height=lambda: self._choice_panel_height(),
332
+ dont_extend_width=True,
333
+ dont_extend_height=True,
334
+ style="class:choice.pad",
335
+ ),
336
+ filter=footer_choice_mode,
337
+ )
338
+ permission_choice_overlay = ConditionalContainer(
339
+ Window(
340
+ FormattedTextControl(self._render_compact_choice_panel),
341
+ width=lambda: self._choice_menu_width(),
342
+ height=lambda: self._choice_panel_height(),
343
+ dont_extend_width=True,
344
+ dont_extend_height=True,
345
+ style="class:choice.pad",
346
+ ),
347
+ filter=permission_choice_mode,
348
+ )
349
+ command_panel = ConditionalContainer(
350
+ Window(
351
+ FormattedTextControl(self._render_command_panel),
352
+ height=lambda: self._command_panel_height(),
353
+ dont_extend_height=True,
354
+ style="class:command",
355
+ ),
356
+ filter=command_mode,
357
+ )
358
+ attachment_panel = ConditionalContainer(
359
+ Window(
360
+ FormattedTextControl(self._render_attachment_panel),
361
+ height=lambda: self._command_panel_height(),
362
+ dont_extend_height=True,
363
+ style="class:command",
364
+ ),
365
+ filter=attachment_mode,
366
+ )
367
+ changes_bar = ConditionalContainer(
368
+ Window(
369
+ FormattedTextControl(self._render_changes_bar),
370
+ height=1,
371
+ style="class:body",
372
+ ),
373
+ filter=has_changes,
374
+ )
375
+ review_panel = ConditionalContainer(
376
+ Window(
377
+ FormattedTextControl(self._render_review_panel),
378
+ height=lambda: self._review_panel_height(),
379
+ dont_extend_height=True,
380
+ style="class:body",
381
+ ),
382
+ filter=review_mode,
383
+ )
384
+ command_output_overlay = ConditionalContainer(
385
+ Window(
386
+ FormattedTextControl(self._render_command_output_panel),
387
+ width=self._command_output_float_width,
388
+ height=self._command_output_float_height,
389
+ dont_extend_width=True,
390
+ dont_extend_height=True,
391
+ wrap_lines=True,
392
+ style="class:command-output",
393
+ ),
394
+ filter=command_output_mode,
395
+ )
396
+ bottom_bar = VSplit(
397
+ [
398
+ HSplit(
399
+ [
400
+ self.input,
401
+ Window(FormattedTextControl(self._render_footer), height=1, style="class:hints"),
402
+ ],
403
+ width=self._input_panel_width,
404
+ height=self._bottom_bar_height(),
405
+ style="class:body",
406
+ ),
407
+ Window(char="│", width=1, style="class:rule"),
408
+ Window(
409
+ FormattedTextControl(self._render_detail_status_panel),
410
+ width=self._detail_status_width,
411
+ height=self._bottom_bar_height(),
412
+ style="class:status",
413
+ ),
414
+ ],
415
+ height=self._bottom_bar_height(),
416
+ )
417
+
418
+ left = HSplit(
419
+ [
420
+ Window(
421
+ self.body_control,
422
+ right_margins=[TranscriptScrollbarMargin(self)],
423
+ wrap_lines=True,
424
+ dont_extend_height=False,
425
+ style="class:body",
426
+ get_line_prefix=self._body_line_prefix,
427
+ ),
428
+ Window(height=self._transcript_bottom_gap_height, style="class:body"),
429
+ Window(char="─", height=1, style="class:rule"),
430
+ command_panel,
431
+ attachment_panel,
432
+ review_panel,
433
+ changes_bar,
434
+ bottom_bar,
435
+ Window(char="─", height=1, style="class:rule"),
436
+ ]
437
+ )
438
+ self._compact_choice_float = Float(
439
+ content=compact_choice_overlay,
440
+ left=0,
441
+ bottom=2,
442
+ width=self._choice_float_width,
443
+ height=self._choice_panel_height,
444
+ transparent=True,
445
+ z_index=20,
446
+ )
447
+ self._permission_choice_float = Float(
448
+ content=permission_choice_overlay,
449
+ left=0,
450
+ bottom=self.BOTTOM_BAR_HEIGHT + 1,
451
+ width=lambda: self._choice_menu_width(),
452
+ height=self._choice_panel_height,
453
+ transparent=True,
454
+ z_index=21,
455
+ )
456
+ root = FloatContainer(
457
+ content=left,
458
+ floats=[
459
+ self._compact_choice_float,
460
+ self._permission_choice_float,
461
+ Float(
462
+ content=command_output_overlay,
463
+ top=1,
464
+ right=2,
465
+ width=self._command_output_float_width,
466
+ height=self._command_output_float_height,
467
+ transparent=True,
468
+ z_index=10,
469
+ )
470
+ ],
471
+ )
472
+
473
+ self.app: Application = Application(
474
+ layout=Layout(root, focused_element=self.input),
475
+ key_bindings=kb,
476
+ full_screen=True,
477
+ mouse_support=True,
478
+ refresh_interval=None,
479
+ style=_STYLE,
480
+ )
481
+
482
+ async def run(self, on_submit: SubmitHandler) -> None:
483
+ dock.set_refresh_callback(self.invalidate)
484
+ dock.set_width_provider(self._main_width)
485
+ consumer = asyncio.create_task(self._consume(on_submit))
486
+ try:
487
+ await self.app.run_async()
488
+ finally:
489
+ dock.set_refresh_callback(None)
490
+ dock.set_width_provider(None)
491
+ self._cancel_command_output_clear()
492
+ consumer.cancel()
493
+ try:
494
+ await consumer
495
+ except asyncio.CancelledError:
496
+ pass
497
+
498
+ def invalidate(self) -> None:
499
+ app = get_app_or_none()
500
+ if app is not None:
501
+ app.invalidate()
502
+ else:
503
+ self.app.invalidate()
504
+
505
+ async def _consume(self, on_submit: SubmitHandler) -> None:
506
+ while True:
507
+ item = await self._queue.get()
508
+ if item is None:
509
+ if not self._exit_requested:
510
+ self._exit_requested = True
511
+ self._exit_app()
512
+ return
513
+ self._reset_ctrl_c()
514
+ self._busy = True
515
+ self._current_submitted_text = item
516
+ self._submit_cancel_requested = False
517
+ self._current_submit_task = asyncio.create_task(on_submit(item))
518
+ self.invalidate()
519
+ try:
520
+ keep_running = await self._current_submit_task
521
+ except asyncio.CancelledError:
522
+ if not self._submit_cancel_requested:
523
+ raise
524
+ keep_running = True
525
+ except Exception as exc:
526
+ self._last_error = str(exc)
527
+ if dock.active and ui_events.is_running:
528
+ ui_events.emit_nowait(ErrorAppended(message=str(exc)))
529
+ else:
530
+ dock.append_error(str(exc))
531
+ keep_running = True
532
+ finally:
533
+ self._busy = False
534
+ self._current_submit_task = None
535
+ self._current_submitted_text = ""
536
+ self._submit_cancel_requested = False
537
+ self.invalidate()
538
+ if not keep_running:
539
+ self._exit_requested = True
540
+ self._exit_app()
541
+ return
542
+
543
+ def _on_input_changed(self, _) -> None:
544
+ if self.input.text:
545
+ self._reset_ctrl_c()
546
+ if not self._loading_input_history:
547
+ self._input_history_index = None
548
+ self._input_history_draft = ""
549
+ if self.input.text != self._command_panel_suppressed_text:
550
+ self._command_panel_suppressed_text = ""
551
+ if self.input.text != self._attachment_panel_suppressed_text:
552
+ self._attachment_panel_suppressed_text = ""
553
+ self._clamp_command_selection()
554
+ self._clamp_attachment_selection()
555
+
556
+ def _ignore_input_mouse(self, mouse_event: MouseEvent) -> None:
557
+ return None
558
+
559
+ def _input_prompt(self) -> AnyFormattedText:
560
+ if self._active_text_prompt is not None:
561
+ return [("class:input.prompt", f"{self._active_text_prompt}: ")]
562
+ return [("class:input.prompt", "❯ ")]
563
+
564
+ def _handle_body_mouse(self, mouse_event: MouseEvent) -> None:
565
+ event_type = mouse_event.event_type
566
+ if event_type == MouseEventType.SCROLL_UP:
567
+ self._scroll_by(3)
568
+ self.invalidate()
569
+ return None
570
+ if event_type == MouseEventType.SCROLL_DOWN:
571
+ self._scroll_by(-3)
572
+ self.invalidate()
573
+ return None
574
+ if event_type in (MouseEventType.MOUSE_DOWN, MouseEventType.MOUSE_UP):
575
+ self._last_body_click = mouse_event.position
576
+ if event_type == MouseEventType.MOUSE_DOWN:
577
+ self._body_mouse_down = mouse_event.position
578
+ else:
579
+ down = self._body_mouse_down
580
+ self._body_mouse_down = None
581
+ if down is None or down.y == mouse_event.position.y:
582
+ self._toggle_body_node_at(mouse_event.position.y)
583
+ self.invalidate()
584
+ return None
585
+ if event_type == MouseEventType.MOUSE_MOVE:
586
+ return None
587
+ return None
588
+
589
+ def _handle_ctrl_c(self) -> None:
590
+ if self._busy and self._current_submit_task is not None:
591
+ if not self._current_submit_task.done():
592
+ self._submit_cancel_requested = True
593
+ self._current_submit_task.cancel()
594
+ self._restore_interrupted_input()
595
+ self._reset_ctrl_c()
596
+ self._notice = "Interrupted. Restored last message for editing."
597
+ return
598
+
599
+ if self.input.text:
600
+ self.input.text = ""
601
+ self._reset_ctrl_c()
602
+ self._notice = "Input cleared. Press Ctrl-C twice on empty input to exit."
603
+ return
604
+
605
+ now = time.monotonic()
606
+ if not self._ctrl_c_armed or now > self._ctrl_c_deadline:
607
+ self._ctrl_c_armed = True
608
+ self._ctrl_c_deadline = now + 3.0
609
+ self._notice = "Press Ctrl-C again to exit"
610
+ return
611
+
612
+ self._notice = ""
613
+ self._request_exit()
614
+
615
+ def _reset_ctrl_c(self) -> None:
616
+ self._ctrl_c_armed = False
617
+ self._ctrl_c_deadline = 0.0
618
+ self._notice = ""
619
+
620
+ def _restore_interrupted_input(self) -> None:
621
+ text = self._current_submitted_text
622
+ if not text:
623
+ return
624
+ if self.input.buffer.selection_state is not None:
625
+ self.input.buffer.exit_selection()
626
+ self.input.text = text
627
+ self.input.buffer.cursor_position = len(text)
628
+ self._command_panel_suppressed_text = ""
629
+ self._attachment_panel_suppressed_text = ""
630
+
631
+ def _request_exit(self) -> None:
632
+ if self._exit_requested:
633
+ return
634
+ self._exit_requested = True
635
+ if self.app.is_running:
636
+ self._exit_app()
637
+ else:
638
+ self._queue.put_nowait(None)
639
+
640
+ def _exit_app(self) -> None:
641
+ if not self.app.is_running:
642
+ return
643
+ try:
644
+ self.app.exit()
645
+ except Exception as exc:
646
+ if "Return value already set" not in str(exc):
647
+ raise
648
+
649
+ def _insert_input_newline(self) -> None:
650
+ self._reset_ctrl_c()
651
+ self.input.buffer.insert_text("\n", fire_event=False)
652
+
653
+ def _extend_input_selection(self, amount: int) -> None:
654
+ if amount == 0:
655
+ return
656
+ buffer = self.input.buffer
657
+ if buffer.selection_state is None:
658
+ buffer.start_selection(selection_type=SelectionType.CHARACTERS)
659
+ if amount < 0:
660
+ buffer.cursor_left(count=abs(amount))
661
+ else:
662
+ buffer.cursor_right(count=amount)
663
+ self._reset_ctrl_c()
664
+
665
+ def _input_selection_text(self) -> str:
666
+ buffer = self.input.buffer
667
+ selection = buffer.selection_state
668
+ if selection is None:
669
+ return ""
670
+ start = min(selection.original_cursor_position, buffer.cursor_position)
671
+ end = max(selection.original_cursor_position, buffer.cursor_position)
672
+ return buffer.text[start:end]
673
+
674
+ def _copy_input_selection(self, event: Any | None = None) -> bool:
675
+ text = self._input_selection_text()
676
+ if not text:
677
+ return False
678
+ data = ClipboardData(text)
679
+
680
+ app = getattr(event, "app", None)
681
+ clipboard = getattr(app, "clipboard", None)
682
+ if clipboard is not None:
683
+ try:
684
+ clipboard.set_data(data)
685
+ except AttributeError:
686
+ clipboard.set_text(data.text)
687
+ self._copy_text_to_system_clipboard(data.text)
688
+ self._reset_ctrl_c()
689
+ self._notice = "Copied selection"
690
+ return True
691
+
692
+ def _copy_text_to_system_clipboard(self, text: str) -> None:
693
+ if not text or sys.platform != "darwin":
694
+ return
695
+ try:
696
+ subprocess.run(["pbcopy"], input=text, text=True, timeout=1, check=False)
697
+ except Exception:
698
+ return
699
+
700
+ def _record_input_history(self, text: str) -> None:
701
+ if not text.strip():
702
+ return
703
+ if not self._input_history or self._input_history[-1] != text:
704
+ self._input_history.append(text)
705
+ if len(self._input_history) > 200:
706
+ self._input_history = self._input_history[-200:]
707
+ self._input_history_index = None
708
+ self._input_history_draft = ""
709
+
710
+ def _set_input_from_history(self, text: str) -> None:
711
+ buffer = self.input.buffer
712
+ if buffer.selection_state is not None:
713
+ buffer.exit_selection()
714
+ self._loading_input_history = True
715
+ try:
716
+ self.input.text = text
717
+ self.input.buffer.cursor_position = len(text)
718
+ finally:
719
+ self._loading_input_history = False
720
+ self._review_active = False
721
+ self._reset_ctrl_c()
722
+
723
+ def _previous_input_history(self) -> None:
724
+ if not self._input_history:
725
+ return
726
+ if self._input_history_index is None:
727
+ self._input_history_draft = self.input.text
728
+ self._input_history_index = len(self._input_history) - 1
729
+ else:
730
+ self._input_history_index = max(0, self._input_history_index - 1)
731
+ self._set_input_from_history(self._input_history[self._input_history_index])
732
+
733
+ def _next_input_history(self) -> None:
734
+ if self._input_history_index is None:
735
+ return
736
+ if self._input_history_index < len(self._input_history) - 1:
737
+ self._input_history_index += 1
738
+ self._set_input_from_history(self._input_history[self._input_history_index])
739
+ return
740
+ draft = self._input_history_draft
741
+ self._input_history_index = None
742
+ self._input_history_draft = ""
743
+ self._set_input_from_history(draft)
744
+
745
+ def _choice_initial_index(self, choices: list[tuple[str, str, str]]) -> int:
746
+ if not self._choice_current_value:
747
+ return 0
748
+ cv = self._choice_current_value
749
+ for i, (label, value, _desc) in enumerate(choices):
750
+ if cv == value or cv == label:
751
+ return i
752
+ return 0
753
+
754
+ async def ask_choice(
755
+ self,
756
+ prompt: str,
757
+ choices: list[tuple[str, str, str]],
758
+ details: list[dict[str, Any]] | None = None,
759
+ ) -> str | None:
760
+ self._active_choice = choices
761
+ self._choice_prompt = prompt
762
+ self._choice_selected = self._choice_initial_index(choices)
763
+ self._choice_details = details or []
764
+ if not self._choice_anchor:
765
+ self._choice_anchor = self._choice_anchor_for_prompt(prompt)
766
+ self.invalidate()
767
+ try:
768
+ return await self._choice_queue.get()
769
+ finally:
770
+ self._active_choice = None
771
+ self._choice_details = []
772
+ self._choice_anchor = ""
773
+
774
+ def _finish_choice(self, value: str | None) -> None:
775
+ if self._active_choice is None:
776
+ return
777
+ self._choice_queue.put_nowait(value)
778
+ self._active_choice = None
779
+ self._choice_details = []
780
+ self._choice_anchor = ""
781
+
782
+ def _move_choice_selection(self, amount: int) -> None:
783
+ choices = self._active_choice or []
784
+ if not choices:
785
+ return
786
+ self._choice_selected = (self._choice_selected + amount) % len(choices)
787
+
788
+ def _submit_choice_selection(self) -> None:
789
+ choices = self._active_choice or []
790
+ if not choices:
791
+ return
792
+ index = max(0, min(self._choice_selected, len(choices) - 1))
793
+ self._finish_choice(choices[index][1])
794
+
795
+ async def ask_text(self, prompt: str, default: str = "", secret: bool = False) -> str | None:
796
+ if self._active_text_prompt is not None:
797
+ raise RuntimeError("Text prompt is already active")
798
+ self._saved_input_text = self.input.text
799
+ self._saved_input_cursor = self.input.buffer.cursor_position
800
+ self._active_text_prompt = prompt
801
+ self._active_text_default = default
802
+ self._active_text_secret = secret
803
+ self.input.text = ""
804
+ self.input.buffer.cursor_position = 0
805
+ self._command_panel_suppressed_text = ""
806
+ self._attachment_panel_suppressed_text = ""
807
+ self.invalidate()
808
+ try:
809
+ result = await self._text_queue.get()
810
+ if result is None:
811
+ return None
812
+ return result if result else default
813
+ finally:
814
+ if self._active_text_prompt is not None:
815
+ self._restore_text_prompt()
816
+
817
+ def set_notice(self, text: str) -> None:
818
+ self._notice = text
819
+ self.invalidate()
820
+
821
+ def show_transient_output(self, text: str, title: str = "") -> None:
822
+ self.begin_command_output(title)
823
+ self.append_command_output(text)
824
+
825
+ def begin_command_output(self, title: str) -> None:
826
+ self._command_output_title = title
827
+ self._command_output_lines = []
828
+ self._command_output_visible = False
829
+ self._cancel_command_output_clear()
830
+ self.invalidate()
831
+
832
+ def append_command_output(self, text: str) -> None:
833
+ if not text.strip():
834
+ return
835
+ for line in text.rstrip("\n").splitlines():
836
+ cleaned = _strip_ansi_trailing_space(line)
837
+ self._command_output_lines.append(_ansi_line(cleaned))
838
+ if len(self._command_output_lines) > 500:
839
+ self._command_output_lines = self._command_output_lines[-500:]
840
+ self._command_output_visible = True
841
+ self._schedule_command_output_clear()
842
+ self.invalidate()
843
+
844
+ def hide_command_output(self) -> None:
845
+ self._command_output_visible = False
846
+ self._cancel_command_output_clear()
847
+ self.invalidate()
848
+
849
+ def clear_command_output(self) -> None:
850
+ self._command_output_title = ""
851
+ self._command_output_lines = []
852
+ self._command_output_visible = False
853
+ self._cancel_command_output_clear()
854
+ self.invalidate()
855
+
856
+ def _schedule_command_output_clear(self) -> None:
857
+ self._cancel_command_output_clear()
858
+ try:
859
+ loop = asyncio.get_running_loop()
860
+ except RuntimeError:
861
+ return
862
+ self._command_output_clear_handle = loop.call_later(
863
+ self.COMMAND_OUTPUT_TTL_SECONDS,
864
+ self.clear_command_output,
865
+ )
866
+
867
+ def _cancel_command_output_clear(self) -> None:
868
+ handle = self._command_output_clear_handle
869
+ self._command_output_clear_handle = None
870
+ if handle is not None and not handle.cancelled():
871
+ handle.cancel()
872
+
873
+ def queue_quiet_command(self, command: str) -> None:
874
+ command = command.strip()
875
+ if not command:
876
+ return
877
+ self._quiet_commands.append(command)
878
+ self._queue.put_nowait(command)
879
+
880
+ def consume_quiet_command(self, command: str) -> bool:
881
+ command = command.strip()
882
+ try:
883
+ index = self._quiet_commands.index(command)
884
+ except ValueError:
885
+ return False
886
+ del self._quiet_commands[index]
887
+ return True
888
+
889
+ def command_output_width(self) -> int:
890
+ return self._command_output_float_width()
891
+
892
+ def _submit_text_prompt(self) -> None:
893
+ value = self.input.text
894
+ self._text_queue.put_nowait(value)
895
+ self._restore_text_prompt()
896
+
897
+ def _cancel_text_prompt(self) -> None:
898
+ self._text_queue.put_nowait(None)
899
+ self._restore_text_prompt()
900
+
901
+ def _restore_text_prompt(self) -> None:
902
+ saved_text = self._saved_input_text
903
+ saved_cursor = max(0, min(self._saved_input_cursor, len(saved_text)))
904
+ self._active_text_prompt = None
905
+ self._active_text_default = ""
906
+ self._active_text_secret = False
907
+ self._saved_input_text = ""
908
+ self._saved_input_cursor = 0
909
+ self.input.text = saved_text
910
+ self.input.buffer.cursor_position = saved_cursor
911
+ self.invalidate()
912
+
913
+ def _submit_input(self) -> None:
914
+ text = self.input.text
915
+ stripped = text.strip()
916
+ if stripped == "/paste":
917
+ self._record_input_history(text)
918
+ self.input.text = ""
919
+ self._scroll_offset = 0
920
+ self._command_panel_suppressed_text = ""
921
+ self._attachment_panel_suppressed_text = ""
922
+ self._reset_ctrl_c()
923
+ self.paste_clipboard_image()
924
+ return
925
+
926
+ self.input.text = ""
927
+ self._scroll_offset = 0
928
+ self._command_panel_suppressed_text = ""
929
+ self._attachment_panel_suppressed_text = ""
930
+ self._reset_ctrl_c()
931
+ if stripped and not stripped.startswith("/"):
932
+ self.clear_command_output()
933
+ if stripped:
934
+ self._record_input_history(text)
935
+ self._queue.put_nowait(text)
936
+
937
+ def paste_clipboard_image(self, *, quiet_no_image: bool = False) -> ClipboardImageResult:
938
+ result = paste_clipboard_image_from_system(self.status.workspace)
939
+ if result.ok:
940
+ self._insert_attachment_token(result.rel_path)
941
+ self.clear_command_output()
942
+ if result.ok or not quiet_no_image:
943
+ self._notice = result.message
944
+ return result
945
+
946
+ def _insert_attachment_token(self, rel_path: str) -> None:
947
+ token = attachment_token_text(rel_path) + " "
948
+ text = self.input.text
949
+ cursor = max(0, min(self.input.buffer.cursor_position, len(text)))
950
+ prefix = " " if cursor > 0 and not text[cursor - 1].isspace() else ""
951
+ new_text = text[:cursor] + prefix + token + text[cursor:]
952
+ self.input.text = new_text
953
+ self.input.buffer.cursor_position = cursor + len(prefix) + len(token)
954
+ self._attachment_panel_suppressed_text = ""
955
+ self._command_panel_suppressed_text = ""
956
+
957
+ def _accept_attachment_panel_selection(self) -> bool:
958
+ token = self._attachment_token()
959
+ if token is None:
960
+ return False
961
+ matches = self._attachment_matches()
962
+ if not matches:
963
+ return False
964
+ selected = matches[min(self._attachment_selected, len(matches) - 1)]
965
+ replacement = attachment_token_text(selected.rel_path) + " "
966
+ text = self.input.text
967
+ new_text = text[:token.start] + replacement + text[token.end:]
968
+ self.input.text = new_text
969
+ new_cursor = token.start + len(replacement)
970
+ self.input.buffer.cursor_position = new_cursor
971
+ self._attachment_panel_suppressed_text = ""
972
+ self._attachment_selected = 0
973
+ return True
974
+
975
+ _STYLE = Style.from_dict(
976
+ {
977
+ "body": "#ECEFF4 bg:#000000",
978
+ "rule": "#4C566A",
979
+ "input": "#ECEFF4 bg:#000000",
980
+ "input.prompt": "bold #ECEFF4 bg:#000000",
981
+ "hints": "#8FBCBB",
982
+ "hints.click": "#D8DEE9",
983
+ "footer.permission": "#A3BE8C bg:#000000",
984
+ "footer.model": "#88C0D0 bg:#000000",
985
+ "footer.reasoning": "#EBCB8B bg:#000000",
986
+ "dim": "#D8DEE9",
987
+ "status": "#8FBCBB bg:#000000",
988
+ "status.label": "#8FBCBB bg:#000000",
989
+ "status.value": "#D8DEE9 bg:#000000",
990
+ "status.dim": "#9AA1AD bg:#000000",
991
+ "choice": "#ECEFF4 bg:#000000",
992
+ "choice.pad": "bg:#000000",
993
+ "choice.selected": "bold #EBCB8B bg:#000000",
994
+ "choice.prompt": "bold #ECEFF4",
995
+ "choice.tool": "bold #8FBCBB",
996
+ "choice.dim": "#A7B0BE",
997
+ "command": "bg:#000000 #D8DEE9",
998
+ "command.divider": "#B7C1FF bg:#000000",
999
+ "command.title": "bold #B7C1FF bg:#000000",
1000
+ "command.group": "bold #ECEFF4 bg:#000000",
1001
+ "command.name": "#ECEFF4 bg:#000000",
1002
+ "command.selected": "bold #EBCB8B bg:#000000",
1003
+ "command.marker": "bold #EBCB8B bg:#000000",
1004
+ "command.dim": "#9AA1AD bg:#000000",
1005
+ "command.ok": "#5FD27A bg:#000000",
1006
+ "command.error": "#BF616A bg:#000000",
1007
+ "command-output": "#D8DEE9 bg:#000000",
1008
+ "permission": "bg:#000000 #D8DEE9",
1009
+ "permission.border": "#5E81AC bg:#000000",
1010
+ "permission.title": "bold #EBCB8B bg:#000000",
1011
+ "permission.prompt": "bold #ECEFF4 bg:#000000",
1012
+ "permission.tool": "bold #8FBCBB bg:#000000",
1013
+ "permission.dim": "#A7B0BE bg:#000000",
1014
+ "permission.marker": "bold #EBCB8B bg:#000000",
1015
+ "permission.choice": "#D8DEE9 bg:#000000",
1016
+ "permission.choice.selected": "bold #EBCB8B",
1017
+ "permission.key": "#88C0D0 bg:#000000",
1018
+ "scrollbar.background": "bg:#000000",
1019
+ "scrollbar.button": "bg:#4C566A",
1020
+ "changes": "#D8DEE9 bg:#253340",
1021
+ "changes.label": "#D8DEE9 bg:#253340",
1022
+ "changes.dim": "#9AA1AD bg:#253340",
1023
+ "changes.added": "#A3BE8C bg:#253340",
1024
+ "changes.removed": "#BF616A bg:#253340",
1025
+ "changes.review": "bold #8FBCBB bg:#253340",
1026
+ "changes.rollback": "bold #EBCB8B bg:#253340",
1027
+ "review": "#D8DEE9 bg:#253340",
1028
+ "review.file": "bold #5E81AC bg:#253340",
1029
+ "review.added": "#A3BE8C bg:#253340",
1030
+ "review.removed": "#BF616A bg:#253340",
1031
+ "review.dim": "#9AA1AD bg:#253340",
1032
+ }
1033
+ )