shotgun-sh 0.2.11__py3-none-any.whl → 0.2.11.dev2__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 (73) hide show
  1. shotgun/agents/agent_manager.py +28 -194
  2. shotgun/agents/common.py +8 -14
  3. shotgun/agents/config/manager.py +33 -64
  4. shotgun/agents/config/models.py +1 -25
  5. shotgun/agents/config/provider.py +2 -2
  6. shotgun/agents/context_analyzer/analyzer.py +24 -2
  7. shotgun/agents/conversation_manager.py +19 -35
  8. shotgun/agents/export.py +2 -2
  9. shotgun/agents/history/history_processors.py +3 -99
  10. shotgun/agents/history/token_counting/anthropic.py +1 -17
  11. shotgun/agents/history/token_counting/base.py +3 -14
  12. shotgun/agents/history/token_counting/openai.py +1 -11
  13. shotgun/agents/history/token_counting/sentencepiece_counter.py +0 -8
  14. shotgun/agents/history/token_counting/tokenizer_cache.py +1 -3
  15. shotgun/agents/history/token_counting/utils.py +3 -0
  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 +2 -5
  21. shotgun/agents/tools/file_management.py +7 -11
  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 +11 -16
  28. shotgun/build_constants.py +2 -2
  29. shotgun/cli/clear.py +1 -2
  30. shotgun/cli/compact.py +3 -3
  31. shotgun/cli/config.py +5 -8
  32. shotgun/cli/context.py +2 -2
  33. shotgun/cli/export.py +1 -1
  34. shotgun/cli/feedback.py +2 -4
  35. shotgun/cli/plan.py +1 -1
  36. shotgun/cli/research.py +1 -1
  37. shotgun/cli/specify.py +1 -1
  38. shotgun/cli/tasks.py +1 -1
  39. shotgun/codebase/core/change_detector.py +3 -5
  40. shotgun/codebase/core/code_retrieval.py +2 -4
  41. shotgun/codebase/core/ingestor.py +8 -10
  42. shotgun/codebase/core/manager.py +3 -3
  43. shotgun/codebase/core/nl_query.py +1 -1
  44. shotgun/logging_config.py +17 -10
  45. shotgun/main.py +1 -3
  46. shotgun/posthog_telemetry.py +4 -14
  47. shotgun/sentry_telemetry.py +2 -22
  48. shotgun/telemetry.py +1 -3
  49. shotgun/tui/app.py +65 -71
  50. shotgun/tui/components/context_indicator.py +0 -43
  51. shotgun/tui/containers.py +17 -15
  52. shotgun/tui/dependencies.py +2 -2
  53. shotgun/tui/screens/chat/chat_screen.py +40 -164
  54. shotgun/tui/screens/chat/help_text.py +15 -16
  55. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  56. shotgun/tui/screens/feedback.py +4 -4
  57. shotgun/tui/screens/model_picker.py +20 -21
  58. shotgun/tui/screens/provider_config.py +27 -50
  59. shotgun/tui/screens/shotgun_auth.py +2 -2
  60. shotgun/tui/screens/welcome.py +11 -14
  61. shotgun/tui/services/conversation_service.py +14 -16
  62. shotgun/tui/utils/mode_progress.py +7 -14
  63. shotgun/tui/widgets/widget_coordinator.py +0 -15
  64. shotgun/utils/file_system_utils.py +0 -19
  65. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/METADATA +1 -2
  66. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/RECORD +69 -73
  67. shotgun/exceptions.py +0 -32
  68. shotgun/tui/screens/github_issue.py +0 -102
  69. shotgun/tui/screens/onboarding.py +0 -431
  70. shotgun/utils/marketing.py +0 -110
  71. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/WHEEL +0 -0
  72. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/entry_points.txt +0 -0
  73. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/licenses/LICENSE +0 -0
@@ -2,7 +2,6 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from datetime import datetime, timezone
6
5
  from pathlib import Path
7
6
  from typing import cast
8
7
 
@@ -32,7 +31,6 @@ from shotgun.agents.agent_manager import (
32
31
  ModelConfigUpdated,
33
32
  PartialResponseMessage,
34
33
  )
