shotgun-sh 0.2.11.dev2__py3-none-any.whl → 0.2.11.dev7__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 (72) hide show
  1. shotgun/agents/agent_manager.py +194 -28
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/manager.py +64 -33
  4. shotgun/agents/config/models.py +25 -1
  5. shotgun/agents/config/provider.py +2 -2
  6. shotgun/agents/context_analyzer/analyzer.py +2 -24
  7. shotgun/agents/conversation_manager.py +35 -19
  8. shotgun/agents/export.py +2 -2
  9. shotgun/agents/history/history_processors.py +99 -3
  10. shotgun/agents/history/token_counting/anthropic.py +17 -1
  11. shotgun/agents/history/token_counting/base.py +14 -3
  12. shotgun/agents/history/token_counting/openai.py +11 -1
  13. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  14. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  15. shotgun/agents/history/token_counting/utils.py +0 -3
  16. shotgun/agents/plan.py +2 -2
  17. shotgun/agents/research.py +3 -3
  18. shotgun/agents/specify.py +2 -2
  19. shotgun/agents/tasks.py +2 -2
  20. shotgun/agents/tools/codebase/file_read.py +5 -2
  21. shotgun/agents/tools/file_management.py +11 -7
  22. shotgun/agents/tools/web_search/__init__.py +8 -8
  23. shotgun/agents/tools/web_search/anthropic.py +2 -2
  24. shotgun/agents/tools/web_search/gemini.py +1 -1
  25. shotgun/agents/tools/web_search/openai.py +1 -1
  26. shotgun/agents/tools/web_search/utils.py +2 -2
  27. shotgun/agents/usage_manager.py +16 -11
  28. shotgun/cli/clear.py +2 -1
  29. shotgun/cli/compact.py +3 -3
  30. shotgun/cli/config.py +8 -5
  31. shotgun/cli/context.py +2 -2
  32. shotgun/cli/export.py +1 -1
  33. shotgun/cli/feedback.py +4 -2
  34. shotgun/cli/plan.py +1 -1
  35. shotgun/cli/research.py +1 -1
  36. shotgun/cli/specify.py +1 -1
  37. shotgun/cli/tasks.py +1 -1
  38. shotgun/codebase/core/change_detector.py +5 -3
  39. shotgun/codebase/core/code_retrieval.py +4 -2
  40. shotgun/codebase/core/ingestor.py +10 -8
  41. shotgun/codebase/core/manager.py +3 -3
  42. shotgun/codebase/core/nl_query.py +1 -1
  43. shotgun/exceptions.py +32 -0
  44. shotgun/logging_config.py +10 -17
  45. shotgun/main.py +3 -1
  46. shotgun/posthog_telemetry.py +14 -4
  47. shotgun/sentry_telemetry.py +22 -2
  48. shotgun/telemetry.py +3 -1
  49. shotgun/tui/app.py +71 -65
  50. shotgun/tui/components/context_indicator.py +43 -0
  51. shotgun/tui/containers.py +15 -17
  52. shotgun/tui/dependencies.py +2 -2
  53. shotgun/tui/screens/chat/chat_screen.py +164 -40
  54. shotgun/tui/screens/chat/help_text.py +16 -15
  55. shotgun/tui/screens/chat_screen/command_providers.py +10 -0
  56. shotgun/tui/screens/feedback.py +4 -4
  57. shotgun/tui/screens/github_issue.py +102 -0
  58. shotgun/tui/screens/model_picker.py +21 -20
  59. shotgun/tui/screens/onboarding.py +431 -0
  60. shotgun/tui/screens/provider_config.py +50 -27
  61. shotgun/tui/screens/shotgun_auth.py +2 -2
  62. shotgun/tui/screens/welcome.py +14 -11
  63. shotgun/tui/services/conversation_service.py +16 -14
  64. shotgun/tui/utils/mode_progress.py +14 -7
  65. shotgun/tui/widgets/widget_coordinator.py +15 -0
  66. shotgun/utils/file_system_utils.py +19 -0
  67. shotgun/utils/marketing.py +110 -0
  68. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/METADATA +2 -1
  69. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/RECORD +72 -68
  70. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/WHEEL +0 -0
  71. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/entry_points.txt +0 -0
  72. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,431 @@
