shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.dev1__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.
Files changed (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -1,4 +1,4 @@
1
- """Screen for setting up the local .shotgun directory."""
1
+ """Screen for displaying .shotgun directory creation errors."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,13 +8,20 @@ from textual import on
8
8
  from textual.app import ComposeResult
9
9
  from textual.containers import Horizontal, Vertical
10
10
  from textual.screen import Screen
11
- from textual.widgets import Button, Static
12
-
13
- from shotgun.utils.file_system_utils import ensure_shotgun_directory_exists
11
+ from textual.widgets import Button, Label, Static
14
12
 
15
13
 
16
14
  class DirectorySetupScreen(Screen[None]):
17
- """Prompt the user to initialize the .shotgun directory."""
15
+ """Display an error when .shotgun directory creation fails."""
16
+
17
+ def __init__(self, error_message: str) -> None:
18
+ """Initialize the error screen.
19
+
20
+ Args:
21
+ error_message: The error message to display to the user.
22
+ """
23
+ super().__init__()
24
+ self.error_message = error_message
18
25
 
19
26
  CSS = """
20
27
  DirectorySetupScreen {
@@ -56,58 +63,55 @@ class DirectorySetupScreen(Screen[None]):
56
63
  #directory-actions > * {
57
64
  margin-right: 2;
58
65
  }
66
+
67
+ #directory-status {
68
+ height: auto;
69
+ padding: 0 1;
70
+ min-height: 1;
71
+ color: $error;
72
+ text-align: center;
73
+ }
59
74
  """
60
75
 
61
76
  BINDINGS = [
62
- ("enter", "confirm", "Initialize"),
77
+ ("enter", "retry", "Retry"),
63
78
  ("escape", "cancel", "Exit"),
64
79
  ]
65
80
 
66
81
  def compose(self) -> ComposeResult:
67
82
  with Vertical(id="titlebox"):
68
- yield Static("Directory setup", id="directory-setup-title")
69
- yield Static("Shotgun keeps workspace data in a .shotgun directory.\n")
70
- yield Static("Initialize it in the current directory?\n")
71
- yield Static(f"[$foreground-muted]({Path.cwd().resolve()})[/]")
72
- with Horizontal(id="directory-actions"):
73
- yield Button(
74
- "Initialize and proceed \\[ENTER]", variant="primary", id="initialize"
83
+ yield Static(
84
+ "Failed to create .shotgun directory", id="directory-setup-title"
85
+ )
86
+ yield Static("Shotgun was unable to create the .shotgun directory in:\n")
87
+ yield Static(f"[$foreground-muted]({Path.cwd().resolve()})[/]\n")
88
+ yield Static(f"[bold red]Error:[/] {self.error_message}\n")
89
+ yield Static(
90
+ "This directory is required for storing workspace data. "
91
+ "Please check permissions and try again."
75
92
  )
76
- yield Button("Exit without setup \\[ESC]", variant="default", id="exit")
93
+ yield Label("", id="directory-status")
94
+ with Horizontal(id="directory-actions"):
95
+ yield Button("Retry \\[ENTER]", variant="primary", id="retry")
96
+ yield Button("Exit \\[ESC]", variant="default", id="exit")
77
97
 
78
98
  def on_mount(self) -> None:
79
- self.set_focus(self.query_one("#initialize", Button))
99
+ self.set_focus(self.query_one("#retry", Button))
80
100
 
81
- def action_confirm(self) -> None:
82
- self._initialize_directory()
101
+ def action_retry(self) -> None:
102
+ """Retry by dismissing the screen, which will trigger refresh_startup_screen."""
103
+ self.dismiss()
83
104
 
84
105
  def action_cancel(self) -> None:
85
- self._exit_application()
106
+ """Exit the application."""
107
+ self.app.exit()
86
108
 
87
- @on(Button.Pressed, "#initialize")
88
- def _on_initialize_pressed(self) -> None:
89
- self._initialize_directory()
109
+ @on(Button.Pressed, "#retry")
110
+ def _on_retry_pressed(self) -> None:
111
+ """Retry by dismissing the screen."""
112
+ self.dismiss()
90
113
 
91
114
  @on(Button.Pressed, "#exit")
92
115
  def _on_exit_pressed(self) -> None:
93
- self._exit_application()
94
-
95
- def _initialize_directory(self) -> None:
96
- try:
97
- path = ensure_shotgun_directory_exists()
98
- except Exception as exc: # pragma: no cover - defensive; textual path
99
- self.notify(f"Failed to initialize directory: {exc}", severity="error")
100
- return
101
-
102
- # Double-check a directory now exists; guard against unexpected filesystem state.
103
- if not path.is_dir():
104
- self.notify(
105
- "Unable to initialize .shotgun directory due to filesystem conflict.",
106
- severity="error",
107
- )
108
- return
109
-
110
- self.dismiss()
111
-
112
- def _exit_application(self) -> None:
116
+ """Exit the application."""
113
117
  self.app.exit()
@@ -76,6 +76,13 @@ class FeedbackScreen(Screen[Feedback | None]):
76
76
  #feedback-type-list {
77
77
  padding: 1;
78
78
  }
79
+
80
+ #feedback-status {
81
+ height: auto;
82
+ padding: 0 1;
83
+ min-height: 1;
84
+ color: $error;
85
+ }
79
86
  """
