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/latex.py ADDED
@@ -0,0 +1,349 @@
1
+ """LaTeX math-to-Unicode converter.
2
+
3
+ Adapted from innomd by Innomatica GmbH (MIT License).
4
+ Source: https://github.com/Innomatica-GmbH/innomd
5
+ Commit: 35021c740a44d197cae4e8c0ab142e0b5e0cdaec
6
+ """
7
+
8
+ import re
9
+
10
+ GREEK = {
11
+ r"\alpha": "α",
12
+ r"\beta": "β",
13
+ r"\gamma": "γ",
14
+ r"\delta": "δ",
15
+ r"\epsilon": "ε",
16
+ r"\varepsilon": "ε",
17
+ r"\zeta": "ζ",
18
+ r"\eta": "η",
19
+ r"\theta": "θ",
20
+ r"\vartheta": "ϑ",
21
+ r"\iota": "ι",
22
+ r"\kappa": "κ",
23
+ r"\lambda": "λ",
24
+ r"\mu": "μ",
25
+ r"\nu": "ν",
26
+ r"\xi": "ξ",
27
+ r"\pi": "π",
28
+ r"\varpi": "ϖ",
29
+ r"\rho": "ρ",
30
+ r"\varrho": "ϱ",
31
+ r"\sigma": "σ",
32
+ r"\varsigma": "ς",
33
+ r"\tau": "τ",
34
+ r"\upsilon": "υ",
35
+ r"\phi": "φ",
36
+ r"\varphi": "ϕ",
37
+ r"\chi": "χ",
38
+ r"\psi": "ψ",
39
+ r"\omega": "ω",
40
+ r"\Gamma": "Γ",
41
+ r"\Delta": "Δ",
42
+ r"\Theta": "Θ",
43
+ r"\Lambda": "Λ",
44
+ r"\Xi": "Ξ",
45
+ r"\Pi": "Π",
46
+ r"\Sigma": "Σ",
47
+ r"\Upsilon": "Υ",
48
+ r"\Phi": "Φ",
49
+ r"\Psi": "Ψ",
50
+ r"\Omega": "Ω",
51
+ r"\hbar": "ℏ",
52
+ r"\ell": "ℓ",
53
+ r"\Re": "ℜ",
54
+ r"\Im": "ℑ",
55
+ }
56
+
57
+ OPERATORS = {
58
+ r"\cdot": "·",
59
+ r"\times": "×",
60
+ r"\div": "÷",
61
+ r"\pm": "±",
62
+ r"\mp": "∓",
63
+ r"\ast": "∗",
64
+ r"\star": "⋆",
65
+ r"\circ": "∘",
66
+ r"\bullet": "•",
67
+ r"\leq": "≤",
68
+ r"\le": "≤",
69
+ r"\geq": "≥",
70
+ r"\ge": "≥",
71
+ r"\neq": "≠",
72
+ r"\ne": "≠",
73
+ r"\approx": "≈",
74
+ r"\equiv": "≡",
75
+ r"\sim": "∼",
76
+ r"\simeq": "≃",
77
+ r"\cong": "≅",
78
+ r"\propto": "∝",
79
+ r"\ll": "≪",
80
+ r"\gg": "≫",
81
+ r"\infty": "∞",
82
+ r"\partial": "∂",
83
+ r"\nabla": "∇",
84
+ r"\prime": "′",
85
+ r"\sum": "∑",
86
+ r"\prod": "∏",
87
+ r"\coprod": "∐",
88
+ r"\int": "∫",
89
+ r"\iint": "∬",
90
+ r"\iiint": "∭",
91
+ r"\oint": "∮",
92
+ r"\sqrt": "√",
93
+ r"\rightarrow": "→",
94
+ r"\to": "→",
95
+ r"\leftarrow": "←",
96
+ r"\gets": "←",
97
+ r"\Rightarrow": "⇒",
98
+ r"\Leftarrow": "⇐",
99
+ r"\Leftrightarrow": "⇔",
100
+ r"\leftrightarrow": "↔",
101
+ r"\uparrow": "↑",
102
+ r"\downarrow": "↓",
103
+ r"\mapsto": "↦",
104
+ r"\longrightarrow": "⟶",
105
+ r"\longleftarrow": "⟵",
106
+ r"\in": "∈",
107
+ r"\notin": "∉",
108
+ r"\ni": "∋",
109
+ r"\subset": "⊂",
110
+ r"\supset": "⊃",
111
+ r"\subseteq": "⊆",
112
+ r"\supseteq": "⊇",
113
+ r"\cup": "∪",
114
+ r"\cap": "∩",
115
+ r"\setminus": "∖",
116
+ r"\emptyset": "∅",
117
+ r"\varnothing": "∅",
118
+ r"\forall": "∀",
119
+ r"\exists": "∃",
120
+ r"\nexists": "∄",
121
+ r"\neg": "¬",
122
+ r"\land": "∧",
123
+ r"\wedge": "∧",
124
+ r"\lor": "∨",
125
+ r"\vee": "∨",
126
+ r"\therefore": "∴",
127
+ r"\because": "∵",
128
+ r"\ldots": "…",
129
+ r"\cdots": "⋯",
130
+ r"\vdots": "⋮",
131
+ r"\ddots": "⋱",
132
+ r"\dots": "…",
133
+ r"\quad": " ",
134
+ r"\qquad": " ",
135
+ r"\,": " ",
136
+ r"\;": " ",
137
+ r"\:": " ",
138
+ r"\!": "",
139
+ r"\ ": " ",
140
+ r"\%": "%",
141
+ r"\$": "$",
142
+ r"\&": "&",
143
+ r"\#": "#",
144
+ r"\_": "_",
145
+ r"\{": "{",
146
+ r"\}": "}",
147
+ r"\deg": "°",
148
+ r"\degree": "°",
149
+ r"\langle": "⟨",
150
+ r"\rangle": "⟩",
151
+ r"\lfloor": "⌊",
152
+ r"\rfloor": "⌋",
153
+ r"\lceil": "⌈",
154
+ r"\rceil": "⌉",
155
+ r"\aleph": "ℵ",
156
+ r"\Box": "□",
157
+ r"\Diamond": "◇",
158
+ r"\triangle": "△",
159
+ r"\mathbb{R}": "ℝ",
160
+ r"\mathbb{N}": "ℕ",
161
+ r"\mathbb{Z}": "ℤ",
162
+ r"\mathbb{Q}": "ℚ",
163
+ r"\mathbb{C}": "ℂ",
164
+ r"\mathbb{P}": "ℙ",
165
+ r"\mathcal{L}": "ℒ",
166
+ r"\mathcal{H}": "ℋ",
167
+ r"\left": "",
168
+ r"\right": "",
169
+ r"\bigl": "",
170
+ r"\bigr": "",
171
+ r"\big": "",
172
+ r"\Big": "",
173
+ r"\Bigg": "",
174
+ r"\displaystyle": "",
175
+ r"\textstyle": "",
176
+ r"\scriptstyle": "",
177
+ }
178
+
179
+ SUPERSCRIPT = str.maketrans(
180
+ "0123456789+-=()nabcdefghijklmoprstuvwxyzABDEGHIJKLMNOPRTUVW",
181
+ "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿᵃᵇᶜᵈᵉᶠᵍʰᵢʲᵏˡᵐᵒʳˢᵗᵘᵛʷʸᵚᵜᵝᵞᵟᴬᴮᴰᴱᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾᴿᵀᵁ",
182
+ )
183
+ SUBSCRIPT = str.maketrans("0123456789+-=()aehijklmnoprstuvx", "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ")
184
+ _SYMBOL_ITEMS = sorted(list(GREEK.items()) + list(OPERATORS.items()), key=lambda x: -len(x[0]))
185
+ _SQRT_INDEX_RE = re.compile(r"\\sqrt\[([^\]]+)\]\{([^{}]+)\}")
186
+ _DISPLAY_DOLLAR_RE = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
187
+ _DISPLAY_BRACKET_RE = re.compile(r"\\\[(.+?)\\\]", re.DOTALL)
188
+ _INLINE_DOLLAR_RE = re.compile(r"(?<!\$)\$(.+?)\$(?!\$)")
189
+ _INLINE_PAREN_RE = re.compile(r"\\\((.+?)\\\)")
190
+ _FENCE_RE = re.compile(r"(^```.*?^```)", re.DOTALL | re.MULTILINE)
191
+
192
+
193
+ def _to_super(s: str) -> str:
194
+ if s and all(c in "0123456789+-=()nabcdefghijklmoprstuvwxyzABDEGHIJKLMNOPRTUVW" for c in s):
195
+ return s.translate(SUPERSCRIPT)
196
+ if len(s) == 1:
197
+ return "^" + s
198
+ return "^(" + s + ")"
199
+
200
+
201
+ def _to_sub(s: str) -> str:
202
+ if s and all(c in "0123456789+-=()aehijklmnoprstuvx" for c in s):
203
+ return s.translate(SUBSCRIPT)
204
+ if len(s) == 1:
205
+ return "_" + s
206
+ return "_(" + s + ")"
207
+
208
+
209
+ def _balanced_groups(s: str, start: int) -> tuple[str, int] | None:
210
+ if start >= len(s) or s[start] != "{":
211
+ return None
212
+ depth = 0
213
+ for i in range(start, len(s)):
214
+ if s[i] == "{":
215
+ depth += 1
216
+ elif s[i] == "}":
217
+ depth -= 1
218
+ if depth == 0:
219
+ return s[start + 1 : i], i + 1
220
+ return None
221
+
222
+
223
+ def _replace_command_with_groups(text: str, name: str, n_args: int, fn) -> str:
224
+ out = []
225
+ i = 0
226
+ pattern = "\\" + name
227
+ while i < len(text):
228
+ if text.startswith(pattern, i):
229
+ after = i + len(pattern)
230
+ if after < len(text) and text[after].isalpha():
231
+ out.append(text[i])
232
+ i += 1
233
+ continue
234
+ args = []
235
+ j = after
236
+ while j < len(text) and text[j] == " ":
237
+ j += 1
238
+ ok = True
239
+ for _ in range(n_args):
240
+ grp = _balanced_groups(text, j)
241
+ if grp is None:
242
+ ok = False
243
+ break
244
+ args.append(grp[0])
245
+ j = grp[1]
246
+ if ok:
247
+ out.append(fn(args))
248
+ i = j
249
+ continue
250
+ out.append(text[i])
251
+ i += 1
252
+ return "".join(out)
253
+
254
+
255
+ def _convert_math(tex: str) -> str:
256
+ s = tex
257
+ for _ in range(3):
258
+ for cmd in (
259
+ "text",
260
+ "mathrm",
261
+ "mathbf",
262
+ "mathit",
263
+ "mathsf",
264
+ "mathtt",
265
+ "operatorname",
266
+ "textbf",
267
+ "textit",
268
+ ):
269
+ s = _replace_command_with_groups(s, cmd, 1, lambda a: a[0])
270
+ for _ in range(4):
271
+ s = _replace_command_with_groups(
272
+ s,
273
+ "frac",
274
+ 2,
275
+ lambda a: (
276
+ f"({a[0]})/({a[1]})"
277
+ if any(c in a[0] + a[1] for c in "+-**·× ")
278
+ else f"{a[0]}/{a[1]}"
279
+ ),
280
+ )
281
+ s = _replace_command_with_groups(
282
+ s,
283
+ "dfrac",
284
+ 2,
285
+ lambda a: (
286
+ f"({a[0]})/({a[1]})"
287
+ if any(c in a[0] + a[1] for c in "+-**·× ")
288
+ else f"{a[0]}/{a[1]}"
289
+ ),
290
+ )
291
+ s = _replace_command_with_groups(
292
+ s,
293
+ "tfrac",
294
+ 2,
295
+ lambda a: (
296
+ f"({a[0]})/({a[1]})"
297
+ if any(c in a[0] + a[1] for c in "+-**·× ")
298
+ else f"{a[0]}/{a[1]}"
299
+ ),
300
+ )
301
+ s = _SQRT_INDEX_RE.sub(lambda m: _to_super(m.group(1)) + "√(" + m.group(2) + ")", s)
302
+ s = _replace_command_with_groups(s, "sqrt", 1, lambda a: "√(" + a[0] + ")")
303
+ s = _replace_command_with_groups(s, "vec", 1, lambda a: a[0] + "⃗")
304
+ s = _replace_command_with_groups(s, "hat", 1, lambda a: a[0] + "̂")
305
+ s = _replace_command_with_groups(s, "bar", 1, lambda a: a[0] + "̄")
306
+ s = _replace_command_with_groups(s, "dot", 1, lambda a: a[0] + "̇")
307
+ for k, v in _SYMBOL_ITEMS:
308
+ if k and k[-1].isalpha():
309
+ s = re.sub(re.escape(k) + r"(?![A-Za-z])", v, s)
310
+ else:
311
+ s = s.replace(k, v)
312
+ s = re.sub(r"\^\{([^{}]+)\}", lambda m: _to_super(m.group(1)), s)
313
+ s = re.sub(r"_\{([^{}]+)\}", lambda m: _to_sub(m.group(1)), s)
314
+ s = re.sub(r"\^(\\?[A-Za-z0-9+\-])", lambda m: _to_super(m.group(1).lstrip("\\")), s)
315
+ s = re.sub(r"_(\\?[A-Za-z0-9+\-])", lambda m: _to_sub(m.group(1).lstrip("\\")), s)
316
+ prev = None
317
+ while prev != s:
318
+ prev = s
319
+ s = re.sub(r"\{([^{}]*)\}", r"\1", s)
320
+ s = re.sub(r"[ \t]+", " ", s).strip()
321
+ return s
322
+
323
+
324
+ def preprocess_latex(text: str) -> str:
325
+ r"""Convert LaTeX math delimiters in *text* to Unicode, leaving code fences untouched.
326
+
327
+ Handles inline ``$...$``, ``\(...\)``, display ``$$...$$``, and ``\[...\]``.
328
+ Math inside fenced code blocks (`` ``` ``) is preserved verbatim.
329
+ """
330
+ if "$" not in text and "\\(" not in text and "\\[" not in text:
331
+ return text
332
+
333
+ def _process_part(part: str) -> str:
334
+ part = _DISPLAY_DOLLAR_RE.sub(lambda m: "\n\n" + _convert_math(m.group(1)) + "\n\n", part)
335
+ part = _DISPLAY_BRACKET_RE.sub(lambda m: "\n\n" + _convert_math(m.group(1)) + "\n\n", part)
336
+ part = _INLINE_DOLLAR_RE.sub(lambda m: _convert_math(m.group(1)), part)
337
+ return _INLINE_PAREN_RE.sub(lambda m: _convert_math(m.group(1)), part)
338
+
339
+ if "```" not in text:
340
+ return _process_part(text)
341
+
342
+ parts = _FENCE_RE.split(text)
343
+ out = []
344
+ for part in parts:
345
+ if part.startswith("```"):
346
+ out.append(part)
347
+ continue
348
+ out.append(_process_part(part))
349
+ return "".join(out)
vtx/ui/launch.py ADDED
@@ -0,0 +1,108 @@
1
+ """TUI entrypoint and the exit summary printed after the app closes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+
8
+ from rich.console import Console
9
+
10
+ from vtx import config
11
+
12
+ from .app import Vtx
13
+
14
+ _LOGO = [
15
+ "██╗ ██╗████████╗██╗ ██╗",
16
+ "██║ ██║╚══██╔══╝╚██╗██╔╝",
17
+ "██║ ██║ ██║ ╚███╔╝ ",
18
+ "╚██╗ ██╔╝ ██║ ██╔██╗ ",
19
+ " ╚████╔╝ ██║ ██╔╝ ██╗",
20
+ " ╚═══╝ ╚═╝ ╚═╝ ╚═╝",
21
+ ]
22
+
23
+
24
+ def _format_duration(seconds: float) -> str:
25
+ total = int(seconds)
26
+ if total < 60:
27
+ return f"{total}s"
28
+ minutes = total // 60
29
+ secs = total % 60
30
+ return f"{minutes}m {secs}s"
31
+
32
+
33
+ def _print_exit_message(
34
+ hints: list[str],
35
+ session_id: str | None = None,
36
+ duration_seconds: float | None = None,
37
+ file_changes: dict[str, tuple[int, int]] | None = None,
38
+ ) -> None:
39
+ colors = config.ui.colors
40
+ console = Console(highlight=False)
41
+
42
+ for hint in hints:
43
+ console.print(
44
+ f"[{colors.muted}]Hint:[/{colors.muted}] [{colors.dim}]{hint}[/{colors.dim}]"
45
+ )
46
+
47
+ t = colors.dim
48
+ logo_color = colors.dim
49
+ info_lines: list[str] = []
50
+
51
+ if duration_seconds is not None:
52
+ info_lines.append(f"[{t}]Time {_format_duration(duration_seconds)}[/{t}]")
53
+
54
+ if file_changes:
55
+ n_files = len(file_changes)
56
+ total_added = sum(a for a, _ in file_changes.values())
57
+ total_removed = sum(r for _, r in file_changes.values())
58
+ info_lines.append(
59
+ f"[{t}]Changed {n_files} file{'s' if n_files != 1 else ''}[/{t}]"
60
+ f" [{colors.diff_added}]+{total_added}[/{colors.diff_added}]"
61
+ f" [{colors.diff_removed}]-{total_removed}[/{colors.diff_removed}]"
62
+ )
63
+
64
+ if session_id:
65
+ info_lines.append(
66
+ f"[{colors.muted}]To resume:[/{colors.muted}] "
67
+ f"[{colors.accent}]vtx -r {session_id}[/{colors.accent}]"
68
+ )
69
+
70
+ if not info_lines:
71
+ return
72
+
73
+ while len(info_lines) < len(_LOGO):
74
+ info_lines.append("")
75
+
76
+ console.print()
77
+ for logo_line, info_line in zip(_LOGO, info_lines, strict=False):
78
+ padding = " " if info_line else ""
79
+ console.print(f" [{logo_color}]{logo_line}[/{logo_color}]{padding}{info_line}")
80
+ console.print()
81
+
82
+
83
+ def run_tui(args: argparse.Namespace) -> None:
84
+ app = Vtx(
85
+ model=args.model,
86
+ provider=args.provider,
87
+ api_key=args.api_key,
88
+ base_url=args.base_url,
89
+ resume_session=args.resume_session,
90
+ continue_recent=args.continue_recent,
91
+ openai_compat_auth_mode=args.openai_compat_auth,
92
+ anthropic_compat_auth_mode=args.anthropic_compat_auth,
93
+ )
94
+ app.run()
95
+
96
+ hints = list(app._exit_hints)
97
+ session_id: str | None = None
98
+ duration: float | None = None
99
+ file_changes: dict[str, tuple[int, int]] | None = None
100
+
101
+ if app._session:
102
+ session_id = app._session.id
103
+ file_changes = app._session.file_changes_summary() or None
104
+ if app._session_start_time is not None:
105
+ duration = time.time() - app._session_start_time
106
+
107
+ if hints or session_id:
108
+ _print_exit_message(hints, session_id, duration, file_changes)
@@ -0,0 +1,228 @@
1
+ """
2
+ Path completion engine for tab completion.
3
+
4
+ Handles filesystem path completion with caching, tilde expansion,
5
+ and longest common prefix matching for multiple results.
6
+ """
7
+
8
+ import os
9
+ import re
10
+
11
+
12
+ class PathComplete:
13
+ """
14
+ Async path completion with caching.
15
+
16
+ Features:
17
+ - Expands ~ and resolves relative paths
18
+ - Caches directory listings for performance
19
+ - Finds longest common prefix for multiple matches
20
+ - Returns completion text and alternatives
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self._cache: dict[str, list[str]] = {}
25
+
26
+ def clear_cache(self) -> None:
27
+ self._cache.clear()
28
+
29
+ async def __call__(self, cwd: str, path_fragment: str) -> tuple[str, list[str]]:
30
+ """
31
+ Complete a path fragment.
32
+
33
+ Args:
34
+ cwd: Current working directory for resolving relative paths
35
+ path_fragment: The partial path to complete
36
+
37
+ Returns:
38
+ Tuple of (completion_text, alternatives)
39
+ - completion_text: Text to append to complete the path (or longest common prefix)
40
+ - alternatives: List of all matching paths (empty if unique match)
41
+ """
42
+ if not path_fragment:
43
+ return "", []
44
+
45
+ # Expand ~ to home directory
46
+ if path_fragment.startswith("~"):
47
+ expanded = os.path.expanduser(path_fragment)
48
+ else:
49
+ expanded = path_fragment
50
+
51
+ # Resolve relative to cwd if not absolute
52
+ base_path = os.path.join(cwd, expanded) if not os.path.isabs(expanded) else expanded
53
+
54
+ # Normalize the path
55
+ base_path = os.path.normpath(base_path)
56
+
57
+ # Determine directory to list and prefix to match
58
+ # Only list directory contents if path ends with / (explicit intent)
59
+ # Otherwise, match the basename as a prefix in the parent directory
60
+ ends_with_sep = path_fragment.endswith("/") or path_fragment.endswith(os.sep)
61
+ is_home_only = path_fragment in ("~", "~/")
62
+ is_dot_path = path_fragment in (".", "..")
63
+
64
+ if ends_with_sep or is_home_only:
65
+ # User wants to see directory contents
66
+ if os.path.isdir(base_path):
67
+ dir_to_list = base_path
68
+ match_prefix = ""
69
+ else:
70
+ return "", []
71
+ elif is_dot_path:
72
+ # Special case: . or .. should complete to ./ or ../
73
+ return "/", []
74
+ else:
75
+ # User is typing a name - match it in the parent directory
76
+ dir_to_list = os.path.dirname(base_path)
77
+ match_prefix = os.path.basename(base_path)
78
+ if not dir_to_list:
79
+ dir_to_list = cwd
80
+
81
+ # Get directory listing (cached or fresh)
82
+ entries = await self._list_directory(dir_to_list)
83
+ if entries is None:
84
+ return "", []
85
+
86
+ # Filter entries by prefix
87
+ matches = [e for e in entries if e.lower().startswith(match_prefix.lower())]
88
+
89
+ if not matches:
90
+ return "", []
91
+
92
+ if len(matches) == 1:
93
+ # Unique match - complete it
94
+ match = matches[0]
95
+ full_path = os.path.join(dir_to_list, match)
96
+
97
+ # Add trailing separator for directories
98
+ if os.path.isdir(full_path):
99
+ match = match + os.sep
100
+
101
+ # Calculate what to append
102
+ completion = match[len(match_prefix) :]
103
+ return completion, []
104
+
105
+ # Multiple matches - find longest common prefix
106
+ lcp = self._longest_common_prefix(matches)
107
+ completion = lcp[len(match_prefix) :]
108
+
109
+ # Build full display paths for alternatives
110
+ alternatives = []
111
+ for match in sorted(matches):
112
+ full_path = os.path.join(dir_to_list, match)
113
+ if os.path.isdir(full_path):
114
+ alternatives.append(match + os.sep)
115
+ else:
116
+ alternatives.append(match)
117
+
118
+ return completion, alternatives
119
+
120
+ async def _list_directory(self, path: str) -> list[str] | None:
121
+ """
122
+ List directory contents (cached).
123
+
124
+ Returns None if directory doesn't exist or can't be read.
125
+ """
126
+ if path in self._cache:
127
+ return self._cache[path]
128
+
129
+ try:
130
+ entries = os.listdir(path)
131
+ self._cache[path] = entries
132
+ return entries
133
+ except OSError:
134
+ return None
135
+
136
+ @staticmethod
137
+ def extract_path_fragment(text: str) -> tuple[str, int]:
138
+ """
139
+ Extract the path fragment from text before cursor.
140
+
141
+ Returns (path_fragment, start_column) or ("", -1) if no path found.
142
+ """
143
+ if not text:
144
+ return "", -1
145
+
146
+ # Handle quoted paths - look for an unclosed quote
147
+ in_quote = False
148
+ quote_char = None
149
+ quote_start = -1
150
+
151
+ for i, char in enumerate(text):
152
+ if char in "\"'":
153
+ if not in_quote:
154
+ in_quote = True
155
+ quote_char = char
156
+ quote_start = i
157
+ elif char == quote_char:
158
+ in_quote = False
159
+ quote_char = None
160
+ quote_start = -1
161
+
162
+ if in_quote and quote_start >= 0:
163
+ # Return the content after the opening quote
164
+ return text[quote_start + 1 :], quote_start
165
+
166
+ # Not in a quote - find the last whitespace-separated token
167
+ # that looks like a path
168
+ # Match paths:
169
+ # - Starting with ~ (home dir)
170
+ # - Starting with ./ or ../ (relative)
171
+ # - Starting with / (absolute)
172
+ # - Containing / (like src/main.py)
173
+ match = re.search(r"(~[^\s]*|\.\.?/[^\s]*|/[^\s]*|[^\s]*/[^\s]*)$", text)
174
+ if match:
175
+ return match.group(1), match.start()
176
+
177
+ # Check if the last word could be a relative path (no slashes yet)
178
+ # This allows completing "src" to "src/"
179
+ words = text.split()
180
+ if words:
181
+ last_word = words[-1]
182
+ start = text.rfind(last_word)
183
+ # Only treat as path if it could be a valid path start
184
+ if last_word and not last_word.startswith("-"):
185
+ return last_word, start
186
+
187
+ return "", -1
188
+
189
+ @staticmethod
190
+ def get_base_path(path_fragment: str) -> str:
191
+ """
192
+ Get the directory portion of a path fragment.
193
+
194
+ For "src/vtx/t" returns "src/vtx/"
195
+ For "src" returns ""
196
+ """
197
+ if os.sep in path_fragment:
198
+ return path_fragment.rsplit(os.sep, 1)[0] + os.sep
199
+ if "/" in path_fragment: # Handle forward slash on all platforms
200
+ return path_fragment.rsplit("/", 1)[0] + "/"
201
+ return ""
202
+
203
+ def _longest_common_prefix(self, strings: list[str]) -> str:
204
+ """Find the longest common prefix of a list of strings (case-insensitive)."""
205
+ if not strings:
206
+ return ""
207
+ if len(strings) == 1:
208
+ return strings[0]
209
+
210
+ # Use first string as reference
211
+ first = strings[0]
212
+ result = []
213
+
214
+ for i, char in enumerate(first):
215
+ # Check if all other strings have this character at position i
216
+ char_lower = char.lower()
217
+ for s in strings[1:]:
218
+ if i >= len(s) or s[i].lower() != char_lower:
219
+ return "".join(result)
220
+ result.append(char)
221
+
222
+ return "".join(result)
223
+
224
+ def invalidate(self, path: str) -> None:
225
+ """Invalidate cache for a specific directory."""
226
+ normalized = os.path.normpath(path)
227
+ if normalized in self._cache:
228
+ del self._cache[normalized]