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/chat.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from rich.spinner import Spinner
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.containers import VerticalScroll
|
|
8
|
+
from textual.timer import Timer
|
|
9
|
+
from textual.widgets import Label
|
|
10
|
+
|
|
11
|
+
from vtx import config, get_agents_dir
|
|
12
|
+
from vtx.context.skills import Skill
|
|
13
|
+
from vtx.core.types import ImageContent
|
|
14
|
+
from vtx.permissions import ApprovalResponse
|
|
15
|
+
from vtx.tools import BaseTool
|
|
16
|
+
|
|
17
|
+
from .blocks import (
|
|
18
|
+
ContentBlock,
|
|
19
|
+
HandoffLinkBlock,
|
|
20
|
+
LaunchWarning,
|
|
21
|
+
LaunchWarningsBlock,
|
|
22
|
+
ThinkingBlock,
|
|
23
|
+
ToolBlock,
|
|
24
|
+
UpdateAvailableBlock,
|
|
25
|
+
UserBlock,
|
|
26
|
+
stylize_badge_markers,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
MAX_CHILDREN = 300
|
|
30
|
+
PRUNE_TO = 200
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_skill_label(skill: Skill) -> str:
|
|
34
|
+
global_skills_dir = (get_agents_dir() / "skills").resolve(strict=False)
|
|
35
|
+
skill_path = Path(skill.path).resolve(strict=False)
|
|
36
|
+
if skill_path.is_relative_to(global_skills_dir):
|
|
37
|
+
return f"{skill.name} (global)"
|
|
38
|
+
return skill.name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _append_aligned_section(
|
|
42
|
+
text: Text,
|
|
43
|
+
title: str,
|
|
44
|
+
rows: list[tuple[str, str]],
|
|
45
|
+
*,
|
|
46
|
+
notice_color: str,
|
|
47
|
+
dim_color: str,
|
|
48
|
+
muted_color: str,
|
|
49
|
+
) -> None:
|
|
50
|
+
if text.plain.strip():
|
|
51
|
+
text.append("\n")
|
|
52
|
+
text.append(f"[{title}]\n", style=notice_color)
|
|
53
|
+
if not rows:
|
|
54
|
+
return
|
|
55
|
+
max_key_len = max(len(k) for k, _ in rows)
|
|
56
|
+
for key, value in rows:
|
|
57
|
+
padded_key = key.ljust(max_key_len)
|
|
58
|
+
text.append(f" {padded_key} ", style=dim_color)
|
|
59
|
+
text.append(f"{value}\n", style=muted_color)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ChatLog(VerticalScroll):
|
|
63
|
+
can_focus = False
|
|
64
|
+
|
|
65
|
+
def __init__(self, **kwargs) -> None:
|
|
66
|
+
super().__init__(**kwargs)
|
|
67
|
+
self._current_block: ThinkingBlock | ContentBlock | None = None
|
|
68
|
+
self._tool_blocks: dict[str, ToolBlock] = {}
|
|
69
|
+
self._tool_output_expanded = False
|
|
70
|
+
self._anchor_released: bool = False
|
|
71
|
+
self._last_status_label: Label | None = None
|
|
72
|
+
self._spinner_label: Label | None = None
|
|
73
|
+
self._spinner: Spinner | None = None
|
|
74
|
+
self._spinner_timer: Timer | None = None
|
|
75
|
+
self._scroll_pending: bool = False
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
self.anchor()
|
|
79
|
+
|
|
80
|
+
def _scroll_if_anchored(self, animate: bool = False) -> None:
|
|
81
|
+
if not self._anchor_released:
|
|
82
|
+
self.scroll_end(animate=animate)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
max_y = self.max_scroll_y
|
|
86
|
+
current_y = self.scroll_y
|
|
87
|
+
|
|
88
|
+
if abs(max_y - current_y) < 3:
|
|
89
|
+
self._anchor_released = False
|
|
90
|
+
self.scroll_end(animate=animate)
|
|
91
|
+
|
|
92
|
+
def _request_scroll(self) -> None:
|
|
93
|
+
"""Batch scroll-to-bottom into the next refresh frame.
|
|
94
|
+
|
|
95
|
+
Multiple calls between frames coalesce into a single scroll_end(),
|
|
96
|
+
avoiding repeated layout recalculations during fast streaming.
|
|
97
|
+
"""
|
|
98
|
+
if not self._scroll_pending:
|
|
99
|
+
self._scroll_pending = True
|
|
100
|
+
self.call_after_refresh(self._flush_scroll)
|
|
101
|
+
|
|
102
|
+
def _flush_scroll(self) -> None:
|
|
103
|
+
self._scroll_pending = False
|
|
104
|
+
self._scroll_if_anchored(animate=False)
|
|
105
|
+
|
|
106
|
+
def _prune_if_needed(self) -> None:
|
|
107
|
+
children = list(self.children)
|
|
108
|
+
if len(children) <= MAX_CHILDREN:
|
|
109
|
+
return
|
|
110
|
+
to_remove = children[: len(children) - PRUNE_TO]
|
|
111
|
+
active_tool_ids = {tid for tid, block in self._tool_blocks.items() if block in to_remove}
|
|
112
|
+
for tid in active_tool_ids:
|
|
113
|
+
del self._tool_blocks[tid]
|
|
114
|
+
if self._last_status_label in to_remove:
|
|
115
|
+
self._last_status_label = None
|
|
116
|
+
self.call_after_refresh(lambda: self.remove_children(to_remove))
|
|
117
|
+
|
|
118
|
+
async def remove_all_children(self) -> None:
|
|
119
|
+
self._stop_spinner()
|
|
120
|
+
children = list(self.children)
|
|
121
|
+
if children:
|
|
122
|
+
await self.remove_children(children)
|
|
123
|
+
self._tool_blocks.clear()
|
|
124
|
+
self._tool_output_expanded = False
|
|
125
|
+
self._current_block = None
|
|
126
|
+
self._last_status_label = None
|
|
127
|
+
|
|
128
|
+
def on_click(self, event) -> None:
|
|
129
|
+
event.stop()
|
|
130
|
+
from .input import InputBox
|
|
131
|
+
|
|
132
|
+
app = self.app
|
|
133
|
+
input_box = app.query_one("#input-box", InputBox)
|
|
134
|
+
input_box.focus()
|
|
135
|
+
|
|
136
|
+
def _is_last_child_status(self) -> bool:
|
|
137
|
+
if self._last_status_label is None:
|
|
138
|
+
return False
|
|
139
|
+
children = list(self.children)
|
|
140
|
+
if not children:
|
|
141
|
+
return False
|
|
142
|
+
return children[-1] is self._last_status_label
|
|
143
|
+
|
|
144
|
+
def show_status(self, message: str) -> None:
|
|
145
|
+
self._stop_spinner()
|
|
146
|
+
info_color = config.ui.colors.info
|
|
147
|
+
text = Text(f"✓ {message}", style=info_color)
|
|
148
|
+
|
|
149
|
+
# If our tracked status label is still the last child, update it
|
|
150
|
+
if self._is_last_child_status() and self._last_status_label is not None:
|
|
151
|
+
self._last_status_label.update(text)
|
|
152
|
+
self._scroll_if_anchored(animate=False)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Otherwise create a new status label
|
|
156
|
+
label = Label(text)
|
|
157
|
+
label.add_class("info-message")
|
|
158
|
+
self.mount(label)
|
|
159
|
+
self._last_status_label = label
|
|
160
|
+
self._scroll_if_anchored(animate=False)
|
|
161
|
+
|
|
162
|
+
def show_spinner_status(self, message: str) -> None:
|
|
163
|
+
self._stop_spinner()
|
|
164
|
+
self._spinner = Spinner("dots")
|
|
165
|
+
self._spinner_label = Label(self._render_spinner_text(message))
|
|
166
|
+
self._spinner_label.add_class("info-message")
|
|
167
|
+
self.mount(self._spinner_label)
|
|
168
|
+
self._last_status_label = self._spinner_label
|
|
169
|
+
self._spinner_timer = self.set_interval(0.15, lambda: self._tick_spinner(message))
|
|
170
|
+
self._scroll_if_anchored(animate=False)
|
|
171
|
+
|
|
172
|
+
def _render_spinner_text(self, message: str) -> Text:
|
|
173
|
+
info_color = config.ui.colors.info
|
|
174
|
+
spinner_text = self._spinner.render(time.time()) if self._spinner else ""
|
|
175
|
+
result = Text()
|
|
176
|
+
result.append(str(spinner_text), style=info_color)
|
|
177
|
+
result.append(f" {message}", style=info_color)
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
def _tick_spinner(self, message: str) -> None:
|
|
181
|
+
if self._spinner_label is not None and self._spinner is not None:
|
|
182
|
+
self._spinner_label.update(self._render_spinner_text(message))
|
|
183
|
+
|
|
184
|
+
def _stop_spinner(self) -> None:
|
|
185
|
+
if self._spinner_timer is not None:
|
|
186
|
+
self._spinner_timer.stop()
|
|
187
|
+
self._spinner_timer = None
|
|
188
|
+
self._spinner = None
|
|
189
|
+
self._spinner_label = None
|
|
190
|
+
|
|
191
|
+
def add_session_info(self, version: str) -> None:
|
|
192
|
+
info_text = Text()
|
|
193
|
+
accent = config.ui.colors.accent
|
|
194
|
+
dim = config.ui.colors.dim
|
|
195
|
+
muted = config.ui.colors.muted
|
|
196
|
+
|
|
197
|
+
# Logo
|
|
198
|
+
logo_lines = ("░█░█░███░█░█", "░█░█░░█░░░█░", "░░█░░░█░░█░█")
|
|
199
|
+
for i, line in enumerate(logo_lines):
|
|
200
|
+
info_text.append(line, style=accent)
|
|
201
|
+
if i == len(logo_lines) - 1:
|
|
202
|
+
info_text.append(f" v{version}", style=dim)
|
|
203
|
+
info_text.append("\n")
|
|
204
|
+
|
|
205
|
+
if config.ui.show_welcome_shortcuts:
|
|
206
|
+
info_text.append("\n")
|
|
207
|
+
|
|
208
|
+
shortcut_rows = (
|
|
209
|
+
(
|
|
210
|
+
("/", "slash commands"),
|
|
211
|
+
("@", "files/dirs"),
|
|
212
|
+
("tab", "complete paths"),
|
|
213
|
+
("↑/↓", "history"),
|
|
214
|
+
),
|
|
215
|
+
(
|
|
216
|
+
("shift+tab", "permissions"),
|
|
217
|
+
("esc", "to interrupt"),
|
|
218
|
+
("shift+enter", "add newline"),
|
|
219
|
+
),
|
|
220
|
+
(
|
|
221
|
+
("ctrl+c", "clear input"),
|
|
222
|
+
("ctrl+c x2", "exit"),
|
|
223
|
+
("enter", "queue"),
|
|
224
|
+
("alt+enter", "steer"),
|
|
225
|
+
),
|
|
226
|
+
(
|
|
227
|
+
("↑/↓", "select queue"),
|
|
228
|
+
("ctrl+t", "cycle thinking"),
|
|
229
|
+
("ctrl+shift+t", "toggle thinking"),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
for row_idx, row in enumerate(shortcut_rows):
|
|
234
|
+
for item_idx, (key, desc) in enumerate(row):
|
|
235
|
+
if item_idx > 0:
|
|
236
|
+
info_text.append(" • ", style=dim)
|
|
237
|
+
info_text.append(key, style=muted)
|
|
238
|
+
info_text.append(f" {desc}", style=dim)
|
|
239
|
+
if row_idx < len(shortcut_rows) - 1:
|
|
240
|
+
info_text.append("\n")
|
|
241
|
+
|
|
242
|
+
info_text.rstrip()
|
|
243
|
+
|
|
244
|
+
info_label = Label(info_text)
|
|
245
|
+
info_label.add_class("session-info")
|
|
246
|
+
self.mount(info_label, before=0)
|
|
247
|
+
|
|
248
|
+
def add_loaded_resources(
|
|
249
|
+
self, context_paths: list[str], skills: list[Skill], tools: list[BaseTool]
|
|
250
|
+
) -> None:
|
|
251
|
+
if not context_paths and not skills and not tools:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
dim_color = config.ui.colors.dim
|
|
255
|
+
notice_color = config.ui.colors.notice
|
|
256
|
+
text = Text()
|
|
257
|
+
|
|
258
|
+
if tools:
|
|
259
|
+
text.append("[Tools]\n", style=notice_color)
|
|
260
|
+
text.append(" ", style=dim_color)
|
|
261
|
+
text.append(", ".join(tool.name for tool in tools), style=dim_color)
|
|
262
|
+
text.append("\n", style=dim_color)
|
|
263
|
+
|
|
264
|
+
if context_paths:
|
|
265
|
+
if tools:
|
|
266
|
+
text.append("\n")
|
|
267
|
+
text.append("[Context]\n", style=notice_color)
|
|
268
|
+
for path in context_paths:
|
|
269
|
+
text.append(f" {path}\n", style=dim_color)
|
|
270
|
+
|
|
271
|
+
if skills:
|
|
272
|
+
if context_paths or tools:
|
|
273
|
+
text.append("\n")
|
|
274
|
+
text.append("[Skills]\n", style=notice_color)
|
|
275
|
+
text.append(" ", style=dim_color)
|
|
276
|
+
text.append(", ".join(_format_skill_label(skill) for skill in skills), style=dim_color)
|
|
277
|
+
text.append("\n", style=dim_color)
|
|
278
|
+
|
|
279
|
+
# Remove trailing newline
|
|
280
|
+
text.rstrip()
|
|
281
|
+
|
|
282
|
+
label = Label(text)
|
|
283
|
+
label.add_class("info-message")
|
|
284
|
+
label.add_class("loaded-resources")
|
|
285
|
+
self.mount(label)
|
|
286
|
+
|
|
287
|
+
def add_session_details(
|
|
288
|
+
self,
|
|
289
|
+
*,
|
|
290
|
+
session_dir: str | None,
|
|
291
|
+
session_file: str,
|
|
292
|
+
user_messages: int,
|
|
293
|
+
assistant_messages: int,
|
|
294
|
+
tool_calls: int,
|
|
295
|
+
tool_results: int,
|
|
296
|
+
total_messages: int,
|
|
297
|
+
input_tokens: int,
|
|
298
|
+
output_tokens: int,
|
|
299
|
+
cache_read_tokens: int,
|
|
300
|
+
cache_write_tokens: int,
|
|
301
|
+
total_tokens: int,
|
|
302
|
+
) -> None:
|
|
303
|
+
notice_color = config.ui.colors.notice
|
|
304
|
+
dim_color = config.ui.colors.dim
|
|
305
|
+
muted_color = config.ui.colors.muted
|
|
306
|
+
colors = dict(notice_color=notice_color, dim_color=dim_color, muted_color=muted_color)
|
|
307
|
+
text = Text("\n")
|
|
308
|
+
|
|
309
|
+
file_rows: list[tuple[str, str]] = []
|
|
310
|
+
if session_dir is not None:
|
|
311
|
+
file_rows.append(("Dir", session_dir))
|
|
312
|
+
file_rows.append(("File", session_file))
|
|
313
|
+
_append_aligned_section(text, "File", file_rows, **colors)
|
|
314
|
+
|
|
315
|
+
msg_rows = [
|
|
316
|
+
("User", str(user_messages)),
|
|
317
|
+
("Assistant", str(assistant_messages)),
|
|
318
|
+
("Tool Calls", str(tool_calls)),
|
|
319
|
+
("Tool Results", str(tool_results)),
|
|
320
|
+
("Total", str(total_messages)),
|
|
321
|
+
]
|
|
322
|
+
_append_aligned_section(text, "Messages", msg_rows, **colors)
|
|
323
|
+
|
|
324
|
+
token_rows = [
|
|
325
|
+
("Input", f"{input_tokens:,}"),
|
|
326
|
+
("Output", f"{output_tokens:,}"),
|
|
327
|
+
("Cache read", f"{cache_read_tokens:,}"),
|
|
328
|
+
("Cache write", f"{cache_write_tokens:,}"),
|
|
329
|
+
("Total", f"{total_tokens:,}"),
|
|
330
|
+
]
|
|
331
|
+
_append_aligned_section(text, "Tokens", token_rows, **colors)
|
|
332
|
+
|
|
333
|
+
text.rstrip()
|
|
334
|
+
label = Label(text)
|
|
335
|
+
label.add_class("info-message")
|
|
336
|
+
label.add_class("loaded-resources")
|
|
337
|
+
self.mount(label)
|
|
338
|
+
|
|
339
|
+
def add_help_details(self) -> None:
|
|
340
|
+
notice_color = config.ui.colors.notice
|
|
341
|
+
dim_color = config.ui.colors.dim
|
|
342
|
+
muted_color = config.ui.colors.muted
|
|
343
|
+
colors = dict(notice_color=notice_color, dim_color=dim_color, muted_color=muted_color)
|
|
344
|
+
text = Text("\n")
|
|
345
|
+
|
|
346
|
+
commands = [
|
|
347
|
+
("/help", "Show this help"),
|
|
348
|
+
("/quit", "Quit (or ctrl+c twice)"),
|
|
349
|
+
("/clear", "Clear conversation history"),
|
|
350
|
+
("/compact", "Compact current conversation now"),
|
|
351
|
+
("/model", "Change model (/model gpt-4o)"),
|
|
352
|
+
("/themes", "Change UI theme (/themes gruvbox-dark)"),
|
|
353
|
+
("/permissions", "Change permission mode (/permissions auto)"),
|
|
354
|
+
("/thinking", "Change thinking level (/thinking high)"),
|
|
355
|
+
("/notifications", "Toggle notifications (/notifications on)"),
|
|
356
|
+
("/new", "Start new conversation"),
|
|
357
|
+
("/handoff", "Start focused handoff in new session"),
|
|
358
|
+
("/resume", "Resume a session"),
|
|
359
|
+
("/session", "Show session info and stats"),
|
|
360
|
+
("/login", "Login to a provider"),
|
|
361
|
+
("/logout", "Logout from a provider"),
|
|
362
|
+
("/export", "Export session to HTML file"),
|
|
363
|
+
("/copy", "Copy last agent response text to clipboard"),
|
|
364
|
+
]
|
|
365
|
+
_append_aligned_section(text, "Commands", commands, **colors)
|
|
366
|
+
|
|
367
|
+
keybindings = [
|
|
368
|
+
("@", "File path search (inline)"),
|
|
369
|
+
("/", "Slash commands (at start of input)"),
|
|
370
|
+
("escape", "Cancel completion / interrupt agent"),
|
|
371
|
+
("ctrl+c", "Clear input (press twice to quit)"),
|
|
372
|
+
("ctrl+t", "Cycle thinking levels"),
|
|
373
|
+
("ctrl+o", "Toggle tool output expansion"),
|
|
374
|
+
("↑/↓ on queue", "Select queued messages"),
|
|
375
|
+
("enter on queue", "Edit selected queued message"),
|
|
376
|
+
("ctrl+d on queue", "Delete selected queued message"),
|
|
377
|
+
("ctrl+shift+t", "Toggle thinking visibility"),
|
|
378
|
+
("shift+tab", "Cycle permission mode"),
|
|
379
|
+
]
|
|
380
|
+
_append_aligned_section(text, "Keybindings", keybindings, **colors)
|
|
381
|
+
|
|
382
|
+
label = Label(text)
|
|
383
|
+
label.add_class("info-message")
|
|
384
|
+
label.add_class("loaded-resources")
|
|
385
|
+
self.mount(label)
|
|
386
|
+
self._scroll_if_anchored(animate=False)
|
|
387
|
+
|
|
388
|
+
def add_launch_warnings(self, warnings: list[LaunchWarning]) -> None:
|
|
389
|
+
if not warnings:
|
|
390
|
+
return
|
|
391
|
+
self.mount(LaunchWarningsBlock(warnings))
|
|
392
|
+
self._scroll_if_anchored(animate=False)
|
|
393
|
+
|
|
394
|
+
def add_user_message(self, content: str, highlighted_skill: str | None = None) -> UserBlock:
|
|
395
|
+
block = UserBlock(content, highlighted_skill=highlighted_skill)
|
|
396
|
+
self.mount(block)
|
|
397
|
+
self._anchor_released = False
|
|
398
|
+
self.scroll_end(animate=False)
|
|
399
|
+
self._prune_if_needed()
|
|
400
|
+
return block
|
|
401
|
+
|
|
402
|
+
def add_handoff_link_message(
|
|
403
|
+
self, label: str, target_session_id: str, query: str, direction: Literal["back", "forward"]
|
|
404
|
+
) -> HandoffLinkBlock:
|
|
405
|
+
block = HandoffLinkBlock(
|
|
406
|
+
label=label, target_session_id=target_session_id, query=query, direction=direction
|
|
407
|
+
)
|
|
408
|
+
self.mount(block)
|
|
409
|
+
self._scroll_if_anchored(animate=False)
|
|
410
|
+
self._prune_if_needed()
|
|
411
|
+
return block
|
|
412
|
+
|
|
413
|
+
def add_update_available_message(
|
|
414
|
+
self, latest_version: str, changelog_url: str | None = None
|
|
415
|
+
) -> UpdateAvailableBlock:
|
|
416
|
+
block = UpdateAvailableBlock(latest_version, changelog_url=changelog_url)
|
|
417
|
+
self.mount(block)
|
|
418
|
+
self._scroll_if_anchored(animate=False)
|
|
419
|
+
self._prune_if_needed()
|
|
420
|
+
return block
|
|
421
|
+
|
|
422
|
+
def start_thinking(self) -> ThinkingBlock:
|
|
423
|
+
block = ThinkingBlock()
|
|
424
|
+
self.mount(block)
|
|
425
|
+
self._scroll_if_anchored(animate=False)
|
|
426
|
+
self._current_block = block
|
|
427
|
+
return block
|
|
428
|
+
|
|
429
|
+
def add_thinking(self, content: str) -> ThinkingBlock:
|
|
430
|
+
block = ThinkingBlock(content, finalized=True)
|
|
431
|
+
self.mount(block)
|
|
432
|
+
self._scroll_if_anchored(animate=False)
|
|
433
|
+
return block
|
|
434
|
+
|
|
435
|
+
def start_content(self) -> ContentBlock:
|
|
436
|
+
block = ContentBlock()
|
|
437
|
+
self.mount(block)
|
|
438
|
+
self._scroll_if_anchored(animate=False)
|
|
439
|
+
self._current_block = block
|
|
440
|
+
return block
|
|
441
|
+
|
|
442
|
+
def add_content(self, content: str) -> ContentBlock:
|
|
443
|
+
block = ContentBlock(content, finalized=True)
|
|
444
|
+
self.mount(block)
|
|
445
|
+
self._scroll_if_anchored(animate=False)
|
|
446
|
+
return block
|
|
447
|
+
|
|
448
|
+
def start_tool(
|
|
449
|
+
self, name: str, tool_id: str, call_msg: str | None = None, icon: str = "→"
|
|
450
|
+
) -> ToolBlock:
|
|
451
|
+
block = ToolBlock(
|
|
452
|
+
name=name, call_msg=call_msg, icon=icon, expanded=self._tool_output_expanded
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Consecutive tool calls without detail output render compactly (no
|
|
456
|
+
# margin). Tools with detail output (diffs, bash output, etc.) always
|
|
457
|
+
# keep a 1-line gap so they don't visually bleed into neighbours.
|
|
458
|
+
previous = self.children[-1] if self.children else None
|
|
459
|
+
if isinstance(previous, ToolBlock) and not previous.has_class("-with-details"):
|
|
460
|
+
block.add_class("-compact")
|
|
461
|
+
|
|
462
|
+
self.mount(block)
|
|
463
|
+
self._scroll_if_anchored(animate=False)
|
|
464
|
+
self._tool_blocks[tool_id] = block
|
|
465
|
+
return block
|
|
466
|
+
|
|
467
|
+
async def append_to_current(self, text: str) -> None:
|
|
468
|
+
if self._current_block:
|
|
469
|
+
await self._current_block.append(text)
|
|
470
|
+
self._request_scroll()
|
|
471
|
+
|
|
472
|
+
def set_block_content(self, text: str) -> None:
|
|
473
|
+
if self._current_block:
|
|
474
|
+
self._current_block.set_content(text)
|
|
475
|
+
self._request_scroll()
|
|
476
|
+
|
|
477
|
+
def set_tool_result(
|
|
478
|
+
self,
|
|
479
|
+
tool_id: str,
|
|
480
|
+
ui_summary: str | None,
|
|
481
|
+
ui_details: str | None,
|
|
482
|
+
success: bool,
|
|
483
|
+
markup: bool = True,
|
|
484
|
+
ui_details_full: str | None = None,
|
|
485
|
+
images: list[ImageContent] | None = None,
|
|
486
|
+
) -> None:
|
|
487
|
+
block = self._tool_blocks.get(tool_id)
|
|
488
|
+
if block:
|
|
489
|
+
block.set_result(
|
|
490
|
+
ui_summary,
|
|
491
|
+
ui_details,
|
|
492
|
+
success,
|
|
493
|
+
markup=markup,
|
|
494
|
+
ui_details_full=ui_details_full,
|
|
495
|
+
images=images,
|
|
496
|
+
)
|
|
497
|
+
if ui_details:
|
|
498
|
+
# All ToolStartEvents arrive during streaming before any
|
|
499
|
+
# results, so later siblings were mounted compact. Now that
|
|
500
|
+
# this block has detail output, the next tool needs its
|
|
501
|
+
# margin back so the detail block doesn't run into it.
|
|
502
|
+
next_sibling = self._next_child(block)
|
|
503
|
+
if isinstance(next_sibling, ToolBlock):
|
|
504
|
+
next_sibling.remove_class("-compact")
|
|
505
|
+
self._scroll_if_anchored(animate=False)
|
|
506
|
+
|
|
507
|
+
def _next_child(self, child):
|
|
508
|
+
children = list(self.children)
|
|
509
|
+
try:
|
|
510
|
+
index = children.index(child)
|
|
511
|
+
except ValueError:
|
|
512
|
+
return None
|
|
513
|
+
next_index = index + 1
|
|
514
|
+
if next_index >= len(children):
|
|
515
|
+
return None
|
|
516
|
+
return children[next_index]
|
|
517
|
+
|
|
518
|
+
def set_tool_output_expanded(self, expanded: bool) -> None:
|
|
519
|
+
self._tool_output_expanded = expanded
|
|
520
|
+
for block in self._tool_blocks.values():
|
|
521
|
+
block.set_expanded(expanded)
|
|
522
|
+
self._scroll_if_anchored(animate=False)
|
|
523
|
+
|
|
524
|
+
def toggle_tool_output_expanded(self) -> bool:
|
|
525
|
+
expanded = not self._tool_output_expanded
|
|
526
|
+
self.set_tool_output_expanded(expanded)
|
|
527
|
+
return expanded
|
|
528
|
+
|
|
529
|
+
def update_tool_call_msg(self, tool_id: str, call_msg: str) -> None:
|
|
530
|
+
block = self._tool_blocks.get(tool_id)
|
|
531
|
+
if block:
|
|
532
|
+
block.update_call_msg(call_msg)
|
|
533
|
+
self._scroll_if_anchored(animate=False)
|
|
534
|
+
|
|
535
|
+
def show_tool_approval(
|
|
536
|
+
self, tool_id: str, preview: str | None = None, selected: ApprovalResponse | None = None
|
|
537
|
+
) -> None:
|
|
538
|
+
block = self._tool_blocks.get(tool_id)
|
|
539
|
+
if block:
|
|
540
|
+
block.show_approval(preview=preview, selected=selected)
|
|
541
|
+
self._scroll_if_anchored(animate=False)
|
|
542
|
+
|
|
543
|
+
def update_tool_approval_selection(self, tool_id: str, selected: ApprovalResponse) -> None:
|
|
544
|
+
block = self._tool_blocks.get(tool_id)
|
|
545
|
+
if block:
|
|
546
|
+
block.update_approval_selection(selected)
|
|
547
|
+
|
|
548
|
+
def hide_tool_approval(self, tool_id: str) -> None:
|
|
549
|
+
block = self._tool_blocks.get(tool_id)
|
|
550
|
+
if block:
|
|
551
|
+
block.hide_approval()
|
|
552
|
+
self._scroll_if_anchored(animate=False)
|
|
553
|
+
|
|
554
|
+
def end_block(self) -> None:
|
|
555
|
+
# Finalize content/thinking blocks to render markdown once
|
|
556
|
+
if isinstance(self._current_block, ContentBlock | ThinkingBlock):
|
|
557
|
+
self._current_block.finalize()
|
|
558
|
+
self._current_block = None
|
|
559
|
+
|
|
560
|
+
def add_compaction_message(self, tokens_before: int) -> None:
|
|
561
|
+
self._stop_spinner()
|
|
562
|
+
# Remove the "Auto-compacting..." status if it's still showing
|
|
563
|
+
if self._is_last_child_status() and self._last_status_label is not None:
|
|
564
|
+
self._last_status_label.remove()
|
|
565
|
+
self._last_status_label = None
|
|
566
|
+
|
|
567
|
+
dim_color = config.ui.colors.dim
|
|
568
|
+
token_str = f"{tokens_before:,}"
|
|
569
|
+
|
|
570
|
+
text = Text(f"[compaction] Compacted from {token_str} tokens", style=dim_color)
|
|
571
|
+
stylize_badge_markers(text, ("[compaction]",))
|
|
572
|
+
|
|
573
|
+
label = Label(text)
|
|
574
|
+
label.add_class("compaction-message")
|
|
575
|
+
self.mount(label)
|
|
576
|
+
self._scroll_if_anchored(animate=False)
|
|
577
|
+
|
|
578
|
+
def add_aborted_message(self, message: str = "Interrupted by user") -> None:
|
|
579
|
+
error_color = config.ui.colors.error
|
|
580
|
+
text = Text(message, style=error_color)
|
|
581
|
+
label = Label(text)
|
|
582
|
+
label.add_class("aborted-message")
|
|
583
|
+
self.mount(label)
|
|
584
|
+
self._scroll_if_anchored(animate=False)
|
|
585
|
+
|
|
586
|
+
def add_info_message(self, message: str, error: bool = False, warning: bool = False) -> None:
|
|
587
|
+
info_color = config.ui.colors.info
|
|
588
|
+
error_color = config.ui.colors.error
|
|
589
|
+
notice_color = config.ui.colors.notice
|
|
590
|
+
|
|
591
|
+
cleaned_message = message.strip()
|
|
592
|
+
if not cleaned_message:
|
|
593
|
+
cleaned_message = (
|
|
594
|
+
"Unknown error (no details provided)." if error else "No details provided."
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
style = info_color
|
|
598
|
+
prefix = "✓ "
|
|
599
|
+
if warning:
|
|
600
|
+
style = notice_color
|
|
601
|
+
prefix = "⚠ "
|
|
602
|
+
if error:
|
|
603
|
+
style = error_color
|
|
604
|
+
prefix = "✗ "
|
|
605
|
+
|
|
606
|
+
text = Text(f"{prefix}{cleaned_message}", style=style)
|
|
607
|
+
label = Label(text)
|
|
608
|
+
label.add_class("info-message")
|
|
609
|
+
self.mount(label)
|
|
610
|
+
self._scroll_if_anchored(animate=False)
|
|
611
|
+
|
|
612
|
+
def clear_tool_blocks(self) -> None:
|
|
613
|
+
self._tool_blocks.clear()
|
vtx/ui/clipboard.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def copy_to_clipboard(text: str) -> None:
|
|
9
|
+
encoded = base64.b64encode(text.encode()).decode()
|
|
10
|
+
print(f"\033]52;c;{encoded}\a", end="", flush=True)
|
|
11
|
+
|
|
12
|
+
if sys.platform == "darwin":
|
|
13
|
+
_try_run(["pbcopy"], text)
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
_try_run(["clip"], text)
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
if os.environ.get("TERMUX_VERSION") and _try_run(["termux-clipboard-set"], text):
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
if _is_wayland_session():
|
|
24
|
+
if _try_run(["wl-copy"], text):
|
|
25
|
+
return
|
|
26
|
+
if _try_run(["xclip", "-selection", "clipboard"], text):
|
|
27
|
+
return
|
|
28
|
+
_try_run(["xsel", "--clipboard", "--input"], text)
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
if _try_run(["xclip", "-selection", "clipboard"], text):
|
|
32
|
+
return
|
|
33
|
+
_try_run(["xsel", "--clipboard", "--input"], text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_wayland_session() -> bool:
|
|
37
|
+
return (
|
|
38
|
+
bool(os.environ.get("WAYLAND_DISPLAY")) or os.environ.get("XDG_SESSION_TYPE") == "wayland"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _try_run(command: list[str], text: str) -> bool:
|
|
43
|
+
if shutil.which(command[0]) is None:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
subprocess.run(
|
|
48
|
+
command,
|
|
49
|
+
input=text,
|
|
50
|
+
text=True,
|
|
51
|
+
check=True,
|
|
52
|
+
timeout=5,
|
|
53
|
+
stdout=subprocess.DEVNULL,
|
|
54
|
+
stderr=subprocess.DEVNULL,
|
|
55
|
+
)
|
|
56
|
+
except (OSError, subprocess.SubprocessError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
return True
|