80
87
 
81
88
  BINDINGS = [
@@ -96,6 +103,7 @@ class FeedbackScreen(Screen[Feedback | None]):
96
103
  "",
97
104
  id="feedback-description",
98
105
  )
106
+ yield Label("", id="feedback-status")
99
107
  with Horizontal(id="feedback-actions"):
100
108
  yield Button("Submit", variant="primary", id="submit")
101
109
  yield Button("Cancel \\[ESC]", id="cancel")
@@ -125,8 +133,8 @@ class FeedbackScreen(Screen[Feedback | None]):
125
133
  self.set_focus(self.query_one("#feedback-description", TextArea))
126
134
 
127
135
  @on(Button.Pressed, "#submit")
128
- def _on_submit_pressed(self) -> None:
129
- self._submit_feedback()
136
+ async def _on_submit_pressed(self) -> None:
137
+ await self._submit_feedback()
130
138
 
131
139
  @on(Button.Pressed, "#cancel")
132
140
  def _on_cancel_pressed(self) -> None:
@@ -171,18 +179,17 @@ class FeedbackScreen(Screen[Feedback | None]):
171
179
  }
172
180
  return placeholders.get(kind, "Enter your feedback...")
173
181
 
174
- def _submit_feedback(self) -> None:
182
+ async def _submit_feedback(self) -> None:
175
183
  text_area = self.query_one("#feedback-description", TextArea)
176
184
  description = text_area.text.strip()
177
185
 
178
186
  if not description:
179
- self.notify(
180
- "Please enter a description before submitting.", severity="error"
181
- )
187
+ status_label = self.query_one("#feedback-status", Label)
188
+ status_label.update("Please enter a description before submitting.")
182
189
  return
183
190
 
184
191
  app = cast("ShotgunApp", self.app)
185
- shotgun_instance_id = app.config_manager.get_shotgun_instance_id()
192
+ shotgun_instance_id = await app.config_manager.get_shotgun_instance_id()
186
193
 
