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

Files changed (132) hide show
  1. shotgun/agents/agent_manager.py +664 -75
  2. shotgun/agents/common.py +76 -70
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +78 -36
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +70 -15
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +14 -2
  18. shotgun/agents/history/token_counting/anthropic.py +49 -11
  19. shotgun/agents/history/token_counting/base.py +14 -3
  20. shotgun/agents/history/token_counting/openai.py +8 -0
  21. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  22. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  23. shotgun/agents/history/token_counting/utils.py +0 -3
  24. shotgun/agents/models.py +50 -2
  25. shotgun/agents/plan.py +6 -7
  26. shotgun/agents/research.py +7 -8
  27. shotgun/agents/specify.py +6 -7
  28. shotgun/agents/tasks.py +6 -7
  29. shotgun/agents/tools/__init__.py +0 -2
  30. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  32. shotgun/agents/tools/codebase/file_read.py +11 -2
  33. shotgun/agents/tools/codebase/query_graph.py +6 -0
  34. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  35. shotgun/agents/tools/file_management.py +82 -16
  36. shotgun/agents/tools/registry.py +217 -0
  37. shotgun/agents/tools/web_search/__init__.py +30 -18
  38. shotgun/agents/tools/web_search/anthropic.py +26 -5
  39. shotgun/agents/tools/web_search/gemini.py +23 -11
  40. shotgun/agents/tools/web_search/openai.py +22 -13
  41. shotgun/agents/tools/web_search/utils.py +2 -2
  42. shotgun/agents/usage_manager.py +16 -11
  43. shotgun/api_endpoints.py +7 -3
  44. shotgun/build_constants.py +1 -1
  45. shotgun/cli/clear.py +53 -0
  46. shotgun/cli/compact.py +186 -0
  47. shotgun/cli/config.py +8 -5
  48. shotgun/cli/context.py +111 -0
  49. shotgun/cli/export.py +1 -1
  50. shotgun/cli/feedback.py +4 -2
  51. shotgun/cli/models.py +1 -0
  52. shotgun/cli/plan.py +1 -1
  53. shotgun/cli/research.py +1 -1
  54. shotgun/cli/specify.py +1 -1
  55. shotgun/cli/tasks.py +1 -1
  56. shotgun/cli/update.py +16 -2
  57. shotgun/codebase/core/change_detector.py +5 -3
  58. shotgun/codebase/core/code_retrieval.py +4 -2
  59. shotgun/codebase/core/ingestor.py +10 -8
  60. shotgun/codebase/core/manager.py +13 -4
  61. shotgun/codebase/core/nl_query.py +1 -1
  62. shotgun/llm_proxy/__init__.py +5 -2
  63. shotgun/llm_proxy/clients.py +12 -7
  64. shotgun/logging_config.py +18 -27
  65. shotgun/main.py +73 -11
  66. shotgun/posthog_telemetry.py +23 -7
  67. shotgun/prompts/agents/export.j2 +18 -1
  68. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  69. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  70. shotgun/prompts/agents/plan.j2 +1 -1
  71. shotgun/prompts/agents/research.j2 +1 -1
  72. shotgun/prompts/agents/specify.j2 +270 -3
  73. shotgun/prompts/agents/state/system_state.j2 +4 -0
  74. shotgun/prompts/agents/tasks.j2 +1 -1
  75. shotgun/prompts/loader.py +2 -2
  76. shotgun/prompts/tools/web_search.j2 +14 -0
  77. shotgun/sentry_telemetry.py +7 -16
  78. shotgun/settings.py +238 -0
  79. shotgun/telemetry.py +18 -33
  80. shotgun/tui/app.py +243 -43
  81. shotgun/tui/commands/__init__.py +1 -1
  82. shotgun/tui/components/context_indicator.py +179 -0
  83. shotgun/tui/components/mode_indicator.py +70 -0
  84. shotgun/tui/components/status_bar.py +48 -0
  85. shotgun/tui/containers.py +91 -0
  86. shotgun/tui/dependencies.py +39 -0
  87. shotgun/tui/protocols.py +45 -0
  88. shotgun/tui/screens/chat/__init__.py +5 -0
  89. shotgun/tui/screens/chat/chat.tcss +54 -0
  90. shotgun/tui/screens/chat/chat_screen.py +1202 -0
  91. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  92. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  93. shotgun/tui/screens/chat/help_text.py +40 -0
  94. shotgun/tui/screens/chat/prompt_history.py +48 -0
  95. shotgun/tui/screens/chat.tcss +11 -0
  96. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  97. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  98. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  99. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  100. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  101. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  102. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  103. shotgun/tui/screens/confirmation_dialog.py +151 -0
  104. shotgun/tui/screens/feedback.py +4 -4
  105. shotgun/tui/screens/github_issue.py +102 -0
  106. shotgun/tui/screens/model_picker.py +49 -24
  107. shotgun/tui/screens/onboarding.py +431 -0
  108. shotgun/tui/screens/pipx_migration.py +153 -0
  109. shotgun/tui/screens/provider_config.py +50 -27
  110. shotgun/tui/screens/shotgun_auth.py +2 -2
  111. shotgun/tui/screens/welcome.py +32 -10
  112. shotgun/tui/services/__init__.py +5 -0
  113. shotgun/tui/services/conversation_service.py +184 -0
  114. shotgun/tui/state/__init__.py +7 -0
  115. shotgun/tui/state/processing_state.py +185 -0
  116. shotgun/tui/utils/mode_progress.py +14 -7
  117. shotgun/tui/widgets/__init__.py +5 -0
  118. shotgun/tui/widgets/widget_coordinator.py +262 -0
  119. shotgun/utils/datetime_utils.py +77 -0
  120. shotgun/utils/file_system_utils.py +22 -2
  121. shotgun/utils/marketing.py +110 -0
  122. shotgun/utils/update_checker.py +69 -14
  123. shotgun_sh-0.2.11.dev5.dist-info/METADATA +130 -0
  124. shotgun_sh-0.2.11.dev5.dist-info/RECORD +193 -0
  125. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/entry_points.txt +1 -0
  126. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/licenses/LICENSE +1 -1
  127. shotgun/agents/tools/user_interaction.py +0 -37
  128. shotgun/tui/screens/chat.py +0 -804
  129. shotgun/tui/screens/chat_screen/history.py +0 -352
  130. shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
  131. shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
  132. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/WHEEL +0 -0
