zjcode 0.0.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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2343 @@
1
+ """Interactive model selector screen for `/model` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
8
+
9
+ from textual.binding import Binding, BindingType
10
+ from textual.containers import Container, Vertical, VerticalScroll
11
+ from textual.content import Content
12
+ from textual.css.query import NoMatches
13
+ from textual.events import (
14
+ Click, # noqa: TC002 - needed at runtime for Textual event dispatch
15
+ )
16
+ from textual.fuzzy import Matcher
17
+ from textual.message import Message
18
+ from textual.screen import ModalScreen
19
+ from textual.widgets import Button, Input, Static
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable, Mapping
23
+
24
+ from textual.app import ComposeResult
25
+
26
+ from deepagents_code import theme
27
+ from deepagents_code.auth_display import format_auth_indicator
28
+ from deepagents_code.config import Glyphs, get_glyphs, is_ascii_mode
29
+ from deepagents_code.model_config import (
30
+ CODEX_PROVIDER,
31
+ ModelConfig,
32
+ ModelProfileEntry,
33
+ ModelSpec,
34
+ ProviderAuthState,
35
+ ProviderAuthStatus,
36
+ clear_default_model,
37
+ get_available_models,
38
+ get_credential_env_var,
39
+ get_model_profiles,
40
+ get_provider_auth_status,
41
+ load_recent_models,
42
+ save_default_model,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ _MODEL_LIST_MAX_HEIGHT = 16
48
+ """Upper bound (in cells) for the model selector list.
49
+
50
+ Keep in sync with the `max-height: 16` in the `.model-list` CSS below; Textual
51
+ CSS cannot reference Python constants, so the static cap and the runtime
52
+ `_fit_model_list` clamp must agree.
53
+ """
54
+
55
+ _MODEL_LIST_MIN_HEIGHT = 1
56
+ """Floor (in cells) so the model selector list never collapses to zero."""
57
+
58
+ _MAX_MODEL_ID_LEN = 255
59
+ """Maximum length allowed for a custom provider's default model ID."""
60
+
61
+ _RECENT_SECTION_LABEL = "Recent"
62
+ """Header label for the MRU pseudo-provider section pinned at the top of `/model`.
63
+
64
+ Recent picks are surfaced regardless of the recommended-only toggle —
65
+ they're a personal signal that outweighs curation — and de-duplicated
66
+ from the per-provider sections below.
67
+ """
68
+
69
+
70
+ _RECOMMENDED_MODELS: dict[str, str] = {
71
+ "anthropic:claude-opus-4-7": "Claude Opus 4.7",
72
+ "anthropic:claude-opus-4-8": "Claude Opus 4.8",
73
+ "anthropic:claude-sonnet-5": "Claude Sonnet 5",
74
+ "baseten:deepseek-ai/DeepSeek-V4-Pro": "DeepSeek V4 Pro",
75
+ "baseten:moonshotai/Kimi-K2.7-Code": "Kimi K2.7 Code",
76
+ "baseten:nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": "Nemotron 3 Ultra 550B A55B",
77
+ "baseten:zai-org/GLM-5.2": "GLM 5.2",
78
+ "fireworks:accounts/fireworks/models/deepseek-v4-pro": "DeepSeek V4 Pro",
79
+ "fireworks:accounts/fireworks/models/glm-5p2": "GLM 5.2",
80
+ "fireworks:accounts/fireworks/models/kimi-k2p7-code": "Kimi K2.7 Code",
81
+ "fireworks:accounts/fireworks/models/minimax-m3": "MiniMax-M3",
82
+ "fireworks:accounts/fireworks/models/qwen3p7-plus": "Qwen 3.7 Plus",
83
+ "google_genai:gemini-3.5-flash": "Gemini 3.5 Flash",
84
+ "google_genai:gemini-3.1-pro-preview": "Gemini 3.1 Pro Preview",
85
+ "meta:muse-spark-1.1": "Muse Spark 1.1",
86
+ "ollama:deepseek-v4-flash:cloud": "DeepSeek V4 Flash",
87
+ "ollama:deepseek-v4-pro:cloud": "DeepSeek V4 Pro",
88
+ "ollama:glm-5.2:cloud": "GLM 5.2",
89
+ "ollama:kimi-k2.7-code:cloud": "Kimi K2.7 Code",
90
+ "ollama:minimax-m3:cloud": "MiniMax-M3",
91
+ "openai:gpt-5.4": "GPT-5.4",
92
+ "openai:gpt-5.4-mini": "GPT-5.4 mini",
93
+ "openai:gpt-5.4-pro": "GPT-5.4 Pro",
94
+ "openai:gpt-5.5": "GPT-5.5",
95
+ "openai:gpt-5.5-pro": "GPT-5.5 Pro",
96
+ "openai:gpt-5.6-luna": "GPT-5.6 Luna",
97
+ "openai:gpt-5.6-sol": "GPT-5.6 Sol",
98
+ "openai:gpt-5.6-terra": "GPT-5.6 Terra",
99
+ "openai_codex:gpt-5.2": "GPT-5.2",
100
+ "openai_codex:gpt-5.3-codex": "GPT-5.3 Codex",
101
+ "openai_codex:gpt-5.4": "GPT-5.4",
102
+ "openai_codex:gpt-5.4-mini": "GPT-5.4 mini",
103
+ "openai_codex:gpt-5.5": "GPT-5.5",
104
+ "openai_codex:gpt-5.6-luna": "GPT-5.6 Luna",
105
+ "openai_codex:gpt-5.6-sol": "GPT-5.6 Sol",
106
+ "openai_codex:gpt-5.6-terra": "GPT-5.6 Terra",
107
+ "openrouter:anthropic/claude-opus-4.6": "Claude Opus 4.6",
108
+ "openrouter:anthropic/claude-opus-4.7": "Claude Opus 4.7",
109
+ "openrouter:anthropic/claude-opus-4.7-fast": "Claude Opus 4.7 Fast",
110
+ "openrouter:anthropic/claude-opus-4.8": "Claude Opus 4.8",
111
+ "openrouter:anthropic/claude-sonnet-5": "Claude Sonnet 5",
112
+ "openrouter:deepseek/deepseek-v4-flash": "DeepSeek V4 Flash",
113
+ "openrouter:deepseek/deepseek-v4-flash:free": "DeepSeek V4 Flash (free)",
114
+ "openrouter:deepseek/deepseek-v4-pro": "DeepSeek V4 Pro",
115
+ "openrouter:google/gemini-3.5-flash": "Gemini 3.5 Flash",
116
+ "openrouter:google/gemini-3.1-pro-preview": "Gemini 3.1 Pro Preview",
117
+ "openrouter:moonshotai/kimi-k2.7-code": "Kimi K2.7 Code",
118
+ "openrouter:nvidia/nemotron-3-ultra-550b-a55b": "Nemotron 3 Ultra 550B A55B",
119
+ "openrouter:openai/gpt-5.4": "GPT-5.4",
120
+ "openrouter:openai/gpt-5.4-mini": "GPT-5.4 mini",
121
+ "openrouter:openai/gpt-5.4-pro": "GPT-5.4 Pro",
122
+ "openrouter:openai/gpt-5.5": "GPT-5.5",
123
+ "openrouter:openai/gpt-5.5-pro": "GPT-5.5 Pro",
124
+ "openrouter:openrouter/fusion": "OpenRouter Fusion",
125
+ "openrouter:qwen/qwen3.7-plus": "Qwen 3.7 Plus",
126
+ "openrouter:z-ai/glm-5.2": "GLM 5.2",
127
+ "xai:grok-4.5": "Grok 4.5",
128
+ }
129
+ """Hand-curated frontier-tier models promoted across the UI, mapped to a
130
+ human-readable display name.
131
+
132
+ Used by the onboarding picker (`curated=True`) and by the in-`/model`
133
+ "Recommended only" toggle (Ctrl+R). Membership tests and iteration operate on
134
+ the spec keys; the names are a display fallback for `_get_model_display_name`
135
+ when a provider package (and thus its profile `name`) is not installed — the
136
+ common case for uninstalled recommendations and onboarding, where the raw
137
+ model id (e.g. `accounts/fireworks/models/kimi-k2p7-code`) would otherwise
138
+ show. When a profile is available its upstream `name` wins, so these stay a
139
+ safety net rather than a second source of truth.
140
+
141
+ Same model IDs may appear under multiple providers (e.g. Kimi K2.7 Code via
142
+ `baseten`, `fireworks`, `ollama`, and `openrouter`) and are listed under each
143
+ provider intentionally so the user can pick whichever provider they have
144
+ credentials for.
145
+ """
146
+
147
+
148
+ class _ModelData(NamedTuple):
149
+ """Model discovery data returned by `ModelSelectorScreen._load_model_data`.
150
+
151
+ Attributes:
152
+ all_models: `(provider:model spec, provider)` pairs for every model to
153
+ surface, including install-required recommended models.
154
+ default_spec: The configured default model spec, or `None`.
155
+ profiles: Spec string to profile entry mapping.
156
+ recent_specs: Most-recent-first `provider:model` specs read from
157
+ `~/.deepagents/.state/recent_models.json`.
158
+ install_extras: Each surfaced-but-uninstalled provider mapped to the
159
+ extra that installs it.
160
+ """
161
+
162
+ all_models: list[tuple[str, str]]
163
+ default_spec: str | None
164
+ profiles: Mapping[str, ModelProfileEntry]
165
+ recent_specs: list[str]
166
+ install_extras: dict[str, str]
167
+
168
+
169
+ class ModelOption(Static):
170
+ """A clickable model option in the selector."""
171
+
172
+ def __init__(
173
+ self,
174
+ label: str | Content,
175
+ model_spec: str,
176
+ provider: str,
177
+ index: int,
178
+ *,
179
+ auth_status: ProviderAuthStatus | None = None,
180
+ classes: str = "",
181
+ show_provider: bool = True,
182
+ ) -> None:
183
+ """Initialize a model option.
184
+
185
+ Args:
186
+ label: Display content — a `Content` object (preferred) or a
187
+ plain string that `Static` will parse as markup.
188
+ model_spec: The model specification (provider:model format).
189
+ provider: The provider name.
190
+ index: The index of this option in the filtered list.
191
+ auth_status: Provider auth/readiness status.
192
+ classes: CSS classes for styling.
193
+ show_provider: Whether the row appends a dim `(provider)` tag after
194
+ the model name. `True` for the cross-provider "Recent" section,
195
+ which has no provider header to disambiguate the same model
196
+ offered by multiple providers; `False` for provider-grouped
197
+ rows where the header already names the provider. Persisted on
198
+ the widget so incremental relabels in `_move_selection`
199
+ reproduce the same display.
200
+ """
201
+ super().__init__(label, classes=classes)
202
+ self.model_spec = model_spec
203
+ self.index = index
204
+ self.show_provider = show_provider
205
+ self.auth_status = auth_status or ProviderAuthStatus(
206
+ state=ProviderAuthState.UNKNOWN,
207
+ provider=provider,
208
+ detail="credentials unknown",
209
+ )
210
+
211
+ @property
212
+ def provider(self) -> str:
213
+ """Provider name, derived from the embedded auth status."""
214
+ return self.auth_status.provider
215
+
216
+ class Clicked(Message):
217
+ """Message sent when a model option is clicked."""
218
+
219
+ def __init__(self, model_spec: str, provider: str, index: int) -> None:
220
+ """Initialize the Clicked message.
221
+
222
+ Args:
223
+ model_spec: The model specification.
224
+ provider: The provider name.
225
+ index: The index of the clicked option.
226
+ """
227
+ super().__init__()
228
+ self.model_spec = model_spec
229
+ self.provider = provider
230
+ self.index = index
231
+
232
+ def on_click(self, event: Click) -> None:
233
+ """Handle click on this option.
234
+
235
+ Args:
236
+ event: The click event.
237
+ """
238
+ event.stop()
239
+ self.post_message(self.Clicked(self.model_spec, self.provider, self.index))
240
+
241
+
242
+ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
243
+ """Full-screen modal for model selection.
244
+
245
+ Displays available models grouped by provider with keyboard navigation
246
+ and search filtering. Current model is highlighted.
247
+
248
+ Returns (model_spec, provider) tuple on selection, or None on cancel.
249
+ """
250
+
251
+ BINDINGS: ClassVar[list[BindingType]] = [
252
+ Binding("up", "move_up", "Up", show=False, priority=True),
253
+ Binding("down", "move_down", "Down", show=False, priority=True),
254
+ Binding("tab", "tab_complete", "Tab complete", show=False, priority=True),
255
+ Binding("pageup", "page_up", "Page up", show=False, priority=True),
256
+ Binding("pagedown", "page_down", "Page down", show=False, priority=True),
257
+ Binding("enter", "select", "Select", show=False, priority=True),
258
+ Binding("ctrl+s", "set_default", "Set default", show=False, priority=True),
259
+ Binding(
260
+ "ctrl+r",
261
+ "toggle_recommended",
262
+ "Recommended only",
263
+ show=False,
264
+ priority=True,
265
+ ),
266
+ Binding(
267
+ "ctrl+a",
268
+ "add_custom_provider",
269
+ "Add custom provider",
270
+ show=False,
271
+ priority=True,
272
+ ),
273
+ Binding("ctrl+n", "toggle_names", "Model IDs", show=False, priority=True),
274
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
275
+ ]
276
+ """Key bindings for model navigation, selection, defaulting, and cancel.
277
+
278
+ Arrows move the cursor, Page Up/Down jump by a visual page, Tab copies
279
+ the highlighted spec into the filter input, Enter selects, Ctrl+S
280
+ toggles the default model, Ctrl+R toggles between showing all installed
281
+ models and the hand-curated "recommended" subset, Ctrl+N toggles rows
282
+ between friendly display names and raw `provider:model` specs (the analog
283
+ of the `/theme` picker's `n` key), and Esc dismisses. All bindings use
284
+ `priority=True` so they take precedence over the embedded `Input`;
285
+ vim-style `j`/`k` bindings — and a bare `n` mirroring the `/theme`
286
+ picker — are deliberately omitted because they would prevent typing those
287
+ letters into the always-focused filter input, which is why the names
288
+ toggle is bound to `ctrl+n` instead.
289
+ """
290
+
291
+ CSS = """
292
+ ModelSelectorScreen {
293
+ align: center middle;
294
+ }
295
+
296
+ ModelSelectorScreen > Vertical {
297
+ width: 76;
298
+ max-width: 90%;
299
+ height: auto;
300
+ background: $surface;
301
+ border: solid $primary;
302
+ padding: 1 2;
303
+ }
304
+
305
+ ModelSelectorScreen .model-selector-title {
306
+ text-style: bold;
307
+ color: $primary;
308
+ text-align: center;
309
+ margin-bottom: 1;
310
+ }
311
+
312
+ ModelSelectorScreen .model-selector-description {
313
+ height: auto;
314
+ color: $text-muted;
315
+ margin-bottom: 1;
316
+ }
317
+
318
+ ModelSelectorScreen .model-selector-info {
319
+ height: auto;
320
+ color: $text-muted;
321
+ margin-bottom: 1;
322
+ }
323
+
324
+ ModelSelectorScreen #model-filter {
325
+ margin-bottom: 1;
326
+ border: solid $primary-lighten-2;
327
+ }
328
+
329
+ ModelSelectorScreen #model-filter:focus {
330
+ border: solid $primary;
331
+ }
332
+
333
+ ModelSelectorScreen .model-list {
334
+ height: auto;
335
+ min-height: 1;
336
+ max-height: 16; /* keep in sync with `_MODEL_LIST_MAX_HEIGHT` */
337
+ scrollbar-gutter: stable;
338
+ background: $background;
339
+ }
340
+
341
+ ModelSelectorScreen #model-options {
342
+ height: auto;
343
+ }
344
+
345
+ ModelSelectorScreen .model-provider-header {
346
+ color: $primary;
347
+ margin-top: 1;
348
+ }
349
+
350
+ ModelSelectorScreen #model-options > .model-provider-header:first-child {
351
+ margin-top: 0;
352
+ }
353
+
354
+ ModelSelectorScreen .model-option {
355
+ height: 1;
356
+ padding: 0 1;
357
+ }
358
+
359
+ ModelSelectorScreen .model-option:hover {
360
+ background: $surface-lighten-1;
361
+ }
362
+
363
+ ModelSelectorScreen .model-option-selected {
364
+ background: $primary;
365
+ color: $background;
366
+ text-style: bold;
367
+ }
368
+
369
+ ModelSelectorScreen .model-option-selected:hover {
370
+ background: $primary-lighten-1;
371
+ }
372
+
373
+ ModelSelectorScreen .model-option-current {
374
+ text-style: italic;
375
+ }
376
+
377
+ ModelSelectorScreen .model-selector-help {
378
+ height: auto; /* keep auto so the standard footer wraps; see _help_text */
379
+ color: $text-muted;
380
+ text-style: italic;
381
+ margin-top: 1;
382
+ text-align: center;
383
+ }
384
+
385
+ ModelSelectorScreen .model-detail-footer {
386
+ height: 4;
387
+ padding: 0 2;
388
+ margin-top: 1;
389
+ }
390
+ """
391
+ """Styling for the modal shell, filter input, provider-grouped list, detail
392
+ footer, and help text."""
393
+
394
+ def __init__(
395
+ self,
396
+ current_model: str | None = None,
397
+ current_provider: str | None = None,
398
+ cli_profile_override: dict[str, Any] | None = None,
399
+ *,
400
+ curated: bool = False,
401
+ title: str | None = None,
402
+ description: str | Content | None = None,
403
+ result_callback: Callable[[tuple[str, str] | None], None] | None = None,
404
+ ) -> None:
405
+ """Initialize the ModelSelectorScreen.
406
+
407
+ Data loading (model discovery, profiles) is deferred to `on_mount`
408
+ so the screen pushes instantly and populates asynchronously.
409
+
410
+ Args:
411
+ current_model: The currently active model name (to highlight).
412
+ current_provider: The provider of the current model.
413
+ cli_profile_override: Extra profile fields from `--profile-override`.
414
+
415
+ Merged on top of upstream + config.toml profiles so that app
416
+ overrides appear with `*` markers in the detail footer.
417
+ curated: Whether to show a short, profile-ranked model subset.
418
+ title: Optional title override for the selector.
419
+ description: Optional description shown below the title.
420
+ result_callback: Optional callback for selector results when the
421
+ screen is displayed without a `push_screen` result callback.
422
+ """
423
+ super().__init__()
424
+ self._current_model = current_model
425
+ self._current_provider = current_provider
426
+ self._cli_profile_override = cli_profile_override
427
+ self._curated = curated
428
+ self._title = title
429
+ self._description = description
430
+ self._result_callback = result_callback
431
+ # Standard /model defaults to the curated recommended subset so users
432
+ # face less decision fatigue; onboarding (`curated=True`) already
433
+ # constrains the list via `_curated`, so leaving this False there
434
+ # avoids double-flagging in `_apply_subset`.
435
+ self._recommended_only = not curated
436
+ # Rows show friendly display names by default; Ctrl+N flips every row
437
+ # to its raw `provider:model` spec (mirrors the `/theme` picker's
438
+ # label/key toggle) so a user can read or copy the canonical id.
439
+ self._show_specs = False
440
+
441
+ self._unfiltered_models: list[tuple[str, str]] = []
442
+ self._recent_specs: list[str] = []
443
+ # Providers surfaced in the list whose integration package is not
444
+ # installed, mapped to the extra that installs them. Selecting one
445
+ # routes through the install-confirm modal instead of an auth prompt.
446
+ self._install_extras: dict[str, str] = {}
447
+ self._reload_task: asyncio.Task[None] | None = None
448
+ # Set when the user confirms installing a provider's extra; the app
449
+ # reads this off the screen after dismissal to install then switch.
450
+ self.pending_install_extra: str | None = None
451
+
452
+ self._all_models: list[tuple[str, str]] = []
453
+ self._filtered_models: list[tuple[str, str]] = []
454
+ self._selected_index = 0
455
+ self._options_container: Container | None = None
456
+ self._option_widgets: list[ModelOption] = []
457
+ self._filter_text = ""
458
+ self._current_spec: str | None = None
459
+ if current_model and current_provider:
460
+ self._current_spec = f"{current_provider}:{current_model}"
461
+ self._default_spec: str | None = None
462
+ self._profiles: Mapping[str, ModelProfileEntry] = {}
463
+ self._loaded = False
464
+
465
+ def _info_line_content(self) -> Content:
466
+ """Build the info line shown above the filter input.
467
+
468
+ Reflects whether the screen is filtered to the recommended subset.
469
+
470
+ Returns:
471
+ Styled `Content` for the info line.
472
+ """
473
+ if self._filter_text.strip():
474
+ return Content.styled("Searching all models from installed providers")
475
+ if self._recommended_only:
476
+ return Content.styled(
477
+ "Showing recommended models — Ctrl+R for all",
478
+ )
479
+ return Content.styled(
480
+ "Showing all models from installed providers — Ctrl+R for recommended",
481
+ )
482
+
483
+ def _update_info_line(self) -> None:
484
+ """Refresh the standard selector info line."""
485
+ if self._curated:
486
+ return
487
+ info = self.query_one("#model-selector-info", Static)
488
+ info.update(self._info_line_content())
489
+
490
+ def _help_text(self) -> str:
491
+ """Build the footer help text.
492
+
493
+ Curated/onboarding mode omits the Ctrl+S, Ctrl+R, and Ctrl+N hints.
494
+ Escape stays bound but is left off the hint line — modal dismissal via
495
+ Escape is conventional, and advertising it would only lengthen an
496
+ already-wrapping line. In standard mode the full line exceeds the modal
497
+ width, so the help `Static` is sized to grow (auto height) and wraps to
498
+ two rows rather than clipping the trailing hints.
499
+
500
+ Returns:
501
+ The bullet-separated help line.
502
+ """
503
+ glyphs = get_glyphs()
504
+ parts = [
505
+ f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate",
506
+ "Tab autocomplete",
507
+ "Enter select",
508
+ ]
509
+ if not self._curated:
510
+ parts.extend((
511
+ "Ctrl+S set default",
512
+ "Ctrl+R recommended",
513
+ "Ctrl+A add provider",
514
+ "Ctrl+N IDs",
515
+ ))
516
+ sep = f" {glyphs.bullet} "
517
+ return sep.join(parts)
518
+
519
+ def _find_current_model_index(self) -> int:
520
+ """Find the index of the current model in the filtered list.
521
+
522
+ Returns:
523
+ Index of the current model, or 0 if not found.
524
+ """
525
+ if not self._current_model or not self._current_provider:
526
+ return 0
527
+
528
+ current_spec = f"{self._current_provider}:{self._current_model}"
529
+ for i, (model_spec, _) in enumerate(self._filtered_models):
530
+ if model_spec == current_spec:
531
+ return i
532
+ return 0
533
+
534
+ def _initial_selected_index(self) -> int:
535
+ """Return the default highlighted row for the current selector mode."""
536
+ if self._curated:
537
+ return 0
538
+ return self._find_current_model_index()
539
+
540
+ def compose(self) -> ComposeResult:
541
+ """Compose the screen layout.
542
+
543
+ Yields:
544
+ Widgets for the model selector UI.
545
+ """
546
+ with Vertical():
547
+ # Title with current model in provider:model format
548
+ if self._title:
549
+ title = self._title
550
+ elif self._current_model and self._current_provider:
551
+ current_spec = f"{self._current_provider}:{self._current_model}"
552
+ title = f"Select Model (current: {current_spec})"
553
+ elif self._current_model:
554
+ title = f"Select Model (current: {self._current_model})"
555
+ else:
556
+ title = "Select Model"
557
+ yield Static(title, classes="model-selector-title")
558
+ if self._description:
559
+ yield Static(
560
+ self._description,
561
+ classes="model-selector-description",
562
+ )
563
+
564
+ if not self._curated:
565
+ yield Static(
566
+ self._info_line_content(),
567
+ classes="model-selector-info",
568
+ id="model-selector-info",
569
+ )
570
+
571
+ # Search input
572
+ yield Input(
573
+ placeholder="Type to filter or enter provider:model...",
574
+ id="model-filter",
575
+ )
576
+
577
+ # Scrollable model list
578
+ with VerticalScroll(classes="model-list"):
579
+ self._options_container = Container(id="model-options")
580
+ yield self._options_container
581
+
582
+ # Model detail footer
583
+ yield Static("", classes="model-detail-footer", id="model-detail-footer")
584
+
585
+ # Add custom provider button (only in non-curated mode)
586
+ if not self._curated:
587
+ yield Button(
588
+ "Add Custom Provider (Ctrl+A)",
589
+ id="add-custom-provider-btn",
590
+ variant="primary",
591
+ )
592
+
593
+ yield Static(self._help_text(), classes="model-selector-help")
594
+
595
+ @staticmethod
596
+ def _load_model_data(
597
+ cli_override: dict[str, Any] | None,
598
+ *,
599
+ include_uninstalled: bool = True,
600
+ include_recent: bool = True,
601
+ ) -> _ModelData:
602
+ """Gather model discovery data synchronously.
603
+
604
+ Intended to be called via `asyncio.to_thread` so filesystem I/O in
605
+ `get_available_models` does not block the event loop.
606
+
607
+ Args:
608
+ cli_override: Extra profile fields from `--profile-override`.
609
+ include_uninstalled: When `True`, append recommended models that
610
+ aren't already surfaced, in two cases: (1) the provider
611
+ integration isn't installed, added as greyed-out
612
+ install-required rows; (2) the provider is installed but its
613
+ upstream profiles omit the model, added as normal selectable
614
+ rows.
615
+ include_recent: When `True`, load the recent-models MRU so the
616
+ pinned "Recent" section can render. Onboarding sets this
617
+ `False`: first-run users have never picked a model, and the
618
+ startup default-fallback resolution writes its auto-detected
619
+ pick into the MRU, which would otherwise surface as a bogus
620
+ "Recent" entry the user never chose.
621
+
622
+ Returns:
623
+ A `_ModelData` bundle of the discovered models, default spec,
624
+ profiles, recent specs, and install-required provider extras.
625
+ """
626
+ available = get_available_models()
627
+ config = ModelConfig.load()
628
+ all_models: list[tuple[str, str]] = [
629
+ (f"{provider}:{model}", provider)
630
+ for provider, models in available.items()
631
+ for model in models
632
+ ]
633
+
634
+ install_extras: dict[str, str] = {}
635
+ if include_uninstalled:
636
+ from deepagents_code.config_manifest import (
637
+ is_provider_package_installed,
638
+ provider_install_extra,
639
+ )
640
+
641
+ # Seeded from the discovered models; a recommended spec already
642
+ # surfaced here is skipped below. Recommended specs are unique (dict
643
+ # keys iterated once), so this entry guard is the only dedup needed
644
+ # and the set never has to grow inside the loop.
645
+ existing_specs = {spec for spec, _ in all_models}
646
+ installed_recommended: list[tuple[str, str]] = []
647
+ uninstalled_recommended: list[tuple[str, str]] = []
648
+ for spec in sorted(_RECOMMENDED_MODELS):
649
+ if spec in existing_specs:
650
+ continue
651
+ provider = spec.split(":", 1)[0]
652
+ try:
653
+ if not config.is_provider_enabled(provider):
654
+ continue
655
+ extra = provider_install_extra(provider)
656
+ provider_installed = is_provider_package_installed(provider)
657
+ except Exception:
658
+ # Isolate per-provider probe failures so one bad recommended
659
+ # provider can't take down the entire model list (the caller
660
+ # degrades any raise here to an empty selector). The append
661
+ # bookkeeping below stays outside this guard so genuine logic
662
+ # bugs surface instead of being silently swallowed.
663
+ logger.warning(
664
+ "Skipping recommended model %r while merging "
665
+ "recommendations into the model list",
666
+ spec,
667
+ exc_info=True,
668
+ )
669
+ continue
670
+ if provider in available and provider_installed:
671
+ # Provider is installed and discoverable, but its upstream
672
+ # profiles don't surface this curated model (missing entry
673
+ # or filtered out). Add it as a normal selectable row so the
674
+ # hardcoded recommendation isn't silently dropped when the
675
+ # profile list lags.
676
+ installed_recommended.append((spec, provider))
677
+ continue
678
+ if extra is None or provider_installed:
679
+ continue
680
+ install_extras[provider] = extra
681
+ uninstalled_recommended.append((spec, provider))
682
+ all_models.extend(installed_recommended)
683
+ all_models.extend(uninstalled_recommended)
684
+
685
+ profiles = get_model_profiles(cli_override=cli_override)
686
+ recent_specs = load_recent_models() if include_recent else []
687
+ return _ModelData(
688
+ all_models,
689
+ config.default_model,
690
+ profiles,
691
+ recent_specs,
692
+ install_extras,
693
+ )
694
+
695
+ def _apply_subset(
696
+ self,
697
+ all_models: list[tuple[str, str]],
698
+ ) -> list[tuple[str, str]]:
699
+ """Apply the active subset filter (onboarding or recommended-only).
700
+
701
+ Recently-used specs are unioned in even when the recommended-only
702
+ toggle is on, so personal usage always wins over curation. Onboarding
703
+ intentionally keeps a tight curated subset and skips this union.
704
+
705
+ Args:
706
+ all_models: Full list of `(provider:model, provider)` pairs.
707
+
708
+ Returns:
709
+ The list reduced to the recommended subset when either
710
+ `_curated` (onboarding) or `_recommended_only` (Ctrl+R) is
711
+ active. Falls back to the full list when no recommended
712
+ models are installed so the screen is never empty.
713
+ """
714
+ if self._curated:
715
+ return self._curate_models(all_models)
716
+ if self._recommended_only:
717
+ curated = self._curate_models(all_models)
718
+ curated_specs = {spec for spec, _ in curated}
719
+ # Order follows all_models (insertion), not MRU; _update_display
720
+ # rebuilds visual order by iterating self._recent_specs directly.
721
+ recent_extra = [
722
+ (spec, provider)
723
+ for spec, provider in all_models
724
+ if spec in self._recent_specs and spec not in curated_specs
725
+ ]
726
+ return [*recent_extra, *curated]
727
+ return list(all_models)
728
+
729
+ @staticmethod
730
+ def _curate_models(
731
+ all_models: list[tuple[str, str]],
732
+ ) -> list[tuple[str, str]]:
733
+ """Return the curated onboarding list in the model switcher's order.
734
+
735
+ Returns the eval-backed frontier subset when any of those models are
736
+ available. When none are, returns the full switcher list so onboarding
737
+ still surfaces every installed provider rather than a truncated slice.
738
+
739
+ Args:
740
+ all_models: Full list of `(provider:model, provider)` pairs.
741
+
742
+ Returns:
743
+ Curated model list for onboarding setup.
744
+ """
745
+ frontier = [
746
+ (spec, provider)
747
+ for spec, provider in all_models
748
+ if spec in _RECOMMENDED_MODELS
749
+ ]
750
+ return frontier or all_models
751
+
752
+ async def on_mount(self) -> None:
753
+ """Set up the screen on mount.
754
+
755
+ Loads model data in a background thread so the screen frame renders
756
+ immediately, then populates the model list.
757
+ """
758
+ if is_ascii_mode():
759
+ colors = theme.get_theme_colors(self)
760
+ container = self.query_one(Vertical)
761
+ container.styles.border = ("ascii", colors.success)
762
+ self.call_after_refresh(self._fit_model_list)
763
+
764
+ # Focus the filter input immediately so the user can start typing
765
+ # while model data loads.
766
+ filter_input = self.query_one("#model-filter", Input)
767
+ filter_input.focus()
768
+
769
+ # Offload to thread because get_available_models does filesystem I/O
770
+ try:
771
+ data = await asyncio.to_thread(
772
+ self._load_model_data,
773
+ self._cli_profile_override,
774
+ include_uninstalled=True,
775
+ include_recent=not self._curated,
776
+ )
777
+ except Exception:
778
+ logger.exception("Failed to load model data for /model selector")
779
+ self._loaded = True
780
+ if self.is_running:
781
+ self.notify(
782
+ "Could not load model list. "
783
+ "Check provider packages and config.toml.",
784
+ severity="error",
785
+ timeout=10,
786
+ markup=False,
787
+ )
788
+ await self._update_display()
789
+ self._update_footer()
790
+ return
791
+
792
+ # Screen may have been dismissed while the thread was running
793
+ if not self.is_running:
794
+ return
795
+
796
+ self._unfiltered_models = data.all_models
797
+ self._default_spec = data.default_spec
798
+ self._profiles = data.profiles
799
+ self._recent_specs = data.recent_specs
800
+ self._install_extras = data.install_extras
801
+ self._all_models = self._apply_subset(self._unfiltered_models)
802
+ self._filtered_models = list(self._all_models)
803
+ self._selected_index = self._initial_selected_index()
804
+ self._loaded = True
805
+
806
+ # Re-apply any filter text the user typed while data was loading
807
+ if self._filter_text:
808
+ self._update_filtered_list()
809
+
810
+ await self._update_display()
811
+ self._update_footer()
812
+
813
+ def on_resize(self) -> None:
814
+ """Refit the model list when terminal dimensions change."""
815
+ self.call_after_refresh(self._fit_model_list)
816
+
817
+ def _fit_model_list(self) -> None:
818
+ """Cap the model list so modal controls stay visible."""
819
+ try:
820
+ container = self.query_one(Vertical)
821
+ except NoMatches:
822
+ # This runs deferred via `call_after_refresh`/`on_resize`; the
823
+ # screen may have been popped before it fires (e.g. a resize racing
824
+ # dismissal). Sizing is cosmetic, so skip quietly but leave a
825
+ # breadcrumb rather than letting it surface in the event loop.
826
+ logger.debug(
827
+ "Skipping model-list refit; screen not mounted",
828
+ exc_info=True,
829
+ )
830
+ return
831
+ # The screen is still mounted, so `.model-list` (always composed) must
832
+ # exist; a missing body here is a structural regression, not the
833
+ # teardown race, so let `NoMatches` surface rather than silently
834
+ # rendering an uncapped list.
835
+ body = self.query_one(".model-list", VerticalScroll)
836
+ non_body_height = max(0, container.region.height - body.region.height)
837
+ available_height = self.size.height - non_body_height
838
+ max_height = max(
839
+ _MODEL_LIST_MIN_HEIGHT,
840
+ min(_MODEL_LIST_MAX_HEIGHT, available_height),
841
+ )
842
+ current = body.styles.max_height
843
+ if current is not None and current.cells == max_height:
844
+ return
845
+ body.styles.max_height = max_height
846
+
847
+ def on_input_changed(self, event: Input.Changed) -> None:
848
+ """Filter models as user types.
849
+
850
+ Args:
851
+ event: The input changed event.
852
+ """
853
+ self._filter_text = event.value
854
+ self._update_info_line()
855
+ if not self._loaded:
856
+ return # on_mount will re-apply filter after data loads
857
+ self._update_filtered_list()
858
+ self.call_after_refresh(self._update_display)
859
+
860
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
861
+ """Handle button presses."""
862
+ if event.button.id == "add-custom-provider-btn":
863
+ await self.action_add_custom_provider()
864
+ self.call_after_refresh(self._update_display)
865
+
866
+ def on_input_submitted(self, event: Input.Submitted) -> None:
867
+ """Handle Enter key when filter input is focused.
868
+
869
+ Args:
870
+ event: The input submitted event.
871
+ """
872
+ event.stop()
873
+ self.action_select()
874
+
875
+ def on_model_option_clicked(self, event: ModelOption.Clicked) -> None:
876
+ """Handle click on a model option.
877
+
878
+ Args:
879
+ event: The click event with model info.
880
+ """
881
+ self._selected_index = event.index
882
+ self._select_with_auth_check(event.model_spec, event.provider)
883
+
884
+ def _update_filtered_list(self) -> None:
885
+ """Update the filtered models based on search text using fuzzy matching.
886
+
887
+ Results are sorted by match score (best first), with installed
888
+ providers ranked above not-yet-installed ones so the common case of
889
+ picking an available model is never displaced by an install-required
890
+ suggestion. In standard `/model` mode, non-empty searches span the
891
+ full installed model list even when the default view is currently
892
+ constrained to recommended models.
893
+ """
894
+ query = self._filter_text.strip()
895
+ if not query:
896
+ self._filtered_models = list(self._all_models)
897
+ self._selected_index = self._initial_selected_index()
898
+ return
899
+
900
+ tokens = query.split()
901
+ search_models = self._all_models if self._curated else self._unfiltered_models
902
+
903
+ # Match against what the user actually sees, not just the raw spec: the
904
+ # friendly model name and provider label are folded into the search
905
+ # haystack so e.g. "Opus 4.8" finds `anthropic:claude-opus-4-8` (whose
906
+ # spec, with hyphens, the "4.8" token can't subsequence-match) and
907
+ # "OpenAI Codex" finds the codex rows. The spec stays in the haystack so
908
+ # existing muscle-memory queries keep working.
909
+ from deepagents_code.tui.widgets.auth import provider_display_name
910
+
911
+ config = ModelConfig.load()
912
+ provider_labels: dict[str, str] = {}
913
+
914
+ # Resolve the display labels up front, *outside* the try below. That
915
+ # fallback exists for `Matcher` choking on edge-case input; folding
916
+ # label resolution into it would misattribute an error from
917
+ # `_get_model_display_name`/`provider_display_name` to the matcher and
918
+ # silently drop the user's filter. Any failure here should surface, not
919
+ # masquerade as "no matches".
920
+ haystacks: list[tuple[str, str, str]] = []
921
+ for spec, provider in search_models:
922
+ label = provider_labels.get(provider)
923
+ if label is None:
924
+ label = provider_display_name(provider, config)
925
+ provider_labels[provider] = label
926
+ haystacks.append(
927
+ (spec, provider, f"{spec} {self._get_model_display_name(spec)} {label}")
928
+ )
929
+
930
+ try:
931
+ matchers = [Matcher(token, case_sensitive=False) for token in tokens]
932
+ scored: list[tuple[float, str, str]] = []
933
+ for spec, provider, haystack in haystacks:
934
+ scores = [m.match(haystack) for m in matchers]
935
+ if all(s > 0 for s in scores):
936
+ scored.append((min(scores), spec, provider))
937
+ except Exception:
938
+ # graceful fallback if Matcher fails on edge-case input
939
+ logger.warning(
940
+ "Fuzzy matcher failed for query %r, falling back to full list",
941
+ query,
942
+ exc_info=True,
943
+ )
944
+ self._filtered_models = list(search_models)
945
+ self._selected_index = self._initial_selected_index()
946
+ return
947
+
948
+ self._filtered_models = [
949
+ (spec, provider)
950
+ for _installed, _score, spec, provider in sorted(
951
+ (
952
+ (provider not in self._install_extras, score, spec, provider)
953
+ for score, spec, provider in scored
954
+ ),
955
+ reverse=True,
956
+ )
957
+ ]
958
+ self._selected_index = 0
959
+
960
+ @staticmethod
961
+ def _unique_model_entries(
962
+ models: list[tuple[str, str]],
963
+ ) -> list[tuple[str, str]]:
964
+ """Return unique model/provider pairs while preserving first-seen order."""
965
+ seen: set[tuple[str, str]] = set()
966
+ unique: list[tuple[str, str]] = []
967
+ for entry in models:
968
+ if entry in seen:
969
+ continue
970
+ seen.add(entry)
971
+ unique.append(entry)
972
+ return unique
973
+
974
+ @staticmethod
975
+ def _find_same_occurrence_index(
976
+ models: list[tuple[str, str]],
977
+ entry: tuple[str, str],
978
+ occurrence: int,
979
+ ) -> int:
980
+ """Find the `occurrence`th matching model/provider tuple in `models`.
981
+
982
+ Args:
983
+ models: Render-ordered model/provider pairs to search.
984
+ entry: Exact `(model_spec, provider)` tuple to match.
985
+ occurrence: One-based occurrence count to find.
986
+
987
+ Returns:
988
+ Matching index, or `0` if no matching occurrence exists.
989
+ """
990
+ matches = 0
991
+ first_match: int | None = None
992
+ for i, candidate in enumerate(models):
993
+ if candidate != entry:
994
+ continue
995
+ if first_match is None:
996
+ first_match = i
997
+ matches += 1
998
+ if matches == occurrence:
999
+ return i
1000
+ return first_match or 0
1001
+
1002
+ # Lower ranks render first: providers the user can use right now lead,
1003
+ # then providers whose readiness is unknown, then providers needing a
1004
+ # missing credential, then ones that aren't even installed. A missing
1005
+ # credential sits above not-installed since fixing it is just an auth
1006
+ # prompt away.
1007
+ _PROVIDER_AVAILABLE_RANK = 0
1008
+ _PROVIDER_UNKNOWN_RANK = 1
1009
+ _PROVIDER_MISSING_RANK = 2
1010
+ _PROVIDER_UNINSTALLED_RANK = 3
1011
+
1012
+ def _provider_availability_rank(
1013
+ self,
1014
+ provider: str,
1015
+ auth_status: ProviderAuthStatus,
1016
+ ) -> int:
1017
+ """Return a sort rank that floats usable providers to the top.
1018
+
1019
+ Args:
1020
+ provider: Provider name being ranked.
1021
+ auth_status: The provider's resolved auth/readiness status.
1022
+
1023
+ Returns:
1024
+ A rank where lower values sort earlier: ready-to-use providers
1025
+ first, then unknown, then missing-credential, then
1026
+ not-installed providers.
1027
+ """
1028
+ if provider in self._install_extras:
1029
+ return self._PROVIDER_UNINSTALLED_RANK
1030
+ state = auth_status.state
1031
+ if state in {
1032
+ ProviderAuthState.CONFIGURED,
1033
+ ProviderAuthState.NOT_REQUIRED,
1034
+ ProviderAuthState.IMPLICIT,
1035
+ ProviderAuthState.MANAGED,
1036
+ }:
1037
+ return self._PROVIDER_AVAILABLE_RANK
1038
+ if state is ProviderAuthState.UNKNOWN:
1039
+ return self._PROVIDER_UNKNOWN_RANK
1040
+ return self._PROVIDER_MISSING_RANK
1041
+
1042
+ async def _update_display(self) -> None:
1043
+ """Render the model list grouped by provider.
1044
+
1045
+ Performs a full DOM rebuild (removes all children, re-mounts).
1046
+ Arrow-key navigation uses `_move_selection` instead to avoid
1047
+ the cost of a full rebuild.
1048
+ """
1049
+ if not self._options_container:
1050
+ return
1051
+
1052
+ await self._options_container.remove_children()
1053
+ self._option_widgets = []
1054
+
1055
+ if not self._filtered_models:
1056
+ if not self._loaded:
1057
+ empty_content: Content = Content.styled("Loading models…", "dim")
1058
+ else:
1059
+ typed = self._filter_text.strip()
1060
+ if typed and ":" in typed:
1061
+ empty_content = Content.assemble(
1062
+ ("No matching models — press ", "dim"),
1063
+ ("Enter", "bold"),
1064
+ (" to use ", "dim"),
1065
+ (typed, "bold"),
1066
+ (" as a custom provider:model spec", "dim"),
1067
+ )
1068
+ elif typed:
1069
+ empty_content = Content.assemble(
1070
+ ("No matching models — press ", "dim"),
1071
+ ("Enter", "bold"),
1072
+ (" to use ", "dim"),
1073
+ (typed, "bold"),
1074
+ (" as a custom model spec (no provider prefix)", "dim"),
1075
+ )
1076
+ else:
1077
+ empty_content = Content.styled("No matching models", "dim")
1078
+ await self._options_container.mount(Static(empty_content))
1079
+ self._update_footer()
1080
+ self.call_after_refresh(self._fit_model_list)
1081
+ return
1082
+
1083
+ has_filter = bool(self._filter_text.strip())
1084
+ source = self._filtered_models if has_filter else self._all_models
1085
+ source_models = self._unique_model_entries(source)
1086
+
1087
+ # Resolve which recent specs are present in the current filtered set.
1088
+ # Recent rendering only happens at the top of an unfiltered view; once
1089
+ # the user starts fuzzy-filtering, recents are surfaced through the
1090
+ # match logic like any other model so the search remains predictable.
1091
+ if has_filter:
1092
+ recent_entries: list[tuple[str, str]] = []
1093
+ else:
1094
+ spec_to_provider = dict(source_models)
1095
+ recent_entries = [
1096
+ (spec, spec_to_provider[spec])
1097
+ for spec in self._recent_specs
1098
+ if spec in spec_to_provider
1099
+ ]
1100
+ # Group models by provider, preserving insertion order so models
1101
+ # from the same provider cluster together in the visual list. Specs
1102
+ # also in the Recent section are intentionally kept here so a user
1103
+ # who opens `/model` always finds their model at its provider's
1104
+ # familiar position in addition to the MRU shortcut at the top.
1105
+ by_provider: dict[str, list[tuple[str, str]]] = {}
1106
+ for model_spec, provider in source_models:
1107
+ by_provider.setdefault(provider, []).append((model_spec, provider))
1108
+
1109
+ # Resolve provider auth upfront so it can both drive the
1110
+ # availability-first ordering below and feed the widget-building loop.
1111
+ auth_statuses = {p: get_provider_auth_status(p) for p in by_provider}
1112
+
1113
+ # In the default (unfiltered) view, float providers the user can
1114
+ # actually use to the top so a usable model is reachable without
1115
+ # scrolling or searching. Providers needing missing credentials or a
1116
+ # package install sink to the bottom. A search already orders by match
1117
+ # score (installed providers first), so leave that ordering untouched.
1118
+ if not has_filter:
1119
+ ordered_providers = sorted(
1120
+ by_provider,
1121
+ key=lambda p: self._provider_availability_rank(p, auth_statuses[p]),
1122
+ )
1123
+ by_provider = {p: by_provider[p] for p in ordered_providers}
1124
+
1125
+ # Rebuild _filtered_models to match the rendered order (recents first,
1126
+ # then provider-grouped). Without this, _filtered_models stays in
1127
+ # score-sorted order while _option_widgets follow rendered order,
1128
+ # causing _update_footer to look up the wrong model for the
1129
+ # highlighted index.
1130
+ grouped_order: list[tuple[str, str]] = list(recent_entries)
1131
+ for entries in by_provider.values():
1132
+ grouped_order.extend(entries)
1133
+
1134
+ # Remap selected_index so the same visual occurrence stays highlighted.
1135
+ old_entry = self._filtered_models[self._selected_index]
1136
+ old_occurrence = self._filtered_models[: self._selected_index + 1].count(
1137
+ old_entry
1138
+ )
1139
+ self._filtered_models = grouped_order
1140
+ self._selected_index = self._find_same_occurrence_index(
1141
+ grouped_order,
1142
+ old_entry,
1143
+ old_occurrence,
1144
+ )
1145
+
1146
+ glyphs = get_glyphs()
1147
+ flat_index = 0
1148
+ selected_widget: ModelOption | None = None
1149
+
1150
+ # Build current model spec for comparison
1151
+ current_spec = None
1152
+ if self._current_model and self._current_provider:
1153
+ current_spec = f"{self._current_provider}:{self._current_model}"
1154
+
1155
+ # Collect all widgets first, then batch-mount once to avoid
1156
+ # individual DOM mutations per widget
1157
+ all_widgets: list[Static] = []
1158
+
1159
+ # Pinned "Recent" section — pseudo-provider header with no auth badge
1160
+ # because each entry already carries its real provider's auth state
1161
+ # via `ModelOption.auth_status`.
1162
+ if recent_entries:
1163
+ all_widgets.append(
1164
+ Static(
1165
+ Content.from_markup(
1166
+ "[bold]$label[/bold]", label=_RECENT_SECTION_LABEL
1167
+ ),
1168
+ classes="model-provider-header",
1169
+ )
1170
+ )
1171
+ for model_spec, real_provider in recent_entries:
1172
+ auth_status = auth_statuses[real_provider]
1173
+ is_current = model_spec == current_spec
1174
+ is_selected = flat_index == self._selected_index
1175
+
1176
+ classes = "model-option"
1177
+ if is_selected:
1178
+ classes += " model-option-selected"
1179
+ if is_current:
1180
+ classes += " model-option-current"
1181
+
1182
+ label = self._build_option_label(
1183
+ model_spec, real_provider, auth_status, selected=is_selected
1184
+ )
1185
+ widget = ModelOption(
1186
+ label=label,
1187
+ model_spec=model_spec,
1188
+ provider=real_provider,
1189
+ index=flat_index,
1190
+ auth_status=auth_status,
1191
+ classes=classes,
1192
+ )
1193
+ all_widgets.append(widget)
1194
+ self._option_widgets.append(widget)
1195
+ if is_selected:
1196
+ selected_widget = widget
1197
+ flat_index += 1
1198
+
1199
+ # Resolve friendly provider labels via the shared helper so headers
1200
+ # match the `/auth` and install UIs (e.g. `openai_codex` renders as
1201
+ # "OpenAI Codex (ChatGPT login)"). Load config once; the helper reads a
1202
+ # user-configured `display_name` before the built-in map.
1203
+ from deepagents_code.tui.widgets.auth import provider_display_name
1204
+
1205
+ config = ModelConfig.load()
1206
+
1207
+ for provider, model_entries in by_provider.items():
1208
+ # Provider header; auth/readiness indicator appended only when non-empty.
1209
+ auth_status = auth_statuses[provider]
1210
+ provider_label = provider_display_name(provider, config)
1211
+ if provider in self._install_extras:
1212
+ auth_indicator = self._install_indicator()
1213
+ else:
1214
+ auth_indicator = self._format_auth_indicator(auth_status, glyphs)
1215
+ if auth_indicator:
1216
+ header_content = Content.from_markup(
1217
+ "[bold]$provider[/bold] [dim]$auth[/dim]",
1218
+ provider=provider_label,
1219
+ auth=auth_indicator,
1220
+ )
1221
+ else:
1222
+ header_content = Content.from_markup(
1223
+ "[bold]$provider[/bold]",
1224
+ provider=provider_label,
1225
+ )
1226
+ all_widgets.append(Static(header_content, classes="model-provider-header"))
1227
+
1228
+ for model_spec, _prov in model_entries:
1229
+ is_current = model_spec == current_spec
1230
+ is_selected = flat_index == self._selected_index
1231
+
1232
+ classes = "model-option"
1233
+ if is_selected:
1234
+ classes += " model-option-selected"
1235
+ if is_current:
1236
+ classes += " model-option-current"
1237
+
1238
+ label = self._build_option_label(
1239
+ model_spec,
1240
+ provider,
1241
+ auth_status,
1242
+ selected=is_selected,
1243
+ show_provider=False,
1244
+ )
1245
+ widget = ModelOption(
1246
+ label=label,
1247
+ model_spec=model_spec,
1248
+ provider=provider,
1249
+ index=flat_index,
1250
+ auth_status=auth_status,
1251
+ classes=classes,
1252
+ show_provider=False,
1253
+ )
1254
+ all_widgets.append(widget)
1255
+ self._option_widgets.append(widget)
1256
+
1257
+ if is_selected:
1258
+ selected_widget = widget
1259
+
1260
+ flat_index += 1
1261
+
1262
+ await self._options_container.mount(*all_widgets)
1263
+
1264
+ # Scroll the selected item into view without animation so the list
1265
+ # appears already scrolled to the current model on first paint.
1266
+ if selected_widget:
1267
+ if self._selected_index == 0:
1268
+ # First item: scroll to top so header is visible
1269
+ scroll_container = self.query_one(".model-list", VerticalScroll)
1270
+ scroll_container.scroll_home(animate=False)
1271
+ else:
1272
+ selected_widget.scroll_visible(animate=False)
1273
+
1274
+ self._update_footer()
1275
+ self.call_after_refresh(self._fit_model_list)
1276
+
1277
+ @staticmethod
1278
+ def _format_auth_indicator(
1279
+ auth_status: ProviderAuthStatus,
1280
+ glyphs: Glyphs,
1281
+ ) -> str:
1282
+ """Build the provider header auth indicator.
1283
+
1284
+ Args:
1285
+ auth_status: Provider auth/readiness status.
1286
+ glyphs: Glyph table for the active terminal mode.
1287
+
1288
+ Returns:
1289
+ Text shown next to the provider name, or an empty string when no
1290
+ indicator should be rendered (e.g., `CONFIGURED`).
1291
+ """
1292
+ return format_auth_indicator(auth_status, glyphs)
1293
+
1294
+ @staticmethod
1295
+ def _install_indicator() -> str:
1296
+ """Return the provider-header text for an uninstalled provider."""
1297
+ return "not installed"
1298
+
1299
+ def _build_option_label(
1300
+ self,
1301
+ model_spec: str,
1302
+ provider: str,
1303
+ auth_status: ProviderAuthStatus,
1304
+ *,
1305
+ selected: bool,
1306
+ show_provider: bool = True,
1307
+ ) -> Content:
1308
+ """Build a model-option label from the current screen state.
1309
+
1310
+ Every row shows the model's human-readable name (via
1311
+ `_get_model_display_name`). The cross-provider "Recent" section
1312
+ (`show_provider=True`) additionally appends a dim `(provider)` tag,
1313
+ since it has no provider header to disambiguate the same model offered
1314
+ by multiple providers.
1315
+
1316
+ Centralizes the per-row flag derivation (current/default/status/
1317
+ `install_required`) shared by the full rebuild in `_update_display`
1318
+ and the incremental relabel in `_move_selection`, so the two paths
1319
+ cannot drift. The original `/model` dim-persistence bug came from
1320
+ exactly such drift: `_move_selection` omitted `install_required`,
1321
+ so uninstalled rows stopped rendering dimmed after navigation.
1322
+
1323
+ Args:
1324
+ model_spec: The `provider:model` string for the row.
1325
+ provider: The row's provider key, tested against the
1326
+ install-required set.
1327
+ auth_status: Provider auth/readiness status for the row.
1328
+ selected: Whether this row is the highlighted one.
1329
+ show_provider: Whether to append the dim `(provider)` tag — `True`
1330
+ for Recent rows, `False` for provider-grouped rows. The tag
1331
+ uses the compact brand label (`provider_short_name`).
1332
+
1333
+ Returns:
1334
+ Styled `Content` label.
1335
+ """
1336
+ provider_label: str | None = None
1337
+ if show_provider:
1338
+ from deepagents_code.tui.widgets.auth import provider_short_name
1339
+
1340
+ provider_label = provider_short_name(provider)
1341
+ # `_show_specs` (Ctrl+N) renders the raw `provider:model` spec instead
1342
+ # of the friendly name; `display_name=None` makes `_format_option_label`
1343
+ # fall back to the spec and drop the redundant `(provider)` tag, which
1344
+ # the spec already embeds.
1345
+ display_name = (
1346
+ None if self._show_specs else self._get_model_display_name(model_spec)
1347
+ )
1348
+ return self._format_option_label(
1349
+ model_spec,
1350
+ selected=selected,
1351
+ current=model_spec == self._current_spec,
1352
+ auth_status=auth_status,
1353
+ is_default=model_spec == self._default_spec,
1354
+ status=self._get_model_status(model_spec),
1355
+ install_required=provider in self._install_extras,
1356
+ display_name=display_name,
1357
+ provider_label=provider_label,
1358
+ )
1359
+
1360
+ @staticmethod
1361
+ def _format_option_label(
1362
+ model_spec: str,
1363
+ *,
1364
+ selected: bool,
1365
+ current: bool,
1366
+ auth_status: ProviderAuthStatus,
1367
+ is_default: bool = False,
1368
+ status: str | None = None,
1369
+ install_required: bool = False,
1370
+ display_name: str | None = None,
1371
+ provider_label: str | None = None,
1372
+ ) -> Content:
1373
+ """Build the display label for a model option.
1374
+
1375
+ Args:
1376
+ model_spec: The `provider:model` string.
1377
+ selected: Whether this option is currently highlighted.
1378
+ current: Whether this is the active model.
1379
+ auth_status: Provider auth/readiness status.
1380
+ is_default: Whether this is the configured default model.
1381
+ status: Model status from profile (e.g., `'deprecated'`,
1382
+ `'beta'`, `'alpha'`). `'deprecated'` renders in red;
1383
+ other non-None values render in yellow.
1384
+ install_required: Whether the provider's integration package is not
1385
+ installed; renders the spec dimmed since selecting it prompts
1386
+ an install rather than switching immediately.
1387
+ display_name: Text to show in place of the full `model_spec`. When
1388
+ `None`, the full spec is shown. Both the Recent and
1389
+ provider-grouped rows pass the model's human-readable name (see
1390
+ `_get_model_display_name`).
1391
+ provider_label: When set (and `display_name` is given), appends a
1392
+ dim ` (provider)` tag after the name — used by the
1393
+ cross-provider Recent section, which has no provider header to
1394
+ disambiguate the same model across providers. `None` for
1395
+ provider-grouped rows. Ignored when `display_name` is `None`,
1396
+ since the raw spec already embeds the provider.
1397
+
1398
+ Returns:
1399
+ Styled Content label.
1400
+ """
1401
+ colors = theme.get_theme_colors()
1402
+ glyphs = get_glyphs()
1403
+ cursor = f"{glyphs.cursor} " if selected else " "
1404
+ display = model_spec if display_name is None else display_name
1405
+ # When selected, skip the inline primary color — CSS already flips the
1406
+ # row to ($primary bg, $background fg). Keep `bold` so the default
1407
+ # emphasis survives both states.
1408
+ if install_required and not selected:
1409
+ spec = Content.styled(display, "dim")
1410
+ elif auth_status.blocks_start:
1411
+ spec = Content.styled(display, colors.warning)
1412
+ elif is_default and selected:
1413
+ spec = Content.styled(display, "bold")
1414
+ elif is_default:
1415
+ spec = Content.styled(display, f"bold {colors.primary}")
1416
+ else:
1417
+ spec = Content(display)
1418
+ # Dim provider tag disambiguates Recent rows (no provider header).
1419
+ # Styled like `(current)` — always dim, so it survives row selection.
1420
+ # Requires a friendly `display_name`: tagging the raw spec (which
1421
+ # already embeds the provider) would print the provider twice.
1422
+ if provider_label and display_name is not None:
1423
+ provider_tag = Content.styled(f" ({provider_label})", "dim")
1424
+ else:
1425
+ provider_tag = Content("")
1426
+ suffix = Content.styled(" (current)", "dim") if current else Content("")
1427
+ if is_default and selected:
1428
+ default_suffix = Content.styled(" (default)", "bold")
1429
+ elif is_default:
1430
+ default_suffix = Content.styled(" (default)", f"bold {colors.primary}")
1431
+ else:
1432
+ default_suffix = Content("")
1433
+ if status == "deprecated":
1434
+ status_suffix = Content.styled(" (deprecated)", colors.error)
1435
+ elif status:
1436
+ status_suffix = Content.styled(f" ({status})", colors.warning)
1437
+ else:
1438
+ status_suffix = Content("")
1439
+ return Content.assemble(
1440
+ cursor, spec, provider_tag, suffix, default_suffix, status_suffix
1441
+ )
1442
+
1443
+ @staticmethod
1444
+ def _format_footer(
1445
+ profile_entry: ModelProfileEntry | None,
1446
+ glyphs: Glyphs,
1447
+ ) -> Content:
1448
+ """Build the detail footer text for the highlighted model.
1449
+
1450
+ Args:
1451
+ profile_entry: Profile data with override tracking, or None.
1452
+ glyphs: Glyph set for display characters.
1453
+
1454
+ Returns:
1455
+ Styled `Content` for the 4-line footer.
1456
+ """
1457
+ from deepagents_code._session_stats import format_token_count
1458
+
1459
+ if profile_entry is None or not profile_entry["profile"]:
1460
+ return Content.styled("Model profile not available :(\n\n\n", "dim")
1461
+
1462
+ profile = profile_entry["profile"]
1463
+ overridden = profile_entry["overridden_keys"]
1464
+
1465
+ colors = theme.get_theme_colors()
1466
+
1467
+ def _mark(key: str, text: str) -> Content:
1468
+ if key in overridden:
1469
+ return Content.styled(f"*{text}", colors.warning)
1470
+ return Content(text)
1471
+
1472
+ def _format_token(key: str, suffix: str) -> Content | None:
1473
+ """Format a token-count profile key, falling back to the raw value.
1474
+
1475
+ Returns:
1476
+ Styled `Content` with override marker, or None if key absent.
1477
+ """
1478
+ val = profile.get(key)
1479
+ if val is None:
1480
+ return None
1481
+ try:
1482
+ text = f"{format_token_count(int(val))} {suffix}"
1483
+ except (ValueError, TypeError, OverflowError):
1484
+ text = f"{val} {suffix}"
1485
+ return _mark(key, text)
1486
+
1487
+ def _format_flags(keys: list[tuple[str, str]]) -> list[Content]:
1488
+ """Render boolean profile keys as green (on) or dim (off) labels.
1489
+
1490
+ Returns:
1491
+ List of styled `Content` objects for present keys.
1492
+ """
1493
+ parts: list[Content] = []
1494
+ for key, label in keys:
1495
+ if key in profile:
1496
+ base = (
1497
+ Content.styled(label, colors.success)
1498
+ if profile[key]
1499
+ else Content.styled(label, "dim")
1500
+ )
1501
+ if key in overridden:
1502
+ base = Content.assemble(
1503
+ Content.styled("*", colors.warning), base
1504
+ )
1505
+ parts.append(base)
1506
+ return parts
1507
+
1508
+ # Line 1: Context window
1509
+ token_keys = [("max_input_tokens", "in"), ("max_output_tokens", "out")]
1510
+ ctx_parts = [p for k, s in token_keys if (p := _format_token(k, s)) is not None]
1511
+ bullet_sep = Content(f" {glyphs.bullet} ")
1512
+ line1 = (
1513
+ Content.assemble("Context: ", bullet_sep.join(ctx_parts))
1514
+ if ctx_parts
1515
+ else Content("")
1516
+ )
1517
+
1518
+ # Line 2: Input modalities
1519
+ modality_keys = [
1520
+ ("text_inputs", "text"),
1521
+ ("image_inputs", "image"),
1522
+ ("audio_inputs", "audio"),
1523
+ ("pdf_inputs", "pdf"),
1524
+ ("video_inputs", "video"),
1525
+ ]
1526
+ modality_parts = _format_flags(modality_keys)
1527
+ space = Content(" ")
1528
+ line2 = (
1529
+ Content.assemble("Input: ", space.join(modality_parts))
1530
+ if modality_parts
1531
+ else Content("")
1532
+ )
1533
+
1534
+ # Line 3: Capabilities
1535
+ capability_keys = [
1536
+ ("reasoning_output", "reasoning"),
1537
+ ("tool_calling", "tool calling"),
1538
+ ("structured_output", "structured output"),
1539
+ ]
1540
+ cap_parts = _format_flags(capability_keys)
1541
+ line3 = (
1542
+ Content.assemble("Capabilities: ", space.join(cap_parts))
1543
+ if cap_parts
1544
+ else Content("")
1545
+ )
1546
+
1547
+ # Line 4: Override notice
1548
+ displayed_keys = {k for k, _ in token_keys + modality_keys + capability_keys}
1549
+ has_visible_override = bool(overridden & displayed_keys)
1550
+ line4 = (
1551
+ Content.from_markup("[dim][yellow]*[/yellow] = override[/dim]")
1552
+ if has_visible_override
1553
+ else Content("")
1554
+ )
1555
+
1556
+ return Content.assemble(line1, "\n", line2, "\n", line3, "\n", line4)
1557
+
1558
+ def _get_model_status(self, model_spec: str) -> str | None:
1559
+ """Look up the status field for a model from its profile.
1560
+
1561
+ Args:
1562
+ model_spec: The `provider:model` string.
1563
+
1564
+ Returns:
1565
+ Status string (e.g., `'deprecated'`) if the model has a profile
1566
+ with a `status` key, otherwise None.
1567
+ """
1568
+ entry = self._profiles.get(model_spec)
1569
+ if entry is None:
1570
+ return None
1571
+ profile = entry.get("profile")
1572
+ if not profile:
1573
+ return None
1574
+ return profile.get("status")
1575
+
1576
+ def _get_model_display_name(self, model_spec: str) -> str:
1577
+ """Resolve the friendly display name for a model spec.
1578
+
1579
+ Used by every row (provider-grouped and the cross-provider Recent
1580
+ section) and folded into the search haystack. Prefers the profile's
1581
+ human-readable `name` (e.g. `'Claude Sonnet 5'`), which reads better
1582
+ than the raw model id. When no profile is loaded — the case for
1583
+ uninstalled recommendations and onboarding — falls back to the
1584
+ hardcoded name in `_RECOMMENDED_MODELS`, then the model portion of the
1585
+ spec, then the spec itself.
1586
+
1587
+ Args:
1588
+ model_spec: The `provider:model` string.
1589
+
1590
+ Returns:
1591
+ The display name for the row.
1592
+ """
1593
+ entry = self._profiles.get(model_spec)
1594
+ if entry:
1595
+ profile = entry.get("profile")
1596
+ # `profile` originates from provider packages, so guard its type
1597
+ # rather than trusting the schema before `.get`.
1598
+ if isinstance(profile, dict):
1599
+ name = profile.get("name")
1600
+ if isinstance(name, str) and name:
1601
+ return name
1602
+ recommended = _RECOMMENDED_MODELS.get(model_spec)
1603
+ if recommended:
1604
+ return recommended
1605
+ parsed = ModelSpec.try_parse(model_spec)
1606
+ # `parsed.model` can be empty for a malformed spec like `provider:`;
1607
+ # fall back to the raw spec rather than rendering a blank row.
1608
+ return parsed.model if parsed and parsed.model else model_spec
1609
+
1610
+ def _update_footer(self) -> None:
1611
+ """Update the detail footer for the currently highlighted model."""
1612
+ footer = self.query_one("#model-detail-footer", Static)
1613
+ if not self._filtered_models:
1614
+ footer.update(Content.styled("No model selected", "dim"))
1615
+ return
1616
+ index = min(self._selected_index, len(self._filtered_models) - 1)
1617
+ spec, _ = self._filtered_models[index]
1618
+ entry = self._profiles.get(spec)
1619
+ try:
1620
+ text = self._format_footer(entry, get_glyphs())
1621
+ except (KeyError, ValueError, TypeError): # Resilient footer rendering
1622
+ logger.warning("Failed to format footer for %s", spec, exc_info=True)
1623
+ text = Content.styled("Could not load profile details\n\n\n", "dim")
1624
+ footer.update(text)
1625
+
1626
+ def _move_selection(self, delta: int) -> None:
1627
+ """Move selection by delta, updating only the affected widgets.
1628
+
1629
+ Args:
1630
+ delta: Number of positions to move (-1 for up, +1 for down).
1631
+ """
1632
+ if not self._filtered_models or not self._option_widgets:
1633
+ return
1634
+
1635
+ count = len(self._filtered_models)
1636
+ old_index = self._selected_index
1637
+ new_index = (old_index + delta) % count
1638
+ self._selected_index = new_index
1639
+
1640
+ # Update the previously selected widget
1641
+ old_widget = self._option_widgets[old_index]
1642
+ old_widget.remove_class("model-option-selected")
1643
+ old_widget.update(
1644
+ self._build_option_label(
1645
+ old_widget.model_spec,
1646
+ old_widget.provider,
1647
+ old_widget.auth_status,
1648
+ selected=False,
1649
+ show_provider=old_widget.show_provider,
1650
+ )
1651
+ )
1652
+
1653
+ # Update the newly selected widget
1654
+ new_widget = self._option_widgets[new_index]
1655
+ new_widget.add_class("model-option-selected")
1656
+ new_widget.update(
1657
+ self._build_option_label(
1658
+ new_widget.model_spec,
1659
+ new_widget.provider,
1660
+ new_widget.auth_status,
1661
+ selected=True,
1662
+ show_provider=new_widget.show_provider,
1663
+ )
1664
+ )
1665
+
1666
+ # Scroll the selected item into view
1667
+ if new_index == 0:
1668
+ scroll_container = self.query_one(".model-list", VerticalScroll)
1669
+ scroll_container.scroll_home(animate=False)
1670
+ else:
1671
+ new_widget.scroll_visible()
1672
+
1673
+ self._update_footer()
1674
+
1675
+ def action_move_up(self) -> None:
1676
+ """Move selection up."""
1677
+ self._move_selection(-1)
1678
+
1679
+ def action_move_down(self) -> None:
1680
+ """Move selection down."""
1681
+ self._move_selection(1)
1682
+
1683
+ def action_tab_complete(self) -> None:
1684
+ """Replace search text with the currently selected model spec."""
1685
+ if not self._filtered_models:
1686
+ return
1687
+ model_spec, _ = self._filtered_models[self._selected_index]
1688
+ filter_input = self.query_one("#model-filter", Input)
1689
+ filter_input.value = model_spec
1690
+ filter_input.cursor_position = len(model_spec)
1691
+
1692
+ def _visible_page_size(self) -> int:
1693
+ """Return the number of model options that fit in one visual page.
1694
+
1695
+ Returns:
1696
+ Number of model options per page, at least 1.
1697
+ """
1698
+ default_page_size = 10
1699
+ try:
1700
+ scroll = self.query_one(".model-list", VerticalScroll)
1701
+ height = scroll.size.height
1702
+ except Exception: # noqa: BLE001 # Fallback to default page size on any widget query error
1703
+ return default_page_size
1704
+ if height <= 0:
1705
+ return default_page_size
1706
+
1707
+ total_models = len(self._filtered_models)
1708
+ if total_models == 0:
1709
+ return default_page_size
1710
+
1711
+ # Each provider header = 1 row + margin-top: 1 (first has margin 0)
1712
+ num_headers = len(self.query(".model-provider-header"))
1713
+ header_rows = max(0, num_headers * 2 - 1) if num_headers else 0
1714
+ total_rows = total_models + header_rows
1715
+ return max(1, int(height * total_models / total_rows))
1716
+
1717
+ def action_page_up(self) -> None:
1718
+ """Move selection up by one visible page."""
1719
+ if not self._filtered_models:
1720
+ return
1721
+ page = self._visible_page_size()
1722
+ target = max(0, self._selected_index - page)
1723
+ delta = target - self._selected_index
1724
+ if delta != 0:
1725
+ self._move_selection(delta)
1726
+
1727
+ def action_page_down(self) -> None:
1728
+ """Move selection down by one visible page."""
1729
+ if not self._filtered_models:
1730
+ return
1731
+ count = len(self._filtered_models)
1732
+ page = self._visible_page_size()
1733
+ target = min(count - 1, self._selected_index + page)
1734
+ delta = target - self._selected_index
1735
+ if delta != 0:
1736
+ self._move_selection(delta)
1737
+
1738
+ def action_select(self) -> None:
1739
+ """Select the current model."""
1740
+ # If there are filtered results, always select the highlighted model
1741
+ if self._filtered_models:
1742
+ model_spec, provider = self._filtered_models[self._selected_index]
1743
+ self._select_with_auth_check(model_spec, provider)
1744
+ return
1745
+
1746
+ # No matches - check if user typed a custom provider:model spec
1747
+ filter_input = self.query_one("#model-filter", Input)
1748
+ custom_input = filter_input.value.strip()
1749
+
1750
+ if custom_input and ":" in custom_input:
1751
+ provider = custom_input.split(":", 1)[0]
1752
+ self._select_with_auth_check(custom_input, provider)
1753
+ elif custom_input:
1754
+ self._dismiss_with_result((custom_input, ""))
1755
+
1756
+ def _select_with_auth_check(self, model_spec: str, provider: str) -> None:
1757
+ """Either dismiss with the selection, or prompt for credentials first.
1758
+
1759
+ When the highlighted provider has `blocks_start` auth (typically a
1760
+ missing API key), open the in-TUI auth prompt instead of dismissing.
1761
+ On save, dismiss with the originally-selected model. On cancel, stay
1762
+ on the selector and refresh the credential indicator so the user can
1763
+ try again or pick a different provider.
1764
+ """
1765
+ if not provider:
1766
+ self._dismiss_with_result((model_spec, provider))
1767
+ return
1768
+
1769
+ from deepagents_code.config_manifest import (
1770
+ is_provider_package_installed,
1771
+ provider_install_extra,
1772
+ )
1773
+
1774
+ extra = provider_install_extra(provider)
1775
+ if extra is not None and not is_provider_package_installed(provider):
1776
+ if self._curated:
1777
+ # Onboarding installs first, then prompts for credentials from the
1778
+ # launch flow, matching the dependency screen's auto-install copy.
1779
+ self._dismiss_with_result((model_spec, provider))
1780
+ return
1781
+ self._prompt_install_provider(model_spec, provider, extra)
1782
+ return
1783
+
1784
+ status = get_provider_auth_status(provider)
1785
+ if not status.blocks_start:
1786
+ self._dismiss_with_result((model_spec, provider))
1787
+ return
1788
+
1789
+ if provider == CODEX_PROVIDER:
1790
+ # ChatGPT auth is an OAuth browser flow, not an API key, so the
1791
+ # generic key/base-url prompt doesn't apply. Route to the
1792
+ # dedicated sign-in modal (the same one the auth manager uses).
1793
+ self._prompt_codex_sign_in(model_spec, provider)
1794
+ return
1795
+
1796
+ env_var = status.env_var or get_credential_env_var(provider)
1797
+
1798
+ from deepagents_code.tui.widgets.auth import AuthPromptScreen, AuthResult
1799
+
1800
+ def _on_auth_done(result: AuthResult | None) -> None:
1801
+ if result is AuthResult.SAVED:
1802
+ self._dismiss_with_result((model_spec, provider))
1803
+ return
1804
+ # On DELETED or CANCELLED the user explicitly chose not to
1805
+ # provide a key; refresh the credential indicator and stay on
1806
+ # the selector so they can pick a different provider.
1807
+ self.call_after_refresh(self._update_display)
1808
+
1809
+ self.app.push_screen(
1810
+ AuthPromptScreen(
1811
+ provider,
1812
+ env_var,
1813
+ reason=f"Required to use {model_spec}",
1814
+ ),
1815
+ _on_auth_done,
1816
+ )
1817
+
1818
+ def _prompt_install_provider(
1819
+ self, model_spec: str, provider: str, extra: str
1820
+ ) -> None:
1821
+ """Confirm installing a provider's extra before selecting its model.
1822
+
1823
+ On confirm, record the extra on `pending_install_extra` and dismiss
1824
+ with the selected model so the app can install the extra and then
1825
+ switch. On cancel, refresh the credential indicator and stay on the
1826
+ selector so the user can pick a different provider.
1827
+ """
1828
+ from deepagents_code.tui.widgets.install_confirm import (
1829
+ InstallProviderConfirmScreen,
1830
+ )
1831
+
1832
+ def _on_confirm(proceed: bool | None) -> None:
1833
+ if proceed:
1834
+ self.pending_install_extra = extra
1835
+ self._dismiss_with_result((model_spec, provider))
1836
+ return
1837
+ self.call_after_refresh(self._update_display)
1838
+
1839
+ self.app.push_screen(
1840
+ InstallProviderConfirmScreen(provider, extra, model_spec),
1841
+ _on_confirm,
1842
+ )
1843
+
1844
+ def _prompt_codex_sign_in(self, model_spec: str, provider: str) -> None:
1845
+ """Confirm, then run the ChatGPT OAuth flow for the selected model.
1846
+
1847
+ Signing in launches a browser and a multi-minute loopback wait, so a
1848
+ confirmation modal is shown first. If the user declines, refresh the
1849
+ credential indicator and stay on the selector so they can retry or
1850
+ pick a different provider.
1851
+ """
1852
+ from deepagents_code.tui.widgets.auth import AuthConfirmScreen
1853
+
1854
+ def _on_confirm(proceed: bool | None) -> None:
1855
+ if proceed:
1856
+ self._run_codex_oauth(model_spec, provider)
1857
+ return
1858
+ self.call_after_refresh(self._update_display)
1859
+
1860
+ confirm = AuthConfirmScreen(
1861
+ title="No ChatGPT sign-in detected",
1862
+ body=Content.from_markup(
1863
+ "[bold]$model[/bold] authenticates with ChatGPT, but no "
1864
+ "sign-in was detected. Sign in now, or return to the model "
1865
+ "list.",
1866
+ model=model_spec,
1867
+ ),
1868
+ help_text="Enter to sign in, Esc to return to model list",
1869
+ )
1870
+ self.app.push_screen(confirm, _on_confirm)
1871
+
1872
+ def _run_codex_oauth(self, model_spec: str, provider: str) -> None:
1873
+ """Run the ChatGPT OAuth sign-in flow for the selected codex model.
1874
+
1875
+ On a successful sign-in, dismiss with the originally-selected model.
1876
+ On cancel or error, refresh the credential indicator and stay on the
1877
+ selector so the user can retry or pick a different provider.
1878
+ """
1879
+ from deepagents_code.model_config import clear_caches
1880
+ from deepagents_code.tui.widgets.codex_auth import CodexAuthScreen
1881
+
1882
+ def _on_codex_done(signed_in: bool | None) -> None:
1883
+ clear_caches()
1884
+ if signed_in:
1885
+ self._dismiss_with_result((model_spec, provider))
1886
+ return
1887
+ self.call_after_refresh(self._update_display)
1888
+
1889
+ self.app.push_screen(CodexAuthScreen(), _on_codex_done)
1890
+
1891
+ async def action_set_default(self) -> None:
1892
+ """Toggle the highlighted model as the default.
1893
+
1894
+ If the highlighted model is already the default, clears it.
1895
+ Otherwise sets it as the new default.
1896
+ """
1897
+ if not self._filtered_models or not self._option_widgets:
1898
+ return
1899
+
1900
+ model_spec, _provider = self._filtered_models[self._selected_index]
1901
+ help_widget = self.query_one(".model-selector-help", Static)
1902
+
1903
+ if model_spec == self._default_spec:
1904
+ # Already default — clear it
1905
+ if await asyncio.to_thread(clear_default_model):
1906
+ self._default_spec = None
1907
+ self.call_after_refresh(self._update_display)
1908
+ help_widget.update(Content.styled("Default cleared", "bold"))
1909
+ self.set_timer(3.0, self._restore_help_text)
1910
+ else:
1911
+ help_widget.update(
1912
+ Content.styled(
1913
+ "Failed to clear default",
1914
+ f"bold {theme.get_theme_colors(self).error}",
1915
+ )
1916
+ )
1917
+ self.set_timer(3.0, self._restore_help_text)
1918
+ elif await asyncio.to_thread(save_default_model, model_spec):
1919
+ self._default_spec = model_spec
1920
+ self.call_after_refresh(self._update_display)
1921
+ help_widget.update(
1922
+ Content.from_markup(
1923
+ "[bold]Default set to $spec[/bold]", spec=model_spec
1924
+ )
1925
+ )
1926
+ self.set_timer(3.0, self._restore_help_text)
1927
+ else:
1928
+ help_widget.update(
1929
+ Content.styled(
1930
+ "Failed to save default",
1931
+ f"bold {theme.get_theme_colors(self).error}",
1932
+ )
1933
+ )
1934
+ self.set_timer(3.0, self._restore_help_text)
1935
+
1936
+ def _restore_help_text(self) -> None:
1937
+ """Restore the default help text after a temporary message."""
1938
+ help_widget = self.query_one(".model-selector-help", Static)
1939
+ help_widget.update(self._help_text())
1940
+
1941
+ async def action_toggle_recommended(self) -> None:
1942
+ """Toggle between the full model list and the recommended subset.
1943
+
1944
+ Disabled while in `_curated` (onboarding) mode — that screen is
1945
+ already constrained to the recommended subset and the user should
1946
+ finish or skip onboarding rather than browse the full list.
1947
+ Preserves the highlighted model when it survives the toggle and
1948
+ falls back to the current/default/first model otherwise.
1949
+ """
1950
+ if self._curated or not self._loaded:
1951
+ return
1952
+
1953
+ prev_spec: str | None = None
1954
+ if self._filtered_models and 0 <= self._selected_index < len(
1955
+ self._filtered_models
1956
+ ):
1957
+ prev_spec = self._filtered_models[self._selected_index][0]
1958
+
1959
+ self._recommended_only = not self._recommended_only
1960
+ self._all_models = self._apply_subset(self._unfiltered_models)
1961
+
1962
+ if self._filter_text.strip():
1963
+ self._update_filtered_list()
1964
+ else:
1965
+ self._filtered_models = list(self._all_models)
1966
+ self._selected_index = self._find_current_model_index()
1967
+
1968
+ if prev_spec is not None:
1969
+ for i, (spec, _) in enumerate(self._filtered_models):
1970
+ if spec == prev_spec:
1971
+ self._selected_index = i
1972
+ break
1973
+
1974
+ info = self.query_one("#model-selector-info", Static)
1975
+ info.update(self._info_line_content())
1976
+
1977
+ await self._update_display()
1978
+
1979
+ def action_toggle_names(self) -> None:
1980
+ """Toggle rows between friendly display names and raw model specs.
1981
+
1982
+ Mirrors the `/theme` picker's label/key toggle: pressing Ctrl+N flips
1983
+ every visible row between its human-readable name (e.g.
1984
+ `Claude Sonnet 5`) and the canonical `provider:model` spec (e.g.
1985
+ `anthropic:claude-sonnet-5`) so the user can read or copy the exact id
1986
+ without leaving the picker. Relabels the mounted rows in place — the
1987
+ toggle changes neither ordering nor selection, so a full rebuild is
1988
+ unnecessary — and stays available in curated/onboarding mode since it
1989
+ only affects presentation.
1990
+ """
1991
+ if not self._loaded:
1992
+ return
1993
+ self._show_specs = not self._show_specs
1994
+ self._relabel_options()
1995
+
1996
+ def _relabel_options(self) -> None:
1997
+ """Rebuild each mounted row's label for the current display mode.
1998
+
1999
+ Used by `action_toggle_names` after flipping `_show_specs`. Each entry
2000
+ in `_option_widgets` lines up with its `_filtered_models` index, so the
2001
+ highlighted row is re-derived from `_selected_index` to preserve the
2002
+ selected styling.
2003
+ """
2004
+ for index, widget in enumerate(self._option_widgets):
2005
+ widget.update(
2006
+ self._build_option_label(
2007
+ widget.model_spec,
2008
+ widget.provider,
2009
+ widget.auth_status,
2010
+ selected=index == self._selected_index,
2011
+ show_provider=widget.show_provider,
2012
+ )
2013
+ )
2014
+
2015
+ def action_cancel(self) -> None:
2016
+ """Cancel the selection."""
2017
+ self._dismiss_with_result(None)
2018
+
2019
+ def _dismiss_with_result(self, result: tuple[str, str] | None) -> None:
2020
+ """Dismiss the selector and notify an optional direct result callback."""
2021
+ if self._result_callback is not None:
2022
+ self._result_callback(result)
2023
+ self.dismiss(result)
2024
+
2025
+ async def action_add_custom_provider(self) -> None:
2026
+ """Open the custom provider add modal."""
2027
+ def _on_provider_saved(success: bool) -> None:
2028
+ help_widget = self.query_one(".model-selector-help", Static)
2029
+ if success:
2030
+ # Reload model list to show new provider
2031
+ self._reload_task = asyncio.create_task(self._reload_model_list())
2032
+ help_widget.update(
2033
+ Content.styled(
2034
+ "Custom provider added successfully!", "bold green"
2035
+ )
2036
+ )
2037
+ self.set_timer(3.0, self._restore_help_text)
2038
+ else:
2039
+ help_widget.update(
2040
+ Content.styled("Failed to add custom provider", "bold red")
2041
+ )
2042
+ self.set_timer(3.0, self._restore_help_text)
2043
+
2044
+ self.app.push_screen(CustomProviderModalScreen(), _on_provider_saved)
2045
+
2046
+ async def _reload_model_list(self) -> None:
2047
+ """Reload the model list after adding a custom provider."""
2048
+ # Offload to thread because get_available_models does filesystem I/O
2049
+ try:
2050
+ data = await asyncio.to_thread(
2051
+ self._load_model_data,
2052
+ self._cli_profile_override,
2053
+ include_uninstalled=True,
2054
+ include_recent=not self._curated,
2055
+ )
2056
+ except Exception:
2057
+ logger.exception("Failed to reload model data")
2058
+ self.notify(
2059
+ "Could not reload model list.",
2060
+ severity="error",
2061
+ timeout=3.0,
2062
+ markup=False,
2063
+ )
2064
+ return
2065
+
2066
+ # Screen may have been dismissed while the thread was running
2067
+ if not self.is_running:
2068
+ return
2069
+
2070
+ self._unfiltered_models = data.all_models
2071
+ self._default_spec = data.default_spec
2072
+ self._profiles = data.profiles
2073
+ self._recent_specs = data.recent_specs
2074
+ self._install_extras = data.install_extras
2075
+ self._all_models = self._apply_subset(self._unfiltered_models)
2076
+ self._filtered_models = list(self._all_models)
2077
+ self._selected_index = self._initial_selected_index()
2078
+
2079
+ # Re-apply any filter text
2080
+ if self._filter_text:
2081
+ self._update_filtered_list()
2082
+
2083
+ await self._update_display()
2084
+ self._update_footer()
2085
+
2086
+
2087
+ class CustomProviderModalScreen(ModalScreen[bool]):
2088
+ """Modal screen for adding/editing a custom OpenAI-compatible provider."""
2089
+
2090
+ BINDINGS: ClassVar[list[BindingType]] = [
2091
+ Binding("enter", "submit", "Save provider", priority=True),
2092
+ Binding("escape", "cancel", "Cancel", priority=True),
2093
+ ]
2094
+
2095
+ def __init__(
2096
+ self, provider_id: str | None = None, **kwargs: Any,
2097
+ ) -> None:
2098
+ """Initialize the custom provider modal.
2099
+
2100
+ Args:
2101
+ provider_id: The provider ID to edit, or ``None`` to add a new one.
2102
+ **kwargs: Extra keyword arguments forwarded to `ModalScreen`.
2103
+ """
2104
+ super().__init__(**kwargs)
2105
+ self.provider_id = provider_id
2106
+ self.existing_providers: dict[str, dict[str, Any]] = {}
2107
+
2108
+ CSS = """
2109
+ CustomProviderModalScreen {
2110
+ align: center middle;
2111
+ }
2112
+
2113
+ CustomProviderModalScreen > Vertical {
2114
+ background: $surface;
2115
+ border: thick $primary;
2116
+ width: 60;
2117
+ height: auto;
2118
+ padding: 1 2;
2119
+ }
2120
+
2121
+ CustomProviderModalScreen .form-title {
2122
+ text-align: center;
2123
+ width: 100%;
2124
+ height: 1;
2125
+ margin-bottom: 1;
2126
+ }
2127
+
2128
+ CustomProviderModalScreen .form-label {
2129
+ width: 100%;
2130
+ height: 1;
2131
+ margin-top: 1;
2132
+ }
2133
+
2134
+ CustomProviderModalScreen Input {
2135
+ width: 100%;
2136
+ height: 3;
2137
+ }
2138
+
2139
+ CustomProviderModalScreen .form-buttons {
2140
+ layout: horizontal;
2141
+ width: 100%;
2142
+ height: 3;
2143
+ margin-top: 1;
2144
+ }
2145
+
2146
+ CustomProviderModalScreen .form-buttons-left {
2147
+ width: 1fr;
2148
+ height: 100%;
2149
+ align: left middle;
2150
+ }
2151
+
2152
+ CustomProviderModalScreen .form-buttons-right {
2153
+ width: 1fr;
2154
+ height: 100%;
2155
+ align: right middle;
2156
+ }
2157
+
2158
+ CustomProviderModalScreen Button {
2159
+ margin-left: 1;
2160
+ }
2161
+
2162
+ CustomProviderModalScreen .error-message {
2163
+ color: $error;
2164
+ height: 1;
2165
+ margin-top: 1;
2166
+ text-align: center;
2167
+ }
2168
+ """
2169
+
2170
+ def compose(self) -> ComposeResult:
2171
+ """Compose the modal's form widgets.
2172
+
2173
+ Yields:
2174
+ Textual widgets that render the form.
2175
+ """
2176
+ with Vertical():
2177
+ title = (
2178
+ "Edit Custom Provider" if self.provider_id else "Add Custom Provider"
2179
+ )
2180
+ yield Static(title, classes="form-title")
2181
+ yield Static(
2182
+ "Provider ID (lowercase, numbers, hyphens, underscores):",
2183
+ classes="form-label",
2184
+ )
2185
+ yield Input(id="provider-id", placeholder="e.g. my-provider")
2186
+ yield Static("Display Name:", classes="form-label")
2187
+ yield Input(id="display-name", placeholder="e.g. My Custom Provider")
2188
+ yield Static("Base URL:", classes="form-label")
2189
+ yield Input(
2190
+ id="base-url", placeholder="e.g. https://api.example.com/v1"
2191
+ )
2192
+ yield Static("API Key (optional):", classes="form-label")
2193
+ yield Input(id="api-key", placeholder="sk-...", password=True)
2194
+ yield Static("Default Model (optional):", classes="form-label")
2195
+ yield Input(id="default-model", placeholder="e.g. gpt-4o")
2196
+ yield Static("", id="error-message", classes="error-message")
2197
+ with Container(classes="form-buttons"):
2198
+ with Container(classes="form-buttons-left"):
2199
+ yield Button("Cancel", id="cancel-btn", variant="default")
2200
+ with Container(classes="form-buttons-right"):
2201
+ yield Button("Confirm", id="save-btn", variant="primary")
2202
+
2203
+ def on_mount(self) -> None:
2204
+ """Focus first input on mount; pre-fill if editing an existing provider."""
2205
+ import tomllib
2206
+
2207
+ from deepagents_code.model_config import DEFAULT_CONFIG_PATH
2208
+
2209
+ # Load all existing custom providers from config
2210
+ self.existing_providers = {}
2211
+ try:
2212
+ if DEFAULT_CONFIG_PATH.exists():
2213
+ with DEFAULT_CONFIG_PATH.open("rb") as f:
2214
+ data = tomllib.load(f)
2215
+ self.existing_providers = data.get("models", {}).get("providers", {})
2216
+ except Exception:
2217
+ logger.exception("Failed to load existing custom providers from config")
2218
+
2219
+ if self.provider_id:
2220
+ # Load existing provider config for editing
2221
+ provider_config = self.existing_providers.get(self.provider_id, {})
2222
+ if provider_config:
2223
+ self.query_one("#provider-id", Input).value = self.provider_id
2224
+ # Can't edit ID for existing providers
2225
+ self.query_one("#provider-id", Input).disabled = True
2226
+ self.query_one("#display-name", Input).value = provider_config.get(
2227
+ "display_name", ""
2228
+ )
2229
+ self.query_one("#base-url", Input).value = provider_config.get(
2230
+ "base_url", ""
2231
+ )
2232
+ self.query_one("#default-model", Input).value = provider_config.get(
2233
+ "default_model", ""
2234
+ )
2235
+ # API key is not stored in config, it's in env var, so leave empty
2236
+
2237
+ if not self.provider_id:
2238
+ self.query_one("#provider-id", Input).focus()
2239
+ else:
2240
+ self.query_one("#display-name", Input).focus()
2241
+
2242
+ def on_input(self, event: Input.Changed) -> None:
2243
+ """Handle input changes, auto-fill existing provider data when ID matches."""
2244
+ if event.input.id == "provider-id" and not self.provider_id:
2245
+ provider_id = event.value.strip()
2246
+ if not provider_id:
2247
+ # If input is cleared, re-enable and clear other fields
2248
+ event.input.disabled = False
2249
+ self.query_one("#display-name", Input).value = ""
2250
+ self.query_one("#base-url", Input).value = ""
2251
+ elif (
2252
+ provider_id in self.existing_providers
2253
+ and not event.input.disabled
2254
+ ):
2255
+ # Auto-fill data for existing provider
2256
+ provider_config = self.existing_providers[provider_id]
2257
+ self.query_one("#display-name", Input).value = provider_config.get(
2258
+ "display_name", ""
2259
+ )
2260
+ self.query_one("#base-url", Input).value = provider_config.get(
2261
+ "base_url", ""
2262
+ )
2263
+ self.query_one("#default-model", Input).value = provider_config.get(
2264
+ "default_model", ""
2265
+ )
2266
+ # Disable provider ID input to prevent modification
2267
+ event.input.disabled = True
2268
+ # Move focus to next field
2269
+ self.query_one("#display-name", Input).focus()
2270
+
2271
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
2272
+ """Handle button presses."""
2273
+ if event.button.id == "cancel-btn":
2274
+ self.action_cancel()
2275
+ elif event.button.id == "save-btn":
2276
+ await self.action_submit()
2277
+
2278
+ async def action_submit(self) -> None:
2279
+ """Validate and submit the form."""
2280
+ provider_id = self.query_one("#provider-id", Input).value.strip()
2281
+ display_name = self.query_one("#display-name", Input).value.strip()
2282
+ base_url = self.query_one("#base-url", Input).value.strip()
2283
+ api_key = self.query_one("#api-key", Input).value.strip() or None
2284
+ default_model = self.query_one("#default-model", Input).value.strip() or None
2285
+ error_widget = self.query_one("#error-message", Static)
2286
+ # Clear any prior validation message so a successful submit doesn't
2287
+ # leave a stale error visible in the modal (or reported by tests).
2288
+ error_widget.update("")
2289
+
2290
+ # Validate inputs
2291
+ if not provider_id:
2292
+ error_widget.update("Provider ID is required")
2293
+ return
2294
+ if not all(c.islower() or c.isdigit() or c in "-_" for c in provider_id):
2295
+ error_widget.update(
2296
+ "Provider ID can only contain lowercase letters, numbers, "
2297
+ "hyphens, and underscores"
2298
+ )
2299
+ return
2300
+ if not display_name:
2301
+ error_widget.update("Display name is required")
2302
+ return
2303
+ if not base_url:
2304
+ error_widget.update("Base URL is required")
2305
+ return
2306
+ if not base_url.startswith(("http://", "https://")):
2307
+ error_widget.update("Base URL must start with http:// or https://")
2308
+ return
2309
+ # Validate model ID if provided
2310
+ if default_model:
2311
+ if len(default_model) > _MAX_MODEL_ID_LEN:
2312
+ error_widget.update("Model ID cannot exceed 255 characters")
2313
+ return
2314
+ # Allow alphanumeric, hyphens, underscores, dots, colons, slashes
2315
+ # (common model ID characters)
2316
+ if not all(c.isalnum() or c in "-_.:/" for c in default_model):
2317
+ error_widget.update(
2318
+ "Model ID can only contain letters, numbers, hyphens, "
2319
+ "underscores, dots, colons, and slashes"
2320
+ )
2321
+ return
2322
+
2323
+ # Save the provider
2324
+ from deepagents_code.model_config import save_custom_provider
2325
+ success = await asyncio.to_thread(
2326
+ save_custom_provider,
2327
+ provider_id=provider_id,
2328
+ display_name=display_name,
2329
+ base_url=base_url,
2330
+ api_key=api_key,
2331
+ default_model=default_model,
2332
+ )
2333
+
2334
+ if success:
2335
+ self.dismiss(success)
2336
+ else:
2337
+ error_widget.update(
2338
+ "Failed to save custom provider. Check permissions and try again."
2339
+ )
2340
+
2341
+ def action_cancel(self) -> None:
2342
+ """Cancel the form."""
2343
+ self.dismiss(False)