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.
- shotgun/agents/agent_manager.py +382 -60
- shotgun/agents/common.py +15 -9
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +383 -82
- shotgun/agents/config/models.py +122 -18
- shotgun/agents/config/provider.py +81 -15
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +475 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +36 -5
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
- shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
- shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
- shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +2 -2
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +27 -7
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +8 -2
- shotgun/agents/tools/web_search/gemini.py +7 -1
- shotgun/agents/tools/web_search/openai.py +8 -2
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +188 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +154 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +18 -10
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +163 -15
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +357 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +60 -27
- shotgun/main.py +77 -11
- shotgun/posthog_telemetry.py +38 -29
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +243 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +310 -46
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1531 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +91 -4
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +191 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +14 -7
- shotgun/tui/screens/github_issue.py +111 -0
- shotgun/tui/screens/model_picker.py +77 -32
- shotgun/tui/screens/onboarding.py +580 -0
- shotgun/tui/screens/pipx_migration.py +205 -0
- shotgun/tui/screens/provider_config.py +116 -35
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +112 -18
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +137 -11
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +187 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +263 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/tui/screens/chat.py +0 -996
- shotgun/tui/screens/chat_screen/history.py +0 -335
- shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
- shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Screen for
|
|
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
|
-
"""
|
|
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", "
|
|
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(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
yield Static(
|
|
72
|
-
|
|
73
|
-
yield
|
|
74
|
-
|
|
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
|
-
|
|
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("#
|
|
99
|
+
self.set_focus(self.query_one("#retry", Button))
|
|
80
100
|
|
|
81
|
-
def
|
|
82
|
-
|
|
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
|
-
|
|
106
|
+
"""Exit the application."""
|
|
107
|
+
self.app.exit()
|
|
86
108
|
|
|
87
|
-
@on(Button.Pressed, "#
|
|
88
|
-
def
|
|
89
|
-
|
|
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
|
-
|
|
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()
|
shotgun/tui/screens/feedback.py
CHANGED
|
@@ -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.
|
|
180
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
295
|
-
|
|
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} · {
|
|
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.
|
|
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.
|
|
312
|
-
ModelName.
|
|
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
|
-
|
|
322
|
-
self.
|
|
323
|
-
|
|
324
|
-
|
|
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.
|
|
371
|
+
status_label = self.query_one("#model-picker-status", Label)
|
|
372
|
+
status_label.update(f"❌ Failed to select model: {exc}")
|