shotgun-sh 0.1.16.dev2__py3-none-any.whl → 0.2.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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (55) hide show
  1. shotgun/agents/common.py +4 -5
  2. shotgun/agents/config/constants.py +23 -6
  3. shotgun/agents/config/manager.py +239 -76
  4. shotgun/agents/config/models.py +74 -84
  5. shotgun/agents/config/provider.py +174 -85
  6. shotgun/agents/history/compaction.py +1 -1
  7. shotgun/agents/history/history_processors.py +18 -9
  8. shotgun/agents/history/token_counting/__init__.py +31 -0
  9. shotgun/agents/history/token_counting/anthropic.py +89 -0
  10. shotgun/agents/history/token_counting/base.py +67 -0
  11. shotgun/agents/history/token_counting/openai.py +80 -0
  12. shotgun/agents/history/token_counting/sentencepiece_counter.py +119 -0
  13. shotgun/agents/history/token_counting/tokenizer_cache.py +90 -0
  14. shotgun/agents/history/token_counting/utils.py +147 -0
  15. shotgun/agents/history/token_estimation.py +12 -12
  16. shotgun/agents/llm.py +62 -0
  17. shotgun/agents/models.py +2 -2
  18. shotgun/agents/tools/web_search/__init__.py +42 -15
  19. shotgun/agents/tools/web_search/anthropic.py +54 -50
  20. shotgun/agents/tools/web_search/gemini.py +31 -20
  21. shotgun/agents/tools/web_search/openai.py +4 -4
  22. shotgun/build_constants.py +2 -2
  23. shotgun/cli/config.py +34 -63
  24. shotgun/cli/feedback.py +4 -2
  25. shotgun/cli/models.py +2 -2
  26. shotgun/codebase/core/ingestor.py +47 -8
  27. shotgun/codebase/core/manager.py +7 -3
  28. shotgun/codebase/models.py +4 -4
  29. shotgun/llm_proxy/__init__.py +16 -0
  30. shotgun/llm_proxy/clients.py +39 -0
  31. shotgun/llm_proxy/constants.py +8 -0
  32. shotgun/main.py +6 -0
  33. shotgun/posthog_telemetry.py +15 -11
  34. shotgun/sentry_telemetry.py +3 -3
  35. shotgun/shotgun_web/__init__.py +19 -0
  36. shotgun/shotgun_web/client.py +138 -0
  37. shotgun/shotgun_web/constants.py +17 -0
  38. shotgun/shotgun_web/models.py +47 -0
  39. shotgun/telemetry.py +7 -4
  40. shotgun/tui/app.py +26 -8
  41. shotgun/tui/screens/chat.py +2 -8
  42. shotgun/tui/screens/chat_screen/command_providers.py +118 -11
  43. shotgun/tui/screens/chat_screen/history.py +3 -1
  44. shotgun/tui/screens/feedback.py +2 -2
  45. shotgun/tui/screens/model_picker.py +327 -0
  46. shotgun/tui/screens/provider_config.py +118 -28
  47. shotgun/tui/screens/shotgun_auth.py +295 -0
  48. shotgun/tui/screens/welcome.py +176 -0
  49. shotgun/utils/env_utils.py +12 -0
  50. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/METADATA +2 -2
  51. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/RECORD +54 -37
  52. shotgun/agents/history/token_counting.py +0 -429
  53. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/WHEEL +0 -0
  54. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/entry_points.txt +0 -0
  55. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,327 @@
