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
@@ -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
+ ]
@@ -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
@@ -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))