35
- from shotgun.agents.config import get_config_manager
36
34
  from shotgun.agents.config.models import MODEL_SPECS
37
35
  from shotgun.agents.conversation_manager import ConversationManager
38
36
  from shotgun.agents.history.compaction import apply_persistent_compaction
@@ -47,7 +45,6 @@ from shotgun.codebase.core.manager import (
47
45
  CodebaseGraphManager,
48
46
  )
49
47
  from shotgun.codebase.models import IndexProgress, ProgressPhase
50
- from shotgun.exceptions import ContextSizeLimitExceeded
51
48
  from shotgun.posthog_telemetry import track_event
52
49
  from shotgun.sdk.codebase import CodebaseSDK
53
50
  from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
@@ -73,13 +70,11 @@ from shotgun.tui.screens.chat_screen.command_providers import (
73
70
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
74
71
  from shotgun.tui.screens.chat_screen.history import ChatHistory
75
72
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
76
- from shotgun.tui.screens.onboarding import OnboardingModal
77
73
  from shotgun.tui.services.conversation_service import ConversationService
78
74
  from shotgun.tui.state.processing_state import ProcessingStateManager
79
75
  from shotgun.tui.utils.mode_progress import PlaceholderHints
80
76
  from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
81
77
  from shotgun.utils import get_shotgun_home
82
- from shotgun.utils.marketing import MarketingManager
83
78
 
84
79
  logger = logging.getLogger(__name__)
85
80
 
@@ -170,17 +165,13 @@ class ChatScreen(Screen[None]):
170
165
  self.processing_state.bind_spinner(self.query_one("#spinner", Spinner))
171
166
 
172
167
  # Load conversation history if --continue flag was provided
173
- # Use call_later to handle async exists() check
174
- if self.continue_session:
175
- self.call_later(self._check_and_load_conversation)
168
+ if self.continue_session and self.conversation_manager.exists():
169
+ self._load_conversation()
176
170
 
177
171
  self.call_later(self.check_if_codebase_is_indexed)
178
172
  # Initial update of context indicator
179
173
  self.update_context_indicator()
180
174
 
181
- # Show onboarding popup if not shown before
182
- self.call_later(self._check_and_show_onboarding)
183
-
184
175
  async def on_key(self, event: events.Key) -> None:
185
176
  """Handle key presses for cancellation."""
186
177
  # If escape is pressed during Q&A mode, exit Q&A
@@ -313,10 +304,6 @@ class ChatScreen(Screen[None]):
313
304
  else:
314
305
  self.notify("No context analysis available", severity="error")
315
306
 
316
- def action_view_onboarding(self) -> None:
317
- """Show the onboarding modal."""
318
- self.app.push_screen(OnboardingModal())
319
-
320
307
  @work
321
308
  async def action_compact_conversation(self) -> None:
322
309
  """Compact the conversation history to reduce size."""
@@ -399,11 +386,11 @@ class ChatScreen(Screen[None]):
399
386
  # Save to conversation file
400
387
  conversation_file = get_shotgun_home() / "conversation.json"
401
388
  manager = ConversationManager(conversation_file)
402
- conversation = await manager.load()
389
+ conversation = manager.load()
403
390
 
404
391
  if conversation:
405
392
  conversation.set_agent_messages(compacted_messages)
406
- await manager.save(conversation)
393
+ manager.save(conversation)
407
394
 
408
395
  # Post compaction completed event
409
396
  self.agent_manager.post_message(CompactionCompletedMessage())
@@ -468,7 +455,7 @@ class ChatScreen(Screen[None]):
468
455
  self.agent_manager.ui_message_history = []
469
456
 
470
457
  # Use conversation service to clear conversation
471
- await self.conversation_service.clear_conversation()
458
+ self.conversation_service.clear_conversation()
472
459
 
473
460
  # Post message history updated event to refresh UI
474
461
  self.agent_manager.post_message(
@@ -515,34 +502,6 @@ class ChatScreen(Screen[None]):
515
502
  f"[CONTEXT] Failed to update context indicator: {e}", exc_info=True
516
503
  )
517
504
 
518
- @work(exclusive=False)
519
- async def update_context_indicator_with_messages(
520
- self,
521
- agent_messages: list[ModelMessage],
522
- ui_messages: list[ModelMessage | HintMessage],
523
- ) -> None:
524
- """Update the context indicator with specific message sets (for streaming updates).
525
-
526
- Args:
527
- agent_messages: Agent message history including streaming messages (for token counting)
528
- ui_messages: UI message history including hints and streaming messages
529
- """
530
- try:
531
- from shotgun.agents.context_analyzer.analyzer import ContextAnalyzer
532
-
533
- analyzer = ContextAnalyzer(self.deps.llm_model)
534
- # Analyze the combined message histories for accurate progressive token counts
535
- analysis = await analyzer.analyze_conversation(agent_messages, ui_messages)
536
-
537
- if analysis:
538
- model_name = self.deps.llm_model.name
539
- self.widget_coordinator.update_context_indicator(analysis, model_name)
540
- except Exception as e:
541
- logger.error(
542
- f"Failed to update context indicator with streaming messages: {e}",
543
- exc_info=True,
544
- )
545
-
546
505
  def compose(self) -> ComposeResult:
547
506
  """Create child widgets for the app."""
548
507
  with Container(id="window"):
@@ -592,7 +551,7 @@ class ChatScreen(Screen[None]):
592
551
  # Keep all ModelResponse and other message types
593
552
  filtered_event_messages.append(msg)
594
553
 
595
- # Build new message list combining existing messages with new streaming content
554
+ # Build new message list
596
555
  new_message_list = self.messages + cast(
597
556
  list[ModelMessage | HintMessage], filtered_event_messages
598
557
  )
@@ -602,13 +561,6 @@ class ChatScreen(Screen[None]):
602
561
  self.partial_message, new_message_list
603
562
  )
