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/autocomplete.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Autocomplete providers for inline completion.
|
|
3
|
+
|
|
4
|
+
Providers handle filtering and suggestion generation for different
|
|
5
|
+
completion types (slash commands, file paths, sessions, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
from vtx import gh_cli
|
|
16
|
+
|
|
17
|
+
from .floating_list import ListItem
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CompletionResult:
|
|
22
|
+
items: list[ListItem]
|
|
23
|
+
prefix: str # The text being completed (e.g., "/hel" or "@src")
|
|
24
|
+
replace_start: int # Column position where replacement starts
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AutocompleteProvider(ABC):
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def trigger_chars(self) -> set[str]: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def should_trigger(self, text: str, cursor_col: int) -> bool: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def get_suggestions(self, text: str, cursor_col: int) -> CompletionResult | None: ...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def apply_completion(
|
|
40
|
+
self, text: str, cursor_col: int, item: ListItem, prefix: str
|
|
41
|
+
) -> tuple[str, int]:
|
|
42
|
+
"""
|
|
43
|
+
Apply the selected completion.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Tuple of (new_text, new_cursor_col)
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FuzzyMatcher:
|
|
52
|
+
def __init__(self, case_sensitive: bool = False) -> None:
|
|
53
|
+
self.case_sensitive = case_sensitive
|
|
54
|
+
|
|
55
|
+
def match(self, query: str, candidate: str) -> tuple[float, Sequence[int]]:
|
|
56
|
+
"""
|
|
57
|
+
Match query against candidate.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (score, list of matching indices). (0, []) for no match.
|
|
61
|
+
"""
|
|
62
|
+
if not query:
|
|
63
|
+
return (1.0, [])
|
|
64
|
+
|
|
65
|
+
if not self.case_sensitive:
|
|
66
|
+
query = query.lower()
|
|
67
|
+
candidate = candidate.lower()
|
|
68
|
+
|
|
69
|
+
positions = []
|
|
70
|
+
idx = 0
|
|
71
|
+
for char in query:
|
|
72
|
+
idx = candidate.find(char, idx)
|
|
73
|
+
if idx == -1:
|
|
74
|
+
return (0.0, [])
|
|
75
|
+
positions.append(idx)
|
|
76
|
+
idx += 1
|
|
77
|
+
|
|
78
|
+
score = self._score(candidate, positions)
|
|
79
|
+
return (score, positions)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
@lru_cache(maxsize=1024)
|
|
83
|
+
def get_first_letters(cls, candidate: str) -> frozenset[int]:
|
|
84
|
+
indices = set()
|
|
85
|
+
word_start = True
|
|
86
|
+
for i, char in enumerate(candidate):
|
|
87
|
+
if char.isalnum():
|
|
88
|
+
if word_start:
|
|
89
|
+
indices.add(i)
|
|
90
|
+
word_start = False
|
|
91
|
+
else:
|
|
92
|
+
word_start = True
|
|
93
|
+
return frozenset(indices)
|
|
94
|
+
|
|
95
|
+
def _score(self, candidate: str, positions: Sequence[int]) -> float:
|
|
96
|
+
if not positions:
|
|
97
|
+
return 0.0
|
|
98
|
+
|
|
99
|
+
score = float(len(positions))
|
|
100
|
+
first_letters = self.get_first_letters(candidate)
|
|
101
|
+
first_letter_matches = len(positions) - len(set(positions) - first_letters)
|
|
102
|
+
score += first_letter_matches * 0.5
|
|
103
|
+
|
|
104
|
+
groups = 1
|
|
105
|
+
for i in range(1, len(positions)):
|
|
106
|
+
if positions[i] != positions[i - 1] + 1:
|
|
107
|
+
groups += 1
|
|
108
|
+
|
|
109
|
+
if len(positions) > 1:
|
|
110
|
+
group_factor = (len(positions) - groups + 1) / len(positions)
|
|
111
|
+
score *= 1 + group_factor
|
|
112
|
+
|
|
113
|
+
if positions[0] == 0:
|
|
114
|
+
score *= 1.2
|
|
115
|
+
|
|
116
|
+
return score
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class SlashCommand:
|
|
121
|
+
name: str
|
|
122
|
+
description: str
|
|
123
|
+
shortcut: str | None = None
|
|
124
|
+
is_skill: bool = False
|
|
125
|
+
submit_on_select: bool = True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SlashCommandProvider(AutocompleteProvider):
|
|
129
|
+
def __init__(self, commands: list[SlashCommand] | None = None) -> None:
|
|
130
|
+
self._commands = commands or []
|
|
131
|
+
self._matcher = FuzzyMatcher(case_sensitive=False)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def commands(self) -> list[SlashCommand]:
|
|
135
|
+
return self._commands
|
|
136
|
+
|
|
137
|
+
@commands.setter
|
|
138
|
+
def commands(self, value: list[SlashCommand]) -> None:
|
|
139
|
+
self._commands = value
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def trigger_chars(self) -> set[str]:
|
|
143
|
+
return {"/"}
|
|
144
|
+
|
|
145
|
+
def _extract_token(self, text: str, cursor_col: int) -> tuple[str, int, bool] | None:
|
|
146
|
+
text_before = text[:cursor_col]
|
|
147
|
+
slash_pos = text_before.rfind("/")
|
|
148
|
+
if slash_pos == -1:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
if slash_pos > 0 and not text_before[slash_pos - 1].isspace():
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
token = text_before[slash_pos:]
|
|
155
|
+
if " " in token or "\n" in token or not token.startswith("/"):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
is_start_command = not text_before[:slash_pos].strip()
|
|
159
|
+
return token, slash_pos, is_start_command
|
|
160
|
+
|
|
161
|
+
def _available_commands(self, is_start_command: bool) -> list[SlashCommand]:
|
|
162
|
+
if is_start_command:
|
|
163
|
+
return self._commands
|
|
164
|
+
return [cmd for cmd in self._commands if cmd.is_skill]
|
|
165
|
+
|
|
166
|
+
def should_trigger(self, text: str, cursor_col: int) -> bool:
|
|
167
|
+
extracted = self._extract_token(text, cursor_col)
|
|
168
|
+
if extracted is None:
|
|
169
|
+
return False
|
|
170
|
+
_, _, is_start_command = extracted
|
|
171
|
+
return bool(self._available_commands(is_start_command))
|
|
172
|
+
|
|
173
|
+
def get_suggestions(self, text: str, cursor_col: int) -> CompletionResult | None:
|
|
174
|
+
extracted = self._extract_token(text, cursor_col)
|
|
175
|
+
if extracted is None:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
token, prefix_start, is_start_command = extracted
|
|
179
|
+
available_commands = self._available_commands(is_start_command)
|
|
180
|
+
if not available_commands:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
# Extract the command prefix (without the /)
|
|
184
|
+
query = token[1:]
|
|
185
|
+
|
|
186
|
+
# Filter and score commands
|
|
187
|
+
scored = []
|
|
188
|
+
for cmd in available_commands:
|
|
189
|
+
score, _ = self._matcher.match(query, cmd.name)
|
|
190
|
+
if score > 0 or not query:
|
|
191
|
+
scored.append((score, cmd))
|
|
192
|
+
|
|
193
|
+
# Sort by score descending
|
|
194
|
+
scored.sort(key=lambda x: (-x[0], x[1].name))
|
|
195
|
+
|
|
196
|
+
items = []
|
|
197
|
+
for _, cmd in scored:
|
|
198
|
+
label = f"/{cmd.name}"
|
|
199
|
+
desc = cmd.description
|
|
200
|
+
if cmd.shortcut:
|
|
201
|
+
desc = f"{desc} ({cmd.shortcut})"
|
|
202
|
+
items.append(ListItem(value=cmd, label=label, description=desc))
|
|
203
|
+
|
|
204
|
+
if not items:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
return CompletionResult(items=items, prefix=token, replace_start=prefix_start)
|
|
208
|
+
|
|
209
|
+
def apply_completion(
|
|
210
|
+
self, text: str, cursor_col: int, item: ListItem, prefix: str
|
|
211
|
+
) -> tuple[str, int]:
|
|
212
|
+
cmd: SlashCommand = item.value
|
|
213
|
+
text_before = text[:cursor_col]
|
|
214
|
+
prefix_start = cursor_col - len(prefix)
|
|
215
|
+
text_after = text[cursor_col:]
|
|
216
|
+
|
|
217
|
+
# Replace prefix with command + space
|
|
218
|
+
new_text = text_before[:prefix_start] + f"/{cmd.name} " + text_after
|
|
219
|
+
new_cursor = prefix_start + len(cmd.name) + 2 # +2 for "/" and space
|
|
220
|
+
|
|
221
|
+
return (new_text, new_cursor)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class PullRequestProvider(AutocompleteProvider):
|
|
225
|
+
def __init__(self, cwd: str = ".") -> None:
|
|
226
|
+
self._cwd = cwd
|
|
227
|
+
self._matcher = FuzzyMatcher(case_sensitive=False)
|
|
228
|
+
|
|
229
|
+
def set_cwd(self, cwd: str) -> None:
|
|
230
|
+
self._cwd = cwd
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def trigger_chars(self) -> set[str]:
|
|
234
|
+
return {"#"}
|
|
235
|
+
|
|
236
|
+
def _extract_token(self, text: str, cursor_col: int) -> tuple[str, int] | None:
|
|
237
|
+
text_before = text[:cursor_col]
|
|
238
|
+
for i in range(len(text_before) - 1, -1, -1):
|
|
239
|
+
if text_before[i] == "#":
|
|
240
|
+
if i == 0 or text_before[i - 1].isspace():
|
|
241
|
+
return text_before[i:], i
|
|
242
|
+
break
|
|
243
|
+
if text_before[i].isspace():
|
|
244
|
+
break
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def should_trigger(self, text: str, cursor_col: int) -> bool:
|
|
248
|
+
return gh_cli.is_available() and self._extract_token(text, cursor_col) is not None
|
|
249
|
+
|
|
250
|
+
def get_suggestions(self, text: str, cursor_col: int) -> CompletionResult | None:
|
|
251
|
+
extracted = self._extract_token(text, cursor_col)
|
|
252
|
+
if extracted is None:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
token, token_start = extracted
|
|
256
|
+
query = token[1:]
|
|
257
|
+
scored = []
|
|
258
|
+
for pr in gh_cli.list_pull_requests(self._cwd):
|
|
259
|
+
label = f"#{pr.number} {pr.branch}"
|
|
260
|
+
haystack = f"{label} {pr.title}"
|
|
261
|
+
score, _ = self._matcher.match(query, haystack)
|
|
262
|
+
if score > 0 or not query:
|
|
263
|
+
scored.append((score, pr, label))
|
|
264
|
+
scored.sort(key=lambda item: (-item[0], item[1].number))
|
|
265
|
+
|
|
266
|
+
items = [
|
|
267
|
+
ListItem(value=pr, label=label, description=pr.title) for _, pr, label in scored[:20]
|
|
268
|
+
]
|
|
269
|
+
if not items:
|
|
270
|
+
return None
|
|
271
|
+
return CompletionResult(items=items, prefix=token, replace_start=token_start)
|
|
272
|
+
|
|
273
|
+
def apply_completion(
|
|
274
|
+
self, text: str, cursor_col: int, item: ListItem, prefix: str
|
|
275
|
+
) -> tuple[str, int]:
|
|
276
|
+
pr: gh_cli.PullRequest = item.value
|
|
277
|
+
text_before = text[:cursor_col]
|
|
278
|
+
prefix_start = cursor_col - len(prefix)
|
|
279
|
+
text_after = text[cursor_col:]
|
|
280
|
+
replacement = pr.chat_reference()
|
|
281
|
+
new_text = text_before[:prefix_start] + replacement + " " + text_after
|
|
282
|
+
return new_text, prefix_start + len(replacement) + 1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class FilePathProvider(AutocompleteProvider):
|
|
286
|
+
def __init__(self, cwd: str = ".", fd_path: str | None = None) -> None:
|
|
287
|
+
self._cwd = cwd
|
|
288
|
+
self._fd_path = fd_path
|
|
289
|
+
self._matcher = FuzzyMatcher(case_sensitive=False)
|
|
290
|
+
self._cached_paths: list[str] = []
|
|
291
|
+
|
|
292
|
+
def set_cwd(self, cwd: str) -> None:
|
|
293
|
+
self._cwd = cwd
|
|
294
|
+
|
|
295
|
+
def set_fd_path(self, fd_path: str | None) -> None:
|
|
296
|
+
self._fd_path = fd_path
|
|
297
|
+
|
|
298
|
+
def set_paths(self, paths: list[str]) -> None:
|
|
299
|
+
self._cached_paths = paths
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def trigger_chars(self) -> set[str]:
|
|
303
|
+
return {"@"}
|
|
304
|
+
|
|
305
|
+
def should_trigger(self, text: str, cursor_col: int) -> bool:
|
|
306
|
+
text_before = text[:cursor_col]
|
|
307
|
+
# Find @ that's at start or after whitespace
|
|
308
|
+
for i in range(len(text_before) - 1, -1, -1):
|
|
309
|
+
if text_before[i] == "@":
|
|
310
|
+
if i == 0 or text_before[i - 1].isspace():
|
|
311
|
+
return True
|
|
312
|
+
break
|
|
313
|
+
elif text_before[i].isspace():
|
|
314
|
+
break
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
def get_suggestions(self, text: str, cursor_col: int) -> CompletionResult | None:
|
|
318
|
+
text_before = text[:cursor_col]
|
|
319
|
+
|
|
320
|
+
# Find the @ and query
|
|
321
|
+
at_pos = -1
|
|
322
|
+
for i in range(len(text_before) - 1, -1, -1):
|
|
323
|
+
if text_before[i] == "@":
|
|
324
|
+
if i == 0 or text_before[i - 1].isspace():
|
|
325
|
+
at_pos = i
|
|
326
|
+
break
|
|
327
|
+
elif text_before[i].isspace():
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
if at_pos == -1:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
query = text_before[at_pos + 1 :] # Text after @
|
|
334
|
+
prefix = text_before[at_pos:] # Including @
|
|
335
|
+
|
|
336
|
+
# Get file suggestions
|
|
337
|
+
paths = self._get_paths(query)
|
|
338
|
+
|
|
339
|
+
items = []
|
|
340
|
+
for path in paths[:20]: # Limit results
|
|
341
|
+
# Format: label = filename (or dirname/), description = parent path
|
|
342
|
+
is_dir = path.endswith("/")
|
|
343
|
+
clean_path = path.rstrip("/")
|
|
344
|
+
basename = os.path.basename(clean_path)
|
|
345
|
+
dirname = os.path.dirname(clean_path)
|
|
346
|
+
|
|
347
|
+
label = basename + ("/" if is_dir else "")
|
|
348
|
+
# Show parent directory as description
|
|
349
|
+
description = dirname if dirname else "."
|
|
350
|
+
|
|
351
|
+
items.append(ListItem(value=path, label=label, description=description))
|
|
352
|
+
|
|
353
|
+
if not items:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
return CompletionResult(items=items, prefix=prefix, replace_start=at_pos)
|
|
357
|
+
|
|
358
|
+
def _get_paths(self, query: str) -> list[str]:
|
|
359
|
+
if self._fd_path:
|
|
360
|
+
return self._query_fd(query)
|
|
361
|
+
return self._fuzzy_filter(query)
|
|
362
|
+
|
|
363
|
+
def _query_fd(self, query: str) -> list[str]:
|
|
364
|
+
fd_path = self._fd_path
|
|
365
|
+
if not fd_path:
|
|
366
|
+
return self._fuzzy_filter(query)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
cmd: Sequence[str] = (
|
|
370
|
+
fd_path,
|
|
371
|
+
"--full-path",
|
|
372
|
+
"--color=never",
|
|
373
|
+
"--max-results",
|
|
374
|
+
"50",
|
|
375
|
+
"-t",
|
|
376
|
+
"f",
|
|
377
|
+
"-t",
|
|
378
|
+
"d",
|
|
379
|
+
)
|
|
380
|
+
cmd = (*cmd, query) if query else (*cmd, ".")
|
|
381
|
+
|
|
382
|
+
result = subprocess.run(
|
|
383
|
+
cmd, cwd=self._cwd, capture_output=True, text=True, timeout=0.3
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if result.returncode == 0:
|
|
387
|
+
return [p for p in result.stdout.strip().split("\n") if p]
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
return self._fuzzy_filter(query)
|
|
392
|
+
|
|
393
|
+
def _fuzzy_filter(self, query: str) -> list[str]:
|
|
394
|
+
if not query:
|
|
395
|
+
return self._cached_paths[:50]
|
|
396
|
+
|
|
397
|
+
scored = []
|
|
398
|
+
for path in self._cached_paths:
|
|
399
|
+
score, _ = self._matcher.match(query, path)
|
|
400
|
+
if score > 0:
|
|
401
|
+
scored.append((score, path))
|
|
402
|
+
|
|
403
|
+
scored.sort(key=lambda x: -x[0])
|
|
404
|
+
return [p for _, p in scored[:50]]
|
|
405
|
+
|
|
406
|
+
def apply_completion(
|
|
407
|
+
self, text: str, cursor_col: int, item: ListItem, prefix: str
|
|
408
|
+
) -> tuple[str, int]:
|
|
409
|
+
path: str = item.value
|
|
410
|
+
text_before = text[:cursor_col]
|
|
411
|
+
prefix_start = cursor_col - len(prefix)
|
|
412
|
+
text_after = text[cursor_col:]
|
|
413
|
+
|
|
414
|
+
# Replace prefix with @path + space
|
|
415
|
+
is_dir = path.endswith("/")
|
|
416
|
+
suffix = "" if is_dir else " "
|
|
417
|
+
new_text = text_before[:prefix_start] + f"@{path}{suffix}" + text_after
|
|
418
|
+
new_cursor = prefix_start + len(path) + 1 + len(suffix) # +1 for @
|
|
419
|
+
|
|
420
|
+
return (new_text, new_cursor)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# Default slash commands
|
|
424
|
+
DEFAULT_COMMANDS = [
|
|
425
|
+
SlashCommand("help", "show available commands"),
|
|
426
|
+
SlashCommand("quit", "quit the application", "ctrl+c,c"),
|
|
427
|
+
SlashCommand("clear", "clear conversation history"),
|
|
428
|
+
SlashCommand("model", "change model"),
|
|
429
|
+
SlashCommand("settings", "themes, permissions, thinking, notifications"),
|
|
430
|
+
SlashCommand("new", "start new conversation"),
|
|
431
|
+
SlashCommand("handoff", "start focused handoff in new session", submit_on_select=False),
|
|
432
|
+
SlashCommand("resume", "resume a session"),
|
|
433
|
+
SlashCommand("tree", "navigate session tree"),
|
|
434
|
+
SlashCommand("session", "show session info and stats"),
|
|
435
|
+
SlashCommand("login", "login to a provider"),
|
|
436
|
+
SlashCommand("logout", "logout from a provider"),
|
|
437
|
+
SlashCommand("export", "export session to HTML"),
|
|
438
|
+
SlashCommand("copy", "copy last agent response text"),
|
|
439
|
+
SlashCommand("compact", "compact current conversation now"),
|
|
440
|
+
]
|