shotgun-sh 0.2.1.dev3__py3-none-any.whl → 0.2.1.dev5__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.

shotgun/tui/app.py CHANGED
@@ -8,6 +8,7 @@ from textual.screen import Screen
8
8
  from shotgun.agents.config import ConfigManager, get_config_manager
9
9
  from shotgun.logging_config import get_logger
10
10
  from shotgun.tui.screens.splash import SplashScreen
11
+ from shotgun.utils.env_utils import is_shotgun_account_enabled
11
12
  from shotgun.utils.file_system_utils import get_shotgun_base_path
12
13
  from shotgun.utils.update_checker import perform_auto_update_async
13
14
 
@@ -16,6 +17,7 @@ from .screens.directory_setup import DirectorySetupScreen
16
17
  from .screens.feedback import FeedbackScreen
17
18
  from .screens.model_picker import ModelPickerScreen
18
19
  from .screens.provider_config import ProviderConfigScreen
20
+ from .screens.welcome import WelcomeScreen
19
21
 
20
22
  logger = get_logger(__name__)
21
23
 
@@ -60,20 +62,34 @@ class ShotgunApp(App[None]):
60
62
  def refresh_startup_screen(self) -> None:
61
63
  """Push the appropriate screen based on configured providers."""
62
64
  if not self.config_manager.has_any_provider_key():
63
- if isinstance(self.screen, ProviderConfigScreen):
65
+ # If Shotgun Account is enabled, show welcome screen with choice
66
+ # Otherwise, go directly to provider config (BYOK only)
67
+ if is_shotgun_account_enabled():
68
+ if isinstance(self.screen, WelcomeScreen):
69
+ return
70
+
71
+ self.push_screen(
72
+ WelcomeScreen(),
73
+ callback=lambda _arg: self.refresh_startup_screen(),
74
+ )
75
+ return
76
+ else:
77
+ if isinstance(self.screen, ProviderConfigScreen):
78
+ return
79
+
80
+ self.push_screen(
81
+ ProviderConfigScreen(),
82
+ callback=lambda _arg: self.refresh_startup_screen(),
83
+ )
64
84
  return
65
-
66
- self.push_screen(
67
- "provider_config", callback=lambda _arg: self.refresh_startup_screen()
68
- )
69
- return
70
85
 
71
86
  if not self.check_local_shotgun_directory_exists():
72
87
  if isinstance(self.screen, DirectorySetupScreen):
73
88
  return
74
89
 
75
90
  self.push_screen(
76
- "directory_setup", callback=lambda _arg: self.refresh_startup_screen()
91
+ DirectorySetupScreen(),
92
+ callback=lambda _arg: self.refresh_startup_screen(),
77
93
  )
78
94
  return
79
95
 
@@ -110,7 +126,7 @@ class ShotgunApp(App[None]):
110
126
  submit_feedback_survey(feedback)
111
127
  self.notify("Feedback sent. Thank you!")
112
128
 
113
- self.push_screen("feedback", callback=handle_feedback)
129
+ self.push_screen(FeedbackScreen(), callback=handle_feedback)
114
130
 
115
131
 
116
132
  def run(no_update_check: bool = False, continue_session: bool = False) -> None:
@@ -5,6 +5,8 @@ from textual.command import DiscoveryHit, Hit, Provider
5
5
 
6
6
  from shotgun.agents.models import AgentType
7
7
  from shotgun.codebase.models import CodebaseGraph
8
+ from shotgun.tui.screens.model_picker import ModelPickerScreen
9
+ from shotgun.tui.screens.provider_config import ProviderConfigScreen
8
10
 
9
11
  if TYPE_CHECKING:
10
12
  from shotgun.tui.screens.chat import ChatScreen
@@ -139,11 +141,11 @@ class ProviderSetupProvider(Provider):
139
141
 
140
142
  def open_provider_config(self) -> None:
141
143
  """Show the provider configuration screen."""
142
- self.chat_screen.app.push_screen("provider_config")
144
+ self.chat_screen.app.push_screen(ProviderConfigScreen())
143
145
 
144
146
  def open_model_picker(self) -> None:
145
147
  """Show the model picker screen."""
146
- self.chat_screen.app.push_screen("model_picker")
148
+ self.chat_screen.app.push_screen(ModelPickerScreen())
147
149
 
148
150
  async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