1
+ """Screen for selecting AI model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.reactive import reactive
11
+ from textual.screen import Screen
12
+ from textual.widgets import Button, Label, ListItem, ListView, Static
13
+
14
+ from shotgun.agents.config import ConfigManager
15
+ from shotgun.agents.config.models import MODEL_SPECS, ModelName, ShotgunConfig
16
+ from shotgun.logging_config import get_logger
17
+
18
+ if TYPE_CHECKING:
19
+ from ..app import ShotgunApp
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ # Available models for selection
25
+ AVAILABLE_MODELS = list(ModelName)
26
+
27
+
28
+ def _sanitize_model_name_for_id(model_name: ModelName) -> str:
29
+ """Convert model name to valid Textual ID by replacing dots with hyphens."""
30
+ return model_name.value.replace(".", "-")
31
+
32
+
33
+ class ModelPickerScreen(Screen[None]):
34
+ """Select AI model to use."""
35
+
36
+ CSS = """
37
+ ModelPicker {
38
+ layout: vertical;
39
+ }
40
+
41
+ #titlebox {
42
+ height: auto;
43
+ margin: 2 0;
44
+ padding: 1;
45
+ border: hkey $border;
46
+ content-align: center middle;
47
+
48
+ & > * {
49
+ text-align: center;
50
+ }
51
+ }
52
+
53
+ #model-picker-title {
54
+ padding: 1 0;
55
+ text-style: bold;
56
+ color: $text-accent;
57
+ }
58
+
59
+ #model-list {
60
+ margin: 2 0;
61
+ height: auto;
62
+ padding: 1;
63
+ & > * {
64
+ padding: 1 0;
65
+ }
66
+ }
67
+ #model-actions {
68
+ padding: 1;
69
+ }
70
+ #model-actions > * {
71
+ margin-right: 2;
72
+ }
73
+ """
74
+
75
+ BINDINGS = [
76
+ ("escape", "done", "Back"),
77
+ ]
78
+
79
+ selected_model: reactive[ModelName] = reactive(ModelName.GPT_5)
80
+
81
+ def compose(self) -> ComposeResult:
82
+ with Vertical(id="titlebox"):
83
+ yield Static("Model selection", id="model-picker-title")
84
+ yield Static(
85
+ "Select the AI model you want to use for your tasks.",
86
+ id="model-picker-summary",
87
+ )
88
+ yield ListView(id="model-list")
89
+ with Horizontal(id="model-actions"):
90
+ yield Button("Select \\[ENTER]", variant="primary", id="select")
91
+ yield Button("Done \\[ESC]", id="done")
92
+
93
+ def _rebuild_model_list(self) -> None:
94
+ """Rebuild the model list from current config.
95
+
96
+ This method is called both on first show and when screen is resumed
97
+ to ensure the list always reflects the current configuration.
98
+ """
99
+ logger.debug("Rebuilding model list from current config")
100
+
101
+ # Load current config with force_reload to get latest API keys
102
+ config_manager = self.config_manager
103
+ config = config_manager.load(force_reload=True)
104
+
105
+ # Log provider key status
106
+ logger.debug(
107
+ "Provider keys: openai=%s, anthropic=%s, google=%s, shotgun=%s",
108
+ config_manager._provider_has_api_key(config.openai),
109
+ config_manager._provider_has_api_key(config.anthropic),
110
+ config_manager._provider_has_api_key(config.google),
111
+ config_manager._provider_has_api_key(config.shotgun),
112
+ )
113
+
114
+ current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
115
+ self.selected_model = current_model
116
+ logger.debug("Current selected model: %s", current_model)
117
+
118
+ # Rebuild the model list with current available models
119
+ list_view = self.query_one(ListView)
120
+
121
+ # Remove all existing items
122
+ old_count = len(list(list_view.children))
123
+ for child in list(list_view.children):
124
+ child.remove()
125
+ logger.debug("Removed %d existing model items from list", old_count)
126
+
127
+ # Add new items (labels already have correct text including current indicator)
128
+ new_items = self._build_model_items(config)
129
+ for item in new_items:
130
+ list_view.append(item)
131
+ logger.debug("Added %d available model items to list", len(new_items))
132
+
133
+ # Find and highlight current selection (if it's in the filtered list)
134
+ if list_view.children:
135
+ for i, child in enumerate(list_view.children):
136
+ if isinstance(child, ListItem) and child.id:
137
+ model_id = child.id.removeprefix("model-")
138
+ # Find the model name
139
+ for model_name in AVAILABLE_MODELS:
140
+ if _sanitize_model_name_for_id(model_name) == model_id:
141
+ if model_name == current_model:
142
+ list_view.index = i
143
+ break
144
+
145
+ def on_show(self) -> None:
146
+ """Rebuild model list when screen is first shown."""
147
+ logger.debug("ModelPickerScreen.on_show() called")
148
+ self._rebuild_model_list()
149
+
150
+ def on_screenresume(self) -> None:
151
+ """Rebuild model list when screen is resumed (subsequent visits).
152
+
153
+ This is called when returning to the screen after it was suspended,
154
+ ensuring the model list reflects any config changes made while away.
155
+ """
156
+ logger.debug("ModelPickerScreen.on_screenresume() called")
157
+ self._rebuild_model_list()
158
+
159
+ def action_done(self) -> None:
160
+ self.dismiss()
161
+
162
+ @on(ListView.Highlighted)
163
+ def _on_model_highlighted(self, event: ListView.Highlighted) -> None:
164
+ model_name = self._model_from_item(event.item)
165
+ if model_name:
166
+ self.selected_model = model_name
167
+
168
+ @on(ListView.Selected)
169
+ def _on_model_selected(self, event: ListView.Selected) -> None:
170
+ model_name = self._model_from_item(event.item)
171
+ if model_name:
172
+ self.selected_model = model_name
173
+ self._select_model()
174
+
175
+ @on(Button.Pressed, "#select")
176
+ def _on_select_pressed(self) -> None:
177
+ self._select_model()
178
+
179
+ @on(Button.Pressed, "#done")
180
+ def _on_done_pressed(self) -> None:
181
+ self.action_done()
182
+
183
+ @property
184
+ def config_manager(self) -> ConfigManager:
185
+ app = cast("ShotgunApp", self.app)
186
+ return app.config_manager
187
+
188
+ def refresh_model_labels(self) -> None:
189
+ """Update the list view entries to reflect current selection.
190
+
191
+ Note: This method only updates labels for currently displayed models.
192
+ To rebuild the entire list after provider changes, on_show() should be used.
193
+ """
194
+ # Load config once with force_reload
195
+ config = self.config_manager.load(force_reload=True)
196
+ current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
197
+
198
+ # Update labels for available models only
199
+ for model_name in AVAILABLE_MODELS:
200
+ # Pass config to avoid multiple force reloads
201
+ if not self._is_model_available(model_name, config):
202
+ continue
203
+ label = self.query_one(
204
+ f"#label-{_sanitize_model_name_for_id(model_name)}", Label
205
+ )
206
+ label.update(
207
+ self._model_label(model_name, is_current=model_name == current_model)
208
+ )
209
+
210
+ def _build_model_items(self, config: ShotgunConfig | None = None) -> list[ListItem]:
211
+ if config is None:
212
+ config = self.config_manager.load(force_reload=True)
213
+
214
+ items: list[ListItem] = []
215
+ current_model = self.selected_model
216
+ for model_name in AVAILABLE_MODELS:
217
+ # Only add models that are available
218
+ if not self._is_model_available(model_name, config):
219
+ continue
220
+
221
+ label = Label(
222
+ self._model_label(model_name, is_current=model_name == current_model),
223
+ id=f"label-{_sanitize_model_name_for_id(model_name)}",
224
+ )
225
+ items.append(
226
+ ListItem(label, id=f"model-{_sanitize_model_name_for_id(model_name)}")
227
+ )
228
+ return items
229
+
230
+ def _model_from_item(self, item: ListItem | None) -> ModelName | None:
231
+ """Get ModelName from a ListItem."""
232
+ if item is None or item.id is None:
233
+ return None
234
+ sanitized_id = item.id.removeprefix("model-")
235
+ # Find the original model name by comparing sanitized versions
236
+ for model_name in AVAILABLE_MODELS:
237
+ if _sanitize_model_name_for_id(model_name) == sanitized_id:
238
+ return model_name
239
+ return None
240
+
241
+ def _is_model_available(
242
+ self, model_name: ModelName, config: ShotgunConfig | None = None
243
+ ) -> bool:
244
+ """Check if a model is available based on provider key configuration.
245
+
246
+ A model is available if:
247
+ 1. Shotgun Account key is configured (provides access to all models), OR
248
+ 2. The model's provider has an API key configured (BYOK mode)
249
+
250
+ Args:
251
+ model_name: The model to check availability for
252
+ config: Optional pre-loaded config to avoid multiple reloads
253
+
254
+ Returns:
255
+ True if the model can be used, False otherwise
256
+ """
257
+ if config is None:
258
+ config = self.config_manager.load(force_reload=True)
259
+
260
+ # If Shotgun Account is configured, all models are available
261
+ if self.config_manager._provider_has_api_key(config.shotgun):
262
+ logger.debug("Model %s available (Shotgun Account configured)", model_name)
263
+ return True
264
+
265
+ # In BYOK mode, check if the model's provider has a key
266
+ if model_name not in MODEL_SPECS:
267
+ logger.debug("Model %s not available (not in MODEL_SPECS)", model_name)
268
+ return False
269
+
270
+ spec = MODEL_SPECS[model_name]
271
+ # Check provider key directly using the loaded config to avoid stale cache
272
+ provider_config = self.config_manager._get_provider_config(
273
+ config, spec.provider
274
+ )
275
+ has_key = self.config_manager._provider_has_api_key(provider_config)
276
+ logger.debug(
277
+ "Model %s available=%s (provider=%s, has_key=%s)",
278
+ model_name,
279
+ has_key,
280
+ spec.provider,
281
+ has_key,
282
+ )
283
+ return has_key
284
+
285
+ def _model_label(self, model_name: ModelName, is_current: bool) -> str:
286
+ """Generate label for model with specs and current indicator."""
287
+ if model_name not in MODEL_SPECS:
288
+ return model_name.value
289
+
290
+ spec = MODEL_SPECS[model_name]
291
+ display_name = self._model_display_name(model_name)
292
+
293
+ # Format context/output tokens in readable format
294
+ input_k = spec.max_input_tokens // 1000
295
+ output_k = spec.max_output_tokens // 1000
296
+
297
+ label = f"{display_name} · {input_k}K context · {output_k}K output"
298
+
299
+ # Add cost indicator for expensive models
300
+ if model_name == ModelName.CLAUDE_OPUS_4_1:
301
+ label += " · Expensive"
302
+
303
+ if is_current:
304
+ label += " · Current"
305
+
306
+ return label
307
+
308
+ def _model_display_name(self, model_name: ModelName) -> str:
309
+ """Get human-readable model name."""
310
+ names = {
311
+ ModelName.GPT_5: "GPT-5 (OpenAI)",
312
+ ModelName.CLAUDE_OPUS_4_1: "Claude Opus 4.1 (Anthropic)",
313
+ ModelName.CLAUDE_SONNET_4_5: "Claude Sonnet 4.5 (Anthropic)",
314
+ ModelName.GEMINI_2_5_PRO: "Gemini 2.5 Pro (Google)",
315
+ }
316
+ return names.get(model_name, model_name.value)
317
+
318
+ def _select_model(self) -> None:
319
+ """Save the selected model."""
320
+ try:
321
+ self.config_manager.update_selected_model(self.selected_model)
322
+ self.refresh_model_labels()
323
+ self.notify(
324
+ f"Selected model: {self._model_display_name(self.selected_model)}"
325
+ )
326
+ except Exception as exc: # pragma: no cover - defensive; textual path
327
+ self.notify(f"Failed to select model: {exc}", severity="error")
@@ -12,11 +12,25 @@ from textual.screen import Screen
12
12
  from textual.widgets import Button, Input, Label, ListItem, ListView, Markdown, Static
13
13
 
14
14
  from shotgun.agents.config import ConfigManager, ProviderType
15
+ from shotgun.utils.env_utils import is_shotgun_account_enabled
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from ..app import ShotgunApp
18
19
 
19
20
 
21
+ def get_configurable_providers() -> list[str]:
22
+ """Get list of configurable providers based on feature flags.
23
+
24
+ Returns:
25
+ List of provider identifiers that can be configured.
26
+ Includes shotgun only if SHOTGUN_ACCOUNT_ENABLED is set.
27
+ """
28
+ providers = ["openai", "anthropic", "google"]
29
+ if is_shotgun_account_enabled():
30
+ providers.append("shotgun")
31
+ return providers
32
+
33
+
20
34
  class ProviderConfigScreen(Screen[None]):
21
35
  """Collect API keys for available providers."""
22
36
 
@@ -71,9 +85,10 @@ class ProviderConfigScreen(Screen[None]):
71
85
 
72
86
  BINDINGS = [
73
87
  ("escape", "done", "Back"),
88
+ ("ctrl+c", "app.quit", "Quit"),
74
89
  ]
75
90
 
76
- selected_provider: reactive[ProviderType] = reactive(ProviderType.OPENAI)
91
+ selected_provider: reactive[str] = reactive("openai")
77
92
 
78
93
  def compose(self) -> ComposeResult:
79
94
  with Vertical(id="titlebox"):
@@ -94,17 +109,30 @@ class ProviderConfigScreen(Screen[None]):
94
109
  )
95
110
  with Horizontal(id="provider-actions"):
96
111
  yield Button("Save key \\[ENTER]", variant="primary", id="save")
112
+ yield Button("Authenticate", variant="success", id="authenticate")
97
113
  yield Button("Clear key", id="clear", variant="warning")
98
114
  yield Button("Done \\[ESC]", id="done")
99
115
 
100
116
  def on_mount(self) -> None:
101
117
  self.refresh_provider_status()
118
+ self._update_done_button_visibility()
102
119
  list_view = self.query_one(ListView)
103
120
  if list_view.children:
104
121
  list_view.index = 0
105
- self.selected_provider = ProviderType.OPENAI
122
+ self.selected_provider = "openai"
123
+
124
+ # Hide authenticate button by default (shown only for shotgun)
125
+ self.query_one("#authenticate", Button).display = False
106
126
  self.set_focus(self.query_one("#api-key", Input))
107
127
 
128
+ def on_screenresume(self) -> None:
129
+ """Refresh provider status when screen is resumed.
130
+
131
+ This ensures the UI reflects any provider changes made elsewhere.
132
+ """
133
+ self.refresh_provider_status()
134
+ self._update_done_button_visibility()
135
+
108
136
  def action_done(self) -> None:
109
137
  self.dismiss()
110
138
 
@@ -125,6 +153,10 @@ class ProviderConfigScreen(Screen[None]):
125
153
  def _on_save_pressed(self) -> None:
126
154
  self._save_api_key()
127
155
 
156
+ @on(Button.Pressed, "#authenticate")
157
+ def _on_authenticate_pressed(self) -> None:
158
+ self.run_worker(self._start_shotgun_auth(), exclusive=True)
159
+
128
160
  @on(Button.Pressed, "#clear")
129
161
  def _on_clear_pressed(self) -> None:
130
162
  self._clear_api_key()
@@ -141,9 +173,31 @@ class ProviderConfigScreen(Screen[None]):
141
173
  def watch_selected_provider(self, provider: ProviderType) -> None:
142
174
  if not self.is_mounted:
143
175
  return
176
+
177
+ # Show/hide UI elements based on provider type
178
+ is_shotgun = provider == "shotgun"
179
+
144
180
  input_widget = self.query_one("#api-key", Input)
145
- input_widget.placeholder = self._input_placeholder(provider)
146
- input_widget.value = ""
181
+ save_button = self.query_one("#save", Button)
182
+ auth_button = self.query_one("#authenticate", Button)
183
+
184
+ if is_shotgun:
185
+ # Hide API key input and save button
186
+ input_widget.display = False
187
+ save_button.display = False
188
+
189
+ # Only show Authenticate button if shotgun is NOT already configured
190
+ if self._has_provider_key("shotgun"):
191
+ auth_button.display = False
192
+ else:
193
+ auth_button.display = True
194
+ else:
195
+ # Show API key input and save button, hide authenticate button
196
+ input_widget.display = True
197
+ save_button.display = True
198
+ auth_button.display = False
199
+ input_widget.placeholder = self._input_placeholder(provider)
200
+ input_widget.value = ""
147
201
 
148
202
  @property
149
203
  def config_manager(self) -> ConfigManager:
@@ -152,45 +206,61 @@ class ProviderConfigScreen(Screen[None]):
152
206
 
153
207
  def refresh_provider_status(self) -> None:
154
208
  """Update the list view entries to reflect configured providers."""
155
- for provider in ProviderType:
156
- label = self.query_one(f"#label-{provider.value}", Label)
157
- label.update(self._provider_label(provider))
209
+ for provider_id in get_configurable_providers():
210
+ label = self.query_one(f"#label-{provider_id}", Label)
211
+ label.update(self._provider_label(provider_id))
212
+
213
+ def _update_done_button_visibility(self) -> None:
214
+ """Show/hide Done button based on whether any provider keys are configured."""
215
+ done_button = self.query_one("#done", Button)
216
+ has_keys = self.config_manager.has_any_provider_key()
217
+ done_button.display = has_keys
158
218
 
159
219
  def _build_provider_items(self) -> list[ListItem]:
160
220
  items: list[ListItem] = []
161
- for provider in ProviderType:
162
- label = Label(self._provider_label(provider), id=f"label-{provider.value}")
163
- items.append(ListItem(label, id=f"provider-{provider.value}"))
221
+ for provider_id in get_configurable_providers():
222
+ label = Label(self._provider_label(provider_id), id=f"label-{provider_id}")
223
+ items.append(ListItem(label, id=f"provider-{provider_id}"))
164
224
  return items
165
225
 
166
- def _provider_from_item(self, item: ListItem | None) -> ProviderType | None:
226
+ def _provider_from_item(self, item: ListItem | None) -> str | None:
167
227
  if item is None or item.id is None:
168
228
  return None
169
229
  provider_id = item.id.removeprefix("provider-")
170
- try:
171
- return ProviderType(provider_id)
172
- except ValueError:
173
- return None
230
+ return provider_id if provider_id in get_configurable_providers() else None
174
231
 
175
- def _provider_label(self, provider: ProviderType) -> str:
176
- display = self._provider_display_name(provider)
232
+ def _provider_label(self, provider_id: str) -> str:
233
+ display = self._provider_display_name(provider_id)
177
234
  status = (
178
- "Configured"
179
- if self.config_manager.has_provider_key(provider)
180
- else "Not configured"
235
+ "Configured" if self._has_provider_key(provider_id) else "Not configured"
181
236
  )
182
237
  return f"{display} · {status}"
183
238
 
184
- def _provider_display_name(self, provider: ProviderType) -> str:
239
+ def _provider_display_name(self, provider_id: str) -> str:
185
240
  names = {
186
- ProviderType.OPENAI: "OpenAI",
187
- ProviderType.ANTHROPIC: "Anthropic",
188
- ProviderType.GOOGLE: "Google Gemini",
241
+ "openai": "OpenAI",
242
+ "anthropic": "Anthropic",
243
+ "google": "Google Gemini",
244
+ "shotgun": "Shotgun Account",
189
245
  }
190
- return names.get(provider, provider.value.title())
191
-
192
- def _input_placeholder(self, provider: ProviderType) -> str:
193
- return f"{self._provider_display_name(provider)} API key"
246
+ return names.get(provider_id, provider_id.title())
247
+
248
+ def _input_placeholder(self, provider_id: str) -> str:
249
+ return f"{self._provider_display_name(provider_id)} API key"
250
+
251
+ def _has_provider_key(self, provider_id: str) -> bool:
252
+ """Check if provider has a configured API key."""
253
+ if provider_id == "shotgun":
254
+ # Check shotgun key directly
255
+ config = self.config_manager.load()
256
+ return self.config_manager._provider_has_api_key(config.shotgun)
257
+ else:
258
+ # Check LLM provider key
259
+ try:
260
+ provider = ProviderType(provider_id)
261
+ return self.config_manager.has_provider_key(provider)
262
+ except ValueError:
263
+ return False
194
264
 
195
265
  def _save_api_key(self) -> None:
196
266
  input_widget = self.query_one("#api-key", Input)
@@ -211,6 +281,7 @@ class ProviderConfigScreen(Screen[None]):
211
281
 
212
282
  input_widget.value = ""
213
283
  self.refresh_provider_status()
284
+ self._update_done_button_visibility()
214
285
  self.notify(
215
286
  f"Saved API key for {self._provider_display_name(self.selected_provider)}."
216
287
  )
@@ -223,7 +294,26 @@ class ProviderConfigScreen(Screen[None]):
223
294
  return
224
295
 
225
296
  self.refresh_provider_status()
297
+ self._update_done_button_visibility()
226
298
  self.query_one("#api-key", Input).value = ""
299
+
300
+ # If we just cleared shotgun, show the Authenticate button
301
+ if self.selected_provider == "shotgun":
302
+ auth_button = self.query_one("#authenticate", Button)
303
+ auth_button.display = True
304
+
227
305
  self.notify(
228
306
  f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
229
307
  )
308
+
309
+ async def _start_shotgun_auth(self) -> None:
310
+ """Launch Shotgun Account authentication flow."""
311
+ from .shotgun_auth import ShotgunAuthScreen
312
+
313
+ # Push the auth screen and wait for result
314
+ result = await self.app.push_screen_wait(ShotgunAuthScreen())
315
+
316
+ # Refresh provider status after auth completes
317
+ if result:
318
+ self.refresh_provider_status()
319
+ # Notify handled by auth screen