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/app.py ADDED
@@ -0,0 +1,665 @@
1
+ """The Vtx app: widget composition, runtime wiring, key bindings and input routing.
2
+
3
+ Behaviour is split across focused mixins:
4
+
5
+ - commands/ - slash-command handling (CommandsMixin)
6
+ - session_ui.py - rendering persisted sessions into the chat log (SessionUIMixin)
7
+ - queue_ui.py - pending/steer message queues (QueueUIMixin)
8
+ - completion_ui.py - completion list and selection-mode pickers (CompletionUIMixin)
9
+ - agent_runner.py - driving agent runs and shell commands (AgentRunnerMixin)
10
+ - startup.py - background startup chores (StartupMixin)
11
+ - launch.py - run_tui() entrypoint and exit summary
12
+ """
13
+
14
+ import asyncio
15
+ import os
16
+ import time
17
+ from collections import deque
18
+ from typing import ClassVar
19
+
20
+ from textual import events, on
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+
24
+ from vtx import config, consume_config_warnings
25
+ from vtx.config import get_last_selected
26
+ from vtx.tools_manager import get_tool_path
27
+ from vtx.version import VERSION
28
+
29
+ from ..context.skills import (
30
+ load_builtin_cmd_skills,
31
+ load_skills,
32
+ merge_registered_skills,
33
+ render_skill_prompt,
34
+ )
35
+ from ..llm import BaseProvider
36
+ from ..llm.base import AuthMode
37
+ from ..permissions import ApprovalResponse
38
+ from ..runtime import ConversationRuntime
39
+ from ..session import Session
40
+ from ..tools import DEFAULT_TOOLS, get_tools
41
+ from .agent_runner import AgentRunnerMixin
42
+ from .autocomplete import DEFAULT_COMMANDS, SlashCommand
43
+ from .blocks import HandoffLinkBlock, LaunchWarning
44
+ from .chat import ChatLog
45
+ from .commands import CommandsMixin
46
+ from .completion_ui import CompletionUIMixin
47
+ from .floating_list import FloatingList
48
+ from .input import InputBox
49
+ from .queue_ui import QueueUIMixin
50
+ from .selection_mode import SelectionMode
51
+ from .session_ui import SessionUIMixin
52
+ from .startup import StartupMixin
53
+ from .styles import get_styles
54
+ from .tree import TreeSelector
55
+ from .widgets import InfoBar, QueueDisplay, StatusLine, format_path
56
+
57
+ _GIT_BRANCH_REFRESH_INTERVAL_SECONDS = 1.0
58
+
59
+
60
+ class Vtx(
61
+ CommandsMixin,
62
+ SessionUIMixin,
63
+ QueueUIMixin,
64
+ CompletionUIMixin,
65
+ AgentRunnerMixin,
66
+ StartupMixin,
67
+ App[None],
68
+ ):
69
+ CSS = get_styles()
70
+ TITLE = "vtx"
71
+ VERSION = VERSION
72
+ PAUSE_GC_ON_SCROLL = True
73
+
74
+ BINDINGS: ClassVar[list] = [
75
+ ("ctrl+c", "handle_ctrl_c", "Clear"),
76
+ Binding("ctrl+d", "handle_ctrl_d", "Delete session", priority=True),
77
+ ("escape", "interrupt_agent", "Interrupt"),
78
+ Binding("left", "tree_page_up", "Tree page up", priority=True),
79
+ Binding("right", "tree_page_down", "Tree page down", priority=True),
80
+ ("ctrl+t", "cycle_thinking_level", "Cycle thinking level"),
81
+ Binding("ctrl+o", "toggle_tool_output", "Toggle tool output", priority=True),
82
+ Binding("ctrl+shift+t", "toggle_thinking", "Toggle thinking", priority=True),
83
+ Binding("shift+tab", "cycle_permission_mode", "Cycle permission mode", priority=True),
84
+ ]
85
+
86
+ # Textual registers @on handlers through a metaclass that only scans this
87
+ # class's own namespace, so handlers defined on plain mixins must be
88
+ # re-bound here or they would silently never be dispatched.
89
+ on_completion_update = CompletionUIMixin.on_completion_update
90
+ on_completion_hide = CompletionUIMixin.on_completion_hide
91
+ on_completion_select = CompletionUIMixin.on_completion_select
92
+ on_search_update = CompletionUIMixin.on_search_update
93
+ on_completion_move = CompletionUIMixin.on_completion_move
94
+ on_tree_selected = CompletionUIMixin.on_tree_selected
95
+ on_tree_cancelled = CompletionUIMixin.on_tree_cancelled
96
+
97
+ _ANSI_THEME_PREFERENCE = ("textual-ansi", "ansi-dark")
98
+
99
+ def _resolve_ansi_theme(self) -> str:
100
+ for name in self._ANSI_THEME_PREFERENCE:
101
+ if name in self.available_themes:
102
+ return name
103
+ return "textual-dark"
104
+
105
+ def __init__(
106
+ self,
107
+ cwd: str | None = None,
108
+ model: str | None = None,
109
+ provider: str | None = None,
110
+ api_key: str | None = None,
111
+ base_url: str | None = None,
112
+ resume_session: str | None = None,
113
+ continue_recent: bool = False,
114
+ thinking_level: str | None = None,
115
+ openai_compat_auth_mode: AuthMode | None = None,
116
+ anthropic_compat_auth_mode: AuthMode | None = None,
117
+ ):
118
+ super().__init__()
119
+ self.theme = self._resolve_ansi_theme()
120
+ self._cwd = cwd or os.getcwd()
121
+ last_selected = get_last_selected()
122
+ initial_model = model or last_selected.model_id or config.llm.default_model
123
+ initial_model_provider = (
124
+ provider
125
+ if provider is not None
126
+ else (
127
+ last_selected.provider
128
+ if last_selected.model_id
129
+ else (config.llm.default_provider if model is None else None)
130
+ )
131
+ )
132
+ self._api_key = api_key
133
+ self._base_url = base_url or config.llm.default_base_url or None
134
+ self._resume_session = resume_session
135
+ self._continue_recent = continue_recent
136
+ initial_thinking_level = (
137
+ thinking_level or last_selected.thinking_level or config.llm.default_thinking_level
138
+ )
139
+ self._openai_compat_auth_mode: AuthMode = (
140
+ openai_compat_auth_mode or config.llm.auth.openai_compat
141
+ )
142
+ self._anthropic_compat_auth_mode: AuthMode = (
143
+ anthropic_compat_auth_mode or config.llm.auth.anthropic_compat
144
+ )
145
+ self._is_running = False
146
+ self._last_ctrl_c_time = 0.0
147
+ self._last_ctrl_d_time = 0.0
148
+ self._ctrl_c_threshold = 2.0
149
+ self._ctrl_d_threshold = 2.0
150
+ self._ctrl_c_timer = None
151
+ self._ctrl_d_timer = None
152
+ self._cancel_event: asyncio.Event | None = None
153
+ self._interrupt_requested = False
154
+ self._pending_session_switch_id: str | None = None
155
+ self._abort_shown = False
156
+ self._current_block_type: str | None = None
157
+ self._approval_future: asyncio.Future[ApprovalResponse] | None = None
158
+ self._approval_tool_id: str | None = None
159
+ self._approval_selection: ApprovalResponse = ApprovalResponse.APPROVE
160
+ self._hide_thinking = False
161
+ self._fd_path: str | None = None
162
+ self._selection_mode: SelectionMode | None = None
163
+ self._settings_active: bool = False
164
+ self._pending_api_key_provider: str | None = None
165
+ self._settings_selected_value: str | None = None
166
+ self._shell_tool_counter = 0
167
+
168
+ self._pending_queue: deque[tuple[str, str]] = deque(maxlen=QueueDisplay.MAX_QUEUE)
169
+ self._steer_queue: deque[tuple[str, str]] = deque(maxlen=QueueDisplay.MAX_QUEUE)
170
+ self._queue_selection: tuple[bool, int] | None = None
171
+ self._queue_editing: tuple[bool, int, tuple[str, str]] | None = None
172
+ self._steer_event: asyncio.Event | None = None
173
+ self._exit_hints: list[str] = []
174
+ self._session_start_time: float | None = None
175
+
176
+ self._pending_update_notice_version: str | None = None
177
+ self._update_notice_shown = False
178
+ self._startup_complete = False
179
+ self._git_branch_refresh_inflight = False
180
+ self._launch_warnings: list[LaunchWarning] = []
181
+
182
+ self._tools = get_tools(DEFAULT_TOOLS)
183
+
184
+ self._runtime = ConversationRuntime(
185
+ cwd=self._cwd,
186
+ model=initial_model,
187
+ model_provider=initial_model_provider,
188
+ api_key=self._api_key,
189
+ base_url=self._base_url,
190
+ thinking_level=initial_thinking_level,
191
+ tools=self._tools,
192
+ openai_compat_auth_mode=self._openai_compat_auth_mode,
193
+ anthropic_compat_auth_mode=self._anthropic_compat_auth_mode,
194
+ )
195
+
196
+ def compose(self) -> ComposeResult:
197
+ yield ChatLog(id="chat-log")
198
+ yield QueueDisplay(id="queue-display")
199
+ yield StatusLine(id="status-line")
200
+ yield InputBox(cwd=self._cwd, id="input-box")
201
+ yield FloatingList(window_size=10, label_width=6, id="completion-list")
202
+ yield TreeSelector(id="tree-selector")
203
+ yield InfoBar(
204
+ cwd=self._cwd,
205
+ model=self._runtime.model,
206
+ thinking_level=self._runtime.thinking_level,
207
+ hide_thinking=self._hide_thinking,
208
+ id="info-bar",
209
+ )
210
+
211
+ @staticmethod
212
+ def _thinking_level_class(level: str) -> str:
213
+ return f"-thinking-{level}"
214
+
215
+ def _apply_thinking_level_style(self, level: str) -> None:
216
+ input_box = self.query_one("#input-box", InputBox)
217
+ for name in ("none", "minimal", "low", "medium", "high", "xhigh"):
218
+ input_box.remove_class(self._thinking_level_class(name))
219
+ input_box.add_class(self._thinking_level_class(level))
220
+
221
+ def _apply_theme(self, theme_id: str) -> None:
222
+ type(self).CSS = get_styles()
223
+ self.refresh_css(animate=False)
224
+ self.query_one("#input-box", InputBox).refresh_theme()
225
+ self._apply_thinking_level_style(self._runtime.thinking_level)
226
+
227
+ @property
228
+ def _model(self) -> str:
229
+ return self._runtime.model
230
+
231
+ @_model.setter
232
+ def _model(self, value: str) -> None:
233
+ self._runtime.model = value
234
+
235
+ @property
236
+ def _model_provider(self) -> str | None:
237
+ return self._runtime.model_provider
238
+
239
+ @_model_provider.setter
240
+ def _model_provider(self, value: str | None) -> None:
241
+ self._runtime.model_provider = value
242
+
243
+ @property
244
+ def _thinking_level(self) -> str:
245
+ return self._runtime.thinking_level
246
+
247
+ @_thinking_level.setter
248
+ def _thinking_level(self, value: str) -> None:
249
+ self._runtime.thinking_level = value
250
+
251
+ @property
252
+ def _provider(self) -> BaseProvider | None:
253
+ return self._runtime.provider
254
+
255
+ @_provider.setter
256
+ def _provider(self, value: BaseProvider | None) -> None:
257
+ self._runtime.provider = value
258
+
259
+ @property
260
+ def _session(self) -> Session | None:
261
+ return self._runtime.session
262
+
263
+ @_session.setter
264
+ def _session(self, value: Session | None) -> None:
265
+ self._runtime.session = value
266
+
267
+ @property
268
+ def _agent(self):
269
+ return self._runtime.agent
270
+
271
+ @_agent.setter
272
+ def _agent(self, value) -> None:
273
+ self._runtime.agent = value
274
+
275
+ def _registered_slash_skills(self):
276
+ agent = self._runtime.agent
277
+ skills = agent.context.skills if agent else load_skills(self._cwd).skills
278
+ builtin_skills = load_builtin_cmd_skills().skills
279
+ return merge_registered_skills(skills, builtin_skills)
280
+
281
+ def _sync_slash_commands(self) -> None:
282
+ input_box = self.query_one("#input-box", InputBox)
283
+ commands = DEFAULT_COMMANDS.copy()
284
+
285
+ for skill in self._registered_slash_skills():
286
+ if not skill.register_cmd:
287
+ continue
288
+ cmd_description = skill.cmd_info
289
+ if not cmd_description:
290
+ cmd_description = skill.description[:32]
291
+ if len(skill.description) > 32:
292
+ cmd_description = f"{cmd_description}..."
293
+ commands.append(
294
+ SlashCommand(name=skill.name, description=cmd_description, is_skill=True)
295
+ )
296
+
297
+ input_box.set_commands(commands)
298
+
299
+ @staticmethod
300
+ def _build_skill_trigger_message(skill_name: str, description: str, query: str) -> str:
301
+ truncated_description = description[:300]
302
+ if len(description) > 300:
303
+ truncated_description = f"{truncated_description}..."
304
+
305
+ parts = [f"[{skill_name}]", truncated_description]
306
+ if query.strip():
307
+ parts.extend(["", "[query]", query.strip()])
308
+ return "\n".join(parts)
309
+
310
+ def _sync_runtime_state(self) -> None:
311
+ # Compatibility hook for mixin/unit-test fakes. Runtime is the source of truth.
312
+ return None
313
+
314
+ @on(events.TextSelected)
315
+ def _on_text_selected(self) -> None:
316
+ selection = self.screen.get_selected_text()
317
+ if selection:
318
+ self.copy_to_clipboard(selection)
319
+
320
+ def on_mount(self) -> None:
321
+ self._fd_path = get_tool_path("fd")
322
+
323
+ input_box = self.query_one("#input-box", InputBox)
324
+ input_box.set_fd_path(self._fd_path)
325
+ input_box.set_commands(DEFAULT_COMMANDS.copy())
326
+
327
+ if not self._fd_path:
328
+ self.run_worker(self._collect_file_paths(), exclusive=False)
329
+
330
+ self.run_worker(self._ensure_binaries(), exclusive=False)
331
+ self.run_worker(self._check_for_updates(), exclusive=False)
332
+ self.run_worker(self._ensure_models_dev(), exclusive=False)
333
+
334
+ try:
335
+ init_result = self._runtime.initialize(
336
+ resume_session=self._resume_session, continue_recent=self._continue_recent
337
+ )
338
+ except Exception as e:
339
+ self._add_launch_warning(str(e), severity="error")
340
+ chat = self.query_one("#chat-log", ChatLog)
341
+ self._flush_launch_warnings(chat)
342
+ return
343
+
344
+ self._session_start_time = time.time()
345
+
346
+ self._sync_slash_commands()
347
+
348
+ chat = self.query_one("#chat-log", ChatLog)
349
+ chat.add_session_info(VERSION)
350
+
351
+ if self._runtime.context:
352
+ chat.add_loaded_resources(
353
+ context_paths=[format_path(f.path) for f in self._runtime.context.agents_files],
354
+ skills=self._runtime.context.skills,
355
+ tools=self._runtime.tools,
356
+ )
357
+ for path, message in self._runtime.context.skill_warnings:
358
+ self._add_launch_warning(f"Skill warning in {format_path(path)}: {message}")
359
+
360
+ if init_result.provider_error:
361
+ self._add_launch_warning(init_result.provider_error, severity="error")
362
+
363
+ for warning in consume_config_warnings():
364
+ self._add_launch_warning(warning)
365
+
366
+ self._flush_launch_warnings(chat)
367
+
368
+ info_bar = self.query_one("#info-bar", InfoBar)
369
+ info_bar.set_model(self._runtime.model, self._runtime.model_provider)
370
+ info_bar.set_thinking_level(self._runtime.thinking_level)
371
+ self._apply_thinking_level_style(self._runtime.thinking_level)
372
+
373
+ if (
374
+ (self._continue_recent or self._resume_session)
375
+ and self._runtime.session
376
+ and self._runtime.session.entries
377
+ ):
378
+ self._render_session_entries(self._runtime.session)
379
+ token_totals = self._runtime.session.token_totals()
380
+ info_bar.set_tokens(
381
+ token_totals.input_tokens,
382
+ token_totals.output_tokens,
383
+ token_totals.context_tokens,
384
+ token_totals.cache_read_tokens,
385
+ token_totals.cache_write_tokens,
386
+ )
387
+ info_bar.set_file_changes(self._runtime.session.file_changes_summary())
388
+ chat.add_info_message("Resumed session")
389
+
390
+ self.set_interval(_GIT_BRANCH_REFRESH_INTERVAL_SECONDS, self._refresh_git_branch)
391
+
392
+ self._startup_complete = True
393
+ self._show_pending_update_notice_if_idle()
394
+ input_box.focus()
395
+
396
+ import gc
397
+
398
+ gc.freeze()
399
+
400
+ # -------------------------------------------------------------------------
401
+ # Key bindings
402
+ # -------------------------------------------------------------------------
403
+
404
+ def action_handle_ctrl_c(self) -> None:
405
+ input_box = self.query_one("#input-box", InputBox)
406
+ status = self.query_one("#status-line", StatusLine)
407
+
408
+ if input_box.text.strip():
409
+ input_box.clear()
410
+ status.hide_exit_hint()
411
+ self._last_ctrl_c_time = 0.0
412
+ return
413
+
414
+ now = time.time()
415
+ if now - self._last_ctrl_c_time < self._ctrl_c_threshold:
416
+ self.exit()
417
+ else:
418
+ self._last_ctrl_c_time = now
419
+ status.show_exit_hint()
420
+
421
+ if self._ctrl_c_timer:
422
+ self._ctrl_c_timer.stop()
423
+ self._ctrl_c_timer = self.set_timer(
424
+ self._ctrl_c_threshold, lambda: status.hide_exit_hint()
425
+ )
426
+
427
+ def action_handle_ctrl_d(self) -> None:
428
+ if self.delete_selected_queue_item():
429
+ return
430
+
431
+ if self._selection_mode != SelectionMode.SESSION:
432
+ return
433
+
434
+ completion_list = self.query_one("#completion-list", FloatingList)
435
+ if not completion_list.is_visible or completion_list.selected_item is None:
436
+ return
437
+
438
+ status = self.query_one("#status-line", StatusLine)
439
+ now = time.time()
440
+ if now - self._last_ctrl_d_time < self._ctrl_d_threshold:
441
+ self._last_ctrl_d_time = 0.0
442
+ if self._ctrl_d_timer:
443
+ self._ctrl_d_timer.stop()
444
+ self._ctrl_d_timer = None
445
+ status.hide_exit_hint()
446
+ self._delete_selected_resume_session()
447
+ return
448
+
449
+ self._last_ctrl_d_time = now
450
+ status.show_delete_session_hint()
451
+ if self._ctrl_d_timer:
452
+ self._ctrl_d_timer.stop()
453
+ self._ctrl_d_timer = self.set_timer(
454
+ self._ctrl_d_threshold, lambda: status.hide_exit_hint()
455
+ )
456
+
457
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
458
+ if action in {"tree_page_up", "tree_page_down"}:
459
+ return self._selection_mode == SelectionMode.TREE
460
+ return True
461
+
462
+ def action_tree_page_up(self) -> None:
463
+ if self._selection_mode == SelectionMode.TREE:
464
+ self.query_one("#tree-selector", TreeSelector).action_page_up()
465
+
466
+ def action_tree_page_down(self) -> None:
467
+ if self._selection_mode == SelectionMode.TREE:
468
+ self.query_one("#tree-selector", TreeSelector).action_page_down()
469
+
470
+ def action_interrupt_agent(self) -> None:
471
+ if self._selection_mode == SelectionMode.TREE:
472
+ self.query_one("#tree-selector", TreeSelector).action_cancel()
473
+ return
474
+ if self._is_running:
475
+ self._request_interrupt()
476
+
477
+ def _request_interrupt(self, status_message: str | None = "Interrupting...") -> None:
478
+ if not self._is_running:
479
+ return
480
+
481
+ self._interrupt_requested = True
482
+
483
+ if status_message:
484
+ chat = self.query_one("#chat-log", ChatLog)
485
+ chat.show_status(status_message)
486
+
487
+ if self._cancel_event:
488
+ self._cancel_event.set()
489
+
490
+ def _reset_ctrl_d_delete_state(self) -> None:
491
+ self._last_ctrl_d_time = 0.0
492
+ if self._ctrl_d_timer:
493
+ self._ctrl_d_timer.stop()
494
+ self._ctrl_d_timer = None
495
+
496
+ status = self.query_one("#status-line", StatusLine)
497
+ status.hide_exit_hint()
498
+
499
+ def action_toggle_tool_output(self) -> None:
500
+ chat = self.query_one("#chat-log", ChatLog)
501
+ expanded = chat.toggle_tool_output_expanded()
502
+ status = "expanded" if expanded else "collapsed"
503
+ chat.show_status(f"Tool output {status}")
504
+
505
+ def action_toggle_thinking(self) -> None:
506
+ self._hide_thinking = not self._hide_thinking
507
+ chat = self.query_one("#chat-log", ChatLog)
508
+ info_bar = self.query_one("#info-bar", InfoBar)
509
+
510
+ info_bar.set_thinking_visibility(self._hide_thinking)
511
+
512
+ for block in chat.query(".thinking-block"):
513
+ if self._hide_thinking:
514
+ block.add_class("-hidden")
515
+ else:
516
+ block.remove_class("-hidden")
517
+
518
+ status = "hidden" if self._hide_thinking else "visible"
519
+ chat.show_status(f"Thinking blocks {status}")
520
+
521
+ def action_cycle_permission_mode(self) -> None:
522
+ current_mode = config.permissions.mode
523
+ new_mode = "prompt" if current_mode == "auto" else "auto"
524
+ self._select_permission_mode(new_mode)
525
+
526
+ def action_cycle_thinking_level(self) -> None:
527
+ if self._runtime.provider is None:
528
+ return
529
+
530
+ levels = self._runtime.provider.thinking_levels
531
+ current_idx = (
532
+ levels.index(self._runtime.thinking_level)
533
+ if self._runtime.thinking_level in levels
534
+ else 0
535
+ )
536
+ new_level = levels[(current_idx + 1) % len(levels)]
537
+ self._select_thinking_level(new_level)
538
+
539
+ @on(HandoffLinkBlock.LinkSelected)
540
+ def on_handoff_link_selected(self, event: HandoffLinkBlock.LinkSelected) -> None:
541
+ if not event.target_session_id:
542
+ return
543
+ event.stop()
544
+ if self._is_running:
545
+ self._pending_session_switch_id = event.target_session_id
546
+ self._request_interrupt(status_message="Interrupting before handoff...")
547
+ return
548
+ self.run_worker(self._load_session_by_id(event.target_session_id), exclusive=True)
549
+
550
+ # -------------------------------------------------------------------------
551
+ # Tool approval
552
+ # -------------------------------------------------------------------------
553
+
554
+ def _clear_approval_state(self) -> None:
555
+ self._approval_future = None
556
+ if self._approval_tool_id is not None:
557
+ chat = self.query_one("#chat-log", ChatLog)
558
+ chat.hide_tool_approval(self._approval_tool_id)
559
+ self._approval_tool_id = None
560
+
561
+ def deny_pending_approval(self) -> bool:
562
+ if self._approval_future and not self._approval_future.done():
563
+ self._approval_future.set_result(ApprovalResponse.DENY)
564
+ self._clear_approval_state()
565
+ return True
566
+ return False
567
+
568
+ def on_key(self, event: events.Key) -> None:
569
+ if self._approval_future is None or self._approval_future.done():
570
+ return
571
+ # Direct y/n keys still work and submit immediately, matching prior
572
+ # behaviour. Left/right move the highlight between the two buttons
573
+ # without submitting; enter submits the highlighted button.
574
+ if event.key in ("y", "Y"):
575
+ self._approval_future.set_result(ApprovalResponse.APPROVE)
576
+ elif event.key in ("n", "N"):
577
+ self._approval_future.set_result(ApprovalResponse.DENY)
578
+ elif event.key in ("left", "right"):
579
+ self._approval_selection = (
580
+ ApprovalResponse.DENY
581
+ if self._approval_selection == ApprovalResponse.APPROVE
582
+ else ApprovalResponse.APPROVE
583
+ )
584
+ if self._approval_tool_id is not None:
585
+ chat = self.query_one("#chat-log", ChatLog)
586
+ chat.update_tool_approval_selection(
587
+ self._approval_tool_id, self._approval_selection
588
+ )
589
+ event.prevent_default()
590
+ event.stop()
591
+ return
592
+ elif event.key == "enter":
593
+ self._approval_future.set_result(self._approval_selection)
594
+ else:
595
+ return
596
+ event.prevent_default()
597
+ event.stop()
598
+ self._clear_approval_state()
599
+
600
+ # -------------------------------------------------------------------------
601
+ # Input submission
602
+ # -------------------------------------------------------------------------
603
+
604
+ @on(InputBox.Submitted)
605
+ def on_input_submitted(self, event: InputBox.Submitted) -> None:
606
+ display_text = event.text.strip()
607
+ if not display_text:
608
+ return
609
+
610
+ # Intercept API-key entry: the user is in the middle of /login <provider>
611
+ # and is typing the key. Route it to the auth command instead of the agent.
612
+ if self._selection_mode == SelectionMode.API_KEY:
613
+ self._submit_api_key(event.text)
614
+ return
615
+
616
+ if display_text.startswith("/") and self._handle_command(display_text):
617
+ return
618
+
619
+ # Handle shell commands (! and !!)
620
+ if display_text.startswith("!") or display_text.startswith("!!"):
621
+ self._handle_shell_command(display_text, event.text)
622
+ return
623
+
624
+ query_text = event.query_text.strip()
625
+
626
+ selected_skill_name = event.selected_skill_name
627
+ highlighted_skill: str | None = None
628
+ if selected_skill_name:
629
+ selected_skill = next(
630
+ (
631
+ skill
632
+ for skill in self._registered_slash_skills()
633
+ if skill.register_cmd and skill.name == selected_skill_name
634
+ ),
635
+ None,
636
+ )
637
+ if selected_skill:
638
+ skill_query = event.selected_skill_query or ""
639
+ display_text = self._build_skill_trigger_message(
640
+ selected_skill.name, selected_skill.description, skill_query
641
+ )
642
+ query_text = render_skill_prompt(selected_skill, skill_query)
643
+ highlighted_skill = selected_skill.name
644
+
645
+ if self._is_running:
646
+ if event.steer:
647
+ if len(self._steer_queue) >= QueueDisplay.MAX_QUEUE:
648
+ self.notify("Steer queue full (max 5)", severity="warning", timeout=2)
649
+ return
650
+ self._steer_queue.append((display_text, query_text))
651
+ if self._steer_event:
652
+ self._steer_event.set()
653
+ else:
654
+ if len(self._pending_queue) >= QueueDisplay.MAX_QUEUE:
655
+ self.notify("Queue full (max 5)", severity="warning", timeout=2)
656
+ return
657
+ self._pending_queue.append((display_text, query_text))
658
+ self._update_queue_display()
659
+ return
660
+
661
+ chat = self.query_one("#chat-log", ChatLog)
662
+ chat.add_user_message(display_text, highlighted_skill=highlighted_skill)
663
+
664
+ self._is_running = True
665
+ self.run_worker(self._run_agent(query_text), exclusive=True)
vtx/ui/app_protocol.py ADDED
@@ -0,0 +1,29 @@
1
+ from typing import Any, Protocol
2
+
3
+ from ..llm import BaseProvider
4
+ from ..session import Session
5
+ from .selection_mode import SelectionMode
6
+
7
+
8
+ class Vtx(Protocol):
9
+ """Protocol defining the interface expected by mixins."""
10
+
11
+ # App-level attributes
12
+ VERSION: str
13
+ _cwd: str
14
+ _model: str
15
+ _api_key: str | None
16
+ _base_url: str | None
17
+ _thinking_level: str
18
+ _hide_thinking: bool
19
+ _selection_mode: SelectionMode | None
20
+ _provider: BaseProvider | None
21
+ _session: Session | None
22
+ _agent: Any
23
+
24
+ # Methods expected by mixins
25
+ def exit(self) -> None: ...
26
+ def query_one(self, selector: str) -> object: ...
27
+ def notify(self, message: str, **kwargs: object) -> None: ...
28
+ def run_worker(self, coro: object, exclusive: bool) -> None: ...
29
+ def call_later(self, callback: object, message: str) -> None: ...