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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ from datetime import datetime, timezone
5
6
  from pathlib import Path
6
7
  from typing import cast
7
8
 
@@ -31,6 +32,7 @@ from shotgun.agents.agent_manager import (
31
32
  ModelConfigUpdated,
32
33
  PartialResponseMessage,
33
34
  )
35
+ from shotgun.agents.config import get_config_manager
34
36
  from shotgun.agents.config.models import MODEL_SPECS
35
37
  from shotgun.agents.conversation_manager import ConversationManager
36
38
  from shotgun.agents.history.compaction import apply_persistent_compaction
@@ -45,6 +47,7 @@ from shotgun.codebase.core.manager import (
45
47
  CodebaseGraphManager,
46
48
  )
47
49
  from shotgun.codebase.models import IndexProgress, ProgressPhase
50
+ from shotgun.exceptions import ContextSizeLimitExceeded
48
51
  from shotgun.posthog_telemetry import track_event
49
52
  from shotgun.sdk.codebase import CodebaseSDK
50
53
  from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
@@ -70,11 +73,13 @@ from shotgun.tui.screens.chat_screen.command_providers import (
70
73
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
71
74
  from shotgun.tui.screens.chat_screen.history import ChatHistory
72
75
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
76
+ from shotgun.tui.screens.onboarding import OnboardingModal
73
77
  from shotgun.tui.services.conversation_service import ConversationService
74
78
  from shotgun.tui.state.processing_state import ProcessingStateManager
75
79
  from shotgun.tui.utils.mode_progress import PlaceholderHints
76
80
  from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
77
81
  from shotgun.utils import get_shotgun_home
82
+ from shotgun.utils.marketing import MarketingManager
78
83
 
79
84
  logger = logging.getLogger(__name__)
80
85
 
@@ -165,13 +170,17 @@ class ChatScreen(Screen[None]):
165
170
  self.processing_state.bind_spinner(self.query_one("#spinner", Spinner))
166
171
 
167
172
  # Load conversation history if --continue flag was provided
168
- if self.continue_session and self.conversation_manager.exists():
169
- self._load_conversation()
173
+ # Use call_later to handle async exists() check
174
+ if self.continue_session:
175
+ self.call_later(self._check_and_load_conversation)
170
176
 
171
177
  self.call_later(self.check_if_codebase_is_indexed)
172
178
  # Initial update of context indicator
173
179
  self.update_context_indicator()
174
180
 
181
+ # Show onboarding popup if not shown before
182
+ self.call_later(self._check_and_show_onboarding)
183
+
175
184
  async def on_key(self, event: events.Key) -> None:
176
185
  """Handle key presses for cancellation."""
177
186
  # If escape is pressed during Q&A mode, exit Q&A
@@ -304,6 +313,10 @@ class ChatScreen(Screen[None]):
304
313
  else:
305
314
  self.notify("No context analysis available", severity="error")
306
315
 
316
+ def action_view_onboarding(self) -> None:
317
+ """Show the onboarding modal."""
318
+ self.app.push_screen(OnboardingModal())
319
+
307
320
  @work
308
321
  async def action_compact_conversation(self) -> None:
309
322
  """Compact the conversation history to reduce size."""
@@ -386,11 +399,11 @@ class ChatScreen(Screen[None]):
386
399
  # Save to conversation file
387
400
  conversation_file = get_shotgun_home() / "conversation.json"
388
401
  manager = ConversationManager(conversation_file)
389
- conversation = manager.load()
402
+ conversation = await manager.load()
390
403
 
391
404
  if conversation:
392
405
  conversation.set_agent_messages(compacted_messages)
393
- manager.save(conversation)
406
+ await manager.save(conversation)
394
407
 
395
408
  # Post compaction completed event
396
409
  self.agent_manager.post_message(CompactionCompletedMessage())
@@ -455,7 +468,7 @@ class ChatScreen(Screen[None]):
455
468
  self.agent_manager.ui_message_history = []
456
469
 
457
470
  # Use conversation service to clear conversation
458
- self.conversation_service.clear_conversation()
471
+ await self.conversation_service.clear_conversation()
459
472
 
460
473
  # Post message history updated event to refresh UI
461
474
  self.agent_manager.post_message(
@@ -502,6 +515,34 @@ class ChatScreen(Screen[None]):
502
515
  f"[CONTEXT] Failed to update context indicator: {e}", exc_info=True
503
516
  )
504
517
 
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
+
505
546
  def compose(self) -> ComposeResult:
506
547
  """Create child widgets for the app."""
507
548
  with Container(id="window"):
@@ -551,7 +592,7 @@ class ChatScreen(Screen[None]):
551
592
  # Keep all ModelResponse and other message types
552
593
  filtered_event_messages.append(msg)
553
594
 
554
- # Build new message list
595
+ # Build new message list combining existing messages with new streaming content
555
596
  new_message_list = self.messages + cast(
556
597
  list[ModelMessage | HintMessage], filtered_event_messages
557
598
  )
@@ -561,6 +602,13 @@ class ChatScreen(Screen[None]):
561
602
  self.partial_message, new_message_list
562
603
  )