149
151
  yield DiscoveryHit(
@@ -282,11 +284,11 @@ class UnifiedCommandProvider(Provider):
282
284
 
283
285
  def open_provider_config(self) -> None:
284
286
  """Show the provider configuration screen."""
285
- self.chat_screen.app.push_screen("provider_config")
287
+ self.chat_screen.app.push_screen(ProviderConfigScreen())
286
288
 
287
289
  def open_model_picker(self) -> None:
288
290
  """Show the model picker screen."""
289
- self.chat_screen.app.push_screen("model_picker")
291
+ self.chat_screen.app.push_screen(ModelPickerScreen())
290
292
 
291
293
  async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
292
294
  """Provide commands in alphabetical order when palette opens."""
@@ -182,12 +182,12 @@ class FeedbackScreen(Screen[Feedback | None]):
182
182
  return
183
183
 
184
184
  app = cast("ShotgunApp", self.app)
185
- user_id = app.config_manager.get_user_id()
185
+ shotgun_instance_id = app.config_manager.get_shotgun_instance_id()
186
186
 
187
187
  feedback = Feedback(
188
188
  kind=self.selected_kind,
189
189
  description=description,
190
- user_id=user_id,
190
+ shotgun_instance_id=shotgun_instance_id,
191
191
  )
192
192
 
193
193
  self.dismiss(feedback)
@@ -12,11 +12,14 @@ from textual.screen import Screen
12
12
  from textual.widgets import Button, Label, ListItem, ListView, Static
13
13
 
14
14
  from shotgun.agents.config import ConfigManager
15
- from shotgun.agents.config.models import MODEL_SPECS, ModelName
15
+ from shotgun.agents.config.models import MODEL_SPECS, ModelName, ShotgunConfig
16
+ from shotgun.logging_config import get_logger
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from ..app import ShotgunApp
19
20
 
21
+ logger = get_logger(__name__)
22
+
20
23
 
21
24
  # Available models for selection
22
25
  AVAILABLE_MODELS = list(ModelName)
@@ -82,20 +85,52 @@ class ModelPickerScreen(Screen[None]):
82
85
  "Select the AI model you want to use for your tasks.",
83
86
  id="model-picker-summary",
84
87
  )
85
- yield ListView(*self._build_model_items(), id="model-list")
88
+ yield ListView(id="model-list")
86
89
  with Horizontal(id="model-actions"):
87
90
  yield Button("Select \\[ENTER]", variant="primary", id="select")
88
91
  yield Button("Done \\[ESC]", id="done")
89
92
 
90
- def on_mount(self) -> None:
91
- # Load current selection
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
92
102
  config_manager = self.config_manager
93
- config = config_manager.load()
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
+
94
114
  current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
95
115
  self.selected_model = current_model
116
+ logger.debug("Current selected model: %s", current_model)
96
117
 
97
- # Find and highlight current selection (if it's in the filtered list)
118
+ # Rebuild the model list with current available models
98
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)
99
134
  if list_view.children:
100
135
  for i, child in enumerate(list_view.children):
101
136
  if isinstance(child, ListItem) and child.id:
@@ -106,7 +141,20 @@ class ModelPickerScreen(Screen[None]):
106
141
  if model_name == current_model:
107
142
  list_view.index = i
108
143
  break
109
- self.refresh_model_labels()
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()
110
158
 
111
159
  def action_done(self) -> None:
112
160
  self.dismiss()
@@ -138,13 +186,19 @@ class ModelPickerScreen(Screen[None]):
138
186
  return app.config_manager
139
187
 
140
188
  def refresh_model_labels(self) -> None:
141
- """Update the list view entries to reflect current selection."""
142
- current_model = (
143
- self.config_manager.load().selected_model or ModelName.CLAUDE_SONNET_4_5
144
- )
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
+
145
198
  # Update labels for available models only
146
199
  for model_name in AVAILABLE_MODELS:
147
- if not self._is_model_available(model_name):
200
+ # Pass config to avoid multiple force reloads
201
+ if not self._is_model_available(model_name, config):
148
202
  continue
149
203
  label = self.query_one(
150
204
  f"#label-{_sanitize_model_name_for_id(model_name)}", Label
@@ -153,12 +207,15 @@ class ModelPickerScreen(Screen[None]):
153
207
  self._model_label(model_name, is_current=model_name == current_model)
154
208
  )
155
209
 
156
- def _build_model_items(self) -> list[ListItem]:
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
+
157
214
  items: list[ListItem] = []
158
215
  current_model = self.selected_model
159
216
  for model_name in AVAILABLE_MODELS:
160
217
  # Only add models that are available
161
- if not self._is_model_available(model_name):
218
+ if not self._is_model_available(model_name, config):
162
219
  continue
163
220
 
