shotgun-sh 0.2.11.dev1__py3-none-any.whl → 0.2.11.dev5__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 +150 -27
- shotgun/agents/common.py +14 -8
- shotgun/agents/config/manager.py +64 -33
- shotgun/agents/config/models.py +25 -1
- shotgun/agents/config/provider.py +2 -2
- shotgun/agents/context_analyzer/analyzer.py +2 -24
- shotgun/agents/conversation_manager.py +35 -19
- shotgun/agents/export.py +2 -2
- 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 +8 -0
- 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/file_read.py +5 -2
- shotgun/agents/tools/file_management.py +11 -7
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +2 -2
- shotgun/agents/tools/web_search/gemini.py +1 -1
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/cli/clear.py +2 -1
- shotgun/cli/compact.py +3 -3
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +2 -2
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +4 -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/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 +3 -3
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/logging_config.py +10 -17
- shotgun/main.py +3 -1
- shotgun/posthog_telemetry.py +14 -4
- shotgun/sentry_telemetry.py +3 -1
- shotgun/telemetry.py +3 -1
- shotgun/tui/app.py +71 -65
- shotgun/tui/components/context_indicator.py +43 -0
- shotgun/tui/containers.py +15 -17
- shotgun/tui/dependencies.py +2 -2
- shotgun/tui/screens/chat/chat_screen.py +110 -18
- shotgun/tui/screens/chat/help_text.py +16 -15
- shotgun/tui/screens/chat_screen/command_providers.py +10 -0
- shotgun/tui/screens/feedback.py +4 -4
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +21 -20
- shotgun/tui/screens/onboarding.py +431 -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/conversation_service.py +16 -14
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/widget_coordinator.py +15 -0
- shotgun/utils/file_system_utils.py +19 -0
- shotgun/utils/marketing.py +110 -0
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/METADATA +2 -1
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/RECORD +70 -67
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/licenses/LICENSE +0 -0
|
@@ -97,7 +97,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
97
97
|
"Don't have an API Key? Use these links to get one: [OpenAI](https://platform.openai.com/api-keys) | [Anthropic](https://console.anthropic.com) | [Google Gemini](https://aistudio.google.com)",
|
|
98
98
|
id="provider-links",
|
|
99
99
|
)
|
|
100
|
-
yield ListView(*self.
|
|
100
|
+
yield ListView(*self._build_provider_items_sync(), id="provider-list")
|
|
101
101
|
yield Input(
|
|
102
102
|
placeholder=self._input_placeholder(self.selected_provider),
|
|
103
103
|
password=True,
|
|
@@ -110,8 +110,6 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
110
110
|
yield Button("Done \\[ESC]", id="done")
|
|
111
111
|
|
|
112
112
|
def on_mount(self) -> None:
|
|
113
|
-
self.refresh_provider_status()
|
|
114
|
-
self._update_done_button_visibility()
|
|
115
113
|
list_view = self.query_one(ListView)
|
|
116
114
|
if list_view.children:
|
|
117
115
|
list_view.index = 0
|
|
@@ -121,13 +119,20 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
121
119
|
self.query_one("#authenticate", Button).display = False
|
|
122
120
|
self.set_focus(self.query_one("#api-key", Input))
|
|
123
121
|
|
|
122
|
+
# Refresh UI asynchronously
|
|
123
|
+
self.run_worker(self._refresh_ui(), exclusive=False)
|
|
124
|
+
|
|
124
125
|
def on_screenresume(self) -> None:
|
|
125
126
|
"""Refresh provider status when screen is resumed.
|
|
126
127
|
|
|
127
128
|
This ensures the UI reflects any provider changes made elsewhere.
|
|
128
129
|
"""
|
|
129
|
-
self.
|
|
130
|
-
|
|
130
|
+
self.run_worker(self._refresh_ui(), exclusive=False)
|
|
131
|
+
|
|
132
|
+
async def _refresh_ui(self) -> None:
|
|
133
|
+
"""Refresh provider status and button visibility."""
|
|
134
|
+
await self.refresh_provider_status()
|
|
135
|
+
await self._update_done_button_visibility()
|
|
131
136
|
|
|
132
137
|
def action_done(self) -> None:
|
|
133
138
|
self.dismiss()
|
|
@@ -170,7 +175,11 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
170
175
|
if not self.is_mounted:
|
|
171
176
|
return
|
|
172
177
|
|
|
173
|
-
# Show/hide UI elements based on provider type
|
|
178
|
+
# Show/hide UI elements based on provider type asynchronously
|
|
179
|
+
self.run_worker(self._update_provider_ui(provider), exclusive=False)
|
|
180
|
+
|
|
181
|
+
async def _update_provider_ui(self, provider: ProviderType) -> None:
|
|
182
|
+
"""Update UI elements based on selected provider."""
|
|
174
183
|
is_shotgun = provider == "shotgun"
|
|
175
184
|
|
|
176
185
|
input_widget = self.query_one("#api-key", Input)
|
|
@@ -183,7 +192,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
183
192
|
save_button.display = False
|
|
184
193
|
|
|
185
194
|
# Only show Authenticate button if shotgun is NOT already configured
|
|
186
|
-
if self._has_provider_key("shotgun"):
|
|
195
|
+
if await self._has_provider_key("shotgun"):
|
|
187
196
|
auth_button.display = False
|
|
188
197
|
else:
|
|
189
198
|
auth_button.display = True
|
|
@@ -200,22 +209,29 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
200
209
|
app = cast("ShotgunApp", self.app)
|
|
201
210
|
return app.config_manager
|
|
202
211
|
|
|
203
|
-
def refresh_provider_status(self) -> None:
|
|
212
|
+
async def refresh_provider_status(self) -> None:
|
|
204
213
|
"""Update the list view entries to reflect configured providers."""
|
|
205
214
|
for provider_id in get_configurable_providers():
|
|
206
215
|
label = self.query_one(f"#label-{provider_id}", Label)
|
|
207
|
-
label.update(self._provider_label(provider_id))
|
|
216
|
+
label.update(await self._provider_label(provider_id))
|
|
208
217
|
|
|
209
|
-
def _update_done_button_visibility(self) -> None:
|
|
218
|
+
async def _update_done_button_visibility(self) -> None:
|
|
210
219
|
"""Show/hide Done button based on whether any provider keys are configured."""
|
|
211
220
|
done_button = self.query_one("#done", Button)
|
|
212
|
-
has_keys = self.config_manager.has_any_provider_key()
|
|
221
|
+
has_keys = await self.config_manager.has_any_provider_key()
|
|
213
222
|
done_button.display = has_keys
|
|
214
223
|
|
|
215
|
-
def
|
|
224
|
+
def _build_provider_items_sync(self) -> list[ListItem]:
|
|
225
|
+
"""Build provider items synchronously for compose().
|
|
226
|
+
|
|
227
|
+
Labels will be populated with status asynchronously in on_mount().
|
|
228
|
+
"""
|
|
216
229
|
items: list[ListItem] = []
|
|
217
230
|
for provider_id in get_configurable_providers():
|
|
218
|
-
|
|
231
|
+
# Create labels with placeholder text - will be updated in on_mount()
|
|
232
|
+
label = Label(
|
|
233
|
+
self._provider_display_name(provider_id), id=f"label-{provider_id}"
|
|
234
|
+
)
|
|
219
235
|
items.append(ListItem(label, id=f"provider-{provider_id}"))
|
|
220
236
|
return items
|
|
221
237
|
|
|
@@ -225,11 +241,10 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
225
241
|
provider_id = item.id.removeprefix("provider-")
|
|
226
242
|
return provider_id if provider_id in get_configurable_providers() else None
|
|
227
243
|
|
|
228
|
-
def _provider_label(self, provider_id: str) -> str:
|
|
244
|
+
async def _provider_label(self, provider_id: str) -> str:
|
|
229
245
|
display = self._provider_display_name(provider_id)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
246
|
+
has_key = await self._has_provider_key(provider_id)
|
|
247
|
+
status = "Configured" if has_key else "Not configured"
|
|
233
248
|
return f"{display} · {status}"
|
|
234
249
|
|
|
235
250
|
def _provider_display_name(self, provider_id: str) -> str:
|
|
@@ -244,21 +259,25 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
244
259
|
def _input_placeholder(self, provider_id: str) -> str:
|
|
245
260
|
return f"{self._provider_display_name(provider_id)} API key"
|
|
246
261
|
|
|
247
|
-
def _has_provider_key(self, provider_id: str) -> bool:
|
|
262
|
+
async def _has_provider_key(self, provider_id: str) -> bool:
|
|
248
263
|
"""Check if provider has a configured API key."""
|
|
249
264
|
if provider_id == "shotgun":
|
|
250
265
|
# Check shotgun key directly
|
|
251
|
-
config = self.config_manager.load()
|
|
266
|
+
config = await self.config_manager.load()
|
|
252
267
|
return self.config_manager._provider_has_api_key(config.shotgun)
|
|
253
268
|
else:
|
|
254
269
|
# Check LLM provider key
|
|
255
270
|
try:
|
|
256
271
|
provider = ProviderType(provider_id)
|
|
257
|
-
return self.config_manager.has_provider_key(provider)
|
|
272
|
+
return await self.config_manager.has_provider_key(provider)
|
|
258
273
|
except ValueError:
|
|
259
274
|
return False
|
|
260
275
|
|
|
261
276
|
def _save_api_key(self) -> None:
|
|
277
|
+
self.run_worker(self._do_save_api_key(), exclusive=True)
|
|
278
|
+
|
|
279
|
+
async def _do_save_api_key(self) -> None:
|
|
280
|
+
"""Async implementation of API key saving."""
|
|
262
281
|
input_widget = self.query_one("#api-key", Input)
|
|
263
282
|
api_key = input_widget.value.strip()
|
|
264
283
|
|
|
@@ -267,7 +286,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
267
286
|
return
|
|
268
287
|
|
|
269
288
|
try:
|
|
270
|
-
self.config_manager.update_provider(
|
|
289
|
+
await self.config_manager.update_provider(
|
|
271
290
|
self.selected_provider,
|
|
272
291
|
api_key=api_key,
|
|
273
292
|
)
|
|
@@ -276,21 +295,25 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
276
295
|
return
|
|
277
296
|
|
|
278
297
|
input_widget.value = ""
|
|
279
|
-
self.refresh_provider_status()
|
|
280
|
-
self._update_done_button_visibility()
|
|
298
|
+
await self.refresh_provider_status()
|
|
299
|
+
await self._update_done_button_visibility()
|
|
281
300
|
self.notify(
|
|
282
301
|
f"Saved API key for {self._provider_display_name(self.selected_provider)}."
|
|
283
302
|
)
|
|
284
303
|
|
|
285
304
|
def _clear_api_key(self) -> None:
|
|
305
|
+
self.run_worker(self._do_clear_api_key(), exclusive=True)
|
|
306
|
+
|
|
307
|
+
async def _do_clear_api_key(self) -> None:
|
|
308
|
+
"""Async implementation of API key clearing."""
|
|
286
309
|
try:
|
|
287
|
-
self.config_manager.clear_provider_key(self.selected_provider)
|
|
310
|
+
await self.config_manager.clear_provider_key(self.selected_provider)
|
|
288
311
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
289
312
|
self.notify(f"Failed to clear key: {exc}", severity="error")
|
|
290
313
|
return
|
|
291
314
|
|
|
292
|
-
self.refresh_provider_status()
|
|
293
|
-
self._update_done_button_visibility()
|
|
315
|
+
await self.refresh_provider_status()
|
|
316
|
+
await self._update_done_button_visibility()
|
|
294
317
|
self.query_one("#api-key", Input).value = ""
|
|
295
318
|
|
|
296
319
|
# If we just cleared shotgun, show the Authenticate button
|
|
@@ -311,5 +334,5 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
311
334
|
|
|
312
335
|
# Refresh provider status after auth completes
|
|
313
336
|
if result:
|
|
314
|
-
self.refresh_provider_status()
|
|
337
|
+
await self.refresh_provider_status()
|
|
315
338
|
# Notify handled by auth screen
|
|
@@ -135,7 +135,7 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
135
135
|
"""Start the authentication flow."""
|
|
136
136
|
try:
|
|
137
137
|
# Get shotgun instance ID from config
|
|
138
|
-
shotgun_instance_id = self.config_manager.get_shotgun_instance_id()
|
|
138
|
+
shotgun_instance_id = await self.config_manager.get_shotgun_instance_id()
|
|
139
139
|
logger.info("Starting auth flow with instance ID: %s", shotgun_instance_id)
|
|
140
140
|
|
|
141
141
|
# Update status
|
|
@@ -215,7 +215,7 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
215
215
|
logger.info("Authentication completed successfully")
|
|
216
216
|
|
|
217
217
|
if status_response.litellm_key and status_response.supabase_key:
|
|
218
|
-
self.config_manager.update_shotgun_account(
|
|
218
|
+
await self.config_manager.update_shotgun_account(
|
|
219
219
|
api_key=status_response.litellm_key,
|
|
220
220
|
supabase_jwt=status_response.supabase_key,
|
|
221
221
|
)
|
shotgun/tui/screens/welcome.py
CHANGED
|
@@ -136,14 +136,17 @@ class WelcomeScreen(Screen[None]):
|
|
|
136
136
|
|
|
137
137
|
def on_mount(self) -> None:
|
|
138
138
|
"""Focus the first button on mount."""
|
|
139
|
-
#
|
|
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."""
|
|
140
145
|
byok_button = self.query_one("#byok-button", Button)
|
|
141
146
|
app = cast("ShotgunApp", self.app)
|
|
142
|
-
if app.config_manager.has_any_provider_key():
|
|
147
|
+
if await app.config_manager.has_any_provider_key():
|
|
143
148
|
byok_button.label = "I'll stick with my BYOK setup"
|
|
144
149
|
|
|
145
|
-
self.query_one("#shotgun-button", Button).focus()
|
|
146
|
-
|
|
147
150
|
@on(Button.Pressed, "#shotgun-button")
|
|
148
151
|
def _on_shotgun_pressed(self) -> None:
|
|
149
152
|
"""Handle Shotgun Account button press."""
|
|
@@ -156,12 +159,12 @@ class WelcomeScreen(Screen[None]):
|
|
|
156
159
|
|
|
157
160
|
async def _start_byok_config(self) -> None:
|
|
158
161
|
"""Launch BYOK provider configuration flow."""
|
|
159
|
-
self._mark_welcome_shown()
|
|
162
|
+
await self._mark_welcome_shown()
|
|
160
163
|
|
|
161
164
|
app = cast("ShotgunApp", self.app)
|
|
162
165
|
|
|
163
166
|
# If user already has providers, just dismiss and continue to chat
|
|
164
|
-
if app.config_manager.has_any_provider_key():
|
|
167
|
+
if await app.config_manager.has_any_provider_key():
|
|
165
168
|
self.dismiss()
|
|
166
169
|
return
|
|
167
170
|
|
|
@@ -171,7 +174,7 @@ class WelcomeScreen(Screen[None]):
|
|
|
171
174
|
await self.app.push_screen_wait(ProviderConfigScreen())
|
|
172
175
|
|
|
173
176
|
# Dismiss welcome screen after config if providers are now configured
|
|
174
|
-
if app.config_manager.has_any_provider_key():
|
|
177
|
+
if await app.config_manager.has_any_provider_key():
|
|
175
178
|
self.dismiss()
|
|
176
179
|
|
|
177
180
|
async def _start_shotgun_auth(self) -> None:
|
|
@@ -179,7 +182,7 @@ class WelcomeScreen(Screen[None]):
|
|
|
179
182
|
from .shotgun_auth import ShotgunAuthScreen
|
|
180
183
|
|
|
181
184
|
# Mark welcome screen as shown before auth
|
|
182
|
-
self._mark_welcome_shown()
|
|
185
|
+
await self._mark_welcome_shown()
|
|
183
186
|
|
|
184
187
|
# Push the auth screen and wait for result
|
|
185
188
|
await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
@@ -187,9 +190,9 @@ class WelcomeScreen(Screen[None]):
|
|
|
187
190
|
# Dismiss welcome screen after auth
|
|
188
191
|
self.dismiss()
|
|
189
192
|
|
|
190
|
-
def _mark_welcome_shown(self) -> None:
|
|
193
|
+
async def _mark_welcome_shown(self) -> None:
|
|
191
194
|
"""Mark the welcome screen as shown in config."""
|
|
192
195
|
app = cast("ShotgunApp", self.app)
|
|
193
|
-
config = app.config_manager.load()
|
|
196
|
+
config = await app.config_manager.load()
|
|
194
197
|
config.shown_welcome_screen = True
|
|
195
|
-
app.config_manager.save(config)
|
|
198
|
+
await app.config_manager.save(config)
|
|
@@ -8,6 +8,8 @@ import logging
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import TYPE_CHECKING
|
|
10
10
|
|
|
11
|
+
import aiofiles.os
|
|
12
|
+
|
|
11
13
|
from shotgun.agents.conversation_history import ConversationHistory, ConversationState
|
|
12
14
|
from shotgun.agents.conversation_manager import ConversationManager
|
|
13
15
|
from shotgun.agents.models import AgentType
|
|
@@ -48,7 +50,7 @@ class ConversationService:
|
|
|
48
50
|
else:
|
|
49
51
|
self.conversation_manager = ConversationManager()
|
|
50
52
|
|
|
51
|
-
def save_conversation(self, agent_manager: "AgentManager") -> bool:
|
|
53
|
+
async def save_conversation(self, agent_manager: "AgentManager") -> bool:
|
|
52
54
|
"""Save the current conversation to persistent storage.
|
|
53
55
|
|
|
54
56
|
Args:
|
|
@@ -68,15 +70,15 @@ class ConversationService:
|
|
|
68
70
|
conversation.set_agent_messages(state.agent_messages)
|
|
69
71
|
conversation.set_ui_messages(state.ui_messages)
|
|
70
72
|
|
|
71
|
-
# Save to file
|
|
72
|
-
self.conversation_manager.save(conversation)
|
|
73
|
+
# Save to file (now async)
|
|
74
|
+
await self.conversation_manager.save(conversation)
|
|
73
75
|
logger.debug("Conversation saved successfully")
|
|
74
76
|
return True
|
|
75
77
|
except Exception as e:
|
|
76
78
|
logger.exception(f"Failed to save conversation: {e}")
|
|
77
79
|
return False
|
|
78
80
|
|
|
79
|
-
def load_conversation(self) -> ConversationHistory | None:
|
|
81
|
+
async def load_conversation(self) -> ConversationHistory | None:
|
|
80
82
|
"""Load conversation from persistent storage.
|
|
81
83
|
|
|
82
84
|
Returns:
|
|
@@ -84,7 +86,7 @@ class ConversationService:
|
|
|
84
86
|
or if loading failed.
|
|
85
87
|
"""
|
|
86
88
|
try:
|
|
87
|
-
conversation = self.conversation_manager.load()
|
|
89
|
+
conversation = await self.conversation_manager.load()
|
|
88
90
|
if conversation is None:
|
|
89
91
|
logger.debug("No conversation file found")
|
|
90
92
|
return None
|
|
@@ -95,7 +97,7 @@ class ConversationService:
|
|
|
95
97
|
logger.exception(f"Failed to load conversation: {e}")
|
|
96
98
|
return None
|
|
97
99
|
|
|
98
|
-
def check_for_corrupted_conversation(self) -> bool:
|
|
100
|
+
async def check_for_corrupted_conversation(self) -> bool:
|
|
99
101
|
"""Check if a conversation backup exists (indicating corruption).
|
|
100
102
|
|
|
101
103
|
Returns:
|
|
@@ -104,9 +106,9 @@ class ConversationService:
|
|
|
104
106
|
backup_path = self.conversation_manager.conversation_path.with_suffix(
|
|
105
107
|
".json.backup"
|
|
106
108
|
)
|
|
107
|
-
return
|
|
109
|
+
return await aiofiles.os.path.exists(str(backup_path))
|
|
108
110
|
|
|
109
|
-
def restore_conversation(
|
|
111
|
+
async def restore_conversation(
|
|
110
112
|
self,
|
|
111
113
|
agent_manager: "AgentManager",
|
|
112
114
|
usage_manager: "SessionUsageManager | None" = None,
|
|
@@ -123,11 +125,11 @@ class ConversationService:
|
|
|
123
125
|
- error_message: Error message if restoration failed, None otherwise
|
|
124
126
|
- restored_agent_type: The agent type from restored conversation
|
|
125
127
|
"""
|
|
126
|
-
conversation = self.load_conversation()
|
|
128
|
+
conversation = await self.load_conversation()
|
|
127
129
|
|
|
128
130
|
if conversation is None:
|
|
129
131
|
# Check for corruption
|
|
130
|
-
if self.check_for_corrupted_conversation():
|
|
132
|
+
if await self.check_for_corrupted_conversation():
|
|
131
133
|
return (
|
|
132
134
|
False,
|
|
133
135
|
"⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation.",
|
|
@@ -151,7 +153,7 @@ class ConversationService:
|
|
|
151
153
|
|
|
152
154
|
# Restore usage state if manager provided
|
|
153
155
|
if usage_manager:
|
|
154
|
-
usage_manager.restore_usage_state()
|
|
156
|
+
await usage_manager.restore_usage_state()
|
|
155
157
|
|
|
156
158
|
restored_type = AgentType(conversation.last_agent_model)
|
|
157
159
|
logger.info(f"Conversation restored successfully (mode: {restored_type})")
|
|
@@ -165,7 +167,7 @@ class ConversationService:
|
|
|
165
167
|
None,
|
|
166
168
|
)
|
|
167
169
|
|
|
168
|
-
def clear_conversation(self) -> bool:
|
|
170
|
+
async def clear_conversation(self) -> bool:
|
|
169
171
|
"""Clear the saved conversation file.
|
|
170
172
|
|
|
171
173
|
Returns:
|
|
@@ -173,8 +175,8 @@ class ConversationService:
|
|
|
173
175
|
"""
|
|
174
176
|
try:
|
|
175
177
|
conversation_path = self.conversation_manager.conversation_path
|
|
176
|
-
if
|
|
177
|
-
|
|
178
|
+
if await aiofiles.os.path.exists(str(conversation_path)):
|
|
179
|
+
await aiofiles.os.unlink(str(conversation_path))
|
|
178
180
|
logger.info("Conversation file cleared")
|
|
179
181
|
return True
|
|
180
182
|
except Exception as e:
|
|
@@ -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]
|
|
@@ -245,3 +245,18 @@ class WidgetCoordinator:
|
|
|
245
245
|
spinner.text = text
|
|
246
246
|
except Exception as e:
|
|
247
247
|
logger.exception(f"Failed to update spinner text: {e}")
|
|
248
|
+
|
|
249
|
+
def set_context_streaming(self, streaming: bool) -> None:
|
|
250
|
+
"""Enable or disable context indicator streaming animation.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
streaming: Whether to show streaming animation.
|
|
254
|
+
"""
|
|
255
|
+
if not self.screen.is_mounted:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
context_indicator = self.screen.query_one(ContextIndicator)
|
|
260
|
+
context_indicator.set_streaming(streaming)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.exception(f"Failed to set context streaming: {e}")
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
+
import aiofiles
|
|
6
|
+
|
|
5
7
|
from shotgun.settings import settings
|
|
6
8
|
|
|
7
9
|
|
|
@@ -35,3 +37,20 @@ def ensure_shotgun_directory_exists() -> Path:
|
|
|
35
37
|
shotgun_dir.mkdir(exist_ok=True)
|
|
36
38
|
# Note: Removed logger to avoid circular dependency with logging_config
|
|
37
39
|
return shotgun_dir
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def async_copy_file(src: Path, dst: Path) -> None:
|
|
43
|
+
"""Asynchronously copy a file from src to dst.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
src: Source file path
|
|
47
|
+
dst: Destination file path
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
FileNotFoundError: If source file doesn't exist
|
|
51
|
+
OSError: If copy operation fails
|
|
52
|
+
"""
|
|
53
|
+
async with aiofiles.open(src, "rb") as src_file:
|
|
54
|
+
content = await src_file.read()
|
|
55
|
+
async with aiofiles.open(dst, "wb") as dst_file:
|
|
56
|
+
await dst_file.write(content)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Marketing message management for Shotgun CLI."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shotgun.agents.config.models import MarketingConfig, MarketingMessageRecord
|
|
9
|
+
from shotgun.agents.models import FileOperation
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from shotgun.agents.config.manager import ConfigManager
|
|
13
|
+
|
|
14
|
+
# Marketing message IDs
|
|
15
|
+
GITHUB_STAR_MESSAGE_ID = "github_star_v1"
|
|
16
|
+
|
|
17
|
+
# Spec files that trigger the GitHub star message
|
|
18
|
+
SPEC_FILES = {"research.md", "specification.md", "plan.md", "tasks.md"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MarketingManager:
|
|
22
|
+
"""Manages marketing messages shown to users."""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def should_show_github_star_message(
|
|
26
|
+
marketing_config: MarketingConfig, file_operations: list[FileOperation]
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""
|
|
29
|
+
Check if the GitHub star message should be shown.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
marketing_config: Current marketing configuration
|
|
33
|
+
file_operations: List of file operations from the current agent run
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if message should be shown, False otherwise
|
|
37
|
+
"""
|
|
38
|
+
# Check if message has already been shown
|
|
39
|
+
if GITHUB_STAR_MESSAGE_ID in marketing_config.messages:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
# Check if any spec file was written
|
|
43
|
+
for operation in file_operations:
|
|
44
|
+
# operation.file_path is a string, so we convert to Path to get the filename
|
|
45
|
+
file_name = Path(operation.file_path).name
|
|
46
|
+
if file_name in SPEC_FILES:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def mark_message_shown(
|
|
53
|
+
marketing_config: MarketingConfig, message_id: str
|
|
54
|
+
) -> MarketingConfig:
|
|
55
|
+
"""
|
|
56
|
+
Mark a marketing message as shown.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
marketing_config: Current marketing configuration
|
|
60
|
+
message_id: ID of the message to mark as shown
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Updated marketing configuration
|
|
64
|
+
"""
|
|
65
|
+
# Create a new record with current timestamp
|
|
66
|
+
record = MarketingMessageRecord(shown_at=datetime.now(timezone.utc))
|
|
67
|
+
|
|
68
|
+
# Update the messages dict
|
|
69
|
+
marketing_config.messages[message_id] = record
|
|
70
|
+
|
|
71
|
+
return marketing_config
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def get_github_star_message() -> str:
|
|
75
|
+
"""Get the GitHub star marketing message text."""
|
|
76
|
+
return "⭐ Enjoying Shotgun? Star us on GitHub: https://github.com/shotgun-sh/shotgun"
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
async def check_and_display_messages(
|
|
80
|
+
config_manager: "ConfigManager",
|
|
81
|
+
file_operations: list[FileOperation],
|
|
82
|
+
display_callback: Callable[[str], None],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Check if any marketing messages should be shown and display them.
|
|
86
|
+
|
|
87
|
+
This is the main entry point for marketing message handling. It checks
|
|
88
|
+
all configured messages, displays them if appropriate, and updates the
|
|
89
|
+
config to mark them as shown.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
config_manager: Config manager to load/save configuration
|
|
93
|
+
file_operations: List of file operations from the current agent run
|
|
94
|
+
display_callback: Callback function to display messages to the user
|
|
95
|
+
"""
|
|
96
|
+
config = await config_manager.load()
|
|
97
|
+
|
|
98
|
+
# Check GitHub star message
|
|
99
|
+
if MarketingManager.should_show_github_star_message(
|
|
100
|
+
config.marketing, file_operations
|
|
101
|
+
):
|
|
102
|
+
# Display the message
|
|
103
|
+
message = MarketingManager.get_github_star_message()
|
|
104
|
+
display_callback(message)
|
|
105
|
+
|
|
106
|
+
# Mark as shown and save
|
|
107
|
+
MarketingManager.mark_message_shown(
|
|
108
|
+
config.marketing, GITHUB_STAR_MESSAGE_ID
|
|
109
|
+
)
|
|
110
|
+
await config_manager.save(config)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shotgun-sh
|
|
3
|
-
Version: 0.2.11.
|
|
3
|
+
Version: 0.2.11.dev5
|
|
4
4
|
Summary: AI-powered research, planning, and task management CLI tool
|
|
5
5
|
Project-URL: Homepage, https://shotgun.sh/
|
|
6
6
|
Project-URL: Repository, https://github.com/shotgun-sh/shotgun
|
|
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
22
|
Classifier: Topic :: Utilities
|
|
23
23
|
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: aiofiles>=24.0.0
|
|
24
25
|
Requires-Dist: anthropic>=0.39.0
|
|
25
26
|
Requires-Dist: dependency-injector>=4.41.0
|
|
26
27
|
Requires-Dist: genai-prices>=0.0.27
|