shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__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 +761 -52
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- 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 +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +23 -3
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +179 -11
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- 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 +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 +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/codebase/commands.py +71 -2
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- 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 +18 -5
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +169 -19
- shotgun/codebase/core/manager.py +177 -13
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +28 -3
- shotgun/codebase/service.py +14 -2
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -4
- shotgun/posthog_telemetry.py +87 -40
- 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/codebase/partials/cypher_rules.j2 +13 -0
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sdk/codebase.py +60 -2
- shotgun/sentry_telemetry.py +28 -21
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +275 -23
- 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/components/vertical_tail.py +6 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/filtered_codebase_service.py +46 -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 +1234 -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 +226 -11
- 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/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- 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 +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/source_detection.py +16 -0
- shotgun/utils/update_checker.py +73 -21
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -818
- shotgun/tui/screens/chat_screen/history.py +0 -222
- shotgun_sh-0.1.9.dist-info/METADATA +0 -466
- shotgun_sh-0.1.9.dist-info/RECORD +0 -131
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Welcome screen for choosing between Shotgun Account and BYOK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
10
|
+
from textual.screen import Screen
|
|
11
|
+
from textual.widgets import Button, Markdown, Static
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..app import ShotgunApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WelcomeScreen(Screen[None]):
|
|
18
|
+
"""Welcome screen for first-time setup."""
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
WelcomeScreen {
|
|
22
|
+
layout: vertical;
|
|
23
|
+
align: center middle;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#titlebox {
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: auto;
|
|
29
|
+
margin: 2 0;
|
|
30
|
+
padding: 1;
|
|
31
|
+
border: hkey $border;
|
|
32
|
+
content-align: center middle;
|
|
33
|
+
|
|
34
|
+
& > * {
|
|
35
|
+
text-align: center;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#welcome-title {
|
|
40
|
+
padding: 1 0;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
color: $text-accent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#welcome-subtitle {
|
|
46
|
+
padding: 0 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#options-container {
|
|
50
|
+
width: 100%;
|
|
51
|
+
height: auto;
|
|
52
|
+
padding: 2;
|
|
53
|
+
align: center middle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#options {
|
|
57
|
+
width: auto;
|
|
58
|
+
height: auto;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.option-box {
|
|
62
|
+
width: 45;
|
|
63
|
+
height: auto;
|
|
64
|
+
border: solid $primary;
|
|
65
|
+
padding: 2;
|
|
66
|
+
margin: 0 1;
|
|
67
|
+
background: $surface;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.option-box:focus-within {
|
|
71
|
+
border: solid $accent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.option-title {
|
|
75
|
+
text-style: bold;
|
|
76
|
+
color: $text-accent;
|
|
77
|
+
padding: 0 0 1 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.option-benefits {
|
|
81
|
+
padding: 1 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.option-button {
|
|
85
|
+
margin: 1 0 0 0;
|
|
86
|
+
width: 100%;
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
BINDINGS = [
|
|
91
|
+
("ctrl+c", "app.quit", "Quit"),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def compose(self) -> ComposeResult:
|
|
95
|
+
with Vertical(id="titlebox"):
|
|
96
|
+
yield Static("Welcome to Shotgun", id="welcome-title")
|
|
97
|
+
yield Static(
|
|
98
|
+
"Choose how you'd like to get started",
|
|
99
|
+
id="welcome-subtitle",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
with Container(id="options-container"):
|
|
103
|
+
with Horizontal(id="options"):
|
|
104
|
+
# Left box - Shotgun Account
|
|
105
|
+
with Vertical(classes="option-box", id="shotgun-box"):
|
|
106
|
+
yield Static("Use a Shotgun Account", classes="option-title")
|
|
107
|
+
yield Markdown(
|
|
108
|
+
"**Benefits:**\n"
|
|
109
|
+
"• Use of all models in the Model Garden\n"
|
|
110
|
+
"• We'll pick the optimal models to give you the best "
|
|
111
|
+
"experience for things like web search, codebase indexing",
|
|
112
|
+
classes="option-benefits",
|
|
113
|
+
)
|
|
114
|
+
yield Button(
|
|
115
|
+
"Sign Up for/Use your Shotgun Account",
|
|
116
|
+
variant="primary",
|
|
117
|
+
id="shotgun-button",
|
|
118
|
+
classes="option-button",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Right box - BYOK
|
|
122
|
+
with Vertical(classes="option-box", id="byok-box"):
|
|
123
|
+
yield Static("Bring Your Own Key (BYOK)", classes="option-title")
|
|
124
|
+
yield Markdown(
|
|
125
|
+
"**Benefits:**\n"
|
|
126
|
+
"• 100% Supported by the application\n"
|
|
127
|
+
"• Use your existing API keys from OpenAI, Anthropic, or Google",
|
|
128
|
+
classes="option-benefits",
|
|
129
|
+
)
|
|
130
|
+
yield Button(
|
|
131
|
+
"Configure API Keys",
|
|
132
|
+
variant="success",
|
|
133
|
+
id="byok-button",
|
|
134
|
+
classes="option-button",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def on_mount(self) -> None:
|
|
138
|
+
"""Focus the first button on mount."""
|
|
139
|
+
self.query_one("#shotgun-button", Button).focus()
|
|
140
|
+
# Update BYOK button text asynchronously
|
|
141
|
+
self.run_worker(self._update_byok_button_text(), exclusive=False)
|
|
142
|
+
|
|
143
|
+
async def _update_byok_button_text(self) -> None:
|
|
144
|
+
"""Update BYOK button text based on whether user has existing providers."""
|
|
145
|
+
byok_button = self.query_one("#byok-button", Button)
|
|
146
|
+
app = cast("ShotgunApp", self.app)
|
|
147
|
+
if await app.config_manager.has_any_provider_key():
|
|
148
|
+
byok_button.label = "I'll stick with my BYOK setup"
|
|
149
|
+
|
|
150
|
+
@on(Button.Pressed, "#shotgun-button")
|
|
151
|
+
def _on_shotgun_pressed(self) -> None:
|
|
152
|
+
"""Handle Shotgun Account button press."""
|
|
153
|
+
self.run_worker(self._start_shotgun_auth(), exclusive=True)
|
|
154
|
+
|
|
155
|
+
@on(Button.Pressed, "#byok-button")
|
|
156
|
+
def _on_byok_pressed(self) -> None:
|
|
157
|
+
"""Handle BYOK button press."""
|
|
158
|
+
self.run_worker(self._start_byok_config(), exclusive=True)
|
|
159
|
+
|
|
160
|
+
async def _start_byok_config(self) -> None:
|
|
161
|
+
"""Launch BYOK provider configuration flow."""
|
|
162
|
+
await self._mark_welcome_shown()
|
|
163
|
+
|
|
164
|
+
app = cast("ShotgunApp", self.app)
|
|
165
|
+
|
|
166
|
+
# If user already has providers, just dismiss and continue to chat
|
|
167
|
+
if await app.config_manager.has_any_provider_key():
|
|
168
|
+
self.dismiss()
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Otherwise, push provider config screen and wait for result
|
|
172
|
+
from .provider_config import ProviderConfigScreen
|
|
173
|
+
|
|
174
|
+
await self.app.push_screen_wait(ProviderConfigScreen())
|
|
175
|
+
|
|
176
|
+
# Dismiss welcome screen after config if providers are now configured
|
|
177
|
+
if await app.config_manager.has_any_provider_key():
|
|
178
|
+
self.dismiss()
|
|
179
|
+
|
|
180
|
+
async def _start_shotgun_auth(self) -> None:
|
|
181
|
+
"""Launch Shotgun Account authentication flow."""
|
|
182
|
+
from .shotgun_auth import ShotgunAuthScreen
|
|
183
|
+
|
|
184
|
+
# Mark welcome screen as shown before auth
|
|
185
|
+
await self._mark_welcome_shown()
|
|
186
|
+
|
|
187
|
+
# Push the auth screen and wait for result
|
|
188
|
+
await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
189
|
+
|
|
190
|
+
# Dismiss welcome screen after auth
|
|
191
|
+
self.dismiss()
|
|
192
|
+
|
|
193
|
+
async def _mark_welcome_shown(self) -> None:
|
|
194
|
+
"""Mark the welcome screen as shown in config."""
|
|
195
|
+
app = cast("ShotgunApp", self.app)
|
|
196
|
+
config = await app.config_manager.load()
|
|
197
|
+
config.shown_welcome_screen = True
|
|
198
|
+
await app.config_manager.save(config)
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
import aiofiles.os
|
|
12
|
+
|
|
13
|
+
from shotgun.agents.conversation_history import ConversationHistory, ConversationState
|
|
14
|
+
from shotgun.agents.conversation_manager import ConversationManager
|
|
15
|
+
from shotgun.agents.models import AgentType
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from shotgun.agents.agent_manager import AgentManager
|
|
19
|
+
from shotgun.agents.usage_manager import SessionUsageManager
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConversationService:
|
|
25
|
+
"""Handles conversation persistence and restoration.
|
|
26
|
+
|
|
27
|
+
This service provides:
|
|
28
|
+
- Save current conversation to disk
|
|
29
|
+
- Load conversation from disk
|
|
30
|
+
- Restore conversation state to agent manager
|
|
31
|
+
- Handle corrupted conversations gracefully
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
conversation_manager: ConversationManager | None = None,
|
|
37
|
+
conversation_path: Path | None = None,
|
|
38
|
+
):
|
|
39
|
+
"""Initialize the conversation service.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
conversation_manager: Optional conversation manager. If not provided,
|
|
43
|
+
creates a default one.
|
|
44
|
+
conversation_path: Optional custom path for conversation storage.
|
|
45
|
+
"""
|
|
46
|
+
if conversation_manager:
|
|
47
|
+
self.conversation_manager = conversation_manager
|
|
48
|
+
elif conversation_path:
|
|
49
|
+
self.conversation_manager = ConversationManager(conversation_path)
|
|
50
|
+
else:
|
|
51
|
+
self.conversation_manager = ConversationManager()
|
|
52
|
+
|
|
53
|
+
async def save_conversation(self, agent_manager: "AgentManager") -> bool:
|
|
54
|
+
"""Save the current conversation to persistent storage.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
agent_manager: The agent manager containing conversation state.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if save was successful, False otherwise.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Get conversation state from agent manager
|
|
64
|
+
state = agent_manager.get_conversation_state()
|
|
65
|
+
|
|
66
|
+
# Create conversation history object
|
|
67
|
+
conversation = ConversationHistory(
|
|
68
|
+
last_agent_model=state.agent_type,
|
|
69
|
+
)
|
|
70
|
+
conversation.set_agent_messages(state.agent_messages)
|
|
71
|
+
conversation.set_ui_messages(state.ui_messages)
|
|
72
|
+
|
|
73
|
+
# Save to file (now async)
|
|
74
|
+
await self.conversation_manager.save(conversation)
|
|
75
|
+
logger.debug("Conversation saved successfully")
|
|
76
|
+
return True
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.exception(f"Failed to save conversation: {e}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
async def load_conversation(self) -> ConversationHistory | None:
|
|
82
|
+
"""Load conversation from persistent storage.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The loaded conversation history, or None if no conversation exists
|
|
86
|
+
or if loading failed.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
conversation = await self.conversation_manager.load()
|
|
90
|
+
if conversation is None:
|
|
91
|
+
logger.debug("No conversation file found")
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
logger.debug("Conversation loaded successfully")
|
|
95
|
+
return conversation
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.exception(f"Failed to load conversation: {e}")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
async def check_for_corrupted_conversation(self) -> bool:
|
|
101
|
+
"""Check if a conversation backup exists (indicating corruption).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if a backup exists (conversation was corrupted), False otherwise.
|
|
105
|
+
"""
|
|
106
|
+
backup_path = self.conversation_manager.conversation_path.with_suffix(
|
|
107
|
+
".json.backup"
|
|
108
|
+
)
|
|
109
|
+
return await aiofiles.os.path.exists(str(backup_path))
|
|
110
|
+
|
|
111
|
+
async def restore_conversation(
|
|
112
|
+
self,
|
|
113
|
+
agent_manager: "AgentManager",
|
|
114
|
+
usage_manager: "SessionUsageManager | None" = None,
|
|
115
|
+
) -> tuple[bool, str | None, AgentType | None]:
|
|
116
|
+
"""Restore conversation state from disk.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
agent_manager: The agent manager to restore state to.
|
|
120
|
+
usage_manager: Optional usage manager to restore usage state.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tuple of (success, error_message, restored_agent_type)
|
|
124
|
+
- success: True if restoration succeeded
|
|
125
|
+
- error_message: Error message if restoration failed, None otherwise
|
|
126
|
+
- restored_agent_type: The agent type from restored conversation
|
|
127
|
+
"""
|
|
128
|
+
conversation = await self.load_conversation()
|
|
129
|
+
|
|
130
|
+
if conversation is None:
|
|
131
|
+
# Check for corruption
|
|
132
|
+
if await self.check_for_corrupted_conversation():
|
|
133
|
+
return (
|
|
134
|
+
False,
|
|
135
|
+
"⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation.",
|
|
136
|
+
None,
|
|
137
|
+
)
|
|
138
|
+
return True, None, None # No conversation to restore is not an error
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# Restore agent state
|
|
142
|
+
agent_messages = conversation.get_agent_messages()
|
|
143
|
+
ui_messages = conversation.get_ui_messages()
|
|
144
|
+
|
|
145
|
+
# Create ConversationState for restoration
|
|
146
|
+
state = ConversationState(
|
|
147
|
+
agent_messages=agent_messages,
|
|
148
|
+
ui_messages=ui_messages,
|
|
149
|
+
agent_type=conversation.last_agent_model,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
agent_manager.restore_conversation_state(state)
|
|
153
|
+
|
|
154
|
+
# Restore usage state if manager provided
|
|
155
|
+
if usage_manager:
|
|
156
|
+
await usage_manager.restore_usage_state()
|
|
157
|
+
|
|
158
|
+
restored_type = AgentType(conversation.last_agent_model)
|
|
159
|
+
logger.info(f"Conversation restored successfully (mode: {restored_type})")
|
|
160
|
+
return True, None, restored_type
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.exception(f"Failed to restore conversation state: {e}")
|
|
164
|
+
return (
|
|
165
|
+
False,
|
|
166
|
+
"⚠️ Could not restore previous session. Starting fresh conversation.",
|
|
167
|
+
None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def clear_conversation(self) -> bool:
|
|
171
|
+
"""Clear the saved conversation file.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
True if clearing succeeded, False otherwise.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
conversation_path = self.conversation_manager.conversation_path
|
|
178
|
+
if await aiofiles.os.path.exists(str(conversation_path)):
|
|
179
|
+
await aiofiles.os.unlink(str(conversation_path))
|
|
180
|
+
logger.info("Conversation file cleared")
|
|
181
|
+
return True
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.exception(f"Failed to clear conversation: {e}")
|
|
184
|
+
return False
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Processing state management for TUI operations.
|
|
2
|
+
|
|
3
|
+
This module provides centralized management of processing state including:
|
|
4
|
+
- Tracking whether operations are in progress
|
|
5
|
+
- Managing worker references for cancellation
|
|
6
|
+
- Coordinating spinner widget updates
|
|
7
|
+
- Providing clean cancellation API
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from shotgun.logging_config import get_logger
|
|
13
|
+
from shotgun.posthog_telemetry import track_event
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from textual.screen import Screen
|
|
17
|
+
from textual.worker import Worker
|
|
18
|
+
|
|
19
|
+
from shotgun.tui.components.spinner import Spinner
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProcessingStateManager:
|
|
25
|
+
"""Manages processing state and spinner coordination for async operations.
|
|
26
|
+
|
|
27
|
+
This class centralizes the logic for tracking whether the TUI is processing
|
|
28
|
+
an operation, managing the current worker for cancellation, and updating
|
|
29
|
+
spinner text.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
```python
|
|
33
|
+
# In ChatScreen
|
|
34
|
+
self.processing_state = ProcessingStateManager(self)
|
|
35
|
+
|
|
36
|
+
# Start processing
|
|
37
|
+
@work
|
|
38
|
+
async def some_operation(self) -> None:
|
|
39
|
+
self.processing_state.start_processing("Doing work...")
|
|
40
|
+
self.processing_state.bind_worker(get_current_worker())
|
|
41
|
+
try:
|
|
42
|
+
# ... do work ...
|
|
43
|
+
finally:
|
|
44
|
+
self.processing_state.stop_processing()
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self, screen: "Screen[Any]", telemetry_context: dict[str, Any] | None = None
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize the processing state manager.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
screen: The Textual screen this manager is attached to
|
|
55
|
+
telemetry_context: Optional context to include in telemetry events
|
|
56
|
+
(e.g., {"agent_mode": "research"})
|
|
57
|
+
"""
|
|
58
|
+
self.screen = screen
|
|
59
|
+
self._working = False
|
|
60
|
+
self._current_worker: Worker[Any] | None = None
|
|
61
|
+
self._spinner_widget: Spinner | None = None
|
|
62
|
+
self._default_spinner_text = "Processing..."
|
|
63
|
+
self._telemetry_context = telemetry_context or {}
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_working(self) -> bool:
|
|
67
|
+
"""Check if an operation is currently in progress.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if processing, False if idle
|
|
71
|
+
"""
|
|
72
|
+
return self._working
|
|
73
|
+
|
|
74
|
+
def bind_spinner(self, spinner: "Spinner") -> None:
|
|
75
|
+
"""Bind a spinner widget for state coordination.
|
|
76
|
+
|
|
77
|
+
Should be called during screen mount after the spinner widget is available.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
spinner: The Spinner widget to coordinate with
|
|
81
|
+
"""
|
|
82
|
+
self._spinner_widget = spinner
|
|
83
|
+
logger.debug(f"Spinner widget bound: {spinner}")
|
|
84
|
+
|
|
85
|
+
def start_processing(self, spinner_text: str | None = None) -> None:
|
|
86
|
+
"""Start processing state with optional custom spinner text.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
spinner_text: Custom text to display in spinner. If None, uses default.
|
|
90
|
+
"""
|
|
91
|
+
if self._working:
|
|
92
|
+
logger.warning("Attempted to start processing while already processing")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
self._working = True
|
|
96
|
+
text = spinner_text or self._default_spinner_text
|
|
97
|
+
|
|
98
|
+
# Update screen's reactive working state
|
|
99
|
+
if hasattr(self.screen, "working"):
|
|
100
|
+
self.screen.working = True
|
|
101
|
+
|
|
102
|
+
if self._spinner_widget:
|
|
103
|
+
self._spinner_widget.text = text
|
|
104
|
+
logger.debug(f"Processing started with spinner text: {text}")
|
|
105
|
+
else:
|
|
106
|
+
logger.warning("Processing started but no spinner widget bound")
|
|
107
|
+
|
|
108
|
+
def stop_processing(self) -> None:
|
|
109
|
+
"""Stop processing state and reset to default."""
|
|
110
|
+
if not self._working:
|
|
111
|
+
logger.debug("stop_processing called when not working (no-op)")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
self._working = False
|
|
115
|
+
self._current_worker = None
|
|
116
|
+
|
|
117
|
+
# Update screen's reactive working state
|
|
118
|
+
if hasattr(self.screen, "working"):
|
|
119
|
+
self.screen.working = False
|
|
120
|
+
|
|
121
|
+
# Reset spinner to default text
|
|
122
|
+
if self._spinner_widget:
|
|
123
|
+
self._spinner_widget.text = self._default_spinner_text
|
|
124
|
+
logger.debug("Processing stopped, spinner reset to default")
|
|
125
|
+
|
|
126
|
+
def bind_worker(self, worker: "Worker[Any]") -> None:
|
|
127
|
+
"""Bind a worker for cancellation tracking.
|
|
128
|
+
|
|
129
|
+
Should be called immediately after starting a @work decorated method
|
|
130
|
+
using get_current_worker().
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
worker: The Worker instance to track for cancellation
|
|
134
|
+
"""
|
|
135
|
+
self._current_worker = worker
|
|
136
|
+
logger.debug(f"Worker bound: {worker}")
|
|
137
|
+
|
|
138
|
+
def cancel_current_operation(self, cancel_key: str | None = None) -> bool:
|
|
139
|
+
"""Attempt to cancel the current operation if one is running.
|
|
140
|
+
|
|
141
|
+
Automatically tracks cancellation telemetry with context from initialization.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
cancel_key: Optional key that triggered cancellation (e.g., "Escape")
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if an operation was cancelled, False if no operation was running
|
|
148
|
+
"""
|
|
149
|
+
if not self._working or not self._current_worker:
|
|
150
|
+
logger.debug("No operation to cancel")
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
self._current_worker.cancel()
|
|
155
|
+
logger.info("Operation cancelled successfully")
|
|
156
|
+
|
|
157
|
+
# Track cancellation event with context
|
|
158
|
+
event_data = {**self._telemetry_context}
|
|
159
|
+
if cancel_key:
|
|
160
|
+
event_data["cancel_key"] = cancel_key
|
|
161
|
+
|
|
162
|
+
track_event("agent_cancelled", event_data)
|
|
163
|
+
|
|
164
|
+
return True
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.error(f"Failed to cancel operation: {e}", exc_info=True)
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def update_spinner_text(self, text: str) -> None:
|
|
170
|
+
"""Update spinner text during processing.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
text: New text to display in spinner
|
|
174
|
+
"""
|
|
175
|
+
if not self._working:
|
|
176
|
+
logger.warning(
|
|
177
|
+
f"Attempted to update spinner text while not working: {text}"
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if self._spinner_widget:
|
|
182
|
+
self._spinner_widget.text = text
|
|
183
|
+
logger.debug(f"Spinner text updated to: {text}")
|
|
184
|
+
else:
|
|
185
|
+
logger.warning(f"Cannot update spinner text, widget not bound: {text}")
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import random
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
import aiofiles
|
|
7
|
+
|
|
6
8
|
from shotgun.agents.models import AgentType
|
|
7
9
|
from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
8
10
|
|
|
@@ -30,7 +32,7 @@ class ModeProgressChecker:
|
|
|
30
32
|
"""
|
|
31
33
|
self.base_path = base_path or get_shotgun_base_path()
|
|
32
34
|
|
|
33
|
-
def has_mode_content(self, mode: AgentType) -> bool:
|
|
35
|
+
async def has_mode_content(self, mode: AgentType) -> bool:
|
|
34
36
|
"""Check if a mode has meaningful content.
|
|
35
37
|
|
|
36
38
|
Args:
|
|
@@ -52,7 +54,8 @@ class ModeProgressChecker:
|
|
|
52
54
|
for item in export_path.glob("*"):
|
|
53
55
|
if item.is_file() and not item.name.startswith("."):
|
|
54
56
|
try:
|
|
55
|
-
|
|
57
|
+
async with aiofiles.open(item, encoding="utf-8") as f:
|
|
58
|
+
content = await f.read()
|
|
56
59
|
if len(content.strip()) > self.MIN_CONTENT_SIZE:
|
|
57
60
|
return True
|
|
58
61
|
except (OSError, UnicodeDecodeError):
|
|
@@ -65,13 +68,16 @@ class ModeProgressChecker:
|
|
|
65
68
|
return False
|
|
66
69
|
|
|
67
70
|
try:
|
|
68
|
-
|
|
71
|
+
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
72
|
+
content = await f.read()
|
|
69
73
|
# Check if file has meaningful content
|
|
70
74
|
return len(content.strip()) > self.MIN_CONTENT_SIZE
|
|
71
75
|
except (OSError, UnicodeDecodeError):
|
|
72
76
|
return False
|
|
73
77
|
|
|
74
|
-
def get_next_suggested_mode(
|
|
78
|
+
async def get_next_suggested_mode(
|
|
79
|
+
self, current_mode: AgentType
|
|
80
|
+
) -> AgentType | None:
|
|
75
81
|
"""Get the next suggested mode based on current progress.
|
|
76
82
|
|
|
77
83
|
Args:
|
|
@@ -94,7 +100,7 @@ class ModeProgressChecker:
|
|
|
94
100
|
return None
|
|
95
101
|
|
|
96
102
|
# Check if current mode has content
|
|
97
|
-
if not self.has_mode_content(current_mode):
|
|
103
|
+
if not await self.has_mode_content(current_mode):
|
|
98
104
|
# Current mode is empty, no suggestion for next mode
|
|
99
105
|
return None
|
|
100
106
|
|
|
@@ -222,8 +228,9 @@ class PlaceholderHints:
|
|
|
222
228
|
if current_mode not in self.HINTS:
|
|
223
229
|
return f"Enter your {current_mode.value} mode prompt (SHIFT+TAB to switch modes)"
|
|
224
230
|
|
|
225
|
-
#
|
|
226
|
-
|
|
231
|
+
# For placeholder text, we default to "no content" state (initial hints)
|
|
232
|
+
# This avoids async file system checks in the UI rendering path
|
|
233
|
+
has_content = False
|
|
227
234
|
|
|
228
235
|
# Get hint variations for this mode and state
|
|
229
236
|
hints_list = self.HINTS[current_mode][has_content]
|