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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|