187
194
  feedback = Feedback(
188
195
  kind=self.selected_kind,
@@ -0,0 +1,111 @@
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, Label, 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
+ #issue-status {
52
+ height: auto;
53
+ padding: 1;
54
+ min-height: 1;
55
+ text-align: center;
56
+ }
57
+ """
58
+
59
+ BINDINGS = [
60
+ ("escape", "dismiss", "Close"),
61
+ ]
62
+
63
+ def compose(self) -> ComposeResult:
64
+ """Compose the GitHub issue screen."""
65
+ with Container(id="issue-container"):
66
+ yield Static("Create a GitHub Issue", id="issue-header")
67
+ with Vertical(id="issue-content"):
68
+ yield Markdown(
69
+ """
70
+ ## Report Bugs or Request Features
71
+
72
+ We track all bugs, feature requests, and improvements on GitHub Issues.
73
+
74
+ ### How to Create an Issue:
75
+
76
+ 1. Click the button below to open our GitHub Issues page
77
+ 2. Click **"New Issue"**
78
+ 3. Choose a template:
79
+ - **Bug Report** - Report a bug or unexpected behavior
80
+ - **Feature Request** - Suggest new functionality
81
+ - **Documentation** - Report docs issues or improvements
82
+ 4. Fill in the details and submit
83
+
84
+ We review all issues and will respond as soon as possible!
85
+
86
+ ### Before Creating an Issue:
87
+
88
+ - Search existing issues to avoid duplicates
89
+ - Include steps to reproduce for bugs
90
+ - Be specific about what you'd like for feature requests
91
+ """,
92
+ id="issue-markdown",
93
+ )
94
+ with Vertical(id="issue-buttons"):
95
+ yield Label("", id="issue-status")
96
+ yield Button(
97
+ "🐙 Open GitHub Issues", id="github-button", variant="primary"
98
+ )
99
+ yield Button("Close", id="close-button")
100
+
101
+ @on(Button.Pressed, "#github-button")
102
+ def handle_github(self) -> None:
103
+ """Open GitHub issues page in browser."""
104
+ webbrowser.open("https://github.com/shotgun-sh/shotgun/issues")
105
+ status_label = self.query_one("#issue-status", Label)
106
+ status_label.update("✓ Opening GitHub Issues in your browser...")
107
+
108
+ @on(Button.Pressed, "#close-button")
109
+ def handle_close(self) -> None:
110
+ """Handle close button press."""
111
+ 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 {
@@ -64,6 +72,11 @@ class ModelPickerScreen(Screen[None]):
64
72
  padding: 1 0;
65
73
  }
66
74
  }
75
+ #model-picker-status {
76
+ height: auto;
77
+ padding: 0 1;
78
+ color: $error;
79
+ }
67
80
  #model-actions {
68
81
  padding: 1;
69
82
  }
@@ -76,7 +89,7 @@ class ModelPickerScreen(Screen[None]):
76
89
  ("escape", "done", "Back"),
77
90
  ]
78
91
 
79
- selected_model: reactive[ModelName] = reactive(ModelName.GPT_5)
92
+ selected_model: reactive[ModelName] = reactive(ModelName.GPT_5_1)
80
93
 
81
94
  def compose(self) -> ComposeResult:
82
95
  with Vertical(id="titlebox"):
@@ -86,11 +99,12 @@ class ModelPickerScreen(Screen[None]):
86
99
  id="model-picker-summary",
87
100
  )
88
101
  yield ListView(id="model-list")
102
+ yield Label("", id="model-picker-status")
89
103
  with Horizontal(id="model-actions"):
90
104
  yield Button("Select \\[ENTER]", variant="primary", id="select")
91
105
  yield Button("Done \\[ESC]", id="done")
92
106
 
93
- def _rebuild_model_list(self) -> None:
107
+ async def _rebuild_model_list(self) -> None:
94
108
  """Rebuild the model list from current config.
95
109
 
96
110
  This method is called both on first show and when screen is resumed
@@ -100,7 +114,7 @@ class ModelPickerScreen(Screen[None]):
100
114
 
101
115
  # Load current config with force_reload to get latest API keys
102
116
  config_manager = self.config_manager
103
- config = config_manager.load(force_reload=True)
117
+ config = await config_manager.load(force_reload=True)
104
118
 
105
119
  # Log provider key status
106
120
  logger.debug(
@@ -111,7 +125,7 @@ class ModelPickerScreen(Screen[None]):
111
125
  config_manager._provider_has_api_key(config.shotgun),
112
126
  )
113
127
 
114
- current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
128
+ current_model = config.selected_model or get_default_model_for_provider(config)
115
129
  self.selected_model = current_model
116
130
  logger.debug("Current selected model: %s", current_model)
117
131
 
@@ -125,7 +139,7 @@ class ModelPickerScreen(Screen[None]):
125
139
  logger.debug("Removed %d existing model items from list", old_count)
126
140
 
127
141
  # Add new items (labels already have correct text including current indicator)
128
- new_items = self._build_model_items(config)
142
+ new_items = await self._build_model_items(config)
129
143
  for item in new_items:
130
144
  list_view.append(item)
131
145
  logger.debug("Added %d available model items to list", len(new_items))
@@ -145,7 +159,7 @@ class ModelPickerScreen(Screen[None]):
145
159
  def on_show(self) -> None:
146
160
  """Rebuild model list when screen is first shown."""
147
161
  logger.debug("ModelPickerScreen.on_show() called")
148
- self._rebuild_model_list()
162
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
149
163
 
150
164
  def on_screenresume(self) -> None:
151
165
  """Rebuild model list when screen is resumed (subsequent visits).
@@ -154,7 +168,7 @@ class ModelPickerScreen(Screen[None]):
154
168
  ensuring the model list reflects any config changes made while away.
155
169
  """
156
170
  logger.debug("ModelPickerScreen.on_screenresume() called")
157
- self._rebuild_model_list()
171
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
158
172
 
159
173
  def action_done(self) -> None:
160
174
  self.dismiss()
@@ -185,15 +199,15 @@ class ModelPickerScreen(Screen[None]):
185
199
  app = cast("ShotgunApp", self.app)
186
200
  return app.config_manager
187
201
 
188
- def refresh_model_labels(self) -> None:
202
+ async def refresh_model_labels(self) -> None:
189
203
  """Update the list view entries to reflect current selection.
190
204
 
191
205
  Note: This method only updates labels for currently displayed models.
192
206
  To rebuild the entire list after provider changes, on_show() should be used.
193
207
  """
194
208
  # 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
209
+ config = await self.config_manager.load(force_reload=True)
210
+ current_model = config.selected_model or get_default_model_for_provider(config)
197
211
 
198
212
  # Update labels for available models only
199
213
  for model_name in AVAILABLE_MODELS:
@@ -207,9 +221,11 @@ class ModelPickerScreen(Screen[None]):
207
221
  self._model_label(model_name, is_current=model_name == current_model)
208
222
  )
209
223
 
210
- def _build_model_items(self, config: ShotgunConfig | None = None) -> list[ListItem]:
224
+ async def _build_model_items(
225
+ self, config: ShotgunConfig | None = None
226
+ ) -> list[ListItem]:
211
227
  if config is None:
212
- config = self.config_manager.load(force_reload=True)
228
+ config = await self.config_manager.load(force_reload=True)
213
229
 
214
230
  items: list[ListItem] = []
215
231
  current_model = self.selected_model
@@ -238,9 +254,7 @@ class ModelPickerScreen(Screen[None]):
238
254
  return model_name
239
255
  return None
240
256
 
241
- def _is_model_available(
242
- self, model_name: ModelName, config: ShotgunConfig | None = None
243
- ) -> bool:
257
+ def _is_model_available(self, model_name: ModelName, config: ShotgunConfig) -> bool:
244
258
  """Check if a model is available based on provider key configuration.
245
259
 
246
260
  A model is available if:
@@ -249,14 +263,11 @@ class ModelPickerScreen(Screen[None]):
249
263
 
250
264
  Args:
251
265
  model_name: The model to check availability for
252
- config: Optional pre-loaded config to avoid multiple reloads
266
+ config: Pre-loaded config (must be provided)
253
267
 
254
268
  Returns:
255
269
  True if the model can be used, False otherwise
256
270
  """
257
- if config is None:
258
- config = self.config_manager.load(force_reload=True)
259
-
260
271
  # If Shotgun Account is configured, all models are available
261
272
  if self.config_manager._provider_has_api_key(config.shotgun):
262
273
  logger.debug("Model %s available (Shotgun Account configured)", model_name)
@@ -282,6 +293,12 @@ class ModelPickerScreen(Screen[None]):
282
293
  )
283
294
  return has_key
284
295
 
296
+ def _format_tokens(self, tokens: int) -> str:
297
+ """Format token count for display (K for thousands, M for millions)."""
298
+ if tokens >= 1_000_000:
299
+ return f"{tokens / 1_000_000:.1f}M"
300
+ return f"{tokens // 1000}K"
301
+
285
302
  def _model_label(self, model_name: ModelName, is_current: bool) -> str:
286
303
  """Generate label for model with specs and current indicator."""
287
304
  if model_name not in MODEL_SPECS:
@@ -291,13 +308,13 @@ class ModelPickerScreen(Screen[None]):
291
308
  display_name = self._model_display_name(model_name)
292
309
 
293
310
  # Format context/output tokens in readable format
294
- input_k = spec.max_input_tokens // 1000
295
- output_k = spec.max_output_tokens // 1000
311
+ input_fmt = self._format_tokens(spec.max_input_tokens)
312
+ output_fmt = self._format_tokens(spec.max_output_tokens)
296
313
 
297
- label = f"{display_name} · {input_k}K context · {output_k}K output"
314
+ label = f"{display_name} · {input_fmt} context · {output_fmt} output"
298
315
 
299
316
  # Add cost indicator for expensive models
300
- if model_name == ModelName.CLAUDE_OPUS_4_1:
317
+ if model_name == ModelName.CLAUDE_OPUS_4_5:
301
318
  label += " · Expensive"
302
319
 
303
320
  if is_current:
@@ -308,20 +325,48 @@ class ModelPickerScreen(Screen[None]):
308
325
  def _model_display_name(self, model_name: ModelName) -> str:
309
326
  """Get human-readable model name."""
310
327
  names = {
311
- ModelName.GPT_5: "GPT-5 (OpenAI)",
312
- ModelName.CLAUDE_OPUS_4_1: "Claude Opus 4.1 (Anthropic)",
328
+ ModelName.GPT_5_1: "GPT-5.1 (OpenAI)",
329
+ ModelName.GPT_5_1_CODEX: "GPT-5.1 Codex (OpenAI)",
330
+ ModelName.GPT_5_1_CODEX_MINI: "GPT-5.1 Codex Mini (OpenAI)",
331
+ ModelName.CLAUDE_OPUS_4_5: "Claude Opus 4.5 (Anthropic)",
332
+ ModelName.CLAUDE_SONNET_4: "Claude Sonnet 4 (Anthropic)",
313
333
  ModelName.CLAUDE_SONNET_4_5: "Claude Sonnet 4.5 (Anthropic)",
334
+ ModelName.CLAUDE_HAIKU_4_5: "Claude Haiku 4.5 (Anthropic)",
314
335
  ModelName.GEMINI_2_5_PRO: "Gemini 2.5 Pro (Google)",
336
+ ModelName.GEMINI_2_5_FLASH: "Gemini 2.5 Flash (Google)",
337
+ ModelName.GEMINI_2_5_FLASH_LITE: "Gemini 2.5 Flash Lite (Google)",
338
+ ModelName.GEMINI_3_PRO_PREVIEW: "Gemini 3 Pro Preview (Google)",
315
339
  }
316
340
  return names.get(model_name, model_name.value)
317
341
 
318
342
  def _select_model(self) -> None:
319
343
  """Save the selected model."""
344
+ self.run_worker(self._do_select_model(), exclusive=True)
345
+
346
+ async def _do_select_model(self) -> None:
347
+ """Async implementation of model selection."""
320
348
  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)}"
349
+ # Get old model before updating
350
+ config = await self.config_manager.load()
351
+ old_model = config.selected_model
352
+
353
+ # Update the selected model in config
354
+ await self.config_manager.update_selected_model(self.selected_model)
355
+ await self.refresh_model_labels()
356
+
357
+ # Get the full model config with provider information
358
+ model_config = await get_provider_model(self.selected_model)
359
+
360
+ # Dismiss the screen and return the model config update to the caller
361
+ self.dismiss(
362
+ ModelConfigUpdated(
363
+ old_model=old_model,
364
+ new_model=self.selected_model,
365
+ provider=model_config.provider,
366
+ key_provider=model_config.key_provider,
367
+ model_config=model_config,
368
+ )
325
369
  )
326
370
  except Exception as exc: # pragma: no cover - defensive; textual path
327
- self.notify(f"Failed to select model: {exc}", severity="error")
371
+ status_label = self.query_one("#model-picker-status", Label)
372
+ status_label.update(f"❌ Failed to select model: {exc}")