@@ -0,0 +1,43 @@
1
+ """Partial response widget for streaming chat messages."""
2
+
3
+ from pydantic_ai.messages import ModelMessage
4
+ from textual.app import ComposeResult
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+
8
+ from .agent_response import AgentResponseWidget
9
+ from .user_question import UserQuestionWidget
10
+
11
+
12
+ class PartialResponseWidget(Widget): # TODO: doesn't work lol
13
+ """Widget that displays a streaming/partial response in the chat history."""
14
+
15
+ DEFAULT_CSS = """
16
+ PartialResponseWidget {
17
+ height: auto;
18
+ }
19
+ Markdown, AgentResponseWidget, UserQuestionWidget {
20
+ height: auto;
21
+ }
22
+ """
23
+
24
+ item: reactive[ModelMessage | None] = reactive(None, recompose=True)
25
+
26
+ def __init__(self, item: ModelMessage | None) -> None:
27
+ super().__init__()
28
+ self.item = item
29
+
30
+ def compose(self) -> ComposeResult:
31
+ if self.item is None:
32
+ pass
33
+ elif self.item.kind == "response":
34
+ yield AgentResponseWidget(self.item)
35
+ elif self.item.kind == "request":
36
+ yield UserQuestionWidget(self.item)
37
+
38
+ def watch_item(self, item: ModelMessage | None) -> None:
39
+ """React to changes in the item."""
40
+ if item is None:
41
+ self.display = False
42
+ else:
43
+ self.display = True
@@ -0,0 +1,42 @@
1
+ """User question widget for chat history."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from pydantic_ai.messages import (
6
+ ModelRequest,
7
+ ModelRequestPart,
8
+ ToolReturnPart,
9
+ UserPromptPart,
10
+ )
11
+ from textual.app import ComposeResult
12
+ from textual.widget import Widget
13
+ from textual.widgets import Markdown
14
+
15
+
16
+ class UserQuestionWidget(Widget):
17
+ """Widget that displays user prompts in the chat history."""
18
+
19
+ def __init__(self, item: ModelRequest | None) -> None:
20
+ super().__init__()
21
+ self.item = item
22
+
23
+ def compose(self) -> ComposeResult:
24
+ self.display = self.item is not None
25
+ if self.item is None:
26
+ yield Markdown(markdown="")
27
+ else:
28
+ prompt = self.format_prompt_parts(self.item.parts)
29
+ yield Markdown(markdown=prompt)
30
+
31
+ def format_prompt_parts(self, parts: Sequence[ModelRequestPart]) -> str:
32
+ """Format user prompt parts into markdown."""
33
+ acc = ""
34
+ for part in parts:
35
+ if isinstance(part, UserPromptPart):
36
+ acc += (
37
+ f"**>** {part.content if isinstance(part.content, str) else ''}\n\n"
38
+ )
39
+ elif isinstance(part, ToolReturnPart):
40
+ # Don't show tool return parts in the UI
41
+ pass
42
+ return acc
@@ -0,0 +1,151 @@
1
+ """Reusable confirmation dialog for destructive actions in the TUI."""
2
+
3
+ from typing import Literal
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Label, Static
10
+
11
+ ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
12
+
13
+
14
+ class ConfirmationDialog(ModalScreen[bool]):
15
+ """Reusable confirmation dialog for destructive actions.
16
+
17
+ This modal dialog presents a confirmation prompt with a title, explanatory
18
+ message, and customizable confirm/cancel buttons. Useful for preventing
19
+ accidental destructive actions like clearing data or deleting resources.
20
+
21
+ Args:
22
+ title: Dialog title text (e.g., "Clear conversation?")
23
+ message: Detailed explanation of what will happen
24
+ confirm_label: Label for the confirm button (default: "Confirm")
25
+ cancel_label: Label for the cancel button (default: "Cancel")
26
+ confirm_variant: Button variant for confirm button (default: "warning")
27
+ danger: Whether this is a dangerous/destructive action (default: False)
28
+
29
+ Returns:
30
+ True if user confirms, False if user cancels
31
+
32
+ Example:
33
+ ```python
34
+ should_delete = await self.app.push_screen_wait(
35
+ ConfirmationDialog(
36
+ title="Delete item?",
37
+ message="This will permanently delete the item. This cannot be undone.",
38
+ confirm_label="Delete",
39
+ cancel_label="Keep",
40
+ confirm_variant="warning",
41
+ danger=True,
42
+ )
43
+ )
44
+ if should_delete:
45
+ # Proceed with deletion
46
+ ...
47
+ ```
48
+ """
49
+
50
+ DEFAULT_CSS = """
51
+ ConfirmationDialog {
52
+ align: center middle;
53
+ background: rgba(0, 0, 0, 0.0);
54
+ }
55
+
56
+ ConfirmationDialog > #dialog-container {
57
+ width: 60%;
58
+ max-width: 70;
59
+ height: auto;
60
+ border: wide $warning;
61
+ padding: 1 2;
62
+ layout: vertical;
63
+ background: $surface;
64
+ }
65
+
66
+ ConfirmationDialog.danger > #dialog-container {
67
+ border: wide $error;
68
+ }
69
+
70
+ #dialog-title {
71
+ text-style: bold;
72
+ color: $text;
73
+ padding-bottom: 1;
74
+ }
75
+
76
+ #dialog-message {
77
+ padding-bottom: 1;
78
+ color: $text-muted;
79
+ }
80
+
81
+ #dialog-buttons {
82
+ layout: horizontal;
83
+ align-horizontal: right;
84
+ height: auto;
85
+ }
86
+
87
+ #dialog-buttons Button {
88
+ margin-left: 1;
89
+ }
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ title: str,
95
+ message: str,
96
+ confirm_label: str = "Confirm",
97
+ cancel_label: str = "Cancel",
98
+ confirm_variant: ButtonVariant = "warning",
99
+ danger: bool = False,
100
+ ) -> None:
101
+ """Initialize the confirmation dialog.
102
+
103
+ Args:
104
+ title: Dialog title text
105
+ message: Detailed explanation of what will happen
106
+ confirm_label: Label for the confirm button
107
+ cancel_label: Label for the cancel button
108
+ confirm_variant: Button variant for confirm button
109
+ danger: Whether this is a dangerous/destructive action
110
+ """
111
+ super().__init__()
112
+ self.title_text = title
113
+ self.message_text = message
114
+ self.confirm_label = confirm_label
115
+ self.cancel_label = cancel_label
116
+ self.confirm_variant = confirm_variant
117
+ self.is_danger = danger
118
+
119
+ def compose(self) -> ComposeResult:
120
+ """Compose the dialog widgets."""
121
+ with Container(id="dialog-container"):
122
+ yield Label(self.title_text, id="dialog-title")
123
+ yield Static(self.message_text, id="dialog-message")
124
+ with Container(id="dialog-buttons"):
125
+ yield Button(
126
+ self.confirm_label,
127
+ id="confirm",
128
+ variant=self.confirm_variant,
129
+ )
130
+ yield Button(self.cancel_label, id="cancel")
131
+
132
+ def on_mount(self) -> None:
133
+ """Set up the dialog after mounting."""
134
+ # Apply danger class if needed
135
+ if self.is_danger:
136
+ self.add_class("danger")
137
+
138
+ # Focus cancel button by default for safety
139
+ self.query_one("#cancel", Button).focus()
140
+
141
+ @on(Button.Pressed, "#cancel")
142
+ def handle_cancel(self, event: Button.Pressed) -> None:
143
+ """Handle cancel button press."""
144
+ event.stop()
145
+ self.dismiss(False)
146
+
147
+ @on(Button.Pressed, "#confirm")
148
+ def handle_confirm(self, event: Button.Pressed) -> None:
149
+ """Handle confirm button press."""
150
+ event.stop()
151
+ self.dismiss(True)
@@ -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()
@@ -11,8 +11,13 @@ from textual.reactive import reactive
11
11
  from textual.screen import Screen