1
+ """Onboarding popup modal for first-time users."""
2
+
3
+ import webbrowser
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Horizontal, VerticalScroll
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Markdown, Static
10
+
11
+
12
+ class OnboardingModal(ModalScreen[None]):
13
+ """Multi-page onboarding modal for new users.
14
+
15
+ This modal presents helpful resources and tips for using Shotgun across
16
+ multiple pages. Users can navigate between pages using Next/Back buttons.
17
+ """
18
+
19
+ CSS = """
20
+ OnboardingModal {
21
+ align: center middle;
22
+ }
23
+
24
+ #onboarding-container {
25
+ width: 95;
26
+ max-width: 100;
27
+ height: auto;
28
+ max-height: 90%;
29
+ border: thick $primary;
30
+ background: $surface;
31
+ padding: 2;
32
+ }
33
+
34
+ #progress-sidebar {
35
+ width: 26;
36
+ dock: left;
37
+ border-right: solid $primary;
38
+ padding: 1;
39
+ height: 100%;
40
+ }
41
+
42
+ #main-content {
43
+ width: 1fr;
44
+ height: auto;
45
+ }
46
+
47
+ #progress-header {
48
+ text-style: bold;
49
+ padding-bottom: 1;
50
+ color: $text-accent;
51
+ }
52
+
53
+ .progress-item {
54
+ padding: 1 0;
55
+ }
56
+
57
+ .progress-item-current {
58
+ color: $accent;
59
+ text-style: bold;
60
+ }
61
+
62
+ .progress-item-visited {
63
+ color: $success;
64
+ }
65
+
66
+ .progress-item-unvisited {
67
+ color: $text-muted;
68
+ }
69
+
70
+ #onboarding-header {
71
+ text-style: bold;
72
+ color: $text-accent;
73
+ padding-bottom: 1;
74
+ text-align: center;
75
+ }
76
+
77
+ #onboarding-content {
78
+ height: 1fr;
79
+ padding: 1 0;
80
+ }
81
+
82
+ #page-indicator {
83
+ text-align: center;
84
+ color: $text-muted;
85
+ padding: 1 0;
86
+ }
87
+
88
+ #buttons-container {
89
+ height: auto;
90
+ padding: 1 0 0 0;
91
+ }
92
+
93
+ #navigation-buttons {
94
+ width: 100%;
95
+ height: auto;
96
+ align: center middle;
97
+ }
98
+
99
+ .nav-button {
100
+ margin: 0 1;
101
+ min-width: 12;
102
+ }
103
+
104
+ #resource-sections {
105
+ padding: 1 0;
106
+ height: auto;
107
+ }
108
+
109
+ #resource-sections Button {
110
+ width: 100%;
111
+ margin: 0 0 2 0;
112
+ }
113
+
114
+ #video-section {
115
+ padding: 0;
116
+ margin: 0 0 1 0;
117
+ }
118
+
119
+ #docs-section {
120
+ padding: 0;
121
+ margin: 2 0 1 0;
122
+ }
123
+ """
124
+
125
+ BINDINGS = [
126
+ ("escape", "dismiss", "Close"),
127
+ ("ctrl+c", "app.quit", "Quit"),
128
+ ]
129
+
130
+ def __init__(self) -> None:
131
+ """Initialize the onboarding modal."""
132
+ super().__init__()
133
+ self.current_page = 0
134
+ self.total_pages = 4
135
+ self.page_titles = [
136
+ "Getting Started",
137
+ "Discovering the 5 Modes",
138
+ "Prompting Better",
139
+ "Context Management!",
140
+ ]
141
+ # Track which pages have been visited (in memory only)
142
+ self.visited_pages: set[int] = {0} # Start on page 0, so it's visited
143
+
144
+ def compose(self) -> ComposeResult:
145
+ """Compose the onboarding modal."""
146
+ with Container(id="onboarding-container"):
147
+ # Left sidebar for progress tracking
148
+ with Container(id="progress-sidebar"):
149
+ yield Static("Progress", id="progress-header")
150
+ for i in range(self.total_pages):
151
+ yield Static(
152
+ f"{i + 1}. {self.page_titles[i]}",
153
+ id=f"progress-item-{i}",
154
+ classes="progress-item",
155
+ )
156
+
157
+ # Main content area
158
+ with Container(id="main-content"):
159
+ yield Static("Welcome to Shotgun!", id="onboarding-header")
160
+ with VerticalScroll(id="onboarding-content"):
161
+ yield Markdown(id="page-content")
162
+ # Resource sections (only shown on page 1)
163
+ with Container(id="resource-sections"):
164
+ yield Markdown(
165
+ "### 🎥 Video Demo\nWatch our demo video to see Shotgun in action",
166
+ id="video-section",
167
+ )
168
+ yield Button(
169
+ "▶️ Watch Demo Video",
170
+ id="youtube-button",
171
+ variant="success",
172
+ )
173
+ yield Markdown(
174
+ "### 📖 Documentation\nRead the comprehensive usage guide for detailed instructions",
175
+ id="docs-section",
176
+ )
177
+ yield Button(
178
+ "📚 Read Usage Guide", id="usage-button", variant="primary"
179
+ )
180
+ yield Static(id="page-indicator")
181
+ with Container(id="buttons-container"):
182
+ with Horizontal(id="navigation-buttons"):
183
+ yield Button("Back", id="back-button", classes="nav-button")
184
+ yield Button(
185
+ "Next",
186
+ id="next-button",
187
+ classes="nav-button",
188
+ variant="primary",
189
+ )
190
+ yield Button("Close", id="close-button", classes="nav-button")
191
+
192
+ def on_mount(self) -> None:
193
+ """Set up the modal after mounting."""
194
+ self.update_page()
195
+
196
+ def update_page(self) -> None:
197
+ """Update the displayed page content and navigation buttons."""
198
+ # Mark current page as visited
199
+ self.visited_pages.add(self.current_page)
200
+
201
+ # Update page content
202
+ content_widget = self.query_one("#page-content", Markdown)
203
+ content_widget.update(self.get_page_content())
204
+
205
+ # Update page indicator
206
+ page_indicator = self.query_one("#page-indicator", Static)
207
+ page_indicator.update(f"Page {self.current_page + 1} of {self.total_pages}")
208
+
209
+ # Update progress sidebar
210
+ for i in range(self.total_pages):
211
+ progress_item = self.query_one(f"#progress-item-{i}", Static)
212
+ # Remove all progress classes first
213
+ progress_item.remove_class(
214
+ "progress-item-current",
215
+ "progress-item-visited",
216
+ "progress-item-unvisited",
217
+ )
218
+ # Add appropriate class
219
+ if i == self.current_page:
220
+ progress_item.add_class("progress-item-current")
221
+ progress_item.update(f"▶ {i + 1}. {self.page_titles[i]}")
222
+ elif i in self.visited_pages:
223
+ progress_item.add_class("progress-item-visited")
224
+ progress_item.update(f"✓ {i + 1}. {self.page_titles[i]}")
225
+ else:
226
+ progress_item.add_class("progress-item-unvisited")
227
+ progress_item.update(f" {i + 1}. {self.page_titles[i]}")
228
+
229
+ # Show/hide resource sections (only on page 1)
230
+ resource_sections = self.query_one("#resource-sections", Container)
231
+ resource_sections.display = self.current_page == 0
232
+
233
+ # Update button visibility and states
234
+ back_button = self.query_one("#back-button", Button)
235
+ next_button = self.query_one("#next-button", Button)
236
+
237
+ # Update back button label and state
238
+ if self.current_page == 0:
239
+ back_button.disabled = True
240
+ back_button.label = "Back"
241
+ else:
242
+ back_button.disabled = False
243
+ prev_title = self.page_titles[self.current_page - 1]
244
+ back_button.label = f"← {prev_title}"
245
+
246
+ # Update next button label
247
+ if self.current_page == self.total_pages - 1:
248
+ next_button.label = "Finish"
249
+ else:
250
+ next_title = self.page_titles[self.current_page + 1]
251
+ next_button.label = f"{next_title} (Next →)"
252
+
253
+ # Focus the appropriate button
254
+ if self.current_page == 0:
255
+ next_button.focus()
256
+ else:
257
+ next_button.focus()
258
+
259
+ # Scroll content to top
260
+ self.query_one("#onboarding-content", VerticalScroll).scroll_home(animate=False)
261
+
262
+ def get_page_content(self) -> str:
263
+ """Get the content for the current page."""
264
+ if self.current_page == 0:
265
+ return self._page_1_resources()
266
+ elif self.current_page == 1:
267
+ return self._page_2_modes()
268
+ elif self.current_page == 2:
269
+ return self._page_3_prompts()
270
+ else:
271
+ return self._page_4_context_management()
272
+
273
+ def _page_1_resources(self) -> str:
274
+ """Page 1: Helpful resources."""
275
+ return """
276
+ ## Getting Started Resources
277
+
278
+ Here are some helpful resources to get you up to speed with Shotgun:
279
+ """
280
+
281
+ def _page_2_modes(self) -> str:
282
+ """Page 2: Explanation of the 5 modes."""
283
+ return """
284
+ ## Understanding Shotgun's 5 Modes
285
+
286
+ Shotgun has 5 specialized modes, each designed for specific tasks. Each mode writes to its own dedicated file in `.shotgun/`:
287
+
288
+ ### 🔬 Research Mode
289
+ Research topics with web search and synthesize findings. Perfect for gathering information and exploring new concepts.
290
+
291
+ **Writes to:** `.shotgun/research.md`
292
+
293
+ ### 📝 Specify Mode
294
+ Create detailed specifications and requirements documents. Great for planning features and documenting requirements.
295
+
296
+ **Writes to:** `.shotgun/specification.md`
297
+
298
+ ### 📋 Plan Mode
299
+ Create comprehensive, actionable plans with milestones. Ideal for breaking down large projects into manageable steps.
300
+
301
+ **Writes to:** `.shotgun/plan.md`
302
+
303
+ ### ✅ Tasks Mode
304
+ Generate specific, actionable tasks from research and plans. Best for getting concrete next steps and action items.
305
+
306
+ **Writes to:** `.shotgun/tasks.md`
307
+
308
+ ### 📤 Export Mode
309
+ Export artifacts and findings to various formats. Creates documentation like Claude.md (AI instructions), Agent.md (agent specs), PRDs, and other deliverables. Can write to any file in `.shotgun/` except the mode-specific files above.
310
+
311
+ **Writes to:** `.shotgun/Claude.md`, `.shotgun/Agent.md`, `.shotgun/PRD.md`, etc.
312
+
313
+ ---
314
+
315
+ **Tip:** You can switch between modes using `Shift+Tab` or `Ctrl+P` to open the command palette!
316
+ """
317
+
318
+ def _page_3_prompts(self) -> str:
319
+ """Page 3: Tips for better prompts."""
320
+ return """
321
+ ## Writing Better Prompts
322
+
323
+ Here are some tips to get the best results from Shotgun:
324
+
325
+ ### 1. Ask for Research First
326
+ Before jumping into a task, ask Shotgun to research the codebase or topic:
327
+
328
+ > "Can you research how authentication works in this codebase?"
329
+
330
+ ### 2. Request Clarifying Questions
331
+ Let Shotgun ask you questions to better understand your needs:
332
+
333
+ > "I want to add user profiles. Please ask me clarifying questions before starting."
334
+
335
+ ### 3. Be Specific About Context
336
+ Provide relevant context about what you're trying to accomplish:
337
+
338
+ > "I'm working on the payment flow. I need to add support for refunds."
339
+
340
+ ### 4. Use the Right Mode
341
+ Switch to the appropriate mode for your task:
342
+ - Use **Research** for exploration
343
+ - Use **Specify** for requirements
344
+ - Use **Plan** for implementation strategy
345
+ - Use **Tasks** for actionable next steps
346
+
347
+ ---
348
+
349
+ **Remember:** Shotgun works best when you give it context and let it ask questions!
350
+ """
351
+
352
+ def _page_4_context_management(self) -> str:
353
+ """Page 4: Context management and conversation controls."""
354
+ return """
355
+ ## Managing Conversation Context
356
+
357
+ As conversations grow, you may need to manage the context sent to the AI model.
358
+
359
+ ### Clear Conversation
360
+ Completely start over with a fresh conversation.
361
+
362
+ **How to use:**
363
+ - Open Command Palette: `Ctrl+P`
364
+ - Type: "Clear Conversation"
365
+ - Confirm the action
366
+
367
+ **When to use:**
368
+ - Starting a completely new task or project
369
+ - When you want a clean slate
370
+ - Context has become too cluttered
371
+
372
+ ---
373
+
374
+ ### Compact Conversation
375
+ Intelligently compress the conversation history while preserving important context.
376
+
377
+ **How to use:**
378
+ - Open Command Palette: `Ctrl+P`
379
+ - Type: "Compact Conversation"
380
+ - Shotgun will compress older messages automatically
381
+
382
+ **When to use:**
383
+ - Conversation is getting long but you want to keep context
384
+ - Running into token limits
385
+ - Want to reduce costs while maintaining continuity
386
+
387
+ **What it does:**
388
+ - Summarizes older messages
389
+ - Keeps recent messages intact
390
+ - Preserves key information and decisions
391
+
392
+ ---
393
+
394
+ **Tip:** Use `Ctrl+U` to view your current usage and see how much context you're using!
395
+ """
396
+
397
+ @on(Button.Pressed, "#back-button")
398
+ def handle_back(self) -> None:
399
+ """Handle back button press."""
400
+ if self.current_page > 0:
401
+ self.current_page -= 1
402
+ self.update_page()
403
+
404
+ @on(Button.Pressed, "#next-button")
405
+ def handle_next(self) -> None:
406
+ """Handle next/finish button press."""
407
+ if self.current_page < self.total_pages - 1:
408
+ self.current_page += 1
409
+ self.update_page()
410
+ else:
411
+ # On last page, finish closes the modal
412
+ self.dismiss()
413
+
414
+ @on(Button.Pressed, "#close-button")
415
+ def handle_close(self) -> None:
416
+ """Handle close button press."""
417
+ self.dismiss()
418
+
419
+ @on(Button.Pressed, "#youtube-button")
420
+ def handle_youtube(self) -> None:
421
+ """Open demo section in README."""
422
+ webbrowser.open(
423
+ "https://github.com/shotgun-sh/shotgun?tab=readme-ov-file#-demo"
424
+ )
425
+
426
+ @on(Button.Pressed, "#usage-button")
427
+ def handle_usage_guide(self) -> None:
428
+ """Open usage guide in browser."""
429
+ webbrowser.open(
430
+ "https://github.com/shotgun-sh/shotgun?tab=readme-ov-file#-usage"
431
+ )
@@ -97,7 +97,7 @@ class ProviderConfigScreen(Screen[None]):
97
97
  "Don't have an API Key? Use these links to get one: [OpenAI](https://platform.openai.com/api-keys) | [Anthropic](https://console.anthropic.com) | [Google Gemini](https://aistudio.google.com)",