563
604
 
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
+
564
612
  def _clear_partial_response(self) -> None:
565
613
  # Use widget coordinator to clear partial response
566
614
  self.widget_coordinator.set_partial_response(None, self.messages)
@@ -602,7 +650,9 @@ class ChatScreen(Screen[None]):
602
650
  self.qa_answers = []
603
651
 
604
652
  @on(MessageHistoryUpdated)
605
- def handle_message_history_updated(self, event: MessageHistoryUpdated) -> None:
653
+ async def handle_message_history_updated(
654
+ self, event: MessageHistoryUpdated
655
+ ) -> None:
606
656
  """Handle message history updates from the agent manager."""
607
657
  self._clear_partial_response()
608
658
  self.messages = event.messages
@@ -617,32 +667,50 @@ class ChatScreen(Screen[None]):
617
667
  self.update_context_indicator()
618
668
 
619
669
  # 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)
620
671
  if event.file_operations:
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
- )
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}`"
639
695
  else:
640
- # Common path is a file, show parent directory
641
- message = (
642
- f"📁 Modified {num_files} files in: `{path_obj.parent}`"
696
+ num_files = len(
697
+ {op.file_path for op in event.file_operations}
643
698
  )
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}`"
644
704
 
645
- self.mount_hint(message)
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
+ )
646
714
 
647
715
  @on(CompactionStartedMessage)
648
716
  def handle_compaction_started(self, event: CompactionStartedMessage) -> None:
@@ -1048,6 +1116,9 @@ class ChatScreen(Screen[None]):
1048
1116
  self.processing_state.start_processing("Processing...")
1049
1117
  self.processing_state.bind_worker(get_current_worker())
1050
1118
 
1119
+ # Start context indicator animation immediately
1120
+ self.widget_coordinator.set_context_streaming(True)
1121
+
1051
1122
  prompt = message
1052
1123
 
1053
1124
  try:
@@ -1057,6 +1128,27 @@ class ChatScreen(Screen[None]):
1057
1128
  except asyncio.CancelledError:
1058
1129
  # Handle cancellation gracefully - DO NOT re-raise
1059
1130
  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
+ )
1060
1152
  except Exception as e:
1061
1153
  # Log with full stack trace to shotgun.log