604
563
 
605
- # Update context indicator with full message history including streaming messages
606
- # Combine existing agent history with new streaming messages for accurate token count
607
- combined_agent_history = self.agent_manager.message_history + event.messages
608
- self.update_context_indicator_with_messages(
609
- combined_agent_history, new_message_list
610
- )
611
-
612
564
  def _clear_partial_response(self) -> None:
613
565
  # Use widget coordinator to clear partial response
614
566
  self.widget_coordinator.set_partial_response(None, self.messages)
@@ -650,9 +602,7 @@ class ChatScreen(Screen[None]):
650
602
  self.qa_answers = []
651
603
 
652
604
  @on(MessageHistoryUpdated)
653
- async def handle_message_history_updated(
654
- self, event: MessageHistoryUpdated
655
- ) -> None:
605
+ def handle_message_history_updated(self, event: MessageHistoryUpdated) -> None:
656
606
  """Handle message history updates from the agent manager."""
657
607
  self._clear_partial_response()
658
608
  self.messages = event.messages
@@ -667,50 +617,32 @@ class ChatScreen(Screen[None]):
667
617
  self.update_context_indicator()
668
618
 
669
619
  # If there are file operations, add a message showing the modified files
670
- # Skip if hint was already added by agent_manager (e.g., in QA mode)
671
620
  if event.file_operations:
672
- # Check if file operation hint already exists in recent messages
673
- file_hint_exists = any(
674
- isinstance(msg, HintMessage)
675
- and (
676
- msg.message.startswith("📝 Modified:")
677
- or msg.message.startswith("📁 Modified")
678
- )
679
- for msg in event.messages[-5:] # Check last 5 messages
680
- )
681
-
682
- if not file_hint_exists:
683
- chat_history = self.query_one(ChatHistory)
684
- if chat_history.vertical_tail:
685
- tracker = FileOperationTracker(operations=event.file_operations)
686
- display_path = tracker.get_display_path()
687
-
688
- if display_path:
689
- # Create a simple markdown message with the file path
690
- # The terminal emulator will make this clickable automatically
691
- path_obj = Path(display_path)
692
-
693
- if len(event.file_operations) == 1:
694
- message = f"📝 Modified: `{display_path}`"
621
+ chat_history = self.query_one(ChatHistory)
622
+ if chat_history.vertical_tail:
623
+ tracker = FileOperationTracker(operations=event.file_operations)
624
+ display_path = tracker.get_display_path()
625
+
626
+ if display_path:
627
+ # Create a simple markdown message with the file path
628
+ # The terminal emulator will make this clickable automatically
629
+ path_obj = Path(display_path)
630
+
631
+ if len(event.file_operations) == 1:
632
+ message = f"📝 Modified: `{display_path}`"
633
+ else:
634
+ num_files = len({op.file_path for op in event.file_operations})
635
+ if path_obj.is_dir():
636
+ message = (
637
+ f"📁 Modified {num_files} files in: `{display_path}`"
638
+ )
695
639
  else:
