zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1997 @@
1
+ """TUI screens for managing stored model-provider credentials.
2
+
3
+ `AuthPromptScreen` accepts an API key for a single provider, persists it via
4
+ `auth_store`, and is the sole place that deletes existing credentials (after
5
+ a `DeleteCredentialConfirmScreen` confirmation). `AuthManagerScreen` lists
6
+ known providers and routes the user into the prompt; it does not delete
7
+ directly. Both are reachable via the `/auth` slash command.
8
+
9
+ Security notes:
10
+
11
+ - Inputs are rendered with `password=True` so the key is never echoed to
12
+ the terminal.
13
+ - This module never logs the key value, never includes it in `notify()`
14
+ payloads, and never round-trips it through Rich markup. Callers that
15
+ introduce new logging here must do the same.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ from enum import StrEnum
23
+ from functools import partial
24
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple
25
+ from urllib.parse import urlsplit
26
+
27
+ from textual.binding import Binding, BindingType
28
+ from textual.color import Color as TColor
29
+ from textual.containers import Vertical
30
+ from textual.content import Content
31
+ from textual.message import Message
32
+ from textual.screen import ModalScreen
33
+ from textual.style import Style as TStyle
34
+ from textual.widgets import Input, OptionList, RadioButton, RadioSet, Static
35
+ from textual.widgets.option_list import Option, OptionDoesNotExist
36
+
37
+ if TYPE_CHECKING:
38
+ from textual.app import ComposeResult
39
+ from textual.events import Click, MouseMove
40
+
41
+ from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction
42
+
43
+ from deepagents_code import auth_store, theme
44
+ from deepagents_code.auth_display import format_auth_badge
45
+ from deepagents_code.config import (
46
+ LANGSMITH_EU_ENDPOINT,
47
+ LANGSMITH_US_ENDPOINT,
48
+ apply_stored_langsmith_auth,
49
+ get_glyphs,
50
+ is_ascii_mode,
51
+ is_http_url,
52
+ normalize_langsmith_endpoint,
53
+ )
54
+ from deepagents_code.model_config import (
55
+ CODEX_PROVIDER,
56
+ PROVIDER_API_KEY_ENV,
57
+ PROVIDERS_DOCS_URL as _PROVIDERS_DOCS_URL,
58
+ SERVICE_API_KEY_ENV,
59
+ ModelConfig,
60
+ ProviderAuthSource,
61
+ ProviderAuthState,
62
+ ProviderAuthStatus,
63
+ clear_caches,
64
+ get_available_models,
65
+ get_base_url_env_var,
66
+ get_base_url_env_vars,
67
+ get_credential_env_var,
68
+ get_default_base_url_env,
69
+ get_provider_auth_status,
70
+ get_service_auth_status,
71
+ is_langsmith,
72
+ is_service,
73
+ resolved_env_var_name,
74
+ )
75
+ from deepagents_code.tui.widgets._links import open_style_link
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ CONFIGURATION_DOCS_URL = (
81
+ "https://docs.langchain.com/oss/python/deepagents/code/configuration"
82
+ )
83
+
84
+
85
+ class Region(StrEnum):
86
+ """The closed set of LangSmith region selections in the `/auth` prompt."""
87
+
88
+ US = "us"
89
+ EU = "eu"
90
+ CUSTOM = "custom"
91
+
92
+
93
+ class _RegionSpec(NamedTuple):
94
+ """One region row: its enum, radio widget id, SaaS endpoint, and label.
95
+
96
+ The single source of truth for the region <-> radio-id <-> endpoint mapping,
97
+ so `_REGION_BY_RADIO_ID`, `compose`'s radio buttons, `_region_for_endpoint`,
98
+ and `_resolve_langsmith_endpoint` can't drift apart. `endpoint` is the
99
+ canonical URL stored for a fixed-SaaS region (`""` for the US default, which
100
+ clears the stored endpoint); `Region.CUSTOM` carries `""` here because the
101
+ user supplies its endpoint at prompt time, so it is handled explicitly.
102
+ """
103
+
104
+ region: Region
105
+ radio_id: str
106
+ endpoint: str
107
+ label: str
108
+
109
+
110
+ _REGION_SPECS: tuple[_RegionSpec, ...] = (
111
+ _RegionSpec(Region.US, "auth-region-us", "", "United States (default)"),
112
+ _RegionSpec(Region.EU, "auth-region-eu", LANGSMITH_EU_ENDPOINT, "Europe"),
113
+ _RegionSpec(
114
+ Region.CUSTOM, "auth-region-custom", "", "Custom (self-hosted / proxy)"
115
+ ),
116
+ )
117
+
118
+ _REGION_BY_RADIO_ID: dict[str, Region] = {
119
+ spec.radio_id: spec.region for spec in _REGION_SPECS
120
+ }
121
+ """Map each region radio's widget id to its region, avoiding string-munging."""
122
+
123
+ _ENDPOINT_BY_REGION: dict[Region, str] = {
124
+ spec.region: spec.endpoint for spec in _REGION_SPECS
125
+ }
126
+ """Canonical endpoint stored for each fixed-SaaS region (Custom is dynamic)."""
127
+
128
+
129
+ class ResolvedEndpoint(NamedTuple):
130
+ """Outcome of resolving the region selector to an endpoint to persist.
131
+
132
+ `endpoint` is the canonical URL to store (empty for the US SaaS default);
133
+ `error` is a user-facing message when a Custom URL is missing or malformed,
134
+ in which case `endpoint` is empty and nothing should be saved.
135
+ """
136
+
137
+ endpoint: str
138
+ error: str | None
139
+
140
+
141
+ def _region_for_endpoint(base_url: str) -> Region:
142
+ """Map a stored LangSmith endpoint to its `/auth` region selection.
143
+
144
+ Args:
145
+ base_url: The stored endpoint, or an empty string for the SaaS default.
146
+
147
+ Returns:
148
+ `Region.US` (blank, or the canonical US SaaS URL that the CLI
149
+ `--base-url us` shorthand stores), `Region.EU` (the EU SaaS URL), or
150
+ `Region.CUSTOM` (any other endpoint, e.g. self-hosted).
151
+ """
152
+ if not base_url or base_url == LANGSMITH_US_ENDPOINT:
153
+ return Region.US
154
+ for spec in _REGION_SPECS:
155
+ if spec.endpoint and base_url == spec.endpoint:
156
+ return spec.region
157
+ return Region.CUSTOM
158
+
159
+
160
+ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
161
+ "anthropic": "Anthropic",
162
+ "azure_openai": "Azure OpenAI",
163
+ "baseten": "Baseten",
164
+ "cohere": "Cohere",
165
+ "deepseek": "DeepSeek",
166
+ "fireworks": "Fireworks",
167
+ "google_genai": "Google Gemini",
168
+ "google_vertexai": "Google Vertex AI",
169
+ "groq": "Groq",
170
+ "huggingface": "Hugging Face",
171
+ "ibm": "IBM watsonx",
172
+ "langsmith": "LangSmith (tracing)",
173
+ "litellm": "LiteLLM",
174
+ "meta": "Meta",
175
+ "mistralai": "Mistral AI",
176
+ "nvidia": "NVIDIA",
177
+ "openai": "OpenAI",
178
+ "openai_codex": "OpenAI Codex (ChatGPT login)",
179
+ "openrouter": "OpenRouter",
180
+ "perplexity": "Perplexity",
181
+ "together": "Together AI",
182
+ "xai": "xAI",
183
+ }
184
+
185
+
186
+ PROVIDER_SHORT_NAMES: dict[str, str] = {
187
+ # Only providers whose `PROVIDER_DISPLAY_NAMES` label is too verbose for a
188
+ # compact tag need an entry here; everything else falls back to the display
189
+ # name, which is already short.
190
+ "openai_codex": "OpenAI Codex",
191
+ }
192
+ """Compact brand labels for space-constrained UI (e.g. the `/model` Recent tag).
193
+
194
+ Sparse companion to `PROVIDER_DISPLAY_NAMES`: an entry exists only when the full
195
+ display name carries a parenthetical qualifier that reads badly inside a tag
196
+ (e.g. `"OpenAI Codex (ChatGPT login)"`). Resolved via `provider_short_name`.
197
+ """
198
+
199
+
200
+ PROVIDER_API_KEY_URLS: dict[str, str] = {
201
+ "anthropic": "https://platform.claude.com/login?returnTo=%2Fsettings%2Fkeys",
202
+ "baseten": "https://docs.baseten.co/organization/api-keys",
203
+ "cohere": "https://dashboard.cohere.com/welcome/login?redirect_uri=%2Fapi-keys",
204
+ "deepseek": "https://platform.deepseek.com/api_keys",
205
+ "fireworks": "https://app.fireworks.ai/settings/users/api-keys",
206
+ "google_genai": "https://aistudio.google.com/api-keys",
207
+ "groq": "https://console.groq.com/keys",
208
+ "huggingface": "https://huggingface.co/login?next=%2Fsettings%2Ftokens",
209
+ "ibm": "https://cloud.ibm.com/iam/apikeys",
210
+ "langsmith": "https://smith.langchain.com/settings",
211
+ "litellm": "https://docs.litellm.ai/docs/proxy/virtual_keys",
212
+ "meta": "https://dev.meta.ai/api-keys/",
213
+ "mistralai": "https://console.mistral.ai/api-keys",
214
+ "nvidia": "https://build.nvidia.com/settings/api-keys",
215
+ "openai": "https://platform.openai.com/api-keys",
216
+ "openrouter": "https://openrouter.ai/workspaces/default/keys",
217
+ "perplexity": "https://www.perplexity.ai/settings/api",
218
+ "tavily": "https://app.tavily.com",
219
+ "together": "https://api.together.ai/settings/api-keys",
220
+ "xai": "https://console.x.ai/team/default/api-keys",
221
+ }
222
+
223
+
224
+ def _is_safe_acquisition_url(url: str) -> bool:
225
+ """Return whether `url` is safe to render as a clickable link.
226
+
227
+ Built-in links are trusted, but `api_key_url` can come from user-owned
228
+ config; restricting to `http`/`https` keeps a malformed or
229
+ `javascript:`-scheme value from becoming a live hyperlink.
230
+
231
+ Args:
232
+ url: Candidate link target.
233
+
234
+ Returns:
235
+ `True` if the URL uses an `http` or `https` scheme.
236
+ """
237
+ return urlsplit(url).scheme in {"http", "https"}
238
+
239
+
240
+ def provider_display_name(provider: str, config: ModelConfig | None = None) -> str:
241
+ """Return a human-readable provider label.
242
+
243
+ Shared by the auth UI and the model selector so a provider is labeled
244
+ identically in both. (The install prompt reuses the underlying
245
+ `PROVIDER_DISPLAY_NAMES` map directly rather than this function, to avoid an
246
+ event-loop config read, so a user-configured `display_name` won't surface
247
+ there.)
248
+
249
+ Resolution order: a configured `display_name`, then the built-in
250
+ `PROVIDER_DISPLAY_NAMES` map, then a title-cased form of the provider key.
251
+
252
+ Args:
253
+ provider: Provider config key.
254
+ config: Parsed model config, if already loaded by the caller.
255
+
256
+ Returns:
257
+ Configured display name, built-in display name, or title-cased provider key.
258
+ """
259
+ model_config = config or ModelConfig.load()
260
+ return model_config.get_provider_display_name(
261
+ provider
262
+ ) or PROVIDER_DISPLAY_NAMES.get(provider, provider.replace("_", " ").title())
263
+
264
+
265
+ def provider_short_name(provider: str, config: ModelConfig | None = None) -> str:
266
+ """Return a compact brand label for a provider.
267
+
268
+ For space-constrained UI (e.g. the `/model` Recent tag). Resolution order:
269
+ a configured `short_name`, then the built-in `PROVIDER_SHORT_NAMES` map,
270
+ then the full `provider_display_name` (which is already short for providers
271
+ without a parenthetical qualifier).
272
+
273
+ Args:
274
+ provider: Provider config key.
275
+ config: Parsed model config, if already loaded by the caller.
276
+
277
+ Returns:
278
+ Compact brand label, falling back to the display name when none is set.
279
+ """
280
+ model_config = config or ModelConfig.load()
281
+ return (
282
+ model_config.get_provider_short_name(provider)
283
+ or PROVIDER_SHORT_NAMES.get(provider)
284
+ or provider_display_name(provider, model_config)
285
+ )
286
+
287
+
288
+ def _auth_status_for(provider: str) -> ProviderAuthStatus:
289
+ """Resolve the credential readiness of a provider or non-model service.
290
+
291
+ Routes services (e.g. Tavily) and model providers to their respective
292
+ status helpers. Each call reads the credential file, so callers that need
293
+ the status more than once should resolve it here a single time and reuse
294
+ the result.
295
+
296
+ Args:
297
+ provider: Provider or service config key.
298
+
299
+ Returns:
300
+ The auth status used for both ordering and badge rendering.
301
+ """
302
+ if is_service(provider):
303
+ return get_service_auth_status(provider)
304
+ return get_provider_auth_status(provider)
305
+
306
+
307
+ class AuthResult(StrEnum):
308
+ """Outcome of an `AuthPromptScreen` interaction.
309
+
310
+ The three outcomes need to stay distinguishable because callers in the
311
+ recovery path retry the original failing operation only on `SAVED` —
312
+ retrying after `DELETED` would loop into the same missing-credentials
313
+ error indefinitely.
314
+ """
315
+
316
+ SAVED = "saved"
317
+ """A key was persisted, or a reload (Ctrl+R) made the provider's credential
318
+ resolvable. Either way the caller should retry the original operation."""
319
+
320
+ DELETED = "deleted"
321
+ """User cleared the existing stored key. No retry should follow."""
322
+
323
+ CANCELLED = "cancelled"
324
+ """User dismissed the prompt without saving."""
325
+
326
+
327
+ class AuthConfirmScreen(ModalScreen[bool]):
328
+ """Confirm before launching an authentication flow for a model.
329
+
330
+ A provider-agnostic gate shown when a selected model needs credentials
331
+ that aren't detected, and starting the auth flow is disruptive enough
332
+ that the user should opt in first (e.g. an OAuth flow that launches a
333
+ browser and a multi-minute loopback wait). The caller supplies all copy
334
+ so the screen carries no provider assumptions; currently only the
335
+ `openai_codex` model-switcher path uses it.
336
+
337
+ Dismissal values:
338
+
339
+ - `True`: proceed to the auth flow.
340
+ - `False`: go back without authenticating (also the outcome of Esc).
341
+ """
342
+
343
+ BINDINGS: ClassVar[list[BindingType]] = [
344
+ Binding("enter", "confirm", "Continue", show=False, priority=True),
345
+ Binding("escape", "cancel", "Back", show=False, priority=True),
346
+ ]
347
+
348
+ CSS = """
349
+ AuthConfirmScreen {
350
+ align: center middle;
351
+ }
352
+
353
+ AuthConfirmScreen > Vertical {
354
+ width: 64;
355
+ max-width: 90%;
356
+ height: auto;
357
+ background: $surface;
358
+ border: solid $primary;
359
+ padding: 1 2;
360
+ }
361
+
362
+ AuthConfirmScreen .auth-confirm-title {
363
+ text-style: bold;
364
+ color: $primary;
365
+ text-align: center;
366
+ margin-bottom: 1;
367
+ }
368
+
369
+ AuthConfirmScreen .auth-confirm-body {
370
+ height: auto;
371
+ color: $text;
372
+ margin-bottom: 1;
373
+ }
374
+
375
+ AuthConfirmScreen .auth-confirm-help {
376
+ height: auto;
377
+ color: $text-muted;
378
+ text-style: italic;
379
+ text-align: center;
380
+ }
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ *,
386
+ title: str,
387
+ body: str | Content,
388
+ help_text: str = "Enter to continue, Esc to go back",
389
+ ) -> None:
390
+ """Initialize the prompt.
391
+
392
+ Args:
393
+ title: Heading shown at the top of the dialog.
394
+ body: Explanatory copy. Pass a `Content` for inline styling, or a
395
+ plain string for unstyled text.
396
+ help_text: Key-hint line shown at the bottom.
397
+ """
398
+ super().__init__()
399
+ self._title = title
400
+ self._body = body
401
+ self._help_text = help_text
402
+
403
+ def compose(self) -> ComposeResult:
404
+ """Compose the confirmation dialog.
405
+
406
+ Yields:
407
+ Title, body, and key-hint widgets parented inside a `Vertical`.
408
+ """
409
+ with Vertical():
410
+ yield Static(self._title, classes="auth-confirm-title", markup=False)
411
+ yield Static(self._body, classes="auth-confirm-body", markup=False)
412
+ yield Static(self._help_text, classes="auth-confirm-help", markup=False)
413
+
414
+ def on_mount(self) -> None:
415
+ """Apply ASCII border when needed."""
416
+ if is_ascii_mode():
417
+ container = self.query_one(Vertical)
418
+ colors = theme.get_theme_colors(self)
419
+ container.styles.border = ("ascii", colors.success)
420
+
421
+ def action_confirm(self) -> None:
422
+ """Dismiss with `True` to proceed to the auth flow."""
423
+ self.dismiss(True)
424
+
425
+ def action_cancel(self) -> None:
426
+ """Dismiss with `False` to go back without authenticating.
427
+
428
+ The method name must stay `cancel`: the app owns a priority `escape`
429
+ binding that, for an active `ModalScreen`, dispatches to
430
+ `action_cancel` if present and otherwise falls through to
431
+ `dismiss(None)`. Renaming this would silently regress Esc to a
432
+ `None` dismiss instead of an explicit "go back".
433
+ """
434
+ self.dismiss(False)
435
+
436
+
437
+ class DeleteCredentialConfirmScreen(ModalScreen[bool]):
438
+ """Confirmation overlay shown before clearing a stored credential.
439
+
440
+ Patterned on `DeleteThreadConfirmScreen` so the destructive prompt feels
441
+ consistent across the app. Always dismisses with `True` on confirm or
442
+ `False` on cancel; the caller does the actual delete.
443
+ """
444
+
445
+ BINDINGS: ClassVar[list[BindingType]] = [
446
+ Binding("enter", "confirm", "Confirm", show=False, priority=True),
447
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
448
+ ]
449
+
450
+ CSS = """
451
+ DeleteCredentialConfirmScreen {
452
+ align: center middle;
453
+ }
454
+
455
+ DeleteCredentialConfirmScreen > Vertical {
456
+ width: 56;
457
+ height: auto;
458
+ background: $surface;
459
+ border: solid red;
460
+ padding: 1 2;
461
+ }
462
+
463
+ DeleteCredentialConfirmScreen .auth-confirm-text {
464
+ text-align: center;
465
+ margin-bottom: 1;
466
+ }
467
+
468
+ DeleteCredentialConfirmScreen .auth-confirm-help {
469
+ text-align: center;
470
+ color: $text-muted;
471
+ text-style: italic;
472
+ }
473
+ """
474
+
475
+ def __init__(self, provider: str) -> None:
476
+ """Initialize the confirmation modal.
477
+
478
+ Args:
479
+ provider: Provider whose stored credential is about to be cleared.
480
+ """
481
+ super().__init__()
482
+ self._provider = provider
483
+
484
+ def compose(self) -> ComposeResult:
485
+ """Compose the confirmation dialog.
486
+
487
+ Yields:
488
+ Widgets for the delete confirmation prompt.
489
+ """
490
+ with Vertical():
491
+ yield Static(
492
+ Content.from_markup(
493
+ "Delete stored API key for [bold]$provider[/bold]?",
494
+ provider=self._provider,
495
+ ),
496
+ classes="auth-confirm-text",
497
+ )
498
+ yield Static(
499
+ "Enter to confirm, Esc to cancel",
500
+ classes="auth-confirm-help",
501
+ )
502
+
503
+ def action_confirm(self) -> None:
504
+ """Confirm deletion."""
505
+ self.dismiss(True)
506
+
507
+ def action_cancel(self) -> None:
508
+ """Cancel deletion."""
509
+ self.dismiss(False)
510
+
511
+
512
+ class AuthPromptScreen(ModalScreen[AuthResult]):
513
+ """Modal that captures and persists an API key for one provider.
514
+
515
+ Dismissal values are members of `AuthResult` so callers in the recovery
516
+ path can distinguish "user just saved a key — retry the failed
517
+ operation" from "user just cleared their key — don't retry, that would
518
+ loop into the same error" from "user cancelled — leave state alone".
519
+ """
520
+
521
+ AUTO_FOCUS = "#auth-prompt-input"
522
+
523
+ BINDINGS: ClassVar[list[BindingType]] = [
524
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
525
+ Binding("f2", "toggle_advanced", "Advanced", show=False, priority=True),
526
+ Binding("ctrl+r", "reload_env", "Reload", show=False, priority=True),
527
+ Binding("ctrl+d", "delete_stored", "Delete stored", show=False, priority=True),
528
+ ]
529
+
530
+ CSS = """
531
+ AuthPromptScreen {
532
+ align: center middle;
533
+ }
534
+
535
+ AuthPromptScreen > Vertical {
536
+ width: 72;
537
+ max-width: 90%;
538
+ height: auto;
539
+ background: $surface;
540
+ border: solid $primary;
541
+ padding: 1 2;
542
+ }
543
+
544
+ AuthPromptScreen .auth-prompt-title {
545
+ text-style: bold;
546
+ color: $primary;
547
+ text-align: center;
548
+ margin-bottom: 1;
549
+ }
550
+
551
+ AuthPromptScreen .auth-prompt-copy {
552
+ height: auto;
553
+ color: $text;
554
+ margin-bottom: 1;
555
+ }
556
+
557
+ AuthPromptScreen .auth-prompt-status {
558
+ height: auto;
559
+ color: $text;
560
+ background: $background;
561
+ padding: 0 1;
562
+ margin-bottom: 1;
563
+ }
564
+
565
+ AuthPromptScreen .auth-prompt-instructions {
566
+ height: auto;
567
+ color: $text;
568
+ margin-bottom: 1;
569
+ }
570
+
571
+ AuthPromptScreen .auth-prompt-advanced-toggle {
572
+ height: auto;
573
+ color: $text-muted;
574
+ margin-bottom: 1;
575
+ }
576
+
577
+ AuthPromptScreen #auth-prompt-base-url-label {
578
+ text-align: center;
579
+ }
580
+
581
+ AuthPromptScreen .auth-prompt-meta {
582
+ height: auto;
583
+ color: $text-muted;
584
+ margin-bottom: 1;
585
+ }
586
+
587
+ AuthPromptScreen #auth-prompt-project-hint {
588
+ margin-top: 1;
589
+ }
590
+
591
+ AuthPromptScreen #auth-prompt-region {
592
+ height: auto;
593
+ width: 100%;
594
+ margin-bottom: 1;
595
+ border: solid $primary-lighten-2;
596
+ }
597
+
598
+ AuthPromptScreen #auth-prompt-region:focus-within {
599
+ border: solid $primary;
600
+ }
601
+
602
+ AuthPromptScreen #auth-prompt-input,
603
+ AuthPromptScreen #auth-prompt-base-url {
604
+ margin-bottom: 1;
605
+ border: solid $primary-lighten-2;
606
+ }
607
+
608
+ AuthPromptScreen #auth-prompt-input:focus,
609
+ AuthPromptScreen #auth-prompt-base-url:focus {
610
+ border: solid $primary;
611
+ }
612
+
613
+ AuthPromptScreen .auth-prompt-error {
614
+ height: auto;
615
+ color: $error;
616
+ margin-bottom: 1;
617
+ }
618
+
619
+ AuthPromptScreen .auth-prompt-help {
620
+ height: auto;
621
+ color: $text-muted;
622
+ text-style: italic;
623
+ text-align: center;
624
+ }
625
+ """
626
+
627
+ def __init__(
628
+ self,
629
+ provider: str,
630
+ env_var: str | None,
631
+ *,
632
+ reason: str | None = None,
633
+ allow_empty_submit: bool = False,
634
+ input_placeholder: str | None = None,
635
+ submit_label: str | None = None,
636
+ ) -> None:
637
+ """Initialize the prompt for `provider`.
638
+
639
+ Args:
640
+ provider: Provider name (e.g., `"anthropic"`).
641
+ env_var: Canonical env var the SDK reads, shown as helper text.
642
+ May be `None` for providers that don't use one of the
643
+ hardcoded env-var bindings (rare; the prompt still works).
644
+ reason: Optional context, e.g.,
645
+ `"Required to use anthropic:claude-opus-4-8"`.
646
+ allow_empty_submit: Whether pressing Enter on an empty key dismisses
647
+ with `AuthResult.CANCELLED` instead of showing a validation error.
648
+ input_placeholder: Optional placeholder override for the key input.
649
+ submit_label: Optional help-label override for the Enter action.
650
+ """
651
+ super().__init__()
652
+ self._provider = provider
653
+ self._env_var = env_var
654
+ self._reason = reason
655
+ self._allow_empty_submit = allow_empty_submit
656
+ self._input_placeholder = input_placeholder
657
+ self._submit_label = submit_label
658
+ # LangSmith is configured as a tracing service: it carries an optional
659
+ # project name and an endpoint chosen from a region selector (US/EU SaaS
660
+ # or a custom self-hosted URL), and saving a key turns tracing on.
661
+ self._is_langsmith = is_langsmith(provider)
662
+ # Probe the store once here so compose-time helpers read the cached
663
+ # `self._config` instead of each reloading it. See
664
+ # `_probe_credential_state` for the crash-safety rationale that lets
665
+ # this run at construction (before the modal mounts) without a guard.
666
+ self._probe_credential_state()
667
+ self._advanced_visible = bool(self._existing_base_url or self._existing_project)
668
+ self._refresh_endpoint_env_notice()
669
+
670
+ def _probe_credential_state(self) -> None:
671
+ """Resolve the current credential source, store, base URL, and project.
672
+
673
+ Never let an unreadable auth.json crash the screen: Textual would
674
+ propagate the exception before the modal mounts (at construction) or
675
+ while it is live (on Ctrl+R reload). `auth_store` funnels all store
676
+ corruption into RuntimeError, so catch that, treat unreadable store
677
+ metadata as "no existing key", and surface a one-line warning at
678
+ compose time. The resolved auth status is kept: `get_provider_auth_status`
679
+ already falls back to environment credentials when the store can't be
680
+ read. (A malformed config.toml can't crash us here — `ModelConfig.load()`
681
+ returns an empty config instead of raising.)
682
+ """
683
+ try:
684
+ self._config = ModelConfig.load()
685
+ self._auth_status = _auth_status_for(self._provider)
686
+ except RuntimeError as exc:
687
+ logger.warning(
688
+ "Could not resolve credentials for %s: %s", self._provider, exc
689
+ )
690
+ self._config = ModelConfig()
691
+ self._auth_status = ProviderAuthStatus(
692
+ state=ProviderAuthState.MISSING, provider=self._provider
693
+ )
694
+ self._set_missing_store_metadata(exc)
695
+ return
696
+
697
+ try:
698
+ self._has_existing = auth_store.get_stored_key(self._provider) is not None
699
+ self._existing_base_url = (
700
+ auth_store.get_stored_base_url(self._provider) or ""
701
+ )
702
+ self._existing_project = auth_store.get_stored_project(self._provider) or ""
703
+ self._region: Region = _region_for_endpoint(self._existing_base_url)
704
+ self._store_warning: str | None = None
705
+ except RuntimeError as exc:
706
+ logger.warning(
707
+ "Could not read stored credentials for %s: %s", self._provider, exc
708
+ )
709
+ self._set_missing_store_metadata(exc)
710
+
711
+ def _set_missing_store_metadata(self, exc: RuntimeError) -> None:
712
+ """Clear optional stored metadata after an auth-store read failure."""
713
+ self._has_existing = False
714
+ self._existing_base_url = ""
715
+ self._existing_project = ""
716
+ self._region = Region.US
717
+ self._store_warning = (
718
+ f"Credential file is unreadable ({exc}). Saving here will overwrite it."
719
+ )
720
+
721
+ def _refresh_endpoint_env_notice(self) -> None:
722
+ """Recompute the LangSmith endpoint-precedence notice from the environment.
723
+
724
+ Surface when an environment endpoint is set: at startup an existing
725
+ `LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT` takes precedence over the
726
+ stored region, so without this note the radio could show one region
727
+ while traces route somewhere else. (The note fires on presence, not on
728
+ divergence — it may show even when the env value matches the stored
729
+ region.) Saving here applies the selection (the save path replaces the
730
+ env value), so word the note around that.
731
+
732
+ Called at construction and again on Ctrl+R reload, since the reload can
733
+ add or remove one of those variables and the recompose must reflect it.
734
+ """
735
+ self._endpoint_env_notice: str | None = None
736
+ if self._is_langsmith and any(
737
+ os.environ.get(var) for var in ("LANGSMITH_ENDPOINT", "LANGCHAIN_ENDPOINT")
738
+ ):
739
+ self._endpoint_env_notice = (
740
+ "An endpoint is set in your environment "
741
+ "(LANGSMITH_ENDPOINT/LANGCHAIN_ENDPOINT) and takes precedence at "
742
+ "startup; saving a region here applies your selection instead."
743
+ )
744
+
745
+ def compose(self) -> ComposeResult:
746
+ """Compose the prompt.
747
+
748
+ Yields:
749
+ Widgets that make up the auth prompt modal.
750
+ """
751
+ glyphs = get_glyphs()
752
+ provider_label = provider_display_name(self._provider, self._config)
753
+ with Vertical():
754
+ # Tag the title with `(stored)` so the user knows a replacement
755
+ # (or the `Ctrl+D delete` affordance shown in the help line) is
756
+ # what's about to happen — both are gated on `_has_existing`.
757
+ resolved_from_env = self._auth_status.source is ProviderAuthSource.ENV
758
+ scoped_env_var = None
759
+ if self._auth_status.env_var:
760
+ candidate = resolved_env_var_name(self._auth_status.env_var)
761
+ if candidate.startswith("DEEPAGENTS_CODE_") and os.environ.get(
762
+ candidate
763
+ ):
764
+ scoped_env_var = candidate
765
+ active_env_var = scoped_env_var or (
766
+ resolved_env_var_name(self._auth_status.env_var)
767
+ if resolved_from_env and self._auth_status.env_var
768
+ else None
769
+ )
770
+ if active_env_var and active_env_var.startswith("DEEPAGENTS_CODE_"):
771
+ title_prefix = f"{glyphs.warning} "
772
+ else:
773
+ title_prefix = f"{glyphs.checkmark} " if resolved_from_env else ""
774
+ if self._has_existing:
775
+ title = Content.assemble(
776
+ title_prefix,
777
+ Content.from_markup(
778
+ "Replace key for [bold]$provider[/bold] [dim](stored)[/dim]",
779
+ provider=provider_label,
780
+ ),
781
+ )
782
+ elif resolved_from_env:
783
+ title = Content.assemble(
784
+ title_prefix,
785
+ Content.from_markup(
786
+ "Replace key for [bold]$provider[/bold]",
787
+ provider=provider_label,
788
+ ),
789
+ )
790
+ else:
791
+ title = Content.assemble(
792
+ title_prefix,
793
+ Content.from_markup(
794
+ "API key for [bold]$provider[/bold]",
795
+ provider=provider_label,
796
+ ),
797
+ )
798
+ yield Static(title, classes="auth-prompt-title")
799
+ if active_env_var:
800
+ env_var = active_env_var
801
+ is_scoped_env = env_var.startswith("DEEPAGENTS_CODE_")
802
+ env_status_style = "$warning" if is_scoped_env else "$success"
803
+ env_note = (
804
+ "This scoped env var takes priority. A saved key will be used "
805
+ f"only when {env_var} is unset."
806
+ if is_scoped_env
807
+ else ("Paste a key below to use a different key for dcode.")
808
+ )
809
+ yield Static(
810
+ Content.assemble(
811
+ (
812
+ "Current key is set from environment variable ",
813
+ env_status_style,
814
+ ),
815
+ (env_var, f"bold {env_status_style}"),
816
+ (".", env_status_style),
817
+ "\n",
818
+ (env_note, "italic $text-muted"),
819
+ ),
820
+ classes="auth-prompt-status",
821
+ id="auth-prompt-env-status",
822
+ )
823
+ if self._reason:
824
+ yield Static(
825
+ Content.from_markup("$reason", reason=self._reason),
826
+ classes="auth-prompt-copy",
827
+ )
828
+ yield Static(
829
+ self._build_key_instructions(),
830
+ classes="auth-prompt-instructions",
831
+ id="auth-prompt-key-instructions",
832
+ )
833
+ if self._store_warning:
834
+ yield Static(
835
+ Content.from_markup("$msg", msg=self._store_warning),
836
+ classes="auth-prompt-error",
837
+ )
838
+ yield Input(
839
+ placeholder=self._input_placeholder
840
+ or (
841
+ "Paste a new key to replace the stored one"
842
+ if self._has_existing
843
+ else "Paste your API key"
844
+ ),
845
+ password=True,
846
+ id="auth-prompt-input",
847
+ )
848
+ storage_note: Content | None
849
+ if self._is_langsmith:
850
+ storage_note = Content.from_markup(
851
+ "dcode stores the above key locally and turns on "
852
+ "LangSmith tracing. To pause tracing without removing the key, "
853
+ "set [bold]DEEPAGENTS_CODE_LANGSMITH_TRACING=false[/bold]."
854
+ )
855
+ elif is_service(self._provider):
856
+ # Services (e.g. Tavily) skip the storage note: the title and
857
+ # reason copy already say what the key is for, so it only adds
858
+ # redundant copy here.
859
+ storage_note = None
860
+ else:
861
+ storage_note = Content.from_markup(
862
+ "dcode stores the above key locally and uses it "
863
+ "when you select [bold]$provider[/bold] models.",
864
+ provider=provider_label,
865
+ )
866
+ if storage_note is not None:
867
+ yield Static(
868
+ storage_note,
869
+ classes="auth-prompt-meta",
870
+ id="auth-prompt-storage-note",
871
+ )
872
+ yield Static(
873
+ self._build_advanced_toggle_label(),
874
+ classes="auth-prompt-advanced-toggle",
875
+ id="auth-prompt-advanced-toggle",
876
+ )
877
+ if self._env_var:
878
+ key_meta = Static(
879
+ Content.assemble(
880
+ "Alternatively, environment variables can be used in place "
881
+ "of the key stored above. Set ",
882
+ (f"DEEPAGENTS_CODE_{self._env_var}", TStyle(bold=True)),
883
+ " for a dcode-only key; it has the highest priority. Set ",
884
+ (self._env_var, TStyle(bold=True)),
885
+ " to share a key with other provider SDK tools; it is used "
886
+ "only when no scoped or stored key exists. After setting one "
887
+ "in a .env file, press ",
888
+ ("Ctrl+R", TStyle(bold=True)),
889
+ " to reload without restarting. A variable exported in a "
890
+ "separate shell after launch is invisible to this process; "
891
+ "it needs a full relaunch. ",
892
+ (
893
+ "Configuration docs",
894
+ self._link_style(CONFIGURATION_DOCS_URL),
895
+ ),
896
+ ".",
897
+ ),
898
+ classes="auth-prompt-meta",
899
+ id="auth-prompt-key-meta",
900
+ )
901
+ key_meta.display = self._advanced_visible
902
+ yield key_meta
903
+ if self._is_langsmith:
904
+ if self._endpoint_env_notice:
905
+ yield Static(
906
+ Content.from_markup("$msg", msg=self._endpoint_env_notice),
907
+ classes="auth-prompt-meta",
908
+ id="auth-prompt-endpoint-env-notice",
909
+ )
910
+ region_label = Static(
911
+ Content.from_markup("[bold]LangSmith region[/bold]"),
912
+ classes="auth-prompt-meta",
913
+ id="auth-prompt-region-label",
914
+ )
915
+ region_label.display = self._advanced_visible
916
+ yield region_label
917
+ region_set = RadioSet(
918
+ *(
919
+ RadioButton(
920
+ spec.label,
921
+ value=self._region == spec.region,
922
+ id=spec.radio_id,
923
+ )
924
+ for spec in _REGION_SPECS
925
+ ),
926
+ id="auth-prompt-region",
927
+ )
928
+ region_set.display = self._advanced_visible
929
+ yield region_set
930
+ custom_visible = (
931
+ self._advanced_visible and self._region == Region.CUSTOM
932
+ )
933
+ base_url_input = Input(
934
+ value=(
935
+ self._existing_base_url if self._region == Region.CUSTOM else ""
936
+ ),
937
+ placeholder="https://my-langsmith.example.com",
938
+ id="auth-prompt-base-url",
939
+ )
940
+ base_url_input.display = custom_visible
941
+ yield base_url_input
942
+ base_url_hint_widget = Static(
943
+ Content.from_markup(
944
+ "Point tracing at a self-hosted or proxied LangSmith. "
945
+ "Sets [bold]LANGSMITH_ENDPOINT[/bold]; must be an "
946
+ "http(s) URL."
947
+ ),
948
+ classes="auth-prompt-meta",
949
+ id="auth-prompt-base-url-hint",
950
+ )
951
+ base_url_hint_widget.display = custom_visible
952
+ yield base_url_hint_widget
953
+ project_label = Static(
954
+ Content.from_markup("[bold]Project name[/bold]"),
955
+ classes="auth-prompt-meta",
956
+ id="auth-prompt-project-label",
957
+ )
958
+ project_label.display = self._advanced_visible
959
+ yield project_label
960
+ project_input = Input(
961
+ value=self._existing_project,
962
+ placeholder="LANGSMITH_PROJECT (default: deepagents-code)",
963
+ id="auth-prompt-project",
964
+ )
965
+ project_input.display = self._advanced_visible
966
+ yield project_input
967
+ project_hint_widget = Static(
968
+ Content.from_markup(
969
+ "Route agent traces to this LangSmith project. "
970
+ "Leave blank to use the default [bold]deepagents-code[/bold]."
971
+ ),
972
+ classes="auth-prompt-meta",
973
+ id="auth-prompt-project-hint",
974
+ )
975
+ project_hint_widget.display = self._advanced_visible
976
+ yield project_hint_widget
977
+ else:
978
+ base_url_label = Static(
979
+ Content.from_markup("[bold]Base URL override[/bold]"),
980
+ classes="auth-prompt-meta",
981
+ id="auth-prompt-base-url-label",
982
+ )
983
+ base_url_label.display = self._advanced_visible
984
+ yield base_url_label
985
+ base_url_input = Input(
986
+ value=self._existing_base_url,
987
+ placeholder="Base URL",
988
+ id="auth-prompt-base-url",
989
+ )
990
+ base_url_input.display = self._advanced_visible
991
+ yield base_url_input
992
+ base_url_hint_widget = Static(
993
+ self._build_base_url_hint(),
994
+ classes="auth-prompt-meta",
995
+ id="auth-prompt-base-url-hint",
996
+ )
997
+ base_url_hint_widget.display = self._advanced_visible
998
+ yield base_url_hint_widget
999
+ yield Static("", classes="auth-prompt-error", id="auth-prompt-error")
1000
+ save_label = self._submit_label or (
1001
+ "Enter replace" if self._has_existing else "Enter save"
1002
+ )
1003
+ help_parts = [
1004
+ f"{save_label} {glyphs.bullet} Esc cancel",
1005
+ "F2 advanced",
1006
+ "Ctrl+R reload",
1007
+ ]
1008
+ if self._has_existing:
1009
+ help_parts.append("Ctrl+D delete stored")
1010
+ yield Static(
1011
+ f" {glyphs.bullet} ".join(help_parts),
1012
+ classes="auth-prompt-help",
1013
+ )
1014
+
1015
+ def _link_style(self, url: str) -> TStyle:
1016
+ """Return a theme-aware style for inline modal links.
1017
+
1018
+ Args:
1019
+ url: Link target.
1020
+
1021
+ Returns:
1022
+ Textual style that opens `url` when clicked.
1023
+ """
1024
+ colors = theme.get_theme_colors(self)
1025
+ if self.app.theme in {"ansi-dark", "ansi-light"}:
1026
+ return TStyle(bold=True, underline=True, link=url)
1027
+ return TStyle(foreground=TColor.parse(colors.primary), underline=True, link=url)
1028
+
1029
+ def _build_key_instructions(self) -> Content:
1030
+ """Build provider-specific API-key acquisition guidance.
1031
+
1032
+ Returns:
1033
+ Content shown before the API-key input. May append muted notices: a
1034
+ provider-specific caveat (e.g. Anthropic subscription plans are
1035
+ unsupported) and/or a warning that a user-configured `api_key_url`
1036
+ was rejected for using an unsupported URL scheme.
1037
+ """
1038
+ config = self._config
1039
+ configured_url = config.get_provider_api_key_url(self._provider)
1040
+ rejected_url = False
1041
+ if configured_url and not _is_safe_acquisition_url(configured_url):
1042
+ logger.warning(
1043
+ "Ignoring api_key_url for %s: unsupported URL scheme", self._provider
1044
+ )
1045
+ configured_url = None
1046
+ rejected_url = True
1047
+ url = configured_url or PROVIDER_API_KEY_URLS.get(
1048
+ self._provider, _PROVIDERS_DOCS_URL
1049
+ )
1050
+ provider = provider_display_name(self._provider, config)
1051
+ label = (
1052
+ f"{provider} key page"
1053
+ if configured_url or self._provider in PROVIDER_API_KEY_URLS
1054
+ else f"{provider} setup docs"
1055
+ )
1056
+ if self._provider == "azure_openai":
1057
+ instructions = Content.assemble(
1058
+ "Find your key in your Azure OpenAI resource's "
1059
+ "Keys and Endpoint page, then paste it below. ",
1060
+ (label, self._link_style(url)),
1061
+ )
1062
+ elif self._provider == "openai":
1063
+ instructions = Content.assemble(
1064
+ f"Sign in to {provider}, create or copy an API key, then "
1065
+ "paste it below. Minimum permissions needed: "
1066
+ "under Model capabilities, grant Write access to Responses "
1067
+ "(/v1/responses). For older models, you may also need "
1068
+ "Request access to Chat completions (/v1/chat/completions). ",
1069
+ (label, self._link_style(url)),
1070
+ )
1071
+ elif self._provider == "anthropic":
1072
+ instructions = Content.assemble(
1073
+ f"Sign in to {provider}, create or copy an API key, "
1074
+ "then paste it below. ",
1075
+ (label, self._link_style(url)),
1076
+ "\n",
1077
+ (
1078
+ (
1079
+ "Subscription plans (Claude Pro/Max, Claude Code) cannot "
1080
+ "be used for Anthropic calls in dcode. Only a "
1081
+ "standard API key with pay-as-you-go billing works here."
1082
+ ),
1083
+ "italic $text-muted",
1084
+ ),
1085
+ )
1086
+ else:
1087
+ instructions = Content.assemble(
1088
+ f"Sign in to {provider}, create or copy an API key, "
1089
+ "then paste it below. ",
1090
+ (label, self._link_style(url)),
1091
+ )
1092
+ if rejected_url:
1093
+ notice = (
1094
+ "Your configured api_key_url was ignored (unsupported URL "
1095
+ "scheme); showing the default link instead."
1096
+ )
1097
+ instructions = Content.assemble(
1098
+ instructions,
1099
+ "\n",
1100
+ (notice, "italic $text-muted"),
1101
+ )
1102
+ return instructions
1103
+
1104
+ def _build_advanced_toggle_label(self) -> str:
1105
+ """Build the disclosure-row label for advanced settings.
1106
+
1107
+ Returns:
1108
+ Toggle label reflecting the current expanded state.
1109
+ """
1110
+ glyphs = get_glyphs()
1111
+ marker = (
1112
+ glyphs.disclosure_expanded
1113
+ if self._advanced_visible
1114
+ else glyphs.disclosure_collapsed
1115
+ )
1116
+ return f"{marker} Advanced (F2)"
1117
+
1118
+ def _build_base_url_hint(self) -> Content:
1119
+ """Build the optional base-URL hint shown inside Advanced.
1120
+
1121
+ Returns:
1122
+ Content describing blank behavior and env-var precedence.
1123
+ """
1124
+ surviving_base_url_env = get_default_base_url_env(self._provider)
1125
+ endpoint_envs = get_base_url_env_vars(self._provider)
1126
+ env_order = ", then ".join(
1127
+ item for env in endpoint_envs for item in (f"DEEPAGENTS_CODE_{env}", env)
1128
+ )
1129
+ if surviving_base_url_env and env_order:
1130
+ return Content.from_markup(
1131
+ "Override the provider endpoint for this stored key. "
1132
+ "Leave blank to use [bold]$prefixed[/bold].\n"
1133
+ "Env override order: [bold]$order[/bold].",
1134
+ prefixed=surviving_base_url_env,
1135
+ order=env_order,
1136
+ )
1137
+ endpoint_env = get_base_url_env_var(self._provider)
1138
+ if endpoint_env and env_order:
1139
+ return Content.from_markup(
1140
+ "Override the provider endpoint for this stored key. "
1141
+ "Leave blank to use the provider default.\n"
1142
+ "Env override order: [bold]$order[/bold].",
1143
+ order=env_order,
1144
+ )
1145
+ return Content.from_markup(
1146
+ "Override the provider endpoint for this stored key. "
1147
+ "Leave blank to use the provider default."
1148
+ )
1149
+
1150
+ def action_toggle_advanced(self) -> None:
1151
+ """Show or hide optional endpoint and env-var details.
1152
+
1153
+ Restores focus to the key input when collapsing so keyboard entry
1154
+ resumes on the field the user most likely wants.
1155
+ """
1156
+ self._advanced_visible = not self._advanced_visible
1157
+ for selector in (
1158
+ "#auth-prompt-key-meta",
1159
+ "#auth-prompt-region-label",
1160
+ "#auth-prompt-region",
1161
+ "#auth-prompt-base-url-label",
1162
+ "#auth-prompt-base-url",
1163
+ "#auth-prompt-base-url-hint",
1164
+ "#auth-prompt-project-label",
1165
+ "#auth-prompt-project",
1166
+ "#auth-prompt-project-hint",
1167
+ ):
1168
+ for widget in self.query(selector):
1169
+ widget.display = self._advanced_visible
1170
+ # For LangSmith the custom URL field only shows under the Custom region,
1171
+ # so re-apply its region-gated visibility after the blanket toggle above.
1172
+ if self._is_langsmith:
1173
+ self._refresh_langsmith_custom_visibility()
1174
+ self.query_one("#auth-prompt-advanced-toggle", Static).update(
1175
+ self._build_advanced_toggle_label()
1176
+ )
1177
+ if not self._advanced_visible:
1178
+ self.query_one("#auth-prompt-input", Input).focus()
1179
+
1180
+ def _refresh_langsmith_custom_visibility(self) -> None:
1181
+ """Show the custom URL field only when Advanced is open and region is Custom."""
1182
+ custom_visible = self._advanced_visible and self._region == Region.CUSTOM
1183
+ for selector in ("#auth-prompt-base-url", "#auth-prompt-base-url-hint"):
1184
+ for widget in self.query(selector):
1185
+ widget.display = custom_visible
1186
+
1187
+ def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
1188
+ """Track the chosen LangSmith region and reveal the custom URL field."""
1189
+ event.stop()
1190
+ region = _REGION_BY_RADIO_ID.get(event.pressed.id or "")
1191
+ if region is None:
1192
+ # An unknown id (e.g. a renamed radio) must not silently degrade to a
1193
+ # region — leave the current selection unchanged rather than guess.
1194
+ return
1195
+ self._region = region
1196
+ self._refresh_langsmith_custom_visibility()
1197
+ if self._region == Region.CUSTOM:
1198
+ self.query_one("#auth-prompt-base-url", Input).focus()
1199
+
1200
+ def on_click(self, event: Click) -> None:
1201
+ """Open style-embedded hyperlinks or toggle Advanced."""
1202
+ widget = event.widget
1203
+ if (
1204
+ widget is not None
1205
+ and widget.id == "auth-prompt-advanced-toggle"
1206
+ and not event.style.link
1207
+ ):
1208
+ self.action_toggle_advanced()
1209
+ event.stop()
1210
+ return
1211
+ open_style_link(event)
1212
+
1213
+ def on_mouse_move(self, event: MouseMove) -> None:
1214
+ """Show a pointer over links and the clickable Advanced row."""
1215
+ widget = event.widget
1216
+ self.styles.pointer = (
1217
+ "pointer"
1218
+ if event.style.link
1219
+ or (widget is not None and widget.id == "auth-prompt-advanced-toggle")
1220
+ else "default"
1221
+ )
1222
+
1223
+ def on_leave(self) -> None:
1224
+ """Reset the pointer shape when the mouse leaves the prompt."""
1225
+ self.styles.pointer = "default"
1226
+
1227
+ def on_mount(self) -> None:
1228
+ """Apply ASCII border when needed."""
1229
+ if is_ascii_mode():
1230
+ container = self.query_one(Vertical)
1231
+ colors = theme.get_theme_colors(self)
1232
+ container.styles.border = ("ascii", colors.success)
1233
+
1234
+ def _resolve_langsmith_endpoint(self) -> ResolvedEndpoint:
1235
+ """Resolve the endpoint to store from the LangSmith region selector.
1236
+
1237
+ Returns:
1238
+ A `ResolvedEndpoint`: the canonical endpoint to persist (empty for
1239
+ the US SaaS default), and a non-`None` error when a custom URL is
1240
+ missing or malformed (so an explicit `Custom` selection never
1241
+ silently routes the key to the US SaaS default, and the key is
1242
+ never paired with a non-http(s) endpoint).
1243
+ """
1244
+ if self._region != Region.CUSTOM:
1245
+ # US and EU resolve to their fixed SaaS endpoints from the table
1246
+ # (US -> "" clears the stored endpoint back to the SaaS default).
1247
+ return ResolvedEndpoint(_ENDPOINT_BY_REGION[self._region], None)
1248
+ raw = self.query_one("#auth-prompt-base-url", Input).value.strip()
1249
+ if not raw:
1250
+ # `Custom` is a deliberate non-default choice; a blank field must not
1251
+ # silently fall back to the US SaaS default (which would reroute the
1252
+ # key and traces to the very endpoint a self-hosted user is avoiding).
1253
+ missing_url_error = (
1254
+ "Custom endpoint URL is required (or choose United States/Europe)."
1255
+ )
1256
+ return ResolvedEndpoint("", missing_url_error)
1257
+ endpoint = normalize_langsmith_endpoint(raw)
1258
+ if not is_http_url(endpoint):
1259
+ return ResolvedEndpoint("", "Custom endpoint must be an http(s) URL.")
1260
+ return ResolvedEndpoint(endpoint, None)
1261
+
1262
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1263
+ """Validate, persist, and dismiss.
1264
+
1265
+ Reads both fields regardless of which one was submitted, so pressing
1266
+ Enter in either the key or the secondary input (base URL, or the
1267
+ LangSmith project name) saves the pair.
1268
+ """
1269
+ event.stop()
1270
+ cleaned = self.query_one("#auth-prompt-input", Input).value.strip()
1271
+ if self._is_langsmith:
1272
+ project = self.query_one("#auth-prompt-project", Input).value.strip()
1273
+ resolved = self._resolve_langsmith_endpoint()
1274
+ if resolved.error:
1275
+ self._show_error(resolved.error)
1276
+ return
1277
+ base_url = resolved.endpoint
1278
+ else:
1279
+ base_url = self.query_one("#auth-prompt-base-url", Input).value.strip()
1280
+ # Match the CLI `--base-url` guard: never pair the key with a
1281
+ # non-http(s) endpoint. Validate only a *changed* value, though: the
1282
+ # field is pre-filled with the stored base URL, so rotating just the
1283
+ # key must not be blocked by a legacy non-http(s) endpoint (the CLI
1284
+ # never validated base URLs before this feature). A new or edited
1285
+ # value must still be http(s).
1286
+ if (
1287
+ base_url
1288
+ and base_url != self._existing_base_url
1289
+ and not is_http_url(base_url)
1290
+ ):
1291
+ self._show_error("Base URL must be an http(s) URL.")
1292
+ return
1293
+ project = ""
1294
+ if not cleaned:
1295
+ if self._allow_empty_submit:
1296
+ # Optional prompts (e.g. the Tavily onboarding step) treat an
1297
+ # empty submit as an intentional skip. We deliberately reuse
1298
+ # `CANCELLED` rather than add a `SKIPPED` outcome: every caller
1299
+ # that allows empty submit wants identical "did not save"
1300
+ # handling for skip and Escape, so the distinction would be
1301
+ # dead weight. Revisit if a caller ever needs to tell a
1302
+ # deliberate decline from an accidental dismissal.
1303
+ self.dismiss(AuthResult.CANCELLED)
1304
+ return
1305
+ self._show_error("API key cannot be empty.")
1306
+ return
1307
+ try:
1308
+ outcome = auth_store.set_stored_key(
1309
+ self._provider,
1310
+ cleaned,
1311
+ base_url=base_url or None,
1312
+ project=project or None,
1313
+ )
1314
+ except (ValueError, RuntimeError, OSError) as exc:
1315
+ # `auth_store` exception messages never include the secret value,
1316
+ # but the path can include user-controlled `DEFAULT_STATE_DIR`
1317
+ # bytes — render via `Content.from_markup` so a `[` in the path
1318
+ # can't break Textual's markup pipeline.
1319
+ logger.warning(
1320
+ "Failed to persist credential for %s: %s", self._provider, exc
1321
+ )
1322
+ self._show_error("Could not save credential: $exc", exc=str(exc))
1323
+ return
1324
+ for warning in outcome.warnings:
1325
+ # chmod failures are security regressions the user must see —
1326
+ # `logger.warning` alone is invisible inside a Textual session.
1327
+ self.app.notify(warning, severity="warning", markup=False)
1328
+ if self._is_langsmith:
1329
+ apply_stored_langsmith_auth(replace_project=True)
1330
+ clear_caches()
1331
+ if not outcome.warnings:
1332
+ # Only claim a clean save when the store locked the file down. When
1333
+ # chmod warnings fired above, they *are* the outcome message — an
1334
+ # extra "success" toast on top would compete with (and visually
1335
+ # bury) the one signal that the key isn't secured.
1336
+ provider_label = provider_display_name(self._provider, self._config)
1337
+ # `markup=False`: a configured display name can contain markup
1338
+ # metacharacters (e.g. `[`) that must not be interpreted here. The
1339
+ # same guard applies to every interpolated toast below.
1340
+ self.app.notify(
1341
+ f"Successfully saved key for {provider_label}.",
1342
+ severity="information",
1343
+ markup=False,
1344
+ )
1345
+ self.dismiss(AuthResult.SAVED)
1346
+
1347
+ def action_cancel(self) -> None:
1348
+ """Dismiss without saving."""
1349
+ self.dismiss(AuthResult.CANCELLED)
1350
+
1351
+ async def action_reload_env(self) -> None:
1352
+ """Re-read .env/environment, re-probe credentials, and continue.
1353
+
1354
+ Runs the same environment/credential reload core as the `/reload`
1355
+ command (`reload_from_environment` + `clear_caches`), so a credential
1356
+ env var added to a `.env` file after launch is picked up without a full
1357
+ restart; it intentionally skips `/reload`'s theme and skill
1358
+ re-discovery. If the provider was blocked and is now resolvable, dismiss
1359
+ with SAVED so the caller retries the original operation; otherwise
1360
+ refresh the modal in place and toast the outcome.
1361
+ """
1362
+ from deepagents_code.config import settings
1363
+
1364
+ try:
1365
+ settings.reload_from_environment()
1366
+ clear_caches()
1367
+ except (OSError, ValueError) as exc:
1368
+ logger.warning("Failed to reload configuration from auth prompt: %s", exc)
1369
+ self.app.notify(
1370
+ "Could not reload configuration. Check your .env file and "
1371
+ "environment variables for syntax errors, then try again.",
1372
+ severity="error",
1373
+ markup=False,
1374
+ )
1375
+ return
1376
+
1377
+ was_blocking = self._auth_status.blocks_start
1378
+ self._probe_credential_state()
1379
+
1380
+ if was_blocking and not self._auth_status.blocks_start:
1381
+ self.app.notify(
1382
+ f"Credentials detected for {self._provider}. Continuing.",
1383
+ markup=False,
1384
+ )
1385
+ self.dismiss(AuthResult.SAVED)
1386
+ return
1387
+
1388
+ # The reload may have added/removed a LangSmith endpoint env var; refresh
1389
+ # the precedence notice so the recompose below doesn't render a stale one.
1390
+ self._refresh_endpoint_env_notice()
1391
+
1392
+ # Preserve any in-progress input across the recompose so a reload never
1393
+ # discards values the user already started typing.
1394
+ before = {inp.id: inp.value for inp in self.query(Input)}
1395
+ await self.recompose()
1396
+ for inp in self.query(Input):
1397
+ if inp.id in before:
1398
+ inp.value = before[inp.id]
1399
+ self.query_one("#auth-prompt-input", Input).focus()
1400
+
1401
+ if was_blocking:
1402
+ self.app.notify(
1403
+ "No credentials detected. Set a key in a .env file, then press "
1404
+ "Ctrl+R. A variable exported in a separate shell needs a full "
1405
+ "relaunch to take effect.",
1406
+ markup=False,
1407
+ )
1408
+ else:
1409
+ self.app.notify("Environment reloaded.", markup=False)
1410
+
1411
+ def action_delete_stored(self) -> None:
1412
+ """Open the delete-confirmation overlay, or quit when nothing is stored.
1413
+
1414
+ Ctrl+D deletes a stored credential, but its `priority` binding also
1415
+ intercepts the app-level Ctrl+D=quit. When there's no credential to
1416
+ delete, fall through to quit rather than swallowing the key (mirroring
1417
+ the thread selector). `app.exit()` is used instead of `dismiss()`, which
1418
+ would just close the modal silently and re-swallow the key.
1419
+ """
1420
+ if not self._has_existing:
1421
+ self.app.exit()
1422
+ return
1423
+ self.app.push_screen(
1424
+ DeleteCredentialConfirmScreen(self._provider),
1425
+ self._on_delete_confirmed,
1426
+ )
1427
+
1428
+ def _on_delete_confirmed(self, confirmed: bool | None) -> None:
1429
+ """Handle the result of the confirmation overlay.
1430
+
1431
+ Args:
1432
+ confirmed: `True` if the user pressed Enter, `False` on Esc.
1433
+ """
1434
+ if not confirmed:
1435
+ return
1436
+ try:
1437
+ result = auth_store.delete_stored_key(self._provider)
1438
+ except RuntimeError as exc:
1439
+ logger.warning(
1440
+ "Failed to delete credential for %s: %s", self._provider, exc
1441
+ )
1442
+ self._show_error("Could not delete credential: $exc", exc=str(exc))
1443
+ return
1444
+ for warning in result.warnings:
1445
+ # The rewritten store still holds other providers' secrets, so a
1446
+ # chmod failure here is the same security regression as on save —
1447
+ # surface it rather than dropping it on the floor.
1448
+ self.app.notify(warning, severity="warning", markup=False)
1449
+ # Toast after `clear_caches` (like the save path) so the confirmation
1450
+ # reflects fully-settled state rather than firing before the cache is
1451
+ # invalidated.
1452
+ clear_caches()
1453
+ if not result.removed:
1454
+ # The entry was gone — likely a concurrent delete from another
1455
+ # app instance. Surface that fact so "delete" UX doesn't lie when
1456
+ # nothing actually happened on disk.
1457
+ provider_label = provider_display_name(self._provider, self._config)
1458
+ self.app.notify(
1459
+ f"No stored credential for {provider_label} — already removed.",
1460
+ severity="information",
1461
+ markup=False,
1462
+ )
1463
+ elif not result.warnings:
1464
+ # Mirror the save path: a silent successful delete gives no
1465
+ # confirmation, and the toast is suppressed when warnings fired.
1466
+ provider_label = provider_display_name(self._provider, self._config)
1467
+ self.app.notify(
1468
+ f"Successfully removed key for {provider_label}.",
1469
+ severity="information",
1470
+ markup=False,
1471
+ )
1472
+ self.dismiss(AuthResult.DELETED)
1473
+
1474
+ def _show_error(self, template: str, /, **substitutions: str) -> None:
1475
+ """Render `template` via markup substitution in the inline error slot.
1476
+
1477
+ Args:
1478
+ template: Markup template (e.g. `"Could not save: $exc"`).
1479
+ **substitutions: `$name` substitution values; Textual escapes them.
1480
+ """
1481
+ error = self.query_one("#auth-prompt-error", Static)
1482
+ error.update(Content.from_markup(template, **substitutions))
1483
+
1484
+
1485
+ class AuthManagerScreen(ModalScreen[None]):
1486
+ """Modal that lists configured providers and lets the user manage keys.
1487
+
1488
+ Reachable via the `/auth` slash command. Always dismisses with `None`;
1489
+ state changes are persisted by `AuthPromptScreen` and reflected by
1490
+ re-rendering the option list when this screen is reopened or after a
1491
+ save/delete completes.
1492
+
1493
+ Well-known providers whose integration package isn't installed yet are
1494
+ surfaced greyed-out so they stay discoverable. Selecting one routes
1495
+ through an install confirmation: on confirm the screen records the extra on
1496
+ `pending_install_extra` and dismisses so the app can install it (mirroring
1497
+ the model selector's install-on-select flow) and reopen the manager.
1498
+ """
1499
+
1500
+ class CredentialSaved(Message):
1501
+ """Posted when a key prompt successfully persists credentials.
1502
+
1503
+ Carries the `/auth` config key that was saved so the app can react to
1504
+ credentials that gate spawn-time behavior — e.g. a Tavily key that
1505
+ enables the `web_search` tool only after the server respawns.
1506
+ """
1507
+
1508
+ def __init__(self, provider: str) -> None:
1509
+ """Store the saved provider/service identifier.
1510
+
1511
+ Args:
1512
+ provider: The `/auth` config key that was saved (a model
1513
+ provider name or a service key such as `"tavily"`).
1514
+ """
1515
+ super().__init__()
1516
+ self.provider = provider
1517
+
1518
+ class CredentialDeleted(Message):
1519
+ """Posted when a key prompt deletes stored credentials.
1520
+
1521
+ Carries the `/auth` config key that was deleted so the app can clear
1522
+ any in-memory state derived from the now-removed credential.
1523
+ """
1524
+
1525
+ def __init__(self, provider: str) -> None:
1526
+ """Store the deleted provider/service identifier.
1527
+
1528
+ Args:
1529
+ provider: The `/auth` config key that was deleted (a model
1530
+ provider name or a service key such as `"tavily"`).
1531
+ """
1532
+ super().__init__()
1533
+ self.provider = provider
1534
+
1535
+ BINDINGS: ClassVar[list[BindingType]] = [
1536
+ Binding("escape", "cancel", "Close", show=False, priority=True),
1537
+ Binding("tab", "cursor_down", "Next", show=False, priority=True),
1538
+ Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True),
1539
+ ]
1540
+
1541
+ CSS = """
1542
+ AuthManagerScreen {
1543
+ align: center middle;
1544
+ }
1545
+
1546
+ AuthManagerScreen > Vertical {
1547
+ width: 76;
1548
+ max-width: 90%;
1549
+ height: 80%;
1550
+ background: $surface;
1551
+ border: solid $primary;
1552
+ padding: 1 2;
1553
+ }
1554
+
1555
+ AuthManagerScreen .auth-manager-title {
1556
+ text-style: bold;
1557
+ color: $primary;
1558
+ text-align: center;
1559
+ margin-bottom: 1;
1560
+ }
1561
+
1562
+ AuthManagerScreen .auth-manager-copy {
1563
+ height: auto;
1564
+ color: $text-muted;
1565
+ margin-bottom: 1;
1566
+ }
1567
+
1568
+ /* `1fr` + `min-height` keeps the option list from pushing the footer
1569
+ off-screen on short terminals: the list shrinks (and starts scrolling)
1570
+ before the footer is hidden. */
1571
+ AuthManagerScreen OptionList {
1572
+ height: 1fr;
1573
+ min-height: 3;
1574
+ background: $background;
1575
+ }
1576
+
1577
+ AuthManagerScreen .auth-manager-warning {
1578
+ height: auto;
1579
+ color: $warning;
1580
+ margin-bottom: 1;
1581
+ }
1582
+
1583
+ AuthManagerScreen .auth-manager-help {
1584
+ height: auto;
1585
+ color: $text-muted;
1586
+ text-style: italic;
1587
+ text-align: center;
1588
+ margin-top: 1;
1589
+ }
1590
+ """
1591
+
1592
+ def __init__(self, *, initial_provider: str | None = None) -> None:
1593
+ """Initialize the manager with an empty install-on-select registry.
1594
+
1595
+ Args:
1596
+ initial_provider: Provider whose row should start highlighted —
1597
+ set when reopening after an install-on-select so the cursor
1598
+ lands on the just-installed provider ready for a key, rather
1599
+ than resetting to the top of the list.
1600
+ """
1601
+ super().__init__()
1602
+ # Uninstalled known providers mapped to the extra that installs them,
1603
+ # populated each time the option list is built. Selecting one routes
1604
+ # to the install confirmation instead of the key prompt.
1605
+ self._install_extras: dict[str, str] = {}
1606
+ # Set when the user confirms installing a provider's extra; the app
1607
+ # reads these off the screen after dismissal to install then reopen
1608
+ # the manager with the just-installed provider highlighted.
1609
+ self.pending_install_extra: str | None = None
1610
+ self.pending_install_provider: str | None = None
1611
+ self._initial_provider = initial_provider
1612
+
1613
+ def compose(self) -> ComposeResult:
1614
+ """Compose the manager.
1615
+
1616
+ Yields:
1617
+ Widgets for the manager listing.
1618
+ """
1619
+ glyphs = get_glyphs()
1620
+ options, store_warning = self._build_options_with_warning()
1621
+ with Vertical():
1622
+ yield Static("Manage API keys", classes="auth-manager-title")
1623
+ yield Static(self._build_description(), classes="auth-manager-copy")
1624
+ if store_warning:
1625
+ # Surface auth.json corruption directly — `_build_options`
1626
+ # falling back silently used to make a corrupt file look
1627
+ # identical to "no keys stored".
1628
+ yield Static(
1629
+ Content.from_markup("$msg", msg=store_warning),
1630
+ classes="auth-manager-warning",
1631
+ )
1632
+ yield OptionList(*options, id="auth-manager-options")
1633
+ yield Static(
1634
+ f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab "
1635
+ f"navigate {glyphs.bullet} Enter add/replace/delete/install "
1636
+ f"{glyphs.bullet} Esc close",
1637
+ classes="auth-manager-help",
1638
+ )
1639
+
1640
+ def _build_description(self) -> Content:
1641
+ """Build the description line with an inline docs hyperlink.
1642
+
1643
+ Returns:
1644
+ Description content. Themes other than the ANSI palette render
1645
+ the link in the primary color so it reads as clickable; ANSI
1646
+ users get a bold-only treatment that still reaches the
1647
+ terminal's link handler via `Style(link=...)`.
1648
+ """
1649
+ colors = theme.get_theme_colors(self)
1650
+ ansi = self.app.theme in {"ansi-dark", "ansi-light"}
1651
+ link_style: str | TStyle = (
1652
+ TStyle(bold=True, link=_PROVIDERS_DOCS_URL)
1653
+ if ansi
1654
+ else TStyle(
1655
+ foreground=TColor.parse(colors.primary),
1656
+ link=_PROVIDERS_DOCS_URL,
1657
+ )
1658
+ )
1659
+ return Content.assemble(
1660
+ "Lists installed model providers, services like web search, and any "
1661
+ "providers you've configured in ~/.deepagents/config.toml. Greyed-out "
1662
+ "providers aren't installed yet — select one to install it. ",
1663
+ ("Docs", link_style),
1664
+ )
1665
+
1666
+ def on_mount(self) -> None:
1667
+ """Apply ASCII border and highlight the initial provider when set."""
1668
+ if is_ascii_mode():
1669
+ container = self.query_one(Vertical)
1670
+ colors = theme.get_theme_colors(self)
1671
+ container.styles.border = ("ascii", colors.success)
1672
+ self._highlight_initial_provider()
1673
+
1674
+ def _highlight_initial_provider(self) -> None:
1675
+ """Move the cursor to `initial_provider`'s row if it is listed.
1676
+
1677
+ Used when the manager reopens after an install-on-select so the cursor
1678
+ lands on the just-installed provider (ready for a key) instead of
1679
+ resetting to the top of the list.
1680
+ """
1681
+ if self._initial_provider is None:
1682
+ return
1683
+ option_list = self.query_one("#auth-manager-options", OptionList)
1684
+ try:
1685
+ index = option_list.get_option_index(self._initial_provider)
1686
+ except OptionDoesNotExist:
1687
+ return
1688
+ option_list.highlighted = index
1689
+ option_list.scroll_to_highlight()
1690
+
1691
+ def on_click(self, event: Click) -> None: # noqa: PLR6301 - Textual handler
1692
+ """Open style-embedded hyperlinks (the title `Docs` link)."""
1693
+ open_style_link(event)
1694
+
1695
+ def on_mouse_move(self, event: MouseMove) -> None:
1696
+ """Show a pointer over inline docs links."""
1697
+ self.styles.pointer = "pointer" if event.style.link else "default"
1698
+
1699
+ def on_leave(self) -> None:
1700
+ """Reset the pointer shape when the mouse leaves the manager."""
1701
+ self.styles.pointer = "default"
1702
+
1703
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
1704
+ """Open the prompt for the selected provider.
1705
+
1706
+ Greyed-out (uninstalled) providers route to an install confirmation
1707
+ instead of the key prompt, since their package must be installed before
1708
+ a credential is useful.
1709
+ """
1710
+ provider = event.option.id
1711
+ if not provider:
1712
+ return
1713
+ extra = self._install_extras.get(provider)
1714
+ if extra is not None:
1715
+ self._prompt_install_provider(provider, extra)
1716
+ return
1717
+ if provider == CODEX_PROVIDER:
1718
+ # ChatGPT auth uses an OAuth browser flow, not an API key. The
1719
+ # selector dispatches to a dedicated modal that already knows
1720
+ # how to surface the authorize URL inline (so headless / SSH
1721
+ # users can still paste it manually) and run the loopback
1722
+ # callback wait on a worker.
1723
+ self._open_codex_screen()
1724
+ return
1725
+ if is_service(provider):
1726
+ # Services (e.g. Tavily web search) use a plain API key, stored the
1727
+ # same way as a model-provider key.
1728
+ self.app.push_screen(
1729
+ AuthPromptScreen(provider, SERVICE_API_KEY_ENV[provider]),
1730
+ partial(self._on_prompt_closed, provider),
1731
+ )
1732
+ return
1733
+ env_var = get_credential_env_var(provider)
1734
+ self.app.push_screen(
1735
+ AuthPromptScreen(provider, env_var),
1736
+ partial(self._on_prompt_closed, provider),
1737
+ )
1738
+
1739
+ def _prompt_install_provider(self, provider: str, extra: str) -> None:
1740
+ """Confirm installing an uninstalled provider's extra, then dismiss.
1741
+
1742
+ On confirm, record the extra on `pending_install_extra` and dismiss so
1743
+ the app can install it (with a server restart) and reopen the manager
1744
+ with the provider now installed. On cancel, stay on the manager.
1745
+
1746
+ Args:
1747
+ provider: The uninstalled provider the user selected.
1748
+ extra: The `deepagents-code` extra that installs `provider`.
1749
+ """
1750
+ from deepagents_code.tui.widgets.install_confirm import (
1751
+ InstallProviderConfirmScreen,
1752
+ )
1753
+
1754
+ def _on_confirm(proceed: bool | None) -> None:
1755
+ if proceed:
1756
+ self.pending_install_extra = extra
1757
+ self.pending_install_provider = provider
1758
+ self.dismiss(None)
1759
+ else:
1760
+ # Declined or dismissed: clear any pending request so a reused
1761
+ # screen never carries a stale install request, and stay put.
1762
+ self.pending_install_extra = None
1763
+ self.pending_install_provider = None
1764
+
1765
+ self.app.push_screen(
1766
+ InstallProviderConfirmScreen(provider, extra),
1767
+ _on_confirm,
1768
+ )
1769
+
1770
+ def _open_codex_screen(self) -> None:
1771
+ """Push the ChatGPT OAuth flow modal and refresh on close.
1772
+
1773
+ When `openai_codex` is already signed in, give the user a chance to
1774
+ sign out before launching a fresh sign-in flow. Otherwise just run
1775
+ the sign-in worker.
1776
+ """
1777
+ from deepagents_code.integrations import openai_codex
1778
+ from deepagents_code.tui.widgets.codex_auth import (
1779
+ CodexAuthScreen,
1780
+ CodexSignedInScreen,
1781
+ )
1782
+
1783
+ status = openai_codex.get_status()
1784
+ if status.logged_in and not status.is_expired:
1785
+ self.app.push_screen(
1786
+ CodexSignedInScreen(),
1787
+ self._on_codex_signed_in_closed,
1788
+ )
1789
+ return
1790
+ self.app.push_screen(CodexAuthScreen(), self._on_codex_closed)
1791
+
1792
+ def _on_codex_closed(self, _result: bool | None) -> None:
1793
+ """Refresh the option list once the codex flow dismisses."""
1794
+ clear_caches()
1795
+ self._refresh_options()
1796
+
1797
+ def _on_codex_signed_in_closed(self, action: CodexSignedInAction | None) -> None:
1798
+ """Handle dismissal of the "already signed in" overlay.
1799
+
1800
+ Args:
1801
+ action: `SIGN_OUT` to clear the token, `REAUTH` to run the
1802
+ sign-in flow again, `None` to close cleanly.
1803
+ """
1804
+ from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction
1805
+
1806
+ if action is CodexSignedInAction.SIGN_OUT:
1807
+ from deepagents_code.integrations import openai_codex
1808
+
1809
+ removed = openai_codex.logout()
1810
+ if removed:
1811
+ self.app.notify("Signed out of ChatGPT.", markup=False)
1812
+ clear_caches()
1813
+ self._refresh_options()
1814
+ elif action is CodexSignedInAction.REAUTH:
1815
+ from deepagents_code.tui.widgets.codex_auth import CodexAuthScreen
1816
+
1817
+ self.app.push_screen(CodexAuthScreen(), self._on_codex_closed)
1818
+ else:
1819
+ self._refresh_options()
1820
+
1821
+ def action_cancel(self) -> None:
1822
+ """Close the manager."""
1823
+ self.dismiss(None)
1824
+
1825
+ def action_cursor_down(self) -> None:
1826
+ """Move the option-list cursor down."""
1827
+ self.query_one("#auth-manager-options", OptionList).action_cursor_down()
1828
+
1829
+ def action_cursor_up(self) -> None:
1830
+ """Move the option-list cursor up."""
1831
+ self.query_one("#auth-manager-options", OptionList).action_cursor_up()
1832
+
1833
+ def _on_prompt_closed(self, provider: str, result: AuthResult | None) -> None:
1834
+ """Refresh the option list once the prompt dismisses.
1835
+
1836
+ Args:
1837
+ provider: The provider/service whose prompt just closed.
1838
+ result: Outcome of the prompt interaction.
1839
+ """
1840
+ self._refresh_options()
1841
+ if result is AuthResult.SAVED:
1842
+ self.post_message(self.CredentialSaved(provider))
1843
+ elif result is AuthResult.DELETED:
1844
+ self.post_message(self.CredentialDeleted(provider))
1845
+
1846
+ def _refresh_options(self) -> None:
1847
+ """Rebuild option labels from current store state."""
1848
+ option_list = self.query_one("#auth-manager-options", OptionList)
1849
+ highlighted = option_list.highlighted
1850
+ option_list.clear_options()
1851
+ options, _ = self._build_options_with_warning()
1852
+ for option in options:
1853
+ option_list.add_option(option)
1854
+ if highlighted is not None and option_list.option_count:
1855
+ option_list.highlighted = min(highlighted, option_list.option_count - 1)
1856
+
1857
+ def _build_options_with_warning(self) -> tuple[list[Option], str | None]:
1858
+ """Render the option list, returning a corruption warning if any.
1859
+
1860
+ Returns:
1861
+ `(options, warning_message)`. `warning_message` is `None` when
1862
+ the credential file is readable; otherwise a one-line hint
1863
+ telling the user the file is unreadable so a corrupt store
1864
+ doesn't silently look identical to "no keys stored".
1865
+ """
1866
+ warning: str | None = None
1867
+ try:
1868
+ stored = set(auth_store.list_configured_providers())
1869
+ except RuntimeError as exc:
1870
+ logger.warning("Failed to list stored credentials: %s", exc)
1871
+ stored = set()
1872
+ warning = (
1873
+ f"Credential file is unreadable ({exc}). "
1874
+ "Saving a key here will overwrite it."
1875
+ )
1876
+
1877
+ config = ModelConfig.load()
1878
+ config_providers = {
1879
+ name for name, cfg in config.providers.items() if cfg.get("api_key_env")
1880
+ }
1881
+
1882
+ # Only show well-known providers whose LangChain package is actually
1883
+ # installed. `get_available_models` returns providers it could
1884
+ # successfully import profiles for, so it doubles as an install
1885
+ # gate. Stored and config-defined providers are always shown — even
1886
+ # if the package was later uninstalled — so a stale credential can
1887
+ # still be cleaned up and an explicitly-declared provider stays
1888
+ # visible.
1889
+ installed = set(get_available_models().keys())
1890
+ well_known_installed = set(PROVIDER_API_KEY_ENV) & installed
1891
+ # `openai_codex` is gated on `langchain-openai` being installed (we
1892
+ # surface it whenever `openai` was discovered) rather than on
1893
+ # `PROVIDER_API_KEY_ENV`, since it has no env var of its own.
1894
+ codex_installed = {CODEX_PROVIDER} if "openai" in installed else set()
1895
+
1896
+ shown = well_known_installed | codex_installed | stored | config_providers
1897
+ # Surface well-known providers whose package isn't installed yet as
1898
+ # greyed-out, install-on-select entries so they stay
1899
+ # discoverable (mirrors the model selector). Disabled providers and
1900
+ # ones already shown above are skipped.
1901
+ self._install_extras = self._uninstalled_known_providers(config, shown)
1902
+
1903
+ # Resolve each manageable entry's auth status once and reuse it for
1904
+ # both ordering and badge rendering. `_auth_status_for` reads the
1905
+ # credential file, so resolving it separately in the sort key and in
1906
+ # `_format_label` would read `auth.json` twice per row (and, on a
1907
+ # corrupt store, log the same warning twice). A single pass halves both.
1908
+ services = set(SERVICE_API_KEY_ENV) - shown - set(self._install_extras)
1909
+ status_by_key = {key: _auth_status_for(key) for key in shown | services}
1910
+
1911
+ # Float entries that already have a credential configured to the top so
1912
+ # the keys a user is actively using are easiest to find; everything else
1913
+ # keeps alphabetical order (the `key` tiebreaker). Uninstalled
1914
+ # install-on-select entries are listed afterwards (alphabetically) since
1915
+ # selecting them installs a package rather than managing a key.
1916
+ def sort_key(key: str) -> tuple[int, str]:
1917
+ configured = status_by_key[key].state is ProviderAuthState.CONFIGURED
1918
+ return (0 if configured else 1, key)
1919
+
1920
+ manageable = sorted(status_by_key, key=sort_key)
1921
+ extra_providers = sorted(self._install_extras)
1922
+ options = [
1923
+ Option(self._format_label(key, status=status_by_key[key]), id=key)
1924
+ for key in manageable
1925
+ ]
1926
+ options.extend(
1927
+ Option(self._format_label(provider, installed=False), id=provider)
1928
+ for provider in extra_providers
1929
+ )
1930
+ return options, warning
1931
+
1932
+ @staticmethod
1933
+ def _uninstalled_known_providers(
1934
+ config: ModelConfig, shown: set[str]
1935
+ ) -> dict[str, str]:
1936
+ """Map known providers missing their package to the installing extra.
1937
+
1938
+ Args:
1939
+ config: Loaded model config, used to skip disabled providers.
1940
+ shown: Providers already listed (installed/stored/config) to skip.
1941
+
1942
+ Returns:
1943
+ `{provider: extra}` for each well-known, enabled provider whose
1944
+ integration package is not installed and has a curated extra.
1945
+ """
1946
+ from deepagents_code.config_manifest import (
1947
+ is_provider_package_installed,
1948
+ provider_install_extra,
1949
+ )
1950
+
1951
+ uninstalled: dict[str, str] = {}
1952
+ for provider in PROVIDER_API_KEY_ENV:
1953
+ if provider in shown or not config.is_provider_enabled(provider):
1954
+ continue
1955
+ extra = provider_install_extra(provider)
1956
+ if extra is None or is_provider_package_installed(provider):
1957
+ continue
1958
+ uninstalled[provider] = extra
1959
+ return uninstalled
1960
+
1961
+ @staticmethod
1962
+ def _format_label(
1963
+ provider: str,
1964
+ *,
1965
+ installed: bool = True,
1966
+ status: ProviderAuthStatus | None = None,
1967
+ ) -> Content:
1968
+ """Build a `Content` label for `provider` showing its credential source.
1969
+
1970
+ Args:
1971
+ provider: Provider config key.
1972
+ installed: Whether the provider's integration package is installed.
1973
+ Uninstalled providers render dimmed with a `[not installed]`
1974
+ marker since selecting them prompts an install, not a key.
1975
+ status: Precomputed auth status to render. Pass this when the
1976
+ caller already resolved it to avoid a duplicate credential-file
1977
+ read; resolved on demand when omitted. Ignored for uninstalled
1978
+ providers, which render no badge.
1979
+
1980
+ Returns:
1981
+ A composed `Content` with the provider label and a status badge.
1982
+ """
1983
+ name = provider_display_name(provider)
1984
+ if not installed:
1985
+ return Content.assemble(
1986
+ Content.styled(name, "dim"),
1987
+ " ",
1988
+ Content.styled("[not installed]", "dim"),
1989
+ )
1990
+ if status is None:
1991
+ status = _auth_status_for(provider)
1992
+ badge = format_auth_badge(status)
1993
+ return Content.assemble(
1994
+ Content.from_markup("$provider", provider=name),
1995
+ " ",
1996
+ badge,
1997
+ )