shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.2.17__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 +354 -46
- shotgun/agents/common.py +14 -8
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +66 -35
- shotgun/agents/config/models.py +41 -1
- shotgun/agents/config/provider.py +33 -5
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -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_history.py +2 -0
- shotgun/agents/conversation_manager.py +35 -19
- shotgun/agents/export.py +2 -2
- shotgun/agents/history/compaction.py +9 -4
- shotgun/agents/history/history_processors.py +113 -5
- shotgun/agents/history/token_counting/anthropic.py +17 -1
- shotgun/agents/history/token_counting/base.py +14 -3
- shotgun/agents/history/token_counting/openai.py +11 -1
- shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/history/token_counting/utils.py +0 -3
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- 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 +7 -1
- 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 +3 -3
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- 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 +10 -8
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/exceptions.py +32 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +73 -11
- shotgun/posthog_telemetry.py +37 -28
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +243 -43
- 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/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 +1254 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -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 +78 -2
- 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 +151 -0
- shotgun/tui/screens/feedback.py +4 -4
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +49 -24
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +50 -27
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +14 -11
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -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.2.17.dist-info/METADATA +465 -0
- shotgun_sh-0.2.17.dist-info/RECORD +194 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.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_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.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)
|
shotgun/tui/screens/feedback.py
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
322
|
-
self.
|
|
323
|
-
|
|
324
|
-
|
|
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")
|