696
- num_files = len(
697
- {op.file_path for op in event.file_operations}
640
+ # Common path is a file, show parent directory
641
+ message = (
642
+ f"📁 Modified {num_files} files in: `{path_obj.parent}`"
698
643
  )
699
- if path_obj.is_dir():
700
- message = f"📁 Modified {num_files} files in: `{display_path}`"
701
- else:
702
- # Common path is a file, show parent directory
703
- message = f"📁 Modified {num_files} files in: `{path_obj.parent}`"
704
644
 
705
- self.mount_hint(message)
706
-
707
- # Check and display any marketing messages
708
- from shotgun.tui.app import ShotgunApp
709
-
710
- app = cast(ShotgunApp, self.app)
711
- await MarketingManager.check_and_display_messages(
712
- app.config_manager, event.file_operations, self.mount_hint
713
- )
645
+ self.mount_hint(message)
714
646
 
715
647
  @on(CompactionStartedMessage)
716
648
  def handle_compaction_started(self, event: CompactionStartedMessage) -> None:
@@ -1116,9 +1048,6 @@ class ChatScreen(Screen[None]):
1116
1048
  self.processing_state.start_processing("Processing...")
1117
1049
  self.processing_state.bind_worker(get_current_worker())
1118
1050
 
1119
- # Start context indicator animation immediately
1120
- self.widget_coordinator.set_context_streaming(True)
1121
-
1122
1051
  prompt = message
1123
1052
 
1124
1053
  try:
@@ -1128,27 +1057,6 @@ class ChatScreen(Screen[None]):
1128
1057
  except asyncio.CancelledError:
1129
1058
  # Handle cancellation gracefully - DO NOT re-raise
1130
1059
  self.mount_hint("⚠️ Operation cancelled by user")
1131
- except ContextSizeLimitExceeded as e:
1132
- # User-friendly error with actionable options
1133
- hint = (
1134
- f"⚠️ **Context too large for {e.model_name}**\n\n"
1135
- f"Your conversation history exceeds this model's limit ({e.max_tokens:,} tokens).\n\n"
1136
- f"**Choose an action:**\n\n"
1137
- f"1. Switch to a larger model (`Ctrl+P` → Change Model)\n"
1138
- f"2. Switch to a larger model, compact (`/compact`), then switch back to {e.model_name}\n"
1139
- f"3. Clear conversation (`/clear`)\n"
1140
- )
1141
-
1142
- self.mount_hint(hint)
1143
-
1144
- # Log for debugging (won't send to Sentry due to ErrorNotPickedUpBySentry)
1145
- logger.info(
1146
- "Context size limit exceeded",
1147
- extra={
1148
- "max_tokens": e.max_tokens,
1149
- "model_name": e.model_name,
1150
- },
1151
- )
1152
1060
  except Exception as e:
1153
1061
  # Log with full stack trace to shotgun.log
1154
1062
  logger.exception(
@@ -1175,8 +1083,6 @@ class ChatScreen(Screen[None]):
1175
1083
  self.mount_hint(hint)
1176
1084
  finally:
1177
1085
  self.processing_state.stop_processing()
1178
- # Stop context indicator animation
1179
- self.widget_coordinator.set_context_streaming(False)
1180
1086
 
1181
1087
  # Save conversation after each interaction
1182
1088
  self._save_conversation()
@@ -1185,50 +1091,20 @@ class ChatScreen(Screen[None]):
1185
1091
 
1186
1092
  def _save_conversation(self) -> None:
1187
1093
  """Save the current conversation to persistent storage."""
1188
- # Use conversation service for saving (run async in background)
1189
- # Use exclusive=True to prevent concurrent saves that can cause file contention
1190
- self.run_worker(
1191
- self.conversation_service.save_conversation(self.agent_manager),
1192
- exclusive=True,
1193
- )
1194
-
1195
- async def _check_and_load_conversation(self) -> None:
1196
- """Check if conversation exists and load it if it does."""
1197
- if await self.conversation_manager.exists():
1198
- self._load_conversation()
1094
+ # Use conversation service for saving
1095
+ self.conversation_service.save_conversation(self.agent_manager)
1199
1096
 
1200
1097
  def _load_conversation(self) -> None:
1201
1098
  """Load conversation from persistent storage."""
1202
-
1203
- # Use conversation service for restoration (run async)
1204
- async def _do_load() -> None:
1205
- (
1206
- success,
1207
- error_msg,
1208
- restored_type,
1209
- ) = await self.conversation_service.restore_conversation(
1099
+ # Use conversation service for restoration
1100
+ success, error_msg, restored_type = (
1101
+ self.conversation_service.restore_conversation(
1210
1102
  self.agent_manager, self.deps.usage_manager
1211
1103
  )
1104
+ )
1212
1105
 
1213
- if not success and error_msg:
1214
- self.mount_hint(error_msg)
1215
- elif success and restored_type:
1216
- # Update the current mode to match restored conversation
1217
- self.mode = restored_type
1218
-
1219
- self.run_worker(_do_load(), exclusive=False)
1220
-
1221
- @work
1222
- async def _check_and_show_onboarding(self) -> None:
1223
- """Check if onboarding should be shown and display modal if needed."""
1224
- config_manager = get_config_manager()
1225
- config = await config_manager.load()
1226
-
1227
- # Only show onboarding if it hasn't been shown before
1228
- if config.shown_onboarding_popup is None:
1229
- # Show the onboarding modal
1230
- await self.app.push_screen_wait(OnboardingModal())
1231
-
1232
- # Mark as shown in config with current timestamp
1233
- config.shown_onboarding_popup = datetime.now(timezone.utc)
1234
- await config_manager.save(config)
1106
+ if not success and error_msg:
1107
+ self.mount_hint(error_msg)
1108
+ elif success and restored_type:
1109
+ # Update the current mode to match restored conversation
1110
+ self.mode = restored_type
@@ -11,14 +11,14 @@ def help_text_with_codebase(already_indexed: bool = False) -> str:
11
11
  Formatted help text string.
12
12
  """
13
13
  return (
14
- "Howdy! Welcome to Shotgun - Spec Driven Development for Developers and AI Agents.\n\n"
15
- "Shotgun writes codebase-aware specs for your AI coding agents so they don't derail.\n\n"
16
- f"{'It' if already_indexed else 'Once your codebase is indexed, it'} can help you:\n"
17
- "- Research your codebase and spec out new features\n"
18
- "- Create implementation plans that fit your architecture\n"
19
- "- Generate AGENTS.md files for AI coding agents\n"
20
- "- Onboard to existing projects or plan refactors\n\n"
21
- "Ready to build something? Let's go.\n"
14
+ "Howdy! Welcome to Shotgun - the context tool for software engineering. \n\n"
15
+ "You can research, build specs, plan, create tasks, and export context to your "
16
+ "favorite code-gen agents.\n\n"
17
+ f"{'' if already_indexed else 'Once your codebase is indexed, '}I can help with:\n\n"
18
+ "- Speccing out a new feature\n"
19
+ "- Onboarding you onto this project\n"
20
+ "- Helping with a refactor spec\n"
21
+ "- Creating AGENTS.md file for this project\n"
22
22
  )
23
23
 
24
24
 
@@ -29,12 +29,11 @@ def help_text_empty_dir() -> str:
29
29
  Formatted help text string.
30
30
  """
31
31
  return (
32
- "Howdy! Welcome to Shotgun - Spec Driven Development for Developers and AI Agents.\n\n"
33
- "Shotgun writes codebase-aware specs for your AI coding agents so they don't derail.\n\n"
34
- "It can help you:\n"
35
- "- Research your codebase and spec out new features\n"
36
- "- Create implementation plans that fit your architecture\n"
37
- "- Generate AGENTS.md files for AI coding agents\n"
38
- "- Onboard to existing projects or plan refactors\n\n"
39
- "Ready to build something? Let's go.\n"
32
+ "Howdy! Welcome to Shotgun - the context tool for software engineering.\n\n"
33
+ "You can research, build specs, plan, create tasks, and export context to your "
34
+ "favorite code-gen agents.\n\n"
35
+ "What would you like to build? Here are some examples:\n\n"
36
+ "- Research FastAPI vs Django\n"
37
+ "- Plan my new web app using React\n"
38
+ "- Create PRD for my planned product\n"
40
39
  )
@@ -369,11 +369,6 @@ class UnifiedCommandProvider(Provider):
369
369
  self.chat_screen.action_show_usage,
370
370
  help="Display usage information for the current session",
371
371
  )
372
- yield DiscoveryHit(
373
- "View Onboarding",
374
- self.chat_screen.action_view_onboarding,
375
- help="View the onboarding tutorial and helpful resources",
376
- )
377
372
 
378
373
  async def search(self, query: str) -> AsyncGenerator[Hit, None]:
379
374
  """Search for commands in alphabetical order."""
@@ -421,11 +416,6 @@ class UnifiedCommandProvider(Provider):
421
416
  self.chat_screen.action_show_usage,
422
417
  "Display usage information for the current session",
423
418
  ),
424
- (
425
- "View Onboarding",
426
- self.chat_screen.action_view_onboarding,
427
- "View the onboarding tutorial and helpful resources",
428
- ),
429
419
  ]
