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