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/widgets.py ADDED
@@ -0,0 +1,558 @@
1
+ import asyncio
2
+ import os
3
+ import time
4
+ from typing import ClassVar
5
+
6
+ from rich.spinner import Spinner
7
+ from rich.text import Text
8
+ from textual import events
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Horizontal, Vertical
11
+ from textual.screen import ModalScreen
12
+ from textual.timer import Timer
13
+ from textual.widgets import Label
14
+
15
+ from vtx import config
16
+ from vtx.config import PermissionMode
17
+ from vtx.git_branch import resolve_git_branch
18
+
19
+ from .formatting import format_tokens
20
+
21
+
22
+ def format_path(path: str) -> str:
23
+ home = os.path.expanduser("~")
24
+ if path.startswith(home):
25
+ return "~" + path[len(home) :]
26
+ return path
27
+
28
+
29
+ def get_git_branch(cwd: str) -> str:
30
+ return resolve_git_branch(cwd)
31
+
32
+
33
+ class FileChangesModal(ModalScreen[None]):
34
+ BINDINGS: ClassVar[list] = [("escape", "dismiss_modal", "Close")]
35
+
36
+ CSS = """
37
+ FileChangesModal {
38
+ align: center middle;
39
+ }
40
+
41
+ #file-changes-container {
42
+ width: 80;
43
+ max-width: 90%;
44
+ max-height: 50%;
45
+ padding: 1 2;
46
+ border: solid grey;
47
+ }
48
+
49
+ #file-changes-title {
50
+ width: 100%;
51
+ text-align: center;
52
+ text-style: bold;
53
+ padding-bottom: 1;
54
+ }
55
+
56
+ #file-changes-list {
57
+ width: 100%;
58
+ }
59
+ """
60
+
61
+ def __init__(self, file_changes: dict[str, tuple[int, int]], **kwargs) -> None:
62
+ super().__init__(**kwargs)
63
+ self._file_changes = file_changes
64
+
65
+ def compose(self) -> ComposeResult:
66
+ with Vertical(id="file-changes-container"):
67
+ yield Label(self._format_title(), id="file-changes-title")
68
+ yield Label(self._format_file_list(), id="file-changes-list")
69
+
70
+ def _format_title(self) -> Text:
71
+ colors = config.ui.colors
72
+ n_files = len(self._file_changes)
73
+ total_added = sum(a for a, _ in self._file_changes.values())
74
+ total_removed = sum(r for _, r in self._file_changes.values())
75
+
76
+ result = Text()
77
+ result.append(f"{n_files} file{'s' if n_files != 1 else ''} changed", style="bold")
78
+ result.append(" ")
79
+ result.append(f"+{total_added}", style=f"bold {colors.diff_added}")
80
+ result.append(" ")
81
+ result.append(f"-{total_removed}", style=f"bold {colors.diff_removed}")
82
+ return result
83
+
84
+ def _format_file_list(self) -> Text:
85
+ colors = config.ui.colors
86
+ cwd = os.getcwd()
87
+
88
+ # Sort by filename for stable display
89
+ entries = sorted(self._file_changes.items(), key=lambda x: x[0])
90
+
91
+ # Calculate column widths
92
+ max_added_w = max((len(str(a)) for a, _ in self._file_changes.values()), default=1)
93
+ max_removed_w = max((len(str(r)) for _, r in self._file_changes.values()), default=1)
94
+
95
+ result = Text()
96
+ for i, (path, (added, removed)) in enumerate(entries):
97
+ if i > 0:
98
+ result.append("\n")
99
+
100
+ # Shorten path: strip cwd prefix, then home prefix
101
+ display_path = path
102
+ if display_path.startswith(cwd + "/"):
103
+ display_path = display_path[len(cwd) + 1 :]
104
+ else:
105
+ display_path = format_path(display_path)
106
+
107
+ added_str = f"+{added}".rjust(max_added_w + 1)
108
+ removed_str = f"-{removed}".rjust(max_removed_w + 1)
109
+
110
+ result.append(f" {added_str}", style=colors.diff_added)
111
+ result.append(f" {removed_str}", style=colors.diff_removed)
112
+ result.append(f" {display_path}", style=colors.dim)
113
+
114
+ return result
115
+
116
+ def on_click(self, event: events.Click) -> None:
117
+ # Dismiss when clicking anywhere on the modal overlay
118
+ if self.get_widget_at(event.screen_x, event.screen_y)[0] is self:
119
+ self.dismiss()
120
+
121
+ def action_dismiss_modal(self) -> None:
122
+ self.dismiss()
123
+
124
+
125
+ class InfoBar(Vertical):
126
+ def __init__(
127
+ self,
128
+ cwd: str,
129
+ model: str,
130
+ context_window: int | None = None,
131
+ thinking_level: str | None = None,
132
+ hide_thinking: bool = False,
133
+ **kwargs,
134
+ ) -> None:
135
+ super().__init__(**kwargs)
136
+ self._raw_cwd = cwd
137
+ self._cwd = format_path(cwd)
138
+ self._git_branch = get_git_branch(cwd)
139
+ self._model = model
140
+ self._model_provider = kwargs.get("model_provider")
141
+ if context_window is None:
142
+ from ..llm.models import get_model
143
+
144
+ m_info = get_model(model)
145
+ self._context_window = (
146
+ m_info.context_window if m_info else None
147
+ ) or config.agent.default_context_window
148
+ else:
149
+ self._context_window = context_window
150
+ self._thinking_level = thinking_level or config.llm.default_thinking_level
151
+ self._hide_thinking = hide_thinking
152
+ self._input_tokens = 0
153
+ self._output_tokens = 0
154
+ self._cache_read_tokens = 0
155
+ self._cache_write_tokens = 0
156
+ self._context_tokens: int | None = None
157
+ self._file_changes: dict[str, tuple[int, int]] = {} # path -> (added, removed)
158
+ self._permission_mode = config.permissions.mode
159
+ self._file_changes_text_start: int | None = None
160
+ self._row1_right: Label | None = None
161
+ self._row2_left: Label | None = None
162
+ self._row2_right: Label | None = None
163
+ self.add_class("info-bar")
164
+
165
+ @property
166
+ def _label_row1_right(self) -> Label:
167
+ if self._row1_right is None:
168
+ self._row1_right = self.query_one("#info-row1-right", Label)
169
+ return self._row1_right
170
+
171
+ @property
172
+ def _label_row2_left(self) -> Label:
173
+ if self._row2_left is None:
174
+ self._row2_left = self.query_one("#info-row2-left", Label)
175
+ return self._row2_left
176
+
177
+ @property
178
+ def _label_row2_right(self) -> Label:
179
+ if self._row2_right is None:
180
+ self._row2_right = self.query_one("#info-row2-right", Label)
181
+ return self._row2_right
182
+
183
+ def compose(self) -> ComposeResult:
184
+ with Horizontal(id="info-row-1"):
185
+ yield Label(self._format_row1_left(), id="info-cwd")
186
+ yield Label(self._format_row1_right(), id="info-row1-right")
187
+ with Horizontal(id="info-row-2"):
188
+ yield Label(self._format_row2_left(), id="info-row2-left")
189
+ yield Label(self._format_row2_right(), id="info-row2-right")
190
+
191
+ def _format_row1_left(self) -> Text:
192
+ result = Text(self._cwd)
193
+ if self._git_branch:
194
+ result.append(" ", style="")
195
+ result.append(f"(⌥ {self._git_branch})", style=config.ui.colors.accent)
196
+ return result
197
+
198
+ def _format_row1_right(self) -> Text:
199
+ result = Text()
200
+ parts = []
201
+
202
+ # Context size
203
+ if self._context_tokens is not None:
204
+ ctx = f"{format_tokens(self._context_tokens)}/{format_tokens(self._context_window)}"
205
+ else:
206
+ ctx = f"--/{format_tokens(self._context_window)}"
207
+ parts.append(Text(ctx))
208
+
209
+ input_t = format_tokens(self._input_tokens)
210
+ output_t = format_tokens(self._output_tokens)
211
+ usage = f"↑{input_t} ↓{output_t}"
212
+ if self._cache_read_tokens > 0:
213
+ usage += f" R{format_tokens(self._cache_read_tokens)}"
214
+ if self._cache_write_tokens > 0:
215
+ usage += f" W{format_tokens(self._cache_write_tokens)}"
216
+ parts.append(Text(usage))
217
+
218
+ # Build string with vtx separators
219
+ for i, part in enumerate(parts):
220
+ if i > 0:
221
+ result.append(" • ")
222
+ result.append_text(part)
223
+
224
+ return result
225
+
226
+ def _format_row2_left(self) -> Text:
227
+ result = self._format_permission_mode()
228
+ self._file_changes_text_start = None
229
+ if not self._file_changes:
230
+ return result
231
+
232
+ n_files = len(self._file_changes)
233
+ total_added = sum(a for a, _ in self._file_changes.values())
234
+ total_removed = sum(r for _, r in self._file_changes.values())
235
+ result.append(" • ", style=config.ui.colors.dim)
236
+ self._file_changes_text_start = len(result.plain)
237
+ result.append(f"{n_files} file{'s' if n_files != 1 else ''}")
238
+ result.append(f" +{total_added}", style=config.ui.colors.diff_added)
239
+ result.append(f" -{total_removed}", style=config.ui.colors.diff_removed)
240
+ return result
241
+
242
+ def _format_permission_mode(self) -> Text:
243
+ result = Text()
244
+ if self._permission_mode == "auto":
245
+ result.append("✓ auto", style=config.ui.colors.badge.label)
246
+ else:
247
+ result.append("⏹ prompt", style=config.ui.colors.notice)
248
+ return result
249
+
250
+ def _format_row2_right(self) -> Text:
251
+ model_text = self._model
252
+ if self._model_provider:
253
+ model_text = f"({self._model_provider}) {self._model}"
254
+ result = Text(model_text)
255
+ result.append(f" • {self._thinking_level}")
256
+ return result
257
+
258
+ def update_tokens(
259
+ self,
260
+ input_tokens: int,
261
+ output_tokens: int,
262
+ cache_read_tokens: int = 0,
263
+ cache_write_tokens: int = 0,
264
+ ) -> None:
265
+ self._input_tokens += input_tokens
266
+ self._output_tokens += output_tokens
267
+ self._cache_read_tokens += cache_read_tokens
268
+ self._cache_write_tokens += cache_write_tokens
269
+ # Context size is latest turn's full token footprint.
270
+ self._context_tokens = (
271
+ input_tokens + output_tokens + cache_read_tokens + cache_write_tokens
272
+ )
273
+ self._label_row1_right.update(self._format_row1_right())
274
+
275
+ def set_tokens(
276
+ self,
277
+ input_tokens: int,
278
+ output_tokens: int,
279
+ context_tokens: int = 0,
280
+ cache_read_tokens: int = 0,
281
+ cache_write_tokens: int = 0,
282
+ ) -> None:
283
+ self._input_tokens = input_tokens
284
+ self._output_tokens = output_tokens
285
+ self._cache_read_tokens = cache_read_tokens
286
+ self._cache_write_tokens = cache_write_tokens
287
+ self._context_tokens = context_tokens if context_tokens > 0 else None
288
+ self._label_row1_right.update(self._format_row1_right())
289
+
290
+ def set_model(self, model: str, provider: str | None = None) -> None:
291
+ self._model = model
292
+ self._model_provider = provider
293
+ from ..llm.models import get_model
294
+
295
+ m_info = get_model(model, provider)
296
+ if m_info and m_info.context_window:
297
+ self._context_window = m_info.context_window
298
+ else:
299
+ self._context_window = config.agent.default_context_window
300
+ self._label_row1_right.update(self._format_row1_right())
301
+ self._label_row2_right.update(self._format_row2_right())
302
+
303
+ def set_git_branch(self, branch: str) -> None:
304
+ if self._git_branch == branch:
305
+ return
306
+ self._git_branch = branch
307
+ self.query_one("#info-cwd", Label).update(self._format_row1_left(), layout=False)
308
+
309
+ async def refresh_git_branch(self) -> None:
310
+ # Branch resolution reads git metadata files and may shell out to git;
311
+ # run it off the event loop so the UI never blocks on it.
312
+ self.set_git_branch(await asyncio.to_thread(get_git_branch, self._raw_cwd))
313
+
314
+ def set_thinking_level(self, thinking_level: str) -> None:
315
+ self._thinking_level = thinking_level
316
+ self._label_row2_right.update(self._format_row2_right())
317
+
318
+ def set_thinking_visibility(self, hide_thinking: bool) -> None:
319
+ self._hide_thinking = hide_thinking
320
+
321
+ def set_permission_mode(self, mode: PermissionMode) -> None:
322
+ self._permission_mode = mode
323
+ self._label_row2_left.update(self._format_row2_left(), layout=False)
324
+
325
+ def update_file_changes(self, path: str, added: int, removed: int) -> None:
326
+ prev_added, prev_removed = self._file_changes.get(path, (0, 0))
327
+ self._file_changes[path] = (prev_added + added, prev_removed + removed)
328
+ self._label_row2_left.update(self._format_row2_left(), layout=False)
329
+
330
+ def set_file_changes(self, file_changes: dict[str, tuple[int, int]]) -> None:
331
+ self._file_changes = file_changes
332
+ self._label_row2_left.update(self._format_row2_left(), layout=False)
333
+
334
+ def _is_file_changes_click(self, widget: object, x: int) -> bool:
335
+ return (
336
+ bool(self._file_changes)
337
+ and self._file_changes_text_start is not None
338
+ and widget is self._label_row2_left
339
+ and x >= self._file_changes_text_start
340
+ )
341
+
342
+ def on_click(self, event: events.Click) -> None:
343
+ widget, _ = self.screen.get_widget_at(event.screen_x, event.screen_y)
344
+ if self._is_file_changes_click(widget, event.x):
345
+ event.stop()
346
+ self.app.push_screen(FileChangesModal(self._file_changes))
347
+
348
+
349
+ class QueueDisplay(Vertical):
350
+ MAX_QUEUE = 5
351
+
352
+ def __init__(self, **kwargs) -> None:
353
+ super().__init__(**kwargs)
354
+ self._items: list[tuple[str, bool]] = [] # (text, is_steer)
355
+ self._selected: int | None = None
356
+ self._editing: int | None = None
357
+ self._content_label: Label | None = None
358
+
359
+ def compose(self) -> ComposeResult:
360
+ yield Label("", id="queue-content")
361
+
362
+ @property
363
+ def _queue_label(self) -> Label:
364
+ if self._content_label is None:
365
+ self._content_label = self.query_one("#queue-content", Label)
366
+ return self._content_label
367
+
368
+ def on_mount(self) -> None:
369
+ self.add_class("-hidden")
370
+
371
+ def _truncate_text(self, text: str, max_width: int) -> str:
372
+ if max_width <= 0:
373
+ return ""
374
+ if len(text) <= max_width:
375
+ return text
376
+ if max_width <= 3:
377
+ return "." * max_width
378
+ return text[: max_width - 3] + "..."
379
+
380
+ def _render_items(self) -> Text:
381
+ dim_color = config.ui.colors.dim
382
+ steer_items = [(text, True) for text, is_steer in self._items if is_steer]
383
+ normal_items = [(text, False) for text, is_steer in self._items if not is_steer]
384
+ ordered = steer_items + normal_items
385
+
386
+ content_width = max(0, self.size.width - 2) if self.size.width else 0
387
+ result = Text()
388
+ result.append("Queue", style="bold " + dim_color)
389
+ result.append(
390
+ " (↑/↓ select, enter edit, ctrl+d delete, esc discard edit)", style=dim_color
391
+ )
392
+ for index, (text, is_steer) in enumerate(ordered):
393
+ is_selected = index == self._selected
394
+ is_editing = index == self._editing
395
+ prefix = " > " if is_selected else " L "
396
+ edit_prefix = "[editing] " if is_editing else ""
397
+ steer_prefix = "[steer] " if is_steer else ""
398
+ available = max(0, content_width - len(prefix) - len(edit_prefix) - len(steer_prefix))
399
+ truncated = self._truncate_text(text, available)
400
+ style = config.ui.colors.accent if is_selected else dim_color
401
+ result.append("\n" + prefix, style=style)
402
+ if is_editing:
403
+ result.append(edit_prefix, style=style)
404
+ if is_steer:
405
+ result.append(steer_prefix, style=style)
406
+ result.append(truncated, style=style)
407
+ return result
408
+
409
+ def update_items(
410
+ self,
411
+ items: list[tuple[str, bool]],
412
+ selected: int | None = None,
413
+ editing: int | None = None,
414
+ ) -> None:
415
+ self._items = items
416
+ self._selected = selected
417
+ self._editing = editing
418
+ if not items:
419
+ self._queue_label.update("")
420
+ self.add_class("-hidden")
421
+ return
422
+
423
+ self.remove_class("-hidden")
424
+ self._queue_label.update(self._render_items())
425
+
426
+ def on_resize(self, event: events.Resize) -> None:
427
+ del event
428
+ if not self._items:
429
+ return
430
+ self._queue_label.update(self._render_items())
431
+
432
+
433
+ class StatusLine(Horizontal):
434
+ def __init__(self, **kwargs) -> None:
435
+ super().__init__(**kwargs)
436
+ self._status = "idle"
437
+ self._spinner = Spinner("dots")
438
+ self._timer: Timer | None = None
439
+ self._start_time: float | None = None
440
+ self._tool_calls = 0
441
+ self._show_exit_hint = False
442
+ self._streaming_token_count = 0
443
+ self._status_label: Label | None = None
444
+ self._hint_label: Label | None = None
445
+ self.add_class("status-line")
446
+
447
+ def compose(self) -> ComposeResult:
448
+ yield Label("", id="status-text")
449
+ yield Label("", id="exit-hint")
450
+
451
+ @property
452
+ def _status_text(self) -> Label:
453
+ if self._status_label is None:
454
+ self._status_label = self.query_one("#status-text", Label)
455
+ return self._status_label
456
+
457
+ @property
458
+ def _exit_hint_label(self) -> Label:
459
+ if self._hint_label is None:
460
+ self._hint_label = self.query_one("#exit-hint", Label)
461
+ return self._hint_label
462
+
463
+ def _render_spinner(self) -> Text:
464
+ spinner_color = config.ui.colors.accent
465
+ dim_color = config.ui.colors.dim
466
+ spinner_text = self._spinner.render(time.time())
467
+ result = Text()
468
+ if isinstance(spinner_text, Text):
469
+ result.append(str(spinner_text), style=spinner_color)
470
+ else:
471
+ result.append(str(spinner_text), style=spinner_color)
472
+ result.append(" Working...", style=config.ui.colors.muted)
473
+ result.append(" (esc to interrupt)", style=dim_color)
474
+ if self._streaming_token_count > 20:
475
+ result.append(f" ↓{self._streaming_token_count!s}", style=dim_color)
476
+ return result
477
+
478
+ def _format_complete_status(self) -> Text:
479
+ elapsed = time.time() - self._start_time if self._start_time else 0
480
+ elapsed_str = f"{int(elapsed)}s"
481
+ if elapsed >= 60:
482
+ minutes = int(elapsed // 60)
483
+ seconds = round(elapsed % 60)
484
+ elapsed_str = f"{minutes}m {seconds}s"
485
+
486
+ dim_color = config.ui.colors.dim
487
+ result = Text()
488
+ status = f"{elapsed_str} • {self._tool_calls}x"
489
+ result.append(status, style=dim_color)
490
+ return result
491
+
492
+ def _start_spinner_timer(self) -> None:
493
+ if self._timer is None:
494
+ self._timer = self.set_interval(0.15, self._update_spinner)
495
+
496
+ def _stop_spinner_timer(self) -> None:
497
+ if self._timer is not None:
498
+ self._timer.stop()
499
+ self._timer = None
500
+
501
+ def _update_spinner(self) -> None:
502
+ if self._status != "idle":
503
+ self._status_text.update(self._render_spinner(), layout=False)
504
+
505
+ def set_status(self, status: str) -> None:
506
+ old_status = self._status
507
+ self._status = status
508
+
509
+ if status == "idle":
510
+ self._stop_spinner_timer()
511
+ self._streaming_token_count = 0
512
+ if old_status != "idle" and self._start_time is not None:
513
+ self._status_text.update(self._format_complete_status(), layout=False)
514
+ elif old_status == "idle" and self._start_time is None:
515
+ self._status_text.update("", layout=False)
516
+ else:
517
+ if old_status == "idle":
518
+ self._start_time = time.time()
519
+ self._tool_calls = 0
520
+ self._streaming_token_count = 0
521
+ self._start_spinner_timer()
522
+ self._status_text.update(self._render_spinner(), layout=False)
523
+
524
+ def increment_tool_calls(self) -> None:
525
+ self._tool_calls += 1
526
+
527
+ def set_streaming_tokens(self, token_count: int) -> None:
528
+ self._streaming_token_count = token_count
529
+ self._update_spinner()
530
+
531
+ def show_exit_hint(self) -> None:
532
+ self._show_exit_hint = True
533
+ muted_color = config.ui.colors.muted
534
+ dim_color = config.ui.colors.dim
535
+ text = Text()
536
+ text.append("ctrl+c", style=muted_color)
537
+ text.append(" again to exit", style=dim_color)
538
+ self._exit_hint_label.update(text)
539
+
540
+ def show_delete_session_hint(self) -> None:
541
+ muted_color = config.ui.colors.muted
542
+ dim_color = config.ui.colors.dim
543
+ text = Text()
544
+ text.append("ctrl+d", style=muted_color)
545
+ text.append(" again to delete session", style=dim_color)
546
+ self._exit_hint_label.update(text)
547
+
548
+ def hide_exit_hint(self) -> None:
549
+ self._show_exit_hint = False
550
+ self._exit_hint_label.update("")
551
+
552
+ def reset(self) -> None:
553
+ self._stop_spinner_timer()
554
+ self._start_time = None
555
+ self._tool_calls = 0
556
+ self._show_exit_hint = False
557
+ self._status_text.update("", layout=False)
558
+ self._exit_hint_label.update("")
vtx/update_check.py ADDED
@@ -0,0 +1,49 @@
1
+ import aiohttp
2
+
3
+
4
+ def _semver_tuple(version: str) -> tuple[int, int, int] | None:
5
+ """Parse Vtx versions that follow numeric semantic versioning.
6
+
7
+ Update checks intentionally only support `MAJOR.MINOR.PATCH` versions
8
+ such as `0.2.7` or `0.3.0`. If Vtx's release versioning changes, this parser
9
+ and the comparison logic in this module should be updated to match the new scheme.
10
+ """
11
+ parts = version.strip().split(".")
12
+ if len(parts) != 3 or any(not part.isdigit() for part in parts):
13
+ return None
14
+ return int(parts[0]), int(parts[1]), int(parts[2])
15
+
16
+
17
+ def is_newer_version(current_version: str, latest_version: str) -> bool:
18
+ current_tuple = _semver_tuple(current_version)
19
+ latest_tuple = _semver_tuple(latest_version)
20
+ if current_tuple is None or latest_tuple is None:
21
+ return False
22
+ return latest_tuple > current_tuple
23
+
24
+
25
+ async def fetch_latest_pypi_version(package_name: str, timeout_seconds: float = 4.0) -> str | None:
26
+ url = f"https://pypi.org/pypi/{package_name}/json"
27
+ timeout = aiohttp.ClientTimeout(total=timeout_seconds)
28
+
29
+ try:
30
+ async with (
31
+ aiohttp.ClientSession(timeout=timeout) as session,
32
+ session.get(url, headers={"User-Agent": "vtx"}) as response,
33
+ ):
34
+ if response.status != 200:
35
+ return None
36
+ payload = await response.json(content_type=None)
37
+ except Exception:
38
+ return None
39
+
40
+ info = payload.get("info") if isinstance(payload, dict) else None
41
+ version = info.get("version") if isinstance(info, dict) else None
42
+ return version if isinstance(version, str) and version.strip() else None
43
+
44
+
45
+ async def get_newer_pypi_version(package_name: str, current_version: str) -> str | None:
46
+ latest_version = await fetch_latest_pypi_version(package_name)
47
+ if latest_version is None:
48
+ return None
49
+ return latest_version if is_newer_version(current_version, latest_version) else None
vtx/version.py ADDED
@@ -0,0 +1,22 @@
1
+ import tomllib
2
+ from importlib.metadata import PackageNotFoundError, version
3
+ from pathlib import Path
4
+
5
+
6
+ def _get_package_name() -> str:
7
+ pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
8
+ if pyproject_path.exists():
9
+ try:
10
+ data = tomllib.loads(pyproject_path.read_text())
11
+ return data["project"]["name"]
12
+ except Exception:
13
+ pass
14
+ return "vtx-coding-agent"
15
+
16
+
17
+ PACKAGE_NAME = _get_package_name()
18
+
19
+ try:
20
+ VERSION = version(PACKAGE_NAME)
21
+ except PackageNotFoundError:
22
+ VERSION = "0.1.1" # Fallback version if package metadata is not available