12
12
  from textual.widgets import Button, Label, ListItem, ListView, Static
13
13
 
14
+ from shotgun.agents.agent_manager import ModelConfigUpdated
14
15
  from shotgun.agents.config import ConfigManager
15
16
  from shotgun.agents.config.models import MODEL_SPECS, ModelName, ShotgunConfig
17
+ from shotgun.agents.config.provider import (
18
+ get_default_model_for_provider,
19
+ get_provider_model,
20
+ )
16
21
  from shotgun.logging_config import get_logger
17
22
 
18
23
  if TYPE_CHECKING:
@@ -30,8 +35,11 @@ def _sanitize_model_name_for_id(model_name: ModelName) -> str:
30
35
  return model_name.value.replace(".", "-")
31
36
 
32
37
 
33
- class ModelPickerScreen(Screen[None]):
34
- """Select AI model to use."""
38
+ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
39
+ """Select AI model to use.
40
+
41
+ Returns ModelConfigUpdated when a model is selected, None if cancelled.
42
+ """
35
43
 
36
44
  CSS = """
37
45
  ModelPicker {
@@ -90,7 +98,7 @@ class ModelPickerScreen(Screen[None]):
90
98
  yield Button("Select \\[ENTER]", variant="primary", id="select")
91
99
  yield Button("Done \\[ESC]", id="done")
92
100
 
93
- def _rebuild_model_list(self) -> None:
101
+ async def _rebuild_model_list(self) -> None:
94
102
  """Rebuild the model list from current config.
