vtx-coding-agent 0.1.1__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 (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/ui/input.py ADDED
@@ -0,0 +1,760 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from collections.abc import Callable
6
+ from types import SimpleNamespace
7
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast
8
+
9
+ from rich.style import Style
10
+ from textual import events
11
+ from textual._ansi_sequences import ANSI_SEQUENCES_KEYS
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical
15
+ from textual.message import Message
16
+ from textual.widgets import Label, TextArea
17
+ from textual.widgets.text_area import TextAreaTheme
18
+
19
+ from vtx import config
20
+
21
+ from .autocomplete import (
22
+ DEFAULT_COMMANDS,
23
+ AutocompleteProvider,
24
+ FilePathProvider,
25
+ PullRequestProvider,
26
+ SlashCommand,
27
+ SlashCommandProvider,
28
+ )
29
+ from .floating_list import ListItem
30
+ from .path_complete import PathComplete
31
+ from .prompt_history import PromptHistory
32
+
33
+ if TYPE_CHECKING:
34
+ pass
35
+
36
+
37
+ class _AppWithInterrupt(Protocol):
38
+ """Subset of the Vtx app surface that the input box relies on at runtime."""
39
+
40
+ def action_interrupt_agent(self) -> None: ...
41
+
42
+
43
+ # Support both legacy ESC+CR and modern CSI-u sequences for modified Enter keys.
44
+ # ANSI_SEQUENCES_KEYS is exposed as a read-only Mapping in the type stubs, but
45
+ # is a real dict at runtime. Cast through dict so we can use update() safely.
46
+ cast("dict[str, Any]", ANSI_SEQUENCES_KEYS).update(
47
+ {
48
+ "\x1b\r": (SimpleNamespace(value="shift+enter"),),
49
+ "\x1b[13;3u": (SimpleNamespace(value="alt+enter"),),
50
+ "\x1b[13;2u": (SimpleNamespace(value="shift+enter"),),
51
+ }
52
+ )
53
+
54
+ _PASTE_LINE_THRESHOLD = 5
55
+ _PASTE_CHAR_THRESHOLD = 500
56
+ _PASTE_MARKER_RE = re.compile(r"\[paste #(\d+)(?: (\+\d+ lines|\d+ chars))?\]")
57
+ _SKILL_TRIGGER_MARKER = "\u2063"
58
+ _SHELL_COMMAND_CLASS = "-shell-command"
59
+ _TEXTAREA_THEME = "vtx-input"
60
+
61
+
62
+ def _get_textarea_theme() -> TextAreaTheme:
63
+ colors = config.ui.colors
64
+ return TextAreaTheme(
65
+ name=_TEXTAREA_THEME,
66
+ base_style=Style(color=colors.fg),
67
+ cursor_style=Style(color=colors.bg, bgcolor=colors.fg),
68
+ )
69
+
70
+
71
+ class Vtx(TextArea):
72
+ class ScrollInfo(Message):
73
+ def __init__(self, lines_above: int, lines_below: int) -> None:
74
+ super().__init__()
75
+ self.lines_above = lines_above
76
+ self.lines_below = lines_below
77
+
78
+ def __init__(self, on_paste: Callable[[str], str], **kwargs) -> None:
79
+ super().__init__(**kwargs)
80
+ self._on_paste_transform = on_paste
81
+
82
+ async def _on_key(self, event: events.Key) -> None:
83
+ future = getattr(self.app, "_approval_future", None)
84
+ approval_keys = ("y", "Y", "n", "N")
85
+ if not self.text:
86
+ approval_keys += ("left", "right", "enter")
87
+ if future and not future.done() and event.key in approval_keys:
88
+ app_on_key = getattr(self.app, "on_key", None)
89
+ if callable(app_on_key):
90
+ app_on_key(event)
91
+ return
92
+ await super()._on_key(event)
93
+
94
+ async def _on_paste(self, event: events.Paste) -> None:
95
+ # Prevent TextArea._on_paste from also running on the original event.
96
+ event.prevent_default()
97
+ transformed = self._on_paste_transform(event.text)
98
+ await super()._on_paste(events.Paste(transformed))
99
+
100
+ def _notify_scroll_info(self) -> None:
101
+ total_lines = self.document.line_count
102
+ visible_lines = self.scrollable_content_region.height
103
+ if visible_lines <= 0:
104
+ return
105
+ lines_above = int(self.scroll_y)
106
+ lines_below = max(0, total_lines - lines_above - visible_lines)
107
+ self.post_message(self.ScrollInfo(lines_above, lines_below))
108
+
109
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
110
+ super().watch_scroll_y(old_value, new_value)
111
+ self.call_after_refresh(self._notify_scroll_info)
112
+
113
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
114
+ self.call_after_refresh(self._notify_scroll_info)
115
+
116
+
117
+ class InputBox(Vertical):
118
+ """
119
+ Multi-line input with inline completion support.
120
+
121
+ - Enter: Submit
122
+ - Shift+Enter/Ctrl+J: Newline
123
+ - Up/Down: History navigation when at top/bottom, or list navigation when completing
124
+ - @ triggers file search (inline)
125
+ - / triggers slash commands (inline, at start of input)
126
+ - Escape: Cancel completion or clear input
127
+
128
+ The FloatingList is managed externally (at app level) but controlled
129
+ via messages from InputBox.
130
+ """
131
+
132
+ BINDINGS: ClassVar[list] = [
133
+ Binding("enter", "submit", "Send", priority=True),
134
+ Binding("ctrl+j,shift+enter", "newline", "New line", priority=True),
135
+ Binding("alt+enter", "steer_submit", "Steer", priority=True),
136
+ Binding("escape", "cancel", "Cancel", priority=False), # Lower priority so Shift+Enter win
137
+ Binding("up", "cursor_up", "Up", priority=True),
138
+ Binding("down", "cursor_down", "Down", priority=True),
139
+ Binding("tab", "tab_complete", "Tab complete", priority=True),
140
+ ]
141
+
142
+ DEFAULT_CSS = """
143
+ InputBox {
144
+ height: auto;
145
+ min-height: 3;
146
+ max-height: 30vh;
147
+ border-top: solid transparent;
148
+ border-bottom: solid transparent;
149
+ border-title-align: left;
150
+ border-subtitle-align: left;
151
+ border-title-color: grey;
152
+ border-subtitle-color: grey;
153
+ }
154
+
155
+ #input-row {
156
+ height: auto;
157
+ }
158
+
159
+ #input-prefix {
160
+ width: auto;
161
+ padding: 0 0 0 1;
162
+ text-style: bold;
163
+ }
164
+
165
+ InputBox .input-textarea {
166
+ width: 1fr;
167
+ height: auto;
168
+ max-height: 100%;
169
+ border: none;
170
+ background: transparent;
171
+ padding: 0 1;
172
+ }
173
+
174
+ InputBox .input-textarea:focus {
175
+ border: none;
176
+ }
177
+ """
178
+
179
+ def __init__(
180
+ self, cwd: str | None = None, id: str | None = None, classes: str | None = None
181
+ ) -> None:
182
+ super().__init__(id=id, classes=classes)
183
+ self._cwd = cwd or os.getcwd()
184
+ self._history = PromptHistory()
185
+
186
+ # Autocomplete providers
187
+ self._slash_provider = SlashCommandProvider(DEFAULT_COMMANDS.copy())
188
+ self._file_provider = FilePathProvider(self._cwd)
189
+ self._pr_provider = PullRequestProvider(self._cwd)
190
+ self._providers: list[AutocompleteProvider] = [
191
+ self._slash_provider,
192
+ self._file_provider,
193
+ self._pr_provider,
194
+ ]
195
+
196
+ # Active completion state (the list itself is external)
197
+ self._active_provider: AutocompleteProvider | None = None
198
+ self._completion_prefix: str = ""
199
+ self._is_completing: bool = False
200
+ self._autocomplete_enabled: bool = True
201
+ self._suppress_autocomplete: int = 0 # Skip N autocomplete triggers
202
+
203
+ # Tab path completion state
204
+ self._path_complete = PathComplete()
205
+ self._tab_completing: bool = False
206
+ self._tab_start_col: int = 0
207
+ self._tab_base_fragment: str = ""
208
+
209
+ # Large paste compaction
210
+ self._pastes: dict[int, str] = {}
211
+ self._paste_counter: int = 0
212
+
213
+ # Skill command triggers selected from slash autocomplete
214
+ self._selected_skill_commands: list[str] = []
215
+
216
+ def compose(self) -> ComposeResult:
217
+ with Horizontal(id="input-row"):
218
+ yield Label("\u203a", id="input-prefix")
219
+ yield Vtx(self._transform_paste, id="input-textarea", classes="input-textarea")
220
+
221
+ def on_mount(self) -> None:
222
+ textarea = self.query_one("#input-textarea", TextArea)
223
+ textarea.register_theme(_get_textarea_theme())
224
+ textarea.theme = _TEXTAREA_THEME
225
+ textarea.cursor_blink = False
226
+ textarea.show_line_numbers = False
227
+ textarea.highlight_cursor_line = False
228
+
229
+ def refresh_theme(self) -> None:
230
+ textarea = self.query_one("#input-textarea", TextArea)
231
+ textarea.register_theme(_get_textarea_theme())
232
+ textarea.theme = _TEXTAREA_THEME
233
+
234
+ def on_vtx_scroll_info(self, event: Vtx.ScrollInfo) -> None:
235
+ event.stop()
236
+ self.border_title = f"↑ {event.lines_above} more" if event.lines_above > 0 else ""
237
+ self.border_subtitle = f"↓ {event.lines_below} more" if event.lines_below > 0 else ""
238
+
239
+ @property
240
+ def text(self) -> str:
241
+ return self.query_one("#input-textarea", TextArea).text
242
+
243
+ @property
244
+ def is_completing(self) -> bool:
245
+ return self._is_completing
246
+
247
+ @property
248
+ def is_tab_completing(self) -> bool:
249
+ return self._tab_completing
250
+
251
+ def clear(self, *, reset_pastes: bool = True) -> None:
252
+ self.query_one("#input-textarea", TextArea).clear()
253
+ self._selected_skill_commands.clear()
254
+ self.border_title = ""
255
+ self.border_subtitle = ""
256
+ self._sync_shell_command_style()
257
+ if reset_pastes:
258
+ self._reset_pastes()
259
+
260
+ def insert(self, text: str) -> None:
261
+ self.query_one("#input-textarea", TextArea).insert(text)
262
+
263
+ def focus(self, scroll_visible: bool = True) -> InputBox:
264
+ self.query_one("#input-textarea", TextArea).focus(scroll_visible)
265
+ return self
266
+
267
+ def set_commands(self, commands: list[SlashCommand]) -> None:
268
+ self._slash_provider.commands = commands
269
+
270
+ def set_fd_path(self, fd_path: str | None) -> None:
271
+ self._file_provider.set_fd_path(fd_path)
272
+
273
+ def set_file_paths(self, paths: list[str]) -> None:
274
+ self._file_provider.set_paths(paths)
275
+
276
+ def set_cwd(self, cwd: str) -> None:
277
+ self._cwd = cwd
278
+ self._file_provider.set_cwd(cwd)
279
+ self._pr_provider.set_cwd(cwd)
280
+ self._path_complete.clear_cache()
281
+
282
+ def set_autocomplete_enabled(self, enabled: bool) -> None:
283
+ self._autocomplete_enabled = enabled
284
+
285
+ def set_placeholder(self, value: str) -> None:
286
+ self.query_one("#input-textarea", TextArea).placeholder = value
287
+
288
+ def set_completing(self, is_completing: bool) -> None:
289
+ self._is_completing = is_completing
290
+ if not is_completing:
291
+ self._active_provider = None
292
+ self._completion_prefix = ""
293
+ self._tab_completing = False
294
+ self._tab_start_col = 0
295
+ self._tab_base_fragment = ""
296
+
297
+ def _transform_paste(self, pasted_text: str) -> str:
298
+ normalized = pasted_text.replace("\r\n", "\n").replace("\r", "\n")
299
+ filtered = "".join(char for char in normalized if char == "\n" or ord(char) >= 32)
300
+ line_count = len(filtered.split("\n"))
301
+ char_count = len(filtered)
302
+
303
+ if line_count > _PASTE_LINE_THRESHOLD or char_count > _PASTE_CHAR_THRESHOLD:
304
+ self._paste_counter += 1
305
+ paste_id = self._paste_counter
306
+ self._pastes[paste_id] = filtered
307
+ if line_count > _PASTE_LINE_THRESHOLD:
308
+ return f"[paste #{paste_id} +{line_count} lines]"
309
+ return f"[paste #{paste_id} {char_count} chars]"
310
+
311
+ return filtered
312
+
313
+ def _expand_paste_markers(self, text: str) -> str:
314
+ def replace_match(match: re.Match[str]) -> str:
315
+ paste_id = int(match.group(1))
316
+ return self._pastes.get(paste_id, match.group(0))
317
+
318
+ return _PASTE_MARKER_RE.sub(replace_match, text)
319
+
320
+ def _reset_pastes(self) -> None:
321
+ self._pastes.clear()
322
+ self._paste_counter = 0
323
+
324
+ def _strip_skill_markers(self, text: str) -> str:
325
+ return text.replace(_SKILL_TRIGGER_MARKER, "")
326
+
327
+ def _extract_selected_skill_submission(self, text: str) -> tuple[str | None, str | None]:
328
+ pattern = re.compile(rf"{_SKILL_TRIGGER_MARKER}/([a-z0-9-]+){_SKILL_TRIGGER_MARKER}")
329
+ match = pattern.search(text)
330
+ if not match:
331
+ return None, None
332
+
333
+ skill_name = match.group(1)
334
+ if skill_name not in self._selected_skill_commands:
335
+ return None, None
336
+
337
+ query = (text[: match.start()] + text[match.end() :]).strip()
338
+ return skill_name, self._strip_skill_markers(query)
339
+
340
+ # -------------------------------------------------------------------------
341
+ # Text change handling - trigger autocomplete
342
+ # -------------------------------------------------------------------------
343
+
344
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
345
+ event.stop()
346
+
347
+ # Skip autocomplete if we just applied a completion
348
+ if self._suppress_autocomplete > 0:
349
+ self._suppress_autocomplete -= 1
350
+ return
351
+
352
+ self._sync_shell_command_style()
353
+
354
+ if not self._autocomplete_enabled:
355
+ # When completing with autocomplete disabled (selection mode),
356
+ # route text to the floating list search
357
+ if self._is_completing:
358
+ self.post_message(self.SearchUpdate(self.text))
359
+ return
360
+
361
+ self._try_autocomplete()
362
+
363
+ def _cursor_offset(self, text: str, cursor: tuple[int, int]) -> int:
364
+ row, col = cursor
365
+ lines = text.split("\n")
366
+ if row <= 0:
367
+ return max(0, min(col, len(lines[0]) if lines else 0))
368
+ clamped_row = min(row, len(lines) - 1)
369
+ prefix_len = sum(len(line) + 1 for line in lines[:clamped_row])
370
+ return prefix_len + max(0, min(col, len(lines[clamped_row])))
371
+
372
+ def _sync_shell_command_style(self) -> None:
373
+ if self.text.strip().startswith("!"):
374
+ self.add_class(_SHELL_COMMAND_CLASS)
375
+ else:
376
+ self.remove_class(_SHELL_COMMAND_CLASS)
377
+
378
+ def _try_autocomplete(self) -> None:
379
+ textarea = self.query_one("#input-textarea", TextArea)
380
+ text = textarea.text
381
+ cursor_col = self._cursor_offset(text, textarea.selection.end)
382
+
383
+ # Check each provider
384
+ for provider in self._providers:
385
+ if provider.should_trigger(text, cursor_col):
386
+ result = provider.get_suggestions(text, cursor_col)
387
+ if result and result.items:
388
+ self._active_provider = provider
389
+ self._completion_prefix = result.prefix
390
+ self._is_completing = True
391
+ # Post message for app to show/update the list
392
+ self.post_message(self.CompletionUpdate(result.items))
393
+ return
394
+
395
+ # No provider matched - hide completion
396
+ if self._is_completing:
397
+ self._is_completing = False
398
+ self._active_provider = None
399
+ self._completion_prefix = ""
400
+ self.post_message(self.CompletionHide())
401
+
402
+ # -------------------------------------------------------------------------
403
+ # Key handling
404
+ # -------------------------------------------------------------------------
405
+
406
+ def action_submit(self) -> None:
407
+ future = getattr(self.app, "_approval_future", None)
408
+ if future and not future.done() and not self.text:
409
+ app_on_key = getattr(self.app, "on_key", None)
410
+ if callable(app_on_key):
411
+ app_on_key(events.Key("enter", "enter"))
412
+ return
413
+ if self._is_completing:
414
+ # Tell app to apply the current selection
415
+ self.post_message(self.CompletionSelect())
416
+ return
417
+ if getattr(self.app, "start_queue_edit", lambda: False)():
418
+ return
419
+ self._do_submit(steer=False)
420
+
421
+ def action_steer_submit(self) -> None:
422
+ if self._is_completing:
423
+ self._is_completing = False
424
+ self._active_provider = None
425
+ self._completion_prefix = ""
426
+ self.post_message(self.CompletionHide())
427
+ self._do_submit(steer=True)
428
+
429
+ def _do_submit(self, steer: bool = False) -> None:
430
+ raw_text = self.text.strip()
431
+ if not raw_text:
432
+ return
433
+ query_text = self._expand_paste_markers(raw_text)
434
+ selected_skill_name, selected_skill_query = self._extract_selected_skill_submission(
435
+ query_text
436
+ )
437
+ display_text = self._strip_skill_markers(raw_text)
438
+ query_text = self._strip_skill_markers(query_text)
439
+ self._add_to_history(query_text)
440
+ try:
441
+ if getattr(self.app, "finish_queue_edit", lambda _display, _query: False)(
442
+ display_text, query_text
443
+ ):
444
+ self.clear(reset_pastes=True)
445
+ return
446
+ except Exception:
447
+ pass
448
+ self.post_message(
449
+ self.Submitted(
450
+ display_text,
451
+ query_text=query_text,
452
+ selected_skill_name=selected_skill_name,
453
+ selected_skill_query=selected_skill_query,
454
+ steer=steer,
455
+ )
456
+ )
457
+ self.clear(reset_pastes=True)
458
+
459
+ def submit_raw(self) -> None:
460
+ self._is_completing = False
461
+ self._active_provider = None
462
+ self._completion_prefix = ""
463
+ self._do_submit(steer=False)
464
+
465
+ def action_newline(self) -> None:
466
+ self.query_one("#input-textarea", TextArea).insert("\n")
467
+
468
+ def action_cancel(self) -> None:
469
+ if self._is_completing:
470
+ if getattr(self.app, "_selection_mode", None) == "tree":
471
+ selector: object = self.app.query_one("#tree-selector")
472
+ action = getattr(selector, "action_cancel", None)
473
+ if callable(action):
474
+ action()
475
+ return
476
+ self._is_completing = False
477
+ self._active_provider = None
478
+ self._completion_prefix = ""
479
+ self.post_message(self.CompletionHide())
480
+ return
481
+
482
+ app = self.app
483
+ if getattr(app, "cancel_queue_edit", lambda: False)():
484
+ return
485
+ if getattr(app, "deny_pending_approval", lambda: False)():
486
+ return
487
+ if getattr(app, "_is_running", False):
488
+ cast("_AppWithInterrupt", app).action_interrupt_agent()
489
+ else:
490
+ self.clear()
491
+
492
+ def action_cursor_up(self) -> None:
493
+ if self._is_completing:
494
+ self.post_message(self.CompletionMove(-1))
495
+ return
496
+ textarea = self.query_one("#input-textarea", TextArea)
497
+ row, _ = textarea.selection.start
498
+ if row > 0:
499
+ textarea.action_cursor_up()
500
+ elif getattr(self.app, "select_queue_from_input", lambda _direction: False)(-1):
501
+ return
502
+ elif not textarea.text.strip() or self._history.is_browsing:
503
+ self._history_navigate(-1)
504
+ else:
505
+ textarea.action_cursor_line_start()
506
+
507
+ def action_cursor_down(self) -> None:
508
+ if self._is_completing:
509
+ self.post_message(self.CompletionMove(1))
510
+ return
511
+ textarea = self.query_one("#input-textarea", TextArea)
512
+ row, _ = textarea.selection.start
513
+ if row < textarea.document.line_count - 1:
514
+ textarea.action_cursor_down()
515
+ elif getattr(self.app, "select_queue_from_input", lambda _direction: False)(1):
516
+ return
517
+ elif self._history.is_browsing:
518
+ self._history_navigate(1)
519
+ else:
520
+ textarea.action_cursor_line_end()
521
+
522
+ def action_tab_complete(self) -> None:
523
+ """Handle Tab key for path completion."""
524
+ self.run_worker(self._do_tab_complete())
525
+
526
+ async def _do_tab_complete(self) -> None:
527
+ """Perform tab completion asynchronously."""
528
+ # If already completing, treat Tab as moving down in the list
529
+ if self._is_completing:
530
+ self.post_message(self.CompletionMove(1))
531
+ return
532
+
533
+ textarea = self.query_one("#input-textarea", TextArea)
534
+ cursor_pos = textarea.selection.end
535
+ text = textarea.text
536
+
537
+ # Get text before cursor on current line
538
+ row, col = cursor_pos
539
+ lines = text.split("\n")
540
+ if row >= len(lines):
541
+ return
542
+ line = lines[row]
543
+ text_before_cursor = line[:col]
544
+
545
+ # Extract path fragment (last word/token before cursor)
546
+ path_fragment, start_col = PathComplete.extract_path_fragment(text_before_cursor)
547
+ if not path_fragment:
548
+ # No path to complete - insert literal tab (spaces)
549
+ self._suppress_autocomplete = 1
550
+ textarea.insert(" ")
551
+ return
552
+
553
+ # Call PathComplete
554
+ completion, alternatives = await self._path_complete(self._cwd, path_fragment)
555
+
556
+ if not completion and not alternatives:
557
+ # No matches - beep
558
+ self.app.bell()
559
+ return
560
+
561
+ if completion and not alternatives:
562
+ # Unique completion - insert directly
563
+ self._suppress_autocomplete = 1
564
+ textarea.insert(completion)
565
+ # Add space after files (not directories)
566
+ if not completion.endswith(os.sep):
567
+ textarea.insert(" ")
568
+ return
569
+
570
+ # Multiple alternatives - show floating list
571
+ # First, insert any common prefix
572
+ if completion:
573
+ self._suppress_autocomplete = 1
574
+ textarea.insert(completion)
575
+ # Update cursor position after insertion
576
+ col = col + len(completion)
577
+
578
+ # Prepare items for floating list
579
+ base_fragment = PathComplete.get_base_path(path_fragment + completion)
580
+ items = []
581
+ for alt in alternatives[:20]: # Limit to 20 items
582
+ label = alt
583
+ # Show the base path as description
584
+ description = base_fragment if base_fragment else "."
585
+ items.append(ListItem(value=alt, label=label, description=description))
586
+
587
+ # Save state for applying completion later
588
+ self._tab_completing = True
589
+ self._tab_start_col = start_col
590
+ self._tab_base_fragment = base_fragment
591
+ self._is_completing = True
592
+
593
+ # Show the floating list
594
+ self.post_message(self.CompletionUpdate(items))
595
+
596
+ # -------------------------------------------------------------------------
597
+ # Completion application (called by app after selection)
598
+ # -------------------------------------------------------------------------
599
+
600
+ def apply_slash_command(self, item: ListItem) -> None:
601
+ cmd: SlashCommand = item.value
602
+ self._is_completing = False
603
+ self._active_provider = None
604
+
605
+ if cmd.submit_on_select and not cmd.is_skill:
606
+ self._completion_prefix = ""
607
+ self._suppress_autocomplete = 1 # clear() = 1 event
608
+ self.clear(reset_pastes=True)
609
+ self.post_message(self.Submitted(f"/{cmd.name}"))
610
+ return
611
+
612
+ if not cmd.is_skill:
613
+ prefix = self._completion_prefix
614
+ self._completion_prefix = ""
615
+
616
+ textarea = self.query_one("#input-textarea", TextArea)
617
+ text = textarea.text
618
+ cursor_col = self._cursor_offset(text, textarea.selection.end)
619
+ new_text, _ = self._slash_provider.apply_completion(text, cursor_col, item, prefix)
620
+
621
+ self._suppress_autocomplete = 2 # clear() + insert() = 2 events
622
+ textarea.clear()
623
+ textarea.insert(new_text)
624
+ return
625
+
626
+ prefix = self._completion_prefix
627
+ self._completion_prefix = ""
628
+
629
+ textarea = self.query_one("#input-textarea", TextArea)
630
+ text = textarea.text
631
+ cursor_col = self._cursor_offset(text, textarea.selection.end)
632
+
633
+ new_text, _ = self._slash_provider.apply_completion(text, cursor_col, item, prefix)
634
+
635
+ if cmd.name not in self._selected_skill_commands:
636
+ self._selected_skill_commands.append(cmd.name)
637
+ marker_wrapped = f"{_SKILL_TRIGGER_MARKER}/{cmd.name}{_SKILL_TRIGGER_MARKER} "
638
+ plain = f"/{cmd.name} "
639
+ if plain in new_text:
640
+ new_text = new_text.replace(plain, marker_wrapped, 1)
641
+
642
+ self._suppress_autocomplete = 2 # clear() + insert() = 2 events
643
+ textarea.clear()
644
+ textarea.insert(new_text)
645
+
646
+ def apply_provider_completion(self, item: ListItem) -> None:
647
+ provider = self._active_provider
648
+ if provider is None:
649
+ return
650
+
651
+ textarea = self.query_one("#input-textarea", TextArea)
652
+ text = textarea.text
653
+ cursor_col = self._cursor_offset(text, textarea.selection.end)
654
+ new_text, _ = provider.apply_completion(text, cursor_col, item, self._completion_prefix)
655
+
656
+ self._is_completing = False
657
+ self._active_provider = None
658
+ self._completion_prefix = ""
659
+ self._suppress_autocomplete = 2 # clear() + insert() = 2 events
660
+ textarea.clear()
661
+ textarea.insert(new_text)
662
+
663
+ def apply_file_completion(self, item: ListItem) -> None:
664
+ self.apply_provider_completion(item)
665
+
666
+ def apply_tab_path_completion(self, item: ListItem) -> None:
667
+ """Apply a tab path completion selection."""
668
+ textarea = self.query_one("#input-textarea", TextArea)
669
+ text = textarea.text
670
+ cursor_col = self._cursor_offset(text, textarea.selection.end)
671
+
672
+ # Get the selected path
673
+ selected_path: str = item.value
674
+
675
+ # Build the new path: base_fragment + selected
676
+ new_path = self._tab_base_fragment + selected_path
677
+
678
+ # Quote if contains spaces
679
+ if " " in new_path and not new_path.startswith('"'):
680
+ new_path = f'"{new_path}"'
681
+
682
+ # Replace from start_col to cursor
683
+ text_before = text[: self._tab_start_col]
684
+ text_after = text[cursor_col:]
685
+
686
+ # Add space after files (not directories)
687
+ is_dir = selected_path.endswith("/") or selected_path.endswith(os.sep)
688
+ suffix = "" if is_dir else " "
689
+
690
+ new_text = text_before + new_path + suffix + text_after
691
+
692
+ # Clear state
693
+ self._is_completing = False
694
+ self._tab_completing = False
695
+ self._tab_start_col = 0
696
+ self._tab_base_fragment = ""
697
+ self._suppress_autocomplete = 2 # clear() + insert() = 2 events
698
+
699
+ textarea.clear()
700
+ textarea.insert(new_text)
701
+
702
+ @property
703
+ def active_provider(self) -> AutocompleteProvider | None:
704
+ return self._active_provider
705
+
706
+ # -------------------------------------------------------------------------
707
+ # History
708
+ # -------------------------------------------------------------------------
709
+
710
+ def _add_to_history(self, text: str) -> None:
711
+ self._history.append(text)
712
+
713
+ def _history_navigate(self, direction: int) -> None:
714
+ textarea = self.query_one("#input-textarea", TextArea)
715
+ result = self._history.navigate(direction, textarea.text)
716
+ if result is None:
717
+ return
718
+ textarea.clear()
719
+ textarea.insert(result)
720
+
721
+ # -------------------------------------------------------------------------
722
+ # Messages
723
+ # -------------------------------------------------------------------------
724
+
725
+ class Submitted(Message):
726
+ def __init__(
727
+ self,
728
+ text: str,
729
+ query_text: str | None = None,
730
+ selected_skill_name: str | None = None,
731
+ selected_skill_query: str | None = None,
732
+ steer: bool = False,
733
+ ) -> None:
734
+ super().__init__()
735
+ self.text = text
736
+ self.query_text = query_text if query_text is not None else text
737
+ self.selected_skill_name = selected_skill_name
738
+ self.selected_skill_query = selected_skill_query
739
+ self.steer = steer
740
+
741
+ class CompletionUpdate(Message):
742
+ def __init__(self, items: list[ListItem]) -> None:
743
+ super().__init__()
744
+ self.items = items
745
+
746
+ class CompletionHide(Message):
747
+ pass
748
+
749
+ class CompletionSelect(Message):
750
+ pass
751
+
752
+ class CompletionMove(Message):
753
+ def __init__(self, direction: int) -> None:
754
+ super().__init__()
755
+ self.direction = direction
756
+
757
+ class SearchUpdate(Message):
758
+ def __init__(self, query: str) -> None:
759
+ super().__init__()
760
+ self.query = query