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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Slash-command handling for the Vtx app, split by domain:
|
|
2
|
+
|
|
3
|
+
- settings.py - /settings, /themes, /permissions, /thinking, /notifications
|
|
4
|
+
- models.py - /model
|
|
5
|
+
- sessions.py - /clear, /new, /resume, /tree, /session, /handoff, /compact, /export, /copy
|
|
6
|
+
- auth.py - /login, /logout
|
|
7
|
+
|
|
8
|
+
CommandsMixin composes the domain mixins and owns the command router.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..chat import ChatLog
|
|
14
|
+
from .auth import AuthCommands
|
|
15
|
+
from .base import CommandSupport
|
|
16
|
+
from .models import ModelCommands
|
|
17
|
+
from .sessions import SessionCommands
|
|
18
|
+
from .settings import SettingsCommands, SettingsSelectionResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandsMixin(SettingsCommands, ModelCommands, SessionCommands, AuthCommands):
|
|
22
|
+
def _handle_command(self, text: str) -> bool:
|
|
23
|
+
parts = text[1:].split(maxsplit=1)
|
|
24
|
+
cmd = parts[0] if parts else ""
|
|
25
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
26
|
+
|
|
27
|
+
if cmd in ("quit", "exit", "q"):
|
|
28
|
+
self.exit()
|
|
29
|
+
return True
|
|
30
|
+
if cmd == "help":
|
|
31
|
+
self._show_help()
|
|
32
|
+
return True
|
|
33
|
+
if cmd == "clear":
|
|
34
|
+
self._clear_conversation()
|
|
35
|
+
return True
|
|
36
|
+
if cmd == "model":
|
|
37
|
+
self._handle_model_command(args)
|
|
38
|
+
return True
|
|
39
|
+
if cmd == "new":
|
|
40
|
+
self._new_conversation()
|
|
41
|
+
return True
|
|
42
|
+
if cmd == "settings":
|
|
43
|
+
self._handle_settings_command()
|
|
44
|
+
return True
|
|
45
|
+
if cmd == "themes":
|
|
46
|
+
self._handle_themes_command(args)
|
|
47
|
+
return True
|
|
48
|
+
if cmd == "permissions":
|
|
49
|
+
self._handle_permissions_command(args)
|
|
50
|
+
return True
|
|
51
|
+
if cmd == "thinking":
|
|
52
|
+
self._handle_thinking_command(args)
|
|
53
|
+
return True
|
|
54
|
+
if cmd == "notifications":
|
|
55
|
+
self._handle_notifications_command(args)
|
|
56
|
+
return True
|
|
57
|
+
if cmd == "handoff":
|
|
58
|
+
self._handle_handoff_command(args)
|
|
59
|
+
return True
|
|
60
|
+
if cmd == "resume":
|
|
61
|
+
self._show_resume_sessions()
|
|
62
|
+
return True
|
|
63
|
+
if cmd == "tree":
|
|
64
|
+
self._show_tree_selector()
|
|
65
|
+
return True
|
|
66
|
+
if cmd == "session":
|
|
67
|
+
self._show_session_info()
|
|
68
|
+
return True
|
|
69
|
+
if cmd == "login":
|
|
70
|
+
self._handle_login_command(args)
|
|
71
|
+
return True
|
|
72
|
+
if cmd == "logout":
|
|
73
|
+
self._handle_logout_command(args)
|
|
74
|
+
return True
|
|
75
|
+
if cmd == "export":
|
|
76
|
+
self._handle_export_command()
|
|
77
|
+
return True
|
|
78
|
+
if cmd == "copy":
|
|
79
|
+
self._handle_copy_command()
|
|
80
|
+
return True
|
|
81
|
+
if cmd == "compact":
|
|
82
|
+
self._handle_compact_command()
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def _show_help(self) -> None:
|
|
88
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
89
|
+
chat.add_help_details()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"AuthCommands",
|
|
94
|
+
"CommandSupport",
|
|
95
|
+
"CommandsMixin",
|
|
96
|
+
"ModelCommands",
|
|
97
|
+
"SessionCommands",
|
|
98
|
+
"SettingsCommands",
|
|
99
|
+
"SettingsSelectionResult",
|
|
100
|
+
]
|
vtx/ui/commands/auth.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""/login and /logout commands - provider authentication flows.
|
|
2
|
+
|
|
3
|
+
There are two kinds of "logins" in vtx:
|
|
4
|
+
|
|
5
|
+
- **OAuth flows** for GitHub Copilot and OpenAI Codex, where the user is sent
|
|
6
|
+
to a browser to authorize and we store long-lived tokens.
|
|
7
|
+
- **API-key entries** for every provider in ``src/vtx/llm/provider.yaml``.
|
|
8
|
+
These don't need OAuth; the user pastes an API key and we store it in
|
|
9
|
+
``~/.vtx/dynamic_auth.json`` (mode 0600).
|
|
10
|
+
|
|
11
|
+
Both kinds show up together in the ``/login`` picker so the user has a single
|
|
12
|
+
place to manage credentials. Adding a new provider to ``provider.yaml``
|
|
13
|
+
automatically makes it appear in the picker.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ...llm import (
|
|
19
|
+
clear_api_key,
|
|
20
|
+
clear_copilot_credentials,
|
|
21
|
+
clear_openai_credentials,
|
|
22
|
+
copilot_login,
|
|
23
|
+
get_copilot_token,
|
|
24
|
+
get_dynamic_api_key,
|
|
25
|
+
get_provider_info,
|
|
26
|
+
get_provider_status,
|
|
27
|
+
get_valid_openai_credentials,
|
|
28
|
+
list_providers,
|
|
29
|
+
openai_login,
|
|
30
|
+
save_api_key,
|
|
31
|
+
)
|
|
32
|
+
from ...llm import is_copilot_logged_in as has_saved_copilot_credentials
|
|
33
|
+
from ...llm import is_openai_logged_in as has_saved_openai_credentials
|
|
34
|
+
from ..chat import ChatLog
|
|
35
|
+
from ..floating_list import ListItem
|
|
36
|
+
from ..input import InputBox
|
|
37
|
+
from ..selection_mode import SelectionMode
|
|
38
|
+
from .base import CommandSupport
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _status_label(provider: str) -> str:
|
|
42
|
+
"""Build the description shown next to a provider in the picker."""
|
|
43
|
+
status = get_provider_status(provider)
|
|
44
|
+
if status is None:
|
|
45
|
+
return ""
|
|
46
|
+
if status.has_env_key:
|
|
47
|
+
return f"{status.env_var or 'env'} set"
|
|
48
|
+
if status.has_stored_key:
|
|
49
|
+
return "key stored"
|
|
50
|
+
if status.api_key_optional:
|
|
51
|
+
return "no key needed"
|
|
52
|
+
return "key required"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthCommands(CommandSupport):
|
|
56
|
+
def _handle_login_command(self, args: str) -> None:
|
|
57
|
+
providers: list[tuple[str, str, bool, str]] = [
|
|
58
|
+
("github-copilot", "GitHub Copilot", has_saved_copilot_credentials(), "oauth"),
|
|
59
|
+
("openai", "OpenAI (ChatGPT/Codex)", has_saved_openai_credentials(), "oauth"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Every provider in provider.yaml gets a key-based entry.
|
|
63
|
+
for p in list_providers():
|
|
64
|
+
providers.append((p.slug, p.display_name, False, "key"))
|
|
65
|
+
|
|
66
|
+
items: list[ListItem] = []
|
|
67
|
+
for provider_id, name, has_oauth, kind in providers:
|
|
68
|
+
if kind == "oauth":
|
|
69
|
+
description = "saved credentials" if has_oauth else "oauth login"
|
|
70
|
+
else:
|
|
71
|
+
description = _status_label(provider_id)
|
|
72
|
+
items.append(ListItem(value=provider_id, label=name, description=description))
|
|
73
|
+
|
|
74
|
+
self._show_selection_picker(items, SelectionMode.LOGIN)
|
|
75
|
+
|
|
76
|
+
def _select_login_provider(self, provider_id: str) -> None:
|
|
77
|
+
if provider_id == "github-copilot":
|
|
78
|
+
self.run_worker(self._copilot_login_flow(), exclusive=False)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if provider_id == "openai":
|
|
82
|
+
self.run_worker(self._openai_login_flow(), exclusive=False)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if get_provider_info(provider_id) is not None:
|
|
86
|
+
self._prompt_for_api_key(provider_id)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
def _prompt_for_api_key(self, provider_id: str) -> None:
|
|
90
|
+
"""Show a single-line input that captures the API key and stores it."""
|
|
91
|
+
status = get_provider_status(provider_id)
|
|
92
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
93
|
+
|
|
94
|
+
if status is None:
|
|
95
|
+
chat.add_info_message(f"Unknown provider: {provider_id}", error=True)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
env_var = status.env_var
|
|
99
|
+
prompt_text = f"Enter API key for {provider_id}"
|
|
100
|
+
if env_var:
|
|
101
|
+
prompt_text += f" (or set {env_var})"
|
|
102
|
+
prompt_text += ":"
|
|
103
|
+
|
|
104
|
+
existing = get_dynamic_api_key(provider_id)
|
|
105
|
+
if existing:
|
|
106
|
+
# Already configured - allow update or clear.
|
|
107
|
+
self._show_api_key_actions(provider_id)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
chat.add_info_message(f"Provider {provider_id} needs an API key. {prompt_text}")
|
|
111
|
+
|
|
112
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
113
|
+
input_box.set_placeholder(f"Paste {provider_id} API key (or /cancel)")
|
|
114
|
+
self._selection_mode = SelectionMode.API_KEY
|
|
115
|
+
self._pending_api_key_provider = provider_id
|
|
116
|
+
input_box.focus()
|
|
117
|
+
|
|
118
|
+
def _show_api_key_actions(self, provider_id: str) -> None:
|
|
119
|
+
"""For providers with a stored key, offer replace/clear."""
|
|
120
|
+
items = [
|
|
121
|
+
ListItem(value="update", label="Update key", description="paste a new API key"),
|
|
122
|
+
ListItem(value="clear", label="Clear key", description="remove stored credentials"),
|
|
123
|
+
ListItem(value="cancel", label="Cancel", description="keep current key"),
|
|
124
|
+
]
|
|
125
|
+
self._pending_api_key_provider = provider_id
|
|
126
|
+
self._show_selection_picker(items, SelectionMode.API_KEY_ACTION)
|
|
127
|
+
|
|
128
|
+
def _select_api_key_action(self, action: str) -> None:
|
|
129
|
+
provider_id = getattr(self, "_pending_api_key_provider", None)
|
|
130
|
+
if not provider_id:
|
|
131
|
+
return
|
|
132
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
133
|
+
|
|
134
|
+
if action == "clear":
|
|
135
|
+
removed = clear_api_key(provider_id)
|
|
136
|
+
if removed:
|
|
137
|
+
chat.add_info_message(f"Cleared stored API key for {provider_id}")
|
|
138
|
+
else:
|
|
139
|
+
chat.add_info_message(f"No stored API key for {provider_id}")
|
|
140
|
+
self._pending_api_key_provider = None
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if action == "cancel":
|
|
144
|
+
chat.add_info_message(f"Kept existing API key for {provider_id}")
|
|
145
|
+
self._pending_api_key_provider = None
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if action == "update":
|
|
149
|
+
self._pending_api_key_provider = provider_id
|
|
150
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
151
|
+
input_box.set_placeholder(f"Paste new {provider_id} API key (or /cancel)")
|
|
152
|
+
self._selection_mode = SelectionMode.API_KEY
|
|
153
|
+
input_box.focus()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
def _submit_api_key(self, raw: str) -> None:
|
|
157
|
+
"""Called by the input layer when the user submits a key in API_KEY mode."""
|
|
158
|
+
from ..input import InputBox
|
|
159
|
+
|
|
160
|
+
provider_id = getattr(self, "_pending_api_key_provider", None)
|
|
161
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
162
|
+
# Reset state immediately so a later error doesn't leave us stuck.
|
|
163
|
+
self._pending_api_key_provider = None
|
|
164
|
+
self._selection_mode = None
|
|
165
|
+
|
|
166
|
+
# Restore the input box to its normal state.
|
|
167
|
+
try:
|
|
168
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
169
|
+
input_box.set_placeholder("")
|
|
170
|
+
input_box.set_autocomplete_enabled(True)
|
|
171
|
+
input_box.clear()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
key = raw.strip()
|
|
176
|
+
if not key or key.startswith("/"):
|
|
177
|
+
chat.add_info_message("API key entry cancelled")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if not provider_id:
|
|
181
|
+
chat.add_info_message("No provider selected for API key", error=True)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
save_api_key(provider_id, key)
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
chat.add_info_message(str(exc), error=True)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
chat.add_info_message(
|
|
191
|
+
f"Saved API key for {provider_id} to ~/.vtx/dynamic_auth.json. "
|
|
192
|
+
f"Run `/model refresh {provider_id}` to fetch its model list, "
|
|
193
|
+
"then `/model` to pick one."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def _copilot_login_flow(self) -> None:
|
|
197
|
+
import webbrowser
|
|
198
|
+
|
|
199
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
200
|
+
had_saved_credentials = has_saved_copilot_credentials()
|
|
201
|
+
|
|
202
|
+
def on_user_code(url: str, code: str) -> None:
|
|
203
|
+
webbrowser.open(url)
|
|
204
|
+
self.call_later(
|
|
205
|
+
chat.add_info_message,
|
|
206
|
+
f"Opening browser to: {url}\n"
|
|
207
|
+
f"Enter this code: {code}\n\n"
|
|
208
|
+
"Waiting for authorization...",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
if await get_copilot_token():
|
|
213
|
+
chat.add_info_message("Already logged in to GitHub Copilot")
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
if had_saved_credentials:
|
|
217
|
+
chat.add_info_message(
|
|
218
|
+
"Your saved GitHub Copilot session is no longer valid.", warning=True
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
chat.add_info_message("Starting GitHub Copilot login...")
|
|
222
|
+
|
|
223
|
+
await copilot_login(on_user_code=on_user_code)
|
|
224
|
+
chat.add_info_message(
|
|
225
|
+
"Successfully logged in to GitHub Copilot!\n"
|
|
226
|
+
"You can now use /model to select Copilot models."
|
|
227
|
+
)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
chat.add_info_message(f"Login failed: {e}", error=True)
|
|
230
|
+
|
|
231
|
+
async def _openai_login_flow(self) -> None:
|
|
232
|
+
import webbrowser
|
|
233
|
+
|
|
234
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
235
|
+
had_saved_credentials = has_saved_openai_credentials()
|
|
236
|
+
|
|
237
|
+
def on_auth_url(url: str) -> None:
|
|
238
|
+
webbrowser.open(url)
|
|
239
|
+
self.call_later(
|
|
240
|
+
chat.add_info_message,
|
|
241
|
+
"Opening browser for OpenAI OAuth...\n"
|
|
242
|
+
f"If browser does not open, visit:\n{url}\n\n"
|
|
243
|
+
"Waiting for authorization callback on http://localhost:1455/auth/callback ...",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
if await get_valid_openai_credentials():
|
|
248
|
+
chat.add_info_message("Already logged in to OpenAI")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if had_saved_credentials:
|
|
252
|
+
chat.add_info_message(
|
|
253
|
+
"Your saved OpenAI session is no longer valid.", warning=True
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
chat.add_info_message("Starting OpenAI login...")
|
|
257
|
+
|
|
258
|
+
await openai_login(on_auth_url=on_auth_url)
|
|
259
|
+
chat.add_info_message(
|
|
260
|
+
"Successfully logged in to OpenAI!\n"
|
|
261
|
+
"You can now use /model to select openai-codex models."
|
|
262
|
+
)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
chat.add_info_message(f"Login failed: {e}", error=True)
|
|
265
|
+
|
|
266
|
+
def _handle_logout_command(self, args: str) -> None:
|
|
267
|
+
items: list[ListItem] = []
|
|
268
|
+
|
|
269
|
+
if has_saved_copilot_credentials():
|
|
270
|
+
items.append(ListItem(value="github-copilot", label="GitHub Copilot", description=""))
|
|
271
|
+
if has_saved_openai_credentials():
|
|
272
|
+
items.append(ListItem(value="openai", label="OpenAI (ChatGPT/Codex)", description=""))
|
|
273
|
+
|
|
274
|
+
for p in list_providers():
|
|
275
|
+
status = get_provider_status(p.slug)
|
|
276
|
+
if status and status.has_stored_key:
|
|
277
|
+
items.append(
|
|
278
|
+
ListItem(value=p.slug, label=p.display_name, description="key stored")
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if not items:
|
|
282
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
283
|
+
chat.add_info_message("No providers logged in")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
self._show_selection_picker(items, SelectionMode.LOGOUT)
|
|
287
|
+
|
|
288
|
+
def _select_logout_provider(self, provider_id: str) -> None:
|
|
289
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
290
|
+
|
|
291
|
+
if provider_id == "github-copilot":
|
|
292
|
+
clear_copilot_credentials()
|
|
293
|
+
chat.add_info_message("Logged out of GitHub Copilot")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
if provider_id == "openai":
|
|
297
|
+
clear_openai_credentials()
|
|
298
|
+
chat.add_info_message("Logged out of OpenAI")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
if get_provider_info(provider_id) is not None:
|
|
302
|
+
if clear_api_key(provider_id):
|
|
303
|
+
chat.add_info_message(f"Cleared stored API key for {provider_id}")
|
|
304
|
+
else:
|
|
305
|
+
chat.add_info_message(f"No stored API key for {provider_id}")
|
|
306
|
+
return
|
vtx/ui/commands/base.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Shared plumbing for the command mixins: expected app surface and picker helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
7
|
+
|
|
8
|
+
from ...runtime import ConversationRuntime
|
|
9
|
+
from ...session import Session
|
|
10
|
+
from ..chat import ChatLog
|
|
11
|
+
from ..floating_list import ListItem
|
|
12
|
+
from ..input import InputBox
|
|
13
|
+
from ..selection_mode import SelectionMode
|
|
14
|
+
|
|
15
|
+
Choice = TypeVar("Choice", bound=str)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandSupport:
|
|
19
|
+
"""Attributes and helpers every command mixin can rely on."""
|
|
20
|
+
|
|
21
|
+
_cwd: str
|
|
22
|
+
_api_key: str | None
|
|
23
|
+
_agent: Any
|
|
24
|
+
_is_running: bool
|
|
25
|
+
_selection_mode: Any
|
|
26
|
+
_tools: list
|
|
27
|
+
_openai_compat_auth_mode: Any
|
|
28
|
+
_anthropic_compat_auth_mode: Any
|
|
29
|
+
_runtime: ConversationRuntime
|
|
30
|
+
|
|
31
|
+
# Methods from App - declared for type checking
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
exit: Any
|
|
34
|
+
notify: Any
|
|
35
|
+
query_one: Any
|
|
36
|
+
run_worker: Any
|
|
37
|
+
call_later: Any
|
|
38
|
+
batch_update: Any
|
|
39
|
+
_settings_active: bool
|
|
40
|
+
_settings_selected_value: str | None
|
|
41
|
+
|
|
42
|
+
# Methods from other mixins/main class
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
|
|
45
|
+
def _sync_runtime_state(self) -> None: ...
|
|
46
|
+
def _sync_slash_commands(self) -> None: ...
|
|
47
|
+
def _render_session_entries(self, session: Session) -> None: ...
|
|
48
|
+
def _apply_theme(self, theme_id: str) -> None: ...
|
|
49
|
+
def _apply_thinking_level_style(self, level: str) -> None: ...
|
|
50
|
+
def _show_completion_list(
|
|
51
|
+
self,
|
|
52
|
+
items: list[ListItem],
|
|
53
|
+
*,
|
|
54
|
+
searchable: bool = False,
|
|
55
|
+
max_label_width: int | None = None,
|
|
56
|
+
) -> None: ...
|
|
57
|
+
def _hide_completion_list(self, *, restore_info_bar: bool = True) -> None: ...
|
|
58
|
+
def _is_chat_at_bottom(self) -> bool: ...
|
|
59
|
+
def _restore_chat_scroll_after_refresh(self, was_at_bottom: bool) -> None: ...
|
|
60
|
+
|
|
61
|
+
def _show_selection_picker(
|
|
62
|
+
self,
|
|
63
|
+
items: list[ListItem],
|
|
64
|
+
selection_mode: SelectionMode,
|
|
65
|
+
*,
|
|
66
|
+
searchable: bool = True,
|
|
67
|
+
max_label_width: int | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
70
|
+
was_at_bottom = self._is_chat_at_bottom()
|
|
71
|
+
|
|
72
|
+
with self.batch_update():
|
|
73
|
+
self._show_completion_list(
|
|
74
|
+
items, searchable=searchable, max_label_width=max_label_width
|
|
75
|
+
)
|
|
76
|
+
input_box.clear()
|
|
77
|
+
input_box.set_autocomplete_enabled(False)
|
|
78
|
+
input_box.set_completing(True)
|
|
79
|
+
input_box.focus()
|
|
80
|
+
|
|
81
|
+
self._selection_mode = selection_mode
|
|
82
|
+
self._restore_chat_scroll_after_refresh(was_at_bottom)
|
|
83
|
+
|
|
84
|
+
def _build_choice_items(
|
|
85
|
+
self, choices: Sequence[Choice], current: Choice, descriptions: Mapping[Choice, str]
|
|
86
|
+
) -> list[ListItem[Choice]]:
|
|
87
|
+
return [
|
|
88
|
+
ListItem(
|
|
89
|
+
value=choice,
|
|
90
|
+
label=f"{choice} ✓" if choice == current else choice,
|
|
91
|
+
description=descriptions[choice],
|
|
92
|
+
)
|
|
93
|
+
for choice in choices
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def _handle_choice_command(
|
|
97
|
+
self,
|
|
98
|
+
args: str,
|
|
99
|
+
*,
|
|
100
|
+
name: str,
|
|
101
|
+
choices: Sequence[Choice],
|
|
102
|
+
current: Choice,
|
|
103
|
+
descriptions: Mapping[Choice, str],
|
|
104
|
+
selection_mode: SelectionMode,
|
|
105
|
+
select: Callable[[Choice], None],
|
|
106
|
+
) -> None:
|
|
107
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
108
|
+
|
|
109
|
+
requested = args.strip()
|
|
110
|
+
if requested:
|
|
111
|
+
if requested in choices:
|
|
112
|
+
select(cast(Choice, requested))
|
|
113
|
+
else:
|
|
114
|
+
valid = ", ".join(choices)
|
|
115
|
+
chat.add_info_message(
|
|
116
|
+
f"Invalid {name} mode: {requested}. Use one of: {valid}", error=True
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
self._show_selection_picker(
|
|
121
|
+
self._build_choice_items(choices, current, descriptions), selection_mode
|
|
122
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""/model command - listing and switching models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from ...config import get_config
|
|
8
|
+
from ...llm import (
|
|
9
|
+
DYNAMIC_PROVIDERS,
|
|
10
|
+
Model,
|
|
11
|
+
get_all_models,
|
|
12
|
+
get_dynamic_provider,
|
|
13
|
+
refresh_all_providers,
|
|
14
|
+
refresh_provider,
|
|
15
|
+
)
|
|
16
|
+
from ..chat import ChatLog
|
|
17
|
+
from ..floating_list import ListItem
|
|
18
|
+
from ..selection_mode import SelectionMode
|
|
19
|
+
from ..widgets import InfoBar
|
|
20
|
+
from .base import CommandSupport
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_hidden_entries(entries: list[str]) -> tuple[set[str], set[tuple[str, str]]]:
|
|
24
|
+
"""Split hidden-model entries into provider names and (provider, model) combos."""
|
|
25
|
+
hidden_providers: set[str] = set()
|
|
26
|
+
hidden_combos: set[tuple[str, str]] = set()
|
|
27
|
+
for entry in entries:
|
|
28
|
+
if ":" in entry:
|
|
29
|
+
provider, _, model_id = entry.partition(":")
|
|
30
|
+
provider, model_id = provider.strip(), model_id.strip()
|
|
31
|
+
if provider and model_id:
|
|
32
|
+
hidden_combos.add((provider, model_id))
|
|
33
|
+
else:
|
|
34
|
+
entry = entry.strip()
|
|
35
|
+
if entry:
|
|
36
|
+
hidden_providers.add(entry)
|
|
37
|
+
return hidden_providers, hidden_combos
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_model_hidden(
|
|
41
|
+
model: Model, hidden_providers: set[str], hidden_combos: set[tuple[str, str]]
|
|
42
|
+
) -> bool:
|
|
43
|
+
return model.provider in hidden_providers or (model.provider, model.id) in hidden_combos
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModelCommands(CommandSupport):
|
|
47
|
+
def _handle_model_command(self, args: str) -> None:
|
|
48
|
+
stripped = args.strip()
|
|
49
|
+
if stripped:
|
|
50
|
+
if stripped == "refresh":
|
|
51
|
+
self.run_worker(self._refresh_dynamic_models(None), exclusive=False)
|
|
52
|
+
return
|
|
53
|
+
if stripped.startswith("refresh "):
|
|
54
|
+
provider = stripped[len("refresh ") :].strip()
|
|
55
|
+
self.run_worker(self._refresh_dynamic_models(provider or None), exclusive=False)
|
|
56
|
+
return
|
|
57
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
58
|
+
chat.add_info_message(
|
|
59
|
+
"Unknown /model sub-command. Use: /model, /model refresh, "
|
|
60
|
+
"/model refresh <provider>",
|
|
61
|
+
error=True,
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
hidden_providers, hidden_combos = _parse_hidden_entries(get_config().ui.hidden_models)
|
|
66
|
+
models = get_all_models()
|
|
67
|
+
# Filter out hidden models, but always keep the currently active model
|
|
68
|
+
# so its selection state is visible in the picker.
|
|
69
|
+
if hidden_providers or hidden_combos:
|
|
70
|
+
models = [
|
|
71
|
+
m
|
|
72
|
+
for m in models
|
|
73
|
+
if not _is_model_hidden(m, hidden_providers, hidden_combos)
|
|
74
|
+
or (m.id == self._runtime.model and m.provider == self._runtime.model_provider)
|
|
75
|
+
]
|
|
76
|
+
if not models:
|
|
77
|
+
self.notify("No models configured", title="Models", timeout=3, severity="warning")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
models.sort(key=lambda m: (m.provider, m.id))
|
|
81
|
+
|
|
82
|
+
items: list[ListItem] = []
|
|
83
|
+
for m in models:
|
|
84
|
+
parts = [m.provider]
|
|
85
|
+
if not m.supports_images:
|
|
86
|
+
parts.append("[no-vision]")
|
|
87
|
+
caption = " ".join(parts)
|
|
88
|
+
label = (
|
|
89
|
+
f"{m.id} ✓"
|
|
90
|
+
if m.id == self._runtime.model and m.provider == self._runtime.model_provider
|
|
91
|
+
else m.id
|
|
92
|
+
)
|
|
93
|
+
items.append(ListItem(value=m, label=label, description=caption))
|
|
94
|
+
|
|
95
|
+
self._show_selection_picker(items, SelectionMode.MODEL)
|
|
96
|
+
|
|
97
|
+
def _select_model(self, model) -> None:
|
|
98
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
99
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
self._runtime.switch_model(model)
|
|
103
|
+
except ValueError as e:
|
|
104
|
+
chat.add_info_message(str(e), error=True)
|
|
105
|
+
return
|
|
106
|
+
self._sync_runtime_state()
|
|
107
|
+
|
|
108
|
+
info_bar.set_model(model.id, model.provider)
|
|
109
|
+
|
|
110
|
+
chat.add_info_message(f"Model changed to {model.id} ({model.provider})")
|
|
111
|
+
|
|
112
|
+
async def _refresh_dynamic_models(self, provider: str | None) -> None:
|
|
113
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
114
|
+
|
|
115
|
+
if provider is not None and get_dynamic_provider(provider) is None:
|
|
116
|
+
valid = ", ".join(sorted(DYNAMIC_PROVIDERS))
|
|
117
|
+
chat.add_info_message(
|
|
118
|
+
f"Unknown provider: {provider}. Dynamic providers: {valid}", error=True
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
chat.add_info_message(f"Refreshing {provider if provider else 'all dynamic providers'}...")
|
|
123
|
+
|
|
124
|
+
def _run() -> dict[str, int | str]:
|
|
125
|
+
if provider is not None:
|
|
126
|
+
try:
|
|
127
|
+
count = refresh_provider(provider)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
return {provider: -1, "_error": str(exc)}
|
|
130
|
+
return {provider: count}
|
|
131
|
+
return dict[str, int | str](refresh_all_providers())
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
result = await asyncio.to_thread(_run)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
chat.add_info_message(f"Refresh failed: {exc}", error=True)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
error = result.pop("_error", None)
|
|
140
|
+
if error:
|
|
141
|
+
chat.add_info_message(f"Refresh failed: {error}", error=True)
|
|
142
|
+
return
|
|
143
|
+
lines = [f" {name}: {count} models" for name, count in result.items()]
|
|
144
|
+
chat.add_info_message("Refresh complete:\n" + "\n".join(lines))
|