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