1062
1154
  logger.exception(
@@ -1083,6 +1175,8 @@ class ChatScreen(Screen[None]):
1083
1175
  self.mount_hint(hint)
1084
1176
  finally:
1085
1177
  self.processing_state.stop_processing()
1178
+ # Stop context indicator animation
1179
+ self.widget_coordinator.set_context_streaming(False)
1086
1180
 
1087
1181
  # Save conversation after each interaction
1088
1182
  self._save_conversation()
@@ -1091,20 +1185,50 @@ class ChatScreen(Screen[None]):
1091
1185
 
1092
1186
  def _save_conversation(self) -> None:
1093
1187
  """Save the current conversation to persistent storage."""
1094
- # Use conversation service for saving
1095
- self.conversation_service.save_conversation(self.agent_manager)
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()
1096
1199
 
1097
1200
  def _load_conversation(self) -> None:
1098
1201
  """Load conversation from persistent storage."""
1099
- # Use conversation service for restoration
1100
- success, error_msg, restored_type = (
1101
- self.conversation_service.restore_conversation(
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(
1102
1210
  self.agent_manager, self.deps.usage_manager
1103
1211
  )
1104
- )
1105
1212
 
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
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)
@@ -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 - 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"
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"
22
22
  )
23
23
 
24
24
 
@@ -29,11 +29,12 @@ def help_text_empty_dir() -> str:
29
29
  Formatted help text string.
30
30
  """
31
31
  return (
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"
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"
39
40
  )
@@ -369,6 +369,11 @@ 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
+ )
372
377
 
373
378
  async def search(self, query: str) -> AsyncGenerator[Hit, None]:
374
379
  """Search for commands in alphabetical order."""
@@ -416,6 +421,11 @@ class UnifiedCommandProvider(Provider):
416
421
  self.chat_screen.action_show_usage,
417
422
  "Display usage information for the current session",
418
423
  ),
424
+ (
425
+ "View Onboarding",
426
+ self.chat_screen.action_view_onboarding,
427
+ "View the onboarding tutorial and helpful resources",
428
+ ),
419
429
  ]
420
430
 
421
431
  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
- def _on_submit_pressed(self) -> None:
129
- self._submit_feedback()
128
+ async def _on_submit_pressed(self) -> None:
129
+ await 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
- def _submit_feedback(self) -> None:
174
+ async 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 = app.config_manager.get_shotgun_instance_id()
185
+ shotgun_instance_id = await app.config_manager.get_shotgun_instance_id()
186
186
 
187
187
  feedback = Feedback(
188
188
  kind=self.selected_kind,
@@ -0,0 +1,102 @@
1
+ """Screen for guiding users to create GitHub issues."""
2
+
3
+ import webbrowser
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Markdown, Static
10
+
11
+
12
+ class GitHubIssueScreen(ModalScreen[None]):
13
+ """Guide users to create issues on GitHub."""
14
+
15
+ CSS = """
16
+ GitHubIssueScreen {
17
+ align: center middle;
18
+ }
19
+
20
+ #issue-container {
21
+ width: 70;
22
+ max-width: 100;
23
+ height: auto;
24
+ border: thick $primary;
25
+ background: $surface;
26
+ padding: 2;
27
+ }
28
+
29
+ #issue-header {
30
+ text-style: bold;
31
+ color: $text-accent;
32
+ padding-bottom: 1;
33
+ text-align: center;
34
+ }
35
+
36
+ #issue-content {
37
+ padding: 1 0;
38
+ }
39
+
40
+ #issue-buttons {
41
+ height: auto;
42
+ padding: 2 0 0 0;
43
+ align: center middle;
44
+ }
45
+
46
+ #issue-buttons Button {
47
+ margin: 1 1;
48
+ min-width: 20;
49
+ }
50
+ """
51
+
52
+ BINDINGS = [
53
+ ("escape", "dismiss", "Close"),
54
+ ]
55
+
56
+ def compose(self) -> ComposeResult:
57
+ """Compose the GitHub issue screen."""
58
+ with Container(id="issue-container"):
59
+ yield Static("Create a GitHub Issue", id="issue-header")
60
+ with Vertical(id="issue-content"):
61
+ yield Markdown(
62
+ """
63
+ ## Report Bugs or Request Features
64
+
65
+ We track all bugs, feature requests, and improvements on GitHub Issues.
66
+
67
+ ### How to Create an Issue:
68
+
69
+ 1. Click the button below to open our GitHub Issues page
70
+ 2. Click **"New Issue"**
71
+ 3. Choose a template:
72
+ - **Bug Report** - Report a bug or unexpected behavior
73
+ - **Feature Request** - Suggest new functionality
74
+ - **Documentation** - Report docs issues or improvements
75
+ 4. Fill in the details and submit
76
+
77
+ We review all issues and will respond as soon as possible!
78
+
79
+ ### Before Creating an Issue:
80
+
81
+ - Search existing issues to avoid duplicates
82
+ - Include steps to reproduce for bugs
83
+ - Be specific about what you'd like for feature requests
84
+ """,
85
+ id="issue-markdown",
86
+ )
87
+ with Vertical(id="issue-buttons"):
88
+ yield Button(
89
+ "🐙 Open GitHub Issues", id="github-button", variant="primary"
90
+ )
91
+ yield Button("Close", id="close-button")
92
+
93
+ @on(Button.Pressed, "#github-button")
94
+ def handle_github(self) -> None:
95
+ """Open GitHub issues page in browser."""
96
+ webbrowser.open("https://github.com/shotgun-sh/shotgun/issues")
97
+ self.notify("Opening GitHub Issues in your browser...")
98
+
99
+ @on(Button.Pressed, "#close-button")
100
+ def handle_close(self) -> None:
101
+ """Handle close button press."""
102
+ self.dismiss()
@@ -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
- def _rebuild_model_list(self) -> None:
101
+ async 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 = config_manager.load(force_reload=True)
111
+ config = await 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 = self._build_model_items(config)
136
+ new_items = await 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._rebuild_model_list()
156
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
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._rebuild_model_list()
165
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
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
- def refresh_model_labels(self) -> None:
196
+ async 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 = self.config_manager.load(force_reload=True)
203
+ config = await 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,9 +215,11 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
215
215
  self._model_label(model_name, is_current=model_name == current_model)
216
216
  )
217
217
 
218
- def _build_model_items(self, config: ShotgunConfig | None = None) -> list[ListItem]:
218
+ async def _build_model_items(
219
+ self, config: ShotgunConfig | None = None
220
+ ) -> list[ListItem]:
219
221
  if config is None:
220
- config = self.config_manager.load(force_reload=True)
222
+ config = await self.config_manager.load(force_reload=True)
221
223
 
222
224
  items: list[ListItem] = []
223
225
  current_model = self.selected_model
@@ -246,9 +248,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
246
248
  return model_name
247
249
  return None
248
250
 
249
- def _is_model_available(
250
- self, model_name: ModelName, config: ShotgunConfig | None = None
251
- ) -> bool:
251
+ def _is_model_available(self, model_name: ModelName, config: ShotgunConfig) -> bool:
252
252
  """Check if a model is available based on provider key configuration.