98
98
  id="provider-links",
99
99
  )
100
- yield ListView(*self._build_provider_items(), id="provider-list")
100
+ yield ListView(*self._build_provider_items_sync(), id="provider-list")
101
101
  yield Input(
102
102
  placeholder=self._input_placeholder(self.selected_provider),
103
103
  password=True,
@@ -110,8 +110,6 @@ class ProviderConfigScreen(Screen[None]):
110
110
  yield Button("Done \\[ESC]", id="done")
111
111
 
112
112
  def on_mount(self) -> None:
113
- self.refresh_provider_status()
114
- self._update_done_button_visibility()
115
113
  list_view = self.query_one(ListView)
116
114
  if list_view.children:
117
115
  list_view.index = 0
@@ -121,13 +119,20 @@ class ProviderConfigScreen(Screen[None]):
121
119
  self.query_one("#authenticate", Button).display = False
122
120
  self.set_focus(self.query_one("#api-key", Input))
123
121
 
122
+ # Refresh UI asynchronously
123
+ self.run_worker(self._refresh_ui(), exclusive=False)
124
+
124
125
  def on_screenresume(self) -> None:
125
126
  """Refresh provider status when screen is resumed.
126
127
 
127
128
  This ensures the UI reflects any provider changes made elsewhere.
128
129
  """
129
- self.refresh_provider_status()
130
- self._update_done_button_visibility()
130
+ self.run_worker(self._refresh_ui(), exclusive=False)
131
+
132
+ async def _refresh_ui(self) -> None:
133
+ """Refresh provider status and button visibility."""
134
+ await self.refresh_provider_status()
135
+ await self._update_done_button_visibility()
131
136
 
132
137
  def action_done(self) -> None:
133
138
  self.dismiss()
@@ -170,7 +175,11 @@ class ProviderConfigScreen(Screen[None]):
170
175
  if not self.is_mounted:
171
176
  return
172
177
 
173
- # Show/hide UI elements based on provider type
178
+ # Show/hide UI elements based on provider type asynchronously
179
+ self.run_worker(self._update_provider_ui(provider), exclusive=False)
180
+
181
+ async def _update_provider_ui(self, provider: ProviderType) -> None:
182
+ """Update UI elements based on selected provider."""
174
183
  is_shotgun = provider == "shotgun"
175
184
 
176
185
  input_widget = self.query_one("#api-key", Input)
@@ -183,7 +192,7 @@ class ProviderConfigScreen(Screen[None]):
183
192
  save_button.display = False
184
193
 
185
194
  # Only show Authenticate button if shotgun is NOT already configured
186
- if self._has_provider_key("shotgun"):
195
+ if await self._has_provider_key("shotgun"):
187
196
  auth_button.display = False
188
197
  else:
189
198
  auth_button.display = True
@@ -200,22 +209,29 @@ class ProviderConfigScreen(Screen[None]):
200
209
  app = cast("ShotgunApp", self.app)
201
210
  return app.config_manager
202
211
 
203
- def refresh_provider_status(self) -> None:
212
+ async def refresh_provider_status(self) -> None:
204
213
  """Update the list view entries to reflect configured providers."""
205
214
  for provider_id in get_configurable_providers():
206
215
  label = self.query_one(f"#label-{provider_id}", Label)
207
- label.update(self._provider_label(provider_id))
216
+ label.update(await self._provider_label(provider_id))
208
217
 
209
- def _update_done_button_visibility(self) -> None:
218
+ async def _update_done_button_visibility(self) -> None:
210
219
  """Show/hide Done button based on whether any provider keys are configured."""
211
220
  done_button = self.query_one("#done", Button)
212
- has_keys = self.config_manager.has_any_provider_key()
221
+ has_keys = await self.config_manager.has_any_provider_key()
213
222
  done_button.display = has_keys
214
223
 
215
- def _build_provider_items(self) -> list[ListItem]:
224
+ def _build_provider_items_sync(self) -> list[ListItem]:
225
+ """Build provider items synchronously for compose().
226
+
227
+ Labels will be populated with status asynchronously in on_mount().
228
+ """
216
229
  items: list[ListItem] = []
217
230
  for provider_id in get_configurable_providers():
218
- label = Label(self._provider_label(provider_id), id=f"label-{provider_id}")
231
+ # Create labels with placeholder text - will be updated in on_mount()
232
+ label = Label(
233
+ self._provider_display_name(provider_id), id=f"label-{provider_id}"
234
+ )
219
235
  items.append(ListItem(label, id=f"provider-{provider_id}"))
220
236
  return items
221
237
 
@@ -225,11 +241,10 @@ class ProviderConfigScreen(Screen[None]):
225
241
  provider_id = item.id.removeprefix("provider-")
226
242
  return provider_id if provider_id in get_configurable_providers() else None
227
243
 
228
- def _provider_label(self, provider_id: str) -> str:
244
+ async def _provider_label(self, provider_id: str) -> str:
229
245
  display = self._provider_display_name(provider_id)
230
- status = (
231
- "Configured" if self._has_provider_key(provider_id) else "Not configured"
232
- )
246
+ has_key = await self._has_provider_key(provider_id)
247
+ status = "Configured" if has_key else "Not configured"
233
248
  return f"{display} · {status}"
234
249
 
235
250
  def _provider_display_name(self, provider_id: str) -> str:
@@ -244,21 +259,25 @@ class ProviderConfigScreen(Screen[None]):
244
259
  def _input_placeholder(self, provider_id: str) -> str:
245
260
  return f"{self._provider_display_name(provider_id)} API key"
246
261
 
247
- def _has_provider_key(self, provider_id: str) -> bool:
262
+ async def _has_provider_key(self, provider_id: str) -> bool:
248
263
  """Check if provider has a configured API key."""
249
264
  if provider_id == "shotgun":
250
265
  # Check shotgun key directly
251
- config = self.config_manager.load()
266
+ config = await self.config_manager.load()
252
267
  return self.config_manager._provider_has_api_key(config.shotgun)
253
268
  else:
254
269
  # Check LLM provider key
255
270
  try:
256
271
  provider = ProviderType(provider_id)
257
- return self.config_manager.has_provider_key(provider)
272
+ return await self.config_manager.has_provider_key(provider)
258
273
  except ValueError:
259
274
  return False
260
275
 
261
276
  def _save_api_key(self) -> None:
277
+ self.run_worker(self._do_save_api_key(), exclusive=True)
278
+
279
+ async def _do_save_api_key(self) -> None:
280
+ """Async implementation of API key saving."""
262
281
  input_widget = self.query_one("#api-key", Input)
263
282
  api_key = input_widget.value.strip()
264
283
 
@@ -267,7 +286,7 @@ class ProviderConfigScreen(Screen[None]):
267
286
  return
268
287
 
269
288
  try:
270
- self.config_manager.update_provider(
289
+ await self.config_manager.update_provider(
271
290
  self.selected_provider,
272
291
  api_key=api_key,
273
292
  )
@@ -276,21 +295,25 @@ class ProviderConfigScreen(Screen[None]):
276
295
  return
277
296
 
278
297
  input_widget.value = ""
279
- self.refresh_provider_status()
280
- self._update_done_button_visibility()
298
+ await self.refresh_provider_status()
299
+ await self._update_done_button_visibility()
281
300
  self.notify(
282
301
  f"Saved API key for {self._provider_display_name(self.selected_provider)}."
283
302
  )
284
303
 
285
304
  def _clear_api_key(self) -> None:
305
+ self.run_worker(self._do_clear_api_key(), exclusive=True)
306
+
307
+ async def _do_clear_api_key(self) -> None:
308
+ """Async implementation of API key clearing."""
286
309
  try:
287
- self.config_manager.clear_provider_key(self.selected_provider)
310
+ await self.config_manager.clear_provider_key(self.selected_provider)
288
311
  except Exception as exc: # pragma: no cover - defensive; textual path
289
312
  self.notify(f"Failed to clear key: {exc}", severity="error")
290
313
  return
291
314
 
292
- self.refresh_provider_status()
293
- self._update_done_button_visibility()
315
+ await self.refresh_provider_status()
316
+ await self._update_done_button_visibility()
294
317
  self.query_one("#api-key", Input).value = ""
295
318
 
296
319
  # If we just cleared shotgun, show the Authenticate button
@@ -311,5 +334,5 @@ class ProviderConfigScreen(Screen[None]):
311
334
 
312
335
  # Refresh provider status after auth completes
313
336
  if result:
314
- self.refresh_provider_status()
337
+ await self.refresh_provider_status()
315
338
  # Notify handled by auth screen
@@ -135,7 +135,7 @@ class ShotgunAuthScreen(Screen[bool]):
135
135
  """Start the authentication flow."""
136
136
  try:
137
137
  # Get shotgun instance ID from config
138
- shotgun_instance_id = self.config_manager.get_shotgun_instance_id()
138
+ shotgun_instance_id = await self.config_manager.get_shotgun_instance_id()
139
139
  logger.info("Starting auth flow with instance ID: %s", shotgun_instance_id)
140
140
 
141
141
  # Update status
@@ -215,7 +215,7 @@ class ShotgunAuthScreen(Screen[bool]):
215
215
  logger.info("Authentication completed successfully")
216
216
 
217
217
  if status_response.litellm_key and status_response.supabase_key:
218
- self.config_manager.update_shotgun_account(
218
+ await self.config_manager.update_shotgun_account(
219
219
  api_key=status_response.litellm_key,
220
220
  supabase_jwt=status_response.supabase_key,
221
221
  )