shotgun-sh 0.2.3.dev2__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 +664 -75
- shotgun/agents/common.py +76 -70
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +78 -36
- shotgun/agents/config/models.py +41 -1
- shotgun/agents/config/provider.py +70 -15
- 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 +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 +49 -11
- 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/models.py +50 -2
- 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 +30 -18
- shotgun/agents/tools/web_search/anthropic.py +26 -5
- shotgun/agents/tools/web_search/gemini.py +23 -11
- shotgun/agents/tools/web_search/openai.py +22 -13
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +1 -1
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +10 -8
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/llm_proxy/__init__.py +5 -2
- shotgun/llm_proxy/clients.py +12 -7
- shotgun/logging_config.py +18 -27
- shotgun/main.py +73 -11
- shotgun/posthog_telemetry.py +23 -7
- 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 +7 -16
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +18 -33
- shotgun/tui/app.py +243 -43
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1202 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +78 -2
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +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 +4 -4
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +49 -24
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +50 -27
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +32 -10
- 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/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dev5.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dev5.dist-info/RECORD +193 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.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.dev5.dist-info}/WHEEL +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
|
@@ -137,6 +137,15 @@ class WelcomeScreen(Screen[None]):
|
|
|
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."""
|
|
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"
|
|
140
149
|
|
|
141
150
|
@on(Button.Pressed, "#shotgun-button")
|
|
142
151
|
def _on_shotgun_pressed(self) -> None:
|
|
@@ -146,21 +155,34 @@ class WelcomeScreen(Screen[None]):
|
|
|
146
155
|
@on(Button.Pressed, "#byok-button")
|
|
147
156
|
def _on_byok_pressed(self) -> None:
|
|
148
157
|
"""Handle BYOK button press."""
|
|
149
|
-
self.
|
|
150
|
-
|
|
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
|
|
151
172
|
from .provider_config import ProviderConfigScreen
|
|
152
173
|
|
|
153
|
-
self.app.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
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()
|
|
157
179
|
|
|
158
180
|
async def _start_shotgun_auth(self) -> None:
|
|
159
181
|
"""Launch Shotgun Account authentication flow."""
|
|
160
182
|
from .shotgun_auth import ShotgunAuthScreen
|
|
161
183
|
|
|
162
184
|
# Mark welcome screen as shown before auth
|
|
163
|
-
self._mark_welcome_shown()
|
|
185
|
+
await self._mark_welcome_shown()
|
|
164
186
|
|
|
165
187
|
# Push the auth screen and wait for result
|
|
166
188
|
await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
@@ -168,9 +190,9 @@ class WelcomeScreen(Screen[None]):
|
|
|
168
190
|
# Dismiss welcome screen after auth
|
|
169
191
|
self.dismiss()
|
|
170
192
|
|
|
171
|
-
def _mark_welcome_shown(self) -> None:
|
|
193
|
+
async def _mark_welcome_shown(self) -> None:
|
|
172
194
|
"""Mark the welcome screen as shown in config."""
|
|
173
195
|
app = cast("ShotgunApp", self.app)
|
|
174
|
-
config = app.config_manager.load()
|
|
196
|
+
config = await app.config_manager.load()
|
|
175
197
|
config.shown_welcome_screen = True
|
|
176
|
-
app.config_manager.save(config)
|
|
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}")
|