95
103
 
96
104
  This method is called both on first show and when screen is resumed
@@ -100,7 +108,7 @@ class ModelPickerScreen(Screen[None]):
100
108
 
101
109
  # Load current config with force_reload to get latest API keys
102
110
  config_manager = self.config_manager
103
- config = config_manager.load(force_reload=True)
111
+ config = await config_manager.load(force_reload=True)
104
112
 
105
113
  # Log provider key status
106
114
  logger.debug(
@@ -111,7 +119,7 @@ class ModelPickerScreen(Screen[None]):
111
119
  config_manager._provider_has_api_key(config.shotgun),
112
120
  )
113
121
 
114
- current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
122
+ current_model = config.selected_model or get_default_model_for_provider(config)
115
123
  self.selected_model = current_model
116
124
  logger.debug("Current selected model: %s", current_model)
117
125
 
@@ -125,7 +133,7 @@ class ModelPickerScreen(Screen[None]):
125
133
  logger.debug("Removed %d existing model items from list", old_count)
126
134
 
127
135
  # Add new items (labels already have correct text including current indicator)
128
- new_items = self._build_model_items(config)
136
+ new_items = await self._build_model_items(config)
129
137
  for item in new_items:
130
138
  list_view.append(item)
131
139
  logger.debug("Added %d available model items to list", len(new_items))
@@ -145,7 +153,7 @@ class ModelPickerScreen(Screen[None]):
145
153
  def on_show(self) -> None:
146
154
  """Rebuild model list when screen is first shown."""
147
155
  logger.debug("ModelPickerScreen.on_show() called")
148
- self._rebuild_model_list()
156
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
149
157
 
150
158
  def on_screenresume(self) -> None:
151
159
  """Rebuild model list when screen is resumed (subsequent visits).
@@ -154,7 +162,7 @@ class ModelPickerScreen(Screen[None]):
154
162
  ensuring the model list reflects any config changes made while away.
155
163
  """
156
164
  logger.debug("ModelPickerScreen.on_screenresume() called")
157
- self._rebuild_model_list()
165
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
158
166
 
159
167
  def action_done(self) -> None:
160
168
  self.dismiss()
@@ -185,15 +193,15 @@ class ModelPickerScreen(Screen[None]):
185
193
  app = cast("ShotgunApp", self.app)
186
194
  return app.config_manager
187
195
 
188
- def refresh_model_labels(self) -> None:
196
+ async def refresh_model_labels(self) -> None:
189
197
  """Update the list view entries to reflect current selection.
190
198
 
191
199
  Note: This method only updates labels for currently displayed models.
192
200
  To rebuild the entire list after provider changes, on_show() should be used.
193
201
  """
194
202
  # 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
203
+ config = await self.config_manager.load(force_reload=True)
204
+ current_model = config.selected_model or get_default_model_for_provider(config)
197
205
 
198
206
  # Update labels for available models only
199
207
  for model_name in AVAILABLE_MODELS:
@@ -207,9 +215,11 @@ class ModelPickerScreen(Screen[None]):
207
215
  self._model_label(model_name, is_current=model_name == current_model)
208
216
  )
209
217
 
210
- 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]:
211
221
  if config is None:
212
- config = self.config_manager.load(force_reload=True)
222
+ config = await self.config_manager.load(force_reload=True)
213
223
 
214
224
  items: list[ListItem] = []
215
225
  current_model = self.selected_model
@@ -238,9 +248,7 @@ class ModelPickerScreen(Screen[None]):
238
248
  return model_name
239
249
  return None
240
250
 
241
- def _is_model_available(
242
- self, model_name: ModelName, config: ShotgunConfig | None = None
243
- ) -> bool:
251
+ def _is_model_available(self, model_name: ModelName, config: ShotgunConfig) -> bool:
244
252
  """Check if a model is available based on provider key configuration.
245
253
 
246
254
  A model is available if:
@@ -249,14 +257,11 @@ class ModelPickerScreen(Screen[None]):
249
257
 
250
258
  Args:
251
259
  model_name: The model to check availability for
252
- config: Optional pre-loaded config to avoid multiple reloads
260
+ config: Pre-loaded config (must be provided)
253
261
 
254
262
  Returns:
255
263
  True if the model can be used, False otherwise
256
264
  """
257
- if config is None:
258
- config = self.config_manager.load(force_reload=True)
259
-
260
265
  # If Shotgun Account is configured, all models are available
261
266
  if self.config_manager._provider_has_api_key(config.shotgun):
262
267
  logger.debug("Model %s available (Shotgun Account configured)", model_name)
@@ -317,11 +322,31 @@ class ModelPickerScreen(Screen[None]):
317
322
 
318
323
  def _select_model(self) -> None:
319
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."""
320
329
  try:
321
- self.config_manager.update_selected_model(self.selected_model)
322
- self.refresh_model_labels()
323
- self.notify(
324
- f"Selected model: {self._model_display_name(self.selected_model)}"
330
+ # Get old model before updating
331
+ config = await self.config_manager.load()
332
+ old_model = config.selected_model
333
+
334
+ # Update the selected model in config
335
+ await self.config_manager.update_selected_model(self.selected_model)
336
+ await self.refresh_model_labels()
337
+
338
+ # Get the full model config with provider information
339
+ model_config = await get_provider_model(self.selected_model)
340
+
341
+ # Dismiss the screen and return the model config update to the caller
342
+ self.dismiss(
343
+ ModelConfigUpdated(
344
+ old_model=old_model,
345
+ new_model=self.selected_model,
346
+ provider=model_config.provider,
347
+ key_provider=model_config.key_provider,
348
+ model_config=model_config,
349
+ )
325
350
  )
326
351
  except Exception as exc: # pragma: no cover - defensive; textual path
327
352
  self.notify(f"Failed to select model: {exc}", severity="error")