shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +524 -58
- shotgun/agents/common.py +62 -62
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +14 -3
- shotgun/agents/config/models.py +16 -0
- shotgun/agents/config/provider.py +68 -13
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +493 -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 +125 -2
- shotgun/agents/conversation_manager.py +24 -2
- shotgun/agents/export.py +4 -5
- shotgun/agents/history/compaction.py +9 -4
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +14 -2
- shotgun/agents/history/token_counting/anthropic.py +32 -10
- shotgun/agents/models.py +50 -2
- shotgun/agents/plan.py +4 -5
- shotgun/agents/research.py +4 -5
- shotgun/agents/specify.py +4 -5
- shotgun/agents/tasks.py +4 -5
- shotgun/agents/tools/__init__.py +0 -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 +6 -0
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +71 -9
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +24 -12
- shotgun/agents/tools/web_search/anthropic.py +24 -3
- shotgun/agents/tools/web_search/gemini.py +22 -10
- shotgun/agents/tools/web_search/openai.py +21 -12
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +1 -1
- shotgun/cli/clear.py +52 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/context.py +111 -0
- shotgun/cli/models.py +1 -0
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/manager.py +10 -1
- shotgun/llm_proxy/__init__.py +5 -2
- shotgun/llm_proxy/clients.py +12 -7
- shotgun/logging_config.py +8 -10
- shotgun/main.py +70 -10
- shotgun/posthog_telemetry.py +9 -3
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sentry_telemetry.py +4 -15
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +15 -32
- shotgun/tui/app.py +203 -9
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +136 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +93 -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 +1110 -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 +39 -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 +68 -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 +116 -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/model_picker.py +30 -6
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/welcome.py +24 -5
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +182 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +247 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/file_system_utils.py +3 -2
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dev1.dist-info/METADATA +129 -0
- shotgun_sh-0.2.11.dev1.dist-info/RECORD +190 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -804
- shotgun/tui/screens/chat_screen/history.py +0 -352
- shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
- shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/WHEEL +0 -0
|
@@ -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)
|
|
@@ -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 {
|
|
@@ -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
|
|
|
@@ -193,7 +201,7 @@ class ModelPickerScreen(Screen[None]):
|
|
|
193
201
|
"""
|
|
194
202
|
# Load config once with force_reload
|
|
195
203
|
config = self.config_manager.load(force_reload=True)
|
|
196
|
-
current_model = config.selected_model or
|
|
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:
|
|
@@ -318,10 +326,26 @@ class ModelPickerScreen(Screen[None]):
|
|
|
318
326
|
def _select_model(self) -> None:
|
|
319
327
|
"""Save the selected model."""
|
|
320
328
|
try:
|
|
329
|
+
# Get old model before updating
|
|
330
|
+
config = self.config_manager.load()
|
|
331
|
+
old_model = config.selected_model
|
|
332
|
+
|
|
333
|
+
# Update the selected model in config
|
|
321
334
|
self.config_manager.update_selected_model(self.selected_model)
|
|
322
335
|
self.refresh_model_labels()
|
|
323
|
-
|
|
324
|
-
|
|
336
|
+
|
|
337
|
+
# Get the full model config with provider information
|
|
338
|
+
model_config = get_provider_model(self.selected_model)
|
|
339
|
+
|
|
340
|
+
# Dismiss the screen and return the model config update to the caller
|
|
341
|
+
self.dismiss(
|
|
342
|
+
ModelConfigUpdated(
|
|
343
|
+
old_model=old_model,
|
|
344
|
+
new_model=self.selected_model,
|
|
345
|
+
provider=model_config.provider,
|
|
346
|
+
key_provider=model_config.key_provider,
|
|
347
|
+
model_config=model_config,
|
|
348
|
+
)
|
|
325
349
|
)
|
|
326
350
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
327
351
|
self.notify(f"Failed to select model: {exc}", severity="error")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Migration notice screen for pipx users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Markdown
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PipxMigrationScreen(ModalScreen[None]):
|
|
18
|
+
"""Modal screen warning pipx users about migration to uvx."""
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
PipxMigrationScreen {
|
|
22
|
+
align: center middle;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#migration-container {
|
|
26
|
+
width: 90;
|
|
27
|
+
height: auto;
|
|
28
|
+
max-height: 90%;
|
|
29
|
+
border: thick $error;
|
|
30
|
+
background: $surface;
|
|
31
|
+
padding: 2;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#migration-content {
|
|
35
|
+
height: 1fr;
|
|
36
|
+
padding: 1 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#buttons-container {
|
|
40
|
+
height: auto;
|
|
41
|
+
padding: 2 0 1 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#action-buttons {
|
|
45
|
+
width: 100%;
|
|
46
|
+
height: auto;
|
|
47
|
+
align: center middle;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.action-button {
|
|
51
|
+
margin: 0 1;
|
|
52
|
+
min-width: 20;
|
|
53
|
+
}
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
BINDINGS = [
|
|
57
|
+
("escape", "dismiss", "Continue Anyway"),
|
|
58
|
+
("ctrl+c", "app.quit", "Quit"),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def compose(self) -> ComposeResult:
|
|
62
|
+
"""Compose the migration notice modal."""
|
|
63
|
+
with Container(id="migration-container"):
|
|
64
|
+
with VerticalScroll(id="migration-content"):
|
|
65
|
+
yield Markdown(
|
|
66
|
+
"""
|
|
67
|
+
## We've Switched to uvx
|
|
68
|
+
|
|
69
|
+
We've switched from `pipx` to `uvx` as the primary installation method due to critical build issues with our `kuzu` dependency.
|
|
70
|
+
|
|
71
|
+
### The Problem
|
|
72
|
+
Users with pipx encounter cmake build errors during installation because pip falls back to building from source instead of using pre-built binary wheels.
|
|
73
|
+
|
|
74
|
+
### The Solution: uvx
|
|
75
|
+
- ✅ **No build tools required** - Binary wheels enforced
|
|
76
|
+
- ✅ **10-100x faster** - Much faster than pipx
|
|
77
|
+
- ✅ **Better reliability** - No cmake/build errors
|
|
78
|
+
|
|
79
|
+
### How to Migrate
|
|
80
|
+
|
|
81
|
+
**1. Uninstall shotgun-sh from pipx:**
|
|
82
|
+
```bash
|
|
83
|
+
pipx uninstall shotgun-sh
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**2. Install uv:**
|
|
87
|
+
```bash
|
|
88
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
89
|
+
```
|
|
90
|
+
Or with Homebrew: `brew install uv`
|
|
91
|
+
|
|
92
|
+
**3. Run shotgun-sh with uvx:**
|
|
93
|
+
```bash
|
|
94
|
+
uvx shotgun-sh
|
|
95
|
+
```
|
|
96
|
+
Or install permanently: `uv tool install shotgun-sh`
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### Need Help?
|
|
101
|
+
|
|
102
|
+
**Discord:** https://discord.gg/5RmY6J2N7s
|
|
103
|
+
|
|
104
|
+
**Full Migration Guide:** https://github.com/shotgun-sh/shotgun/blob/main/docs/PIPX_MIGRATION.md
|
|
105
|
+
"""
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
with Container(id="buttons-container"):
|
|
109
|
+
with Horizontal(id="action-buttons"):
|
|
110
|
+
yield Button(
|
|
111
|
+
"Copy Instructions to Clipboard",
|
|
112
|
+
variant="default",
|
|
113
|
+
id="copy-instructions",
|
|
114
|
+
classes="action-button",
|
|
115
|
+
)
|
|
116
|
+
yield Button(
|
|
117
|
+
"Continue Anyway",
|
|
118
|
+
variant="primary",
|
|
119
|
+
id="continue",
|
|
120
|
+
classes="action-button",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def on_mount(self) -> None:
|
|
124
|
+
"""Focus the continue button and ensure scroll starts at top."""
|
|
125
|
+
self.query_one("#continue", Button).focus()
|
|
126
|
+
self.query_one("#migration-content", VerticalScroll).scroll_home(animate=False)
|
|
127
|
+
|
|
128
|
+
@on(Button.Pressed, "#copy-instructions")
|
|
129
|
+
def _copy_instructions(self) -> None:
|
|
130
|
+
"""Copy all migration instructions to clipboard."""
|
|
131
|
+
instructions = """# Step 1: Uninstall from pipx
|
|
132
|
+
pipx uninstall shotgun-sh
|
|
133
|
+
|
|
134
|
+
# Step 2: Install uv
|
|
135
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
136
|
+
|
|
137
|
+
# Step 3: Run shotgun with uvx
|
|
138
|
+
uvx shotgun-sh"""
|
|
139
|
+
try:
|
|
140
|
+
import pyperclip # type: ignore[import-untyped] # noqa: PGH003
|
|
141
|
+
|
|
142
|
+
pyperclip.copy(instructions)
|
|
143
|
+
self.notify("Copied migration instructions to clipboard!")
|
|
144
|
+
except ImportError:
|
|
145
|
+
self.notify(
|
|
146
|
+
"Clipboard not available. See instructions above.",
|
|
147
|
+
severity="warning",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@on(Button.Pressed, "#continue")
|
|
151
|
+
def _continue(self) -> None:
|
|
152
|
+
"""Dismiss the modal and continue."""
|
|
153
|
+
self.dismiss()
|
shotgun/tui/screens/welcome.py
CHANGED
|
@@ -136,6 +136,12 @@ class WelcomeScreen(Screen[None]):
|
|
|
136
136
|
|
|
137
137
|
def on_mount(self) -> None:
|
|
138
138
|
"""Focus the first button on mount."""
|
|
139
|
+
# Update BYOK button text based on whether user has existing providers
|
|
140
|
+
byok_button = self.query_one("#byok-button", Button)
|
|
141
|
+
app = cast("ShotgunApp", self.app)
|
|
142
|
+
if app.config_manager.has_any_provider_key():
|
|
143
|
+
byok_button.label = "I'll stick with my BYOK setup"
|
|
144
|
+
|
|
139
145
|
self.query_one("#shotgun-button", Button).focus()
|
|
140
146
|
|
|
141
147
|
@on(Button.Pressed, "#shotgun-button")
|
|
@@ -146,14 +152,27 @@ class WelcomeScreen(Screen[None]):
|
|
|
146
152
|
@on(Button.Pressed, "#byok-button")
|
|
147
153
|
def _on_byok_pressed(self) -> None:
|
|
148
154
|
"""Handle BYOK button press."""
|
|
155
|
+
self.run_worker(self._start_byok_config(), exclusive=True)
|
|
156
|
+
|
|
157
|
+
async def _start_byok_config(self) -> None:
|
|
158
|
+
"""Launch BYOK provider configuration flow."""
|
|
149
159
|
self._mark_welcome_shown()
|
|
150
|
-
|
|
160
|
+
|
|
161
|
+
app = cast("ShotgunApp", self.app)
|
|
162
|
+
|
|
163
|
+
# If user already has providers, just dismiss and continue to chat
|
|
164
|
+
if app.config_manager.has_any_provider_key():
|
|
165
|
+
self.dismiss()
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Otherwise, push provider config screen and wait for result
|
|
151
169
|
from .provider_config import ProviderConfigScreen
|
|
152
170
|
|
|
153
|
-
self.app.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
171
|
+
await self.app.push_screen_wait(ProviderConfigScreen())
|
|
172
|
+
|
|
173
|
+
# Dismiss welcome screen after config if providers are now configured
|
|
174
|
+
if app.config_manager.has_any_provider_key():
|
|
175
|
+
self.dismiss()
|
|
157
176
|
|
|
158
177
|
async def _start_shotgun_auth(self) -> None:
|
|
159
178
|
"""Launch Shotgun Account authentication flow."""
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Service for managing conversation persistence and restoration.
|
|
2
|
+
|
|
3
|
+
This service extracts conversation save/load/restore logic from ChatScreen,
|
|
4
|
+
making it testable and reusable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from shotgun.agents.conversation_history import ConversationHistory, ConversationState
|
|
12
|
+
from shotgun.agents.conversation_manager import ConversationManager
|
|
13
|
+
from shotgun.agents.models import AgentType
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from shotgun.agents.agent_manager import AgentManager
|
|
17
|
+
from shotgun.agents.usage_manager import SessionUsageManager
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConversationService:
|
|
23
|
+
"""Handles conversation persistence and restoration.
|
|
24
|
+
|
|
25
|
+
This service provides:
|
|
26
|
+
- Save current conversation to disk
|
|
27
|
+
- Load conversation from disk
|
|
28
|
+
- Restore conversation state to agent manager
|
|
29
|
+
- Handle corrupted conversations gracefully
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
conversation_manager: ConversationManager | None = None,
|
|
35
|
+
conversation_path: Path | None = None,
|
|
36
|
+
):
|
|
37
|
+
"""Initialize the conversation service.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
conversation_manager: Optional conversation manager. If not provided,
|
|
41
|
+
creates a default one.
|
|
42
|
+
conversation_path: Optional custom path for conversation storage.
|
|
43
|
+
"""
|
|
44
|
+
if conversation_manager:
|
|
45
|
+
self.conversation_manager = conversation_manager
|
|
46
|
+
elif conversation_path:
|
|
47
|
+
self.conversation_manager = ConversationManager(conversation_path)
|
|
48
|
+
else:
|
|
49
|
+
self.conversation_manager = ConversationManager()
|
|
50
|
+
|
|
51
|
+
def save_conversation(self, agent_manager: "AgentManager") -> bool:
|
|
52
|
+
"""Save the current conversation to persistent storage.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
agent_manager: The agent manager containing conversation state.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if save was successful, False otherwise.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
# Get conversation state from agent manager
|
|
62
|
+
state = agent_manager.get_conversation_state()
|
|
63
|
+
|
|
64
|
+
# Create conversation history object
|
|
65
|
+
conversation = ConversationHistory(
|
|
66
|
+
last_agent_model=state.agent_type,
|
|
67
|
+
)
|
|
68
|
+
conversation.set_agent_messages(state.agent_messages)
|
|
69
|
+
conversation.set_ui_messages(state.ui_messages)
|
|
70
|
+
|
|
71
|
+
# Save to file
|
|
72
|
+
self.conversation_manager.save(conversation)
|
|
73
|
+
logger.debug("Conversation saved successfully")
|
|
74
|
+
return True
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.exception(f"Failed to save conversation: {e}")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def load_conversation(self) -> ConversationHistory | None:
|
|
80
|
+
"""Load conversation from persistent storage.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The loaded conversation history, or None if no conversation exists
|
|
84
|
+
or if loading failed.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
conversation = self.conversation_manager.load()
|
|
88
|
+
if conversation is None:
|
|
89
|
+
logger.debug("No conversation file found")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
logger.debug("Conversation loaded successfully")
|
|
93
|
+
return conversation
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.exception(f"Failed to load conversation: {e}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def check_for_corrupted_conversation(self) -> bool:
|
|
99
|
+
"""Check if a conversation backup exists (indicating corruption).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if a backup exists (conversation was corrupted), False otherwise.
|
|
103
|
+
"""
|
|
104
|
+
backup_path = self.conversation_manager.conversation_path.with_suffix(
|
|
105
|
+
".json.backup"
|
|
106
|
+
)
|
|
107
|
+
return backup_path.exists()
|
|
108
|
+
|
|
109
|
+
def restore_conversation(
|
|
110
|
+
self,
|
|
111
|
+
agent_manager: "AgentManager",
|
|
112
|
+
usage_manager: "SessionUsageManager | None" = None,
|
|
113
|
+
) -> tuple[bool, str | None, AgentType | None]:
|
|
114
|
+
"""Restore conversation state from disk.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
agent_manager: The agent manager to restore state to.
|
|
118
|
+
usage_manager: Optional usage manager to restore usage state.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple of (success, error_message, restored_agent_type)
|
|
122
|
+
- success: True if restoration succeeded
|
|
123
|
+
- error_message: Error message if restoration failed, None otherwise
|
|
124
|
+
- restored_agent_type: The agent type from restored conversation
|
|
125
|
+
"""
|
|
126
|
+
conversation = self.load_conversation()
|
|
127
|
+
|
|
128
|
+
if conversation is None:
|
|
129
|
+
# Check for corruption
|
|
130
|
+
if self.check_for_corrupted_conversation():
|
|
131
|
+
return (
|
|
132
|
+
False,
|
|
133
|
+
"⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation.",
|
|
134
|
+
None,
|
|
135
|
+
)
|
|
136
|
+
return True, None, None # No conversation to restore is not an error
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# Restore agent state
|
|
140
|
+
agent_messages = conversation.get_agent_messages()
|
|
141
|
+
ui_messages = conversation.get_ui_messages()
|
|
142
|
+
|
|
143
|
+
# Create ConversationState for restoration
|
|
144
|
+
state = ConversationState(
|
|
145
|
+
agent_messages=agent_messages,
|
|
146
|
+
ui_messages=ui_messages,
|
|
147
|
+
agent_type=conversation.last_agent_model,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
agent_manager.restore_conversation_state(state)
|
|
151
|
+
|
|
152
|
+
# Restore usage state if manager provided
|
|
153
|
+
if usage_manager:
|
|
154
|
+
usage_manager.restore_usage_state()
|
|
155
|
+
|
|
156
|
+
restored_type = AgentType(conversation.last_agent_model)
|
|
157
|
+
logger.info(f"Conversation restored successfully (mode: {restored_type})")
|
|
158
|
+
return True, None, restored_type
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.exception(f"Failed to restore conversation state: {e}")
|
|
162
|
+
return (
|
|
163
|
+
False,
|
|
164
|
+
"⚠️ Could not restore previous session. Starting fresh conversation.",
|
|
165
|
+
None,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def clear_conversation(self) -> bool:
|
|
169
|
+
"""Clear the saved conversation file.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True if clearing succeeded, False otherwise.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
conversation_path = self.conversation_manager.conversation_path
|
|
176
|
+
if conversation_path.exists():
|
|
177
|
+
conversation_path.unlink()
|
|
178
|
+
logger.info("Conversation file cleared")
|
|
179
|
+
return True
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.exception(f"Failed to clear conversation: {e}")
|
|
182
|
+
return False
|