164
221
  label = Label(
@@ -181,7 +238,9 @@ class ModelPickerScreen(Screen[None]):
181
238
  return model_name
182
239
  return None
183
240
 
184
- def _is_model_available(self, model_name: ModelName) -> bool:
241
+ def _is_model_available(
242
+ self, model_name: ModelName, config: ShotgunConfig | None = None
243
+ ) -> bool:
185
244
  """Check if a model is available based on provider key configuration.
186
245
 
187
246
  A model is available if:
@@ -190,22 +249,38 @@ class ModelPickerScreen(Screen[None]):
190
249
 
191
250
  Args:
192
251
  model_name: The model to check availability for
252
+ config: Optional pre-loaded config to avoid multiple reloads
193
253
 
194
254
  Returns:
195
255
  True if the model can be used, False otherwise
196
256
  """
197
- config = self.config_manager.load()
257
+ if config is None:
258
+ config = self.config_manager.load(force_reload=True)
198
259
 
199
260
  # If Shotgun Account is configured, all models are available
200
261
  if self.config_manager._provider_has_api_key(config.shotgun):
262
+ logger.debug("Model %s available (Shotgun Account configured)", model_name)
201
263
  return True
202
264
 
203
265
  # In BYOK mode, check if the model's provider has a key
204
266
  if model_name not in MODEL_SPECS:
267
+ logger.debug("Model %s not available (not in MODEL_SPECS)", model_name)
205
268
  return False
206
269
 
207
270
  spec = MODEL_SPECS[model_name]
208
- return self.config_manager.has_provider_key(spec.provider)
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
209
284
 
210
285
  def _model_label(self, model_name: ModelName, is_current: bool) -> str:
211
286
  """Generate label for model with specs and current indicator."""
@@ -85,6 +85,7 @@ class ProviderConfigScreen(Screen[None]):
85
85
 
86
86
  BINDINGS = [
87
87
  ("escape", "done", "Back"),
88
+ ("ctrl+c", "app.quit", "Quit"),
88
89
  ]
89
90
 
90
91
  selected_provider: reactive[str] = reactive("openai")
@@ -108,17 +109,30 @@ class ProviderConfigScreen(Screen[None]):
108
109
  )
109
110
  with Horizontal(id="provider-actions"):
110
111
  yield Button("Save key \\[ENTER]", variant="primary", id="save")
112
+ yield Button("Authenticate", variant="success", id="authenticate")
111
113
  yield Button("Clear key", id="clear", variant="warning")
112
114
  yield Button("Done \\[ESC]", id="done")
113
115
 
114
116
  def on_mount(self) -> None:
115
117
  self.refresh_provider_status()
118
+ self._update_done_button_visibility()
116
119
  list_view = self.query_one(ListView)
117
120
  if list_view.children:
118
121
  list_view.index = 0
119
122
  self.selected_provider = "openai"
123
+
124
+ # Hide authenticate button by default (shown only for shotgun)
125
+ self.query_one("#authenticate", Button).display = False
120
126
  self.set_focus(self.query_one("#api-key", Input))
121
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
+
122
136
  def action_done(self) -> None:
123
137
  self.dismiss()
124
138
 
@@ -139,6 +153,10 @@ class ProviderConfigScreen(Screen[None]):
139
153
  def _on_save_pressed(self) -> None:
140
154
  self._save_api_key()
141
155
 
156
+ @on(Button.Pressed, "#authenticate")
157
+ def _on_authenticate_pressed(self) -> None:
158
+ self.run_worker(self._start_shotgun_auth(), exclusive=True)
159
+
142
160
  @on(Button.Pressed, "#clear")
143
161
  def _on_clear_pressed(self) -> None:
144
162
  self._clear_api_key()
@@ -155,9 +173,31 @@ class ProviderConfigScreen(Screen[None]):
155
173
  def watch_selected_provider(self, provider: ProviderType) -> None:
156
174
  if not self.is_mounted:
157
175
  return
176
+
177
+ # Show/hide UI elements based on provider type
178
+ is_shotgun = provider == "shotgun"
179
+
158
180
  input_widget = self.query_one("#api-key", Input)
159
- input_widget.placeholder = self._input_placeholder(provider)
160
- 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 = ""
161
201
 
162
202
  @property
163
203
  def config_manager(self) -> ConfigManager:
@@ -170,6 +210,12 @@ class ProviderConfigScreen(Screen[None]):
170
210
  label = self.query_one(f"#label-{provider_id}", Label)
171
211
  label.update(self._provider_label(provider_id))
172
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
218
+
173
219
  def _build_provider_items(self) -> list[ListItem]:
174
220
  items: list[ListItem] = []
175
221
  for provider_id in get_configurable_providers():
@@ -235,6 +281,7 @@ class ProviderConfigScreen(Screen[None]):
235
281
 
236
282
  input_widget.value = ""
237
283
  self.refresh_provider_status()
284
+ self._update_done_button_visibility()
238
285
  self.notify(
239
286
  f"Saved API key for {self._provider_display_name(self.selected_provider)}."
240
287
  )
@@ -247,7 +294,26 @@ class ProviderConfigScreen(Screen[None]):
247
294
  return
248
295
 
249
296
  self.refresh_provider_status()
297
+ self._update_done_button_visibility()
250
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
+
251
305
  self.notify(
252
306
  f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
253
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