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/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)
|
vtx/ui/path_complete.py
ADDED
|
@@ -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]
|