253
253
 
254
254
  A model is available if:
@@ -257,14 +257,11 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
257
257
 
258
258
  Args:
259
259
  model_name: The model to check availability for
260
- config: Optional pre-loaded config to avoid multiple reloads
260
+ config: Pre-loaded config (must be provided)
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
-
268
265
  # If Shotgun Account is configured, all models are available
269
266
  if self.config_manager._provider_has_api_key(config.shotgun):
270
267
  logger.debug("Model %s available (Shotgun Account configured)", model_name)
@@ -325,17 +322,21 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
325
322
 
326
323
  def _select_model(self) -> None:
327
324
  """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."""
328
329
  try:
329
330
  # Get old model before updating
330
- config = self.config_manager.load()
331
+ config = await self.config_manager.load()
331
332
  old_model = config.selected_model
332
333
 
333
334
  # Update the selected model in config
334
- self.config_manager.update_selected_model(self.selected_model)
335
- self.refresh_model_labels()
335
+ await self.config_manager.update_selected_model(self.selected_model)
336
+ await self.refresh_model_labels()
336
337
 
337
338
  # Get the full model config with provider information
338
- model_config = get_provider_model(self.selected_model)
339
+ model_config = await get_provider_model(self.selected_model)
339
340
 
340
341
  # Dismiss the screen and return the model config update to the caller
341
342
  self.dismiss(