430
420
 
431
421
  for title, callback, help_text in commands:
@@ -125,8 +125,8 @@ class FeedbackScreen(Screen[Feedback | None]):
125
125
  self.set_focus(self.query_one("#feedback-description", TextArea))
126
126
 
127
127
  @on(Button.Pressed, "#submit")
128
- async def _on_submit_pressed(self) -> None:
129
- await self._submit_feedback()
128
+ def _on_submit_pressed(self) -> None:
129
+ self._submit_feedback()
130
130
 
131
131
  @on(Button.Pressed, "#cancel")
132
132
  def _on_cancel_pressed(self) -> None:
@@ -171,7 +171,7 @@ class FeedbackScreen(Screen[Feedback | None]):
171
171
  }
172
172
  return placeholders.get(kind, "Enter your feedback...")
173
173
 
174
- async def _submit_feedback(self) -> None:
174
+ def _submit_feedback(self) -> None:
175
175
  text_area = self.query_one("#feedback-description", TextArea)
176
176
  description = text_area.text.strip()
177
177
 
@@ -182,7 +182,7 @@ class FeedbackScreen(Screen[Feedback | None]):
182
182
  return
183
183
 
184
184
  app = cast("ShotgunApp", self.app)
185
- shotgun_instance_id = await app.config_manager.get_shotgun_instance_id()
185
+ shotgun_instance_id = app.config_manager.get_shotgun_instance_id()
186
186
 
187
187
  feedback = Feedback(
188
188
  kind=self.selected_kind,
@@ -98,7 +98,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
98
98
  yield Button("Select \\[ENTER]", variant="primary", id="select")
99
99
  yield Button("Done \\[ESC]", id="done")
100
100
 
101
- async def _rebuild_model_list(self) -> None:
101
+ def _rebuild_model_list(self) -> None:
102
102
  """Rebuild the model list from current config.
103
103
 
104
104
  This method is called both on first show and when screen is resumed
@@ -108,7 +108,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
108
108
 
109
109
  # Load current config with force_reload to get latest API keys
110
110
  config_manager = self.config_manager
111
- config = await config_manager.load(force_reload=True)
111
+ config = config_manager.load(force_reload=True)
112
112
 
113
113
  # Log provider key status
114
114
  logger.debug(
@@ -133,7 +133,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
133
133
  logger.debug("Removed %d existing model items from list", old_count)
134
134
 
135
135
  # Add new items (labels already have correct text including current indicator)
136
- new_items = await self._build_model_items(config)
136
+ new_items = self._build_model_items(config)
137
137
  for item in new_items:
138
138
  list_view.append(item)
139
139
  logger.debug("Added %d available model items to list", len(new_items))
@@ -153,7 +153,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
153
153
  def on_show(self) -> None:
154
154
  """Rebuild model list when screen is first shown."""
155
155
  logger.debug("ModelPickerScreen.on_show() called")
156
- self.run_worker(self._rebuild_model_list(), exclusive=False)
156
+ self._rebuild_model_list()
157
157
 
158
158
  def on_screenresume(self) -> None:
159
159
  """Rebuild model list when screen is resumed (subsequent visits).
@@ -162,7 +162,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
162
162
  ensuring the model list reflects any config changes made while away.
163
163
  """
164
164
  logger.debug("ModelPickerScreen.on_screenresume() called")
165
- self.run_worker(self._rebuild_model_list(), exclusive=False)
165
+ self._rebuild_model_list()
166
166
 
167
167
  def action_done(self) -> None:
168
168
  self.dismiss()
@@ -193,14 +193,14 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
193
193
  app = cast("ShotgunApp", self.app)
194
194
  return app.config_manager
195
195
 
196
- async def refresh_model_labels(self) -> None:
196
+ def refresh_model_labels(self) -> None:
197
197
  """Update the list view entries to reflect current selection.
198
198
 
199
199
  Note: This method only updates labels for currently displayed models.
200
200
  To rebuild the entire list after provider changes, on_show() should be used.
201
201
  """
202
202
  # Load config once with force_reload
203
- config = await self.config_manager.load(force_reload=True)
203
+ config = self.config_manager.load(force_reload=True)
204
204
  current_model = config.selected_model or get_default_model_for_provider(config)
205
205
 
206
206
  # Update labels for available models only
@@ -215,11 +215,9 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
215
215
  self._model_label(model_name, is_current=model_name == current_model)
216
216
  )
217
217
 
218
- async def _build_model_items(
219
- self, config: ShotgunConfig | None = None
220
- ) -> list[ListItem]:
218
+ def _build_model_items(self, config: ShotgunConfig | None = None) -> list[ListItem]:
221
219
  if config is None:
222
- config = await self.config_manager.load(force_reload=True)
220
+ config = self.config_manager.load(force_reload=True)
223
221
 
224
222
  items: list[ListItem] = []
225
223
  current_model = self.selected_model
@@ -248,7 +246,9 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
248
246
  return model_name
249
247
  return None
250
248
 
251
- def _is_model_available(self, model_name: ModelName, config: ShotgunConfig) -> bool:
249
+ def _is_model_available(
250
+ self, model_name: ModelName, config: ShotgunConfig | None = None
251
+ ) -> bool:
252
252
  """Check if a model is available based on provider key configuration.
253
253
 
254
254
  A model is available if:
@@ -257,11 +257,14 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
257
257
 
258
258
  Args:
259
259
  model_name: The model to check availability for
260
- config: Pre-loaded config (must be provided)
260
+ config: Optional pre-loaded config to avoid multiple reloads
261
261
 
262
262
  Returns:
263
263
  True if the model can be used, False otherwise
264
264
  """
265
+ if config is None:
266
+ config = self.config_manager.load(force_reload=True)
267
+
265
268
  # If Shotgun Account is configured, all models are available
266
269
  if self.config_manager._provider_has_api_key(config.shotgun):
267
270
  logger.debug("Model %s available (Shotgun Account configured)", model_name)
@@ -322,21 +325,17 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
322
325
 
323
326
  def _select_model(self) -> None:
324
327
  """Save the selected model."""
325
- self.run_worker(self._do_select_model(), exclusive=True)
326
-
327
- async def _do_select_model(self) -> None:
328
- """Async implementation of model selection."""
329
328
  try:
330
329
  # Get old model before updating
331
- config = await self.config_manager.load()
330
+ config = self.config_manager.load()
332
331
  old_model = config.selected_model
333
332
 
334
333
  # Update the selected model in config
335
- await self.config_manager.update_selected_model(self.selected_model)
336
- await self.refresh_model_labels()
334
+ self.config_manager.update_selected_model(self.selected_model)
335
+ self.refresh_model_labels()
337
336
 
338
337
  # Get the full model config with provider information
339
- model_config = await get_provider_model(self.selected_model)
338
+ model_config = get_provider_model(self.selected_model)
340
339
 
341
340
  # Dismiss the screen and return the model config update to the caller
342
341
  self.dismiss(