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/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
+ ]