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