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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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)
|