shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shotgun/agents/agent_manager.py +382 -60
- shotgun/agents/common.py +15 -9
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +383 -82
- shotgun/agents/config/models.py +122 -18
- shotgun/agents/config/provider.py +81 -15
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +475 -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/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +36 -5
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
- shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
- shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
- shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +2 -2
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +27 -7
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +8 -2
- shotgun/agents/tools/web_search/gemini.py +7 -1
- shotgun/agents/tools/web_search/openai.py +8 -2
- 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 +2 -2
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +188 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +154 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +18 -10
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- 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 +163 -15
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +357 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +60 -27
- shotgun/main.py +77 -11
- shotgun/posthog_telemetry.py +38 -29
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +243 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +310 -46
- 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/layout.py +5 -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 +1531 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -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 +91 -4
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +191 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +14 -7
- shotgun/tui/screens/github_issue.py +111 -0
- shotgun/tui/screens/model_picker.py +77 -32
- shotgun/tui/screens/onboarding.py +580 -0
- shotgun/tui/screens/pipx_migration.py +205 -0
- shotgun/tui/screens/provider_config.py +116 -35
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +112 -18
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +137 -11
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +187 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +263 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/tui/screens/chat.py +0 -996
- shotgun/tui/screens/chat_screen/history.py +0 -335
- shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
- shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
shotgun/tui/screens/welcome.py
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import webbrowser
|
|
5
6
|
from typing import TYPE_CHECKING, cast
|
|
6
7
|
|
|
7
8
|
from textual import on
|
|
8
9
|
from textual.app import ComposeResult
|
|
9
10
|
from textual.containers import Container, Horizontal, Vertical
|
|
11
|
+
from textual.events import Resize
|
|
10
12
|
from textual.screen import Screen
|
|
11
13
|
from textual.widgets import Button, Markdown, Static
|
|
12
14
|
|
|
15
|
+
from shotgun.tui.layout import TINY_HEIGHT_THRESHOLD
|
|
16
|
+
|
|
13
17
|
if TYPE_CHECKING:
|
|
14
18
|
from ..app import ShotgunApp
|
|
15
19
|
|
|
@@ -85,6 +89,69 @@ class WelcomeScreen(Screen[None]):
|
|
|
85
89
|
margin: 1 0 0 0;
|
|
86
90
|
width: 100%;
|
|
87
91
|
}
|
|
92
|
+
|
|
93
|
+
#migration-warning {
|
|
94
|
+
width: 80%;
|
|
95
|
+
height: auto;
|
|
96
|
+
padding: 2;
|
|
97
|
+
margin: 1 0;
|
|
98
|
+
border: solid $warning;
|
|
99
|
+
background: $warning 20%;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#migration-warning-title {
|
|
103
|
+
text-style: bold;
|
|
104
|
+
color: $warning;
|
|
105
|
+
padding: 0 0 1 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Tiny screen fallback */
|
|
109
|
+
#tiny-welcome-container {
|
|
110
|
+
display: none;
|
|
111
|
+
width: 100%;
|
|
112
|
+
height: auto;
|
|
113
|
+
padding: 0;
|
|
114
|
+
align: center middle;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#tiny-welcome-message {
|
|
118
|
+
text-align: center;
|
|
119
|
+
padding: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#tiny-welcome-link {
|
|
123
|
+
text-align: center;
|
|
124
|
+
padding: 0;
|
|
125
|
+
color: $accent;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#tiny-welcome-buttons {
|
|
129
|
+
width: auto;
|
|
130
|
+
height: auto;
|
|
131
|
+
padding: 0;
|
|
132
|
+
align: center middle;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#tiny-welcome-buttons Button {
|
|
136
|
+
margin: 0 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Tiny mode - hide full welcome, show minimal */
|
|
140
|
+
WelcomeScreen.tiny #titlebox {
|
|
141
|
+
display: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
WelcomeScreen.tiny #options-container {
|
|
145
|
+
display: none;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
WelcomeScreen.tiny #migration-warning {
|
|
149
|
+
display: none;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
WelcomeScreen.tiny #tiny-welcome-container {
|
|
153
|
+
display: block;
|
|
154
|
+
}
|
|
88
155
|
"""
|
|
89
156
|
|
|
90
157
|
BINDINGS = [
|
|
@@ -92,6 +159,24 @@ class WelcomeScreen(Screen[None]):
|
|
|
92
159
|
]
|
|
93
160
|
|
|
94
161
|
def compose(self) -> ComposeResult:
|
|
162
|
+
# Tiny screen fallback
|
|
163
|
+
with Container(id="tiny-welcome-container"):
|
|
164
|
+
yield Static(
|
|
165
|
+
"Welcome to Shotgun",
|
|
166
|
+
id="tiny-welcome-message",
|
|
167
|
+
)
|
|
168
|
+
yield Static(
|
|
169
|
+
"[@click=screen.open_usage_guide]View setup instructions[/]",
|
|
170
|
+
id="tiny-welcome-link",
|
|
171
|
+
markup=True,
|
|
172
|
+
)
|
|
173
|
+
with Horizontal(id="tiny-welcome-buttons"):
|
|
174
|
+
yield Button(
|
|
175
|
+
"Shotgun Account", id="tiny-shotgun-button", variant="primary"
|
|
176
|
+
)
|
|
177
|
+
yield Button("BYOK", id="tiny-byok-button", variant="success")
|
|
178
|
+
|
|
179
|
+
# Full welcome screen
|
|
95
180
|
with Vertical(id="titlebox"):
|
|
96
181
|
yield Static("Welcome to Shotgun", id="welcome-title")
|
|
97
182
|
yield Static(
|
|
@@ -99,6 +184,23 @@ class WelcomeScreen(Screen[None]):
|
|
|
99
184
|
id="welcome-subtitle",
|
|
100
185
|
)
|
|
101
186
|
|
|
187
|
+
# Show migration warning if migration failed
|
|
188
|
+
app = cast("ShotgunApp", self.app)
|
|
189
|
+
# Note: This is a synchronous call in compose, but config should already be loaded
|
|
190
|
+
if hasattr(app, "config_manager") and app.config_manager._config:
|
|
191
|
+
config = app.config_manager._config
|
|
192
|
+
if config.migration_failed:
|
|
193
|
+
with Vertical(id="migration-warning"):
|
|
194
|
+
yield Static(
|
|
195
|
+
"⚠️ Configuration Migration Failed",
|
|
196
|
+
id="migration-warning-title",
|
|
197
|
+
)
|
|
198
|
+
backup_msg = "Your previous configuration couldn't be migrated automatically."
|
|
199
|
+
if config.migration_backup_path:
|
|
200
|
+
backup_msg += f"\n\nYour old configuration (including API keys) has been backed up to:\n{config.migration_backup_path}"
|
|
201
|
+
backup_msg += "\n\nYou'll need to reconfigure Shotgun by choosing an option below."
|
|
202
|
+
yield Markdown(backup_msg)
|
|
203
|
+
|
|
102
204
|
with Container(id="options-container"):
|
|
103
205
|
with Horizontal(id="options"):
|
|
104
206
|
# Left box - Shotgun Account
|
|
@@ -136,32 +238,56 @@ class WelcomeScreen(Screen[None]):
|
|
|
136
238
|
|
|
137
239
|
def on_mount(self) -> None:
|
|
138
240
|
"""Focus the first button on mount."""
|
|
139
|
-
|
|
241
|
+
self._apply_layout_for_height(self.app.size.height)
|
|
242
|
+
self.query_one("#shotgun-button", Button).focus()
|
|
243
|
+
# Update BYOK button text asynchronously
|
|
244
|
+
self.run_worker(self._update_byok_button_text(), exclusive=False)
|
|
245
|
+
|
|
246
|
+
@on(Resize)
|
|
247
|
+
def handle_resize(self, event: Resize) -> None:
|
|
248
|
+
"""Adjust layout based on terminal height."""
|
|
249
|
+
self._apply_layout_for_height(event.size.height)
|
|
250
|
+
|
|
251
|
+
def _apply_layout_for_height(self, height: int) -> None:
|
|
252
|
+
"""Apply appropriate layout based on terminal height."""
|
|
253
|
+
if height < TINY_HEIGHT_THRESHOLD:
|
|
254
|
+
self.add_class("tiny")
|
|
255
|
+
else:
|
|
256
|
+
self.remove_class("tiny")
|
|
257
|
+
|
|
258
|
+
def action_open_usage_guide(self) -> None:
|
|
259
|
+
"""Open the usage guide in browser."""
|
|
260
|
+
webbrowser.open(
|
|
261
|
+
"https://github.com/shotgun-sh/shotgun?tab=readme-ov-file#-usage"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _update_byok_button_text(self) -> None:
|
|
265
|
+
"""Update BYOK button text based on whether user has existing providers."""
|
|
140
266
|
byok_button = self.query_one("#byok-button", Button)
|
|
141
267
|
app = cast("ShotgunApp", self.app)
|
|
142
|
-
if app.config_manager.has_any_provider_key():
|
|
268
|
+
if await app.config_manager.has_any_provider_key():
|
|
143
269
|
byok_button.label = "I'll stick with my BYOK setup"
|
|
144
270
|
|
|
145
|
-
self.query_one("#shotgun-button", Button).focus()
|
|
146
|
-
|
|
147
271
|
@on(Button.Pressed, "#shotgun-button")
|
|
272
|
+
@on(Button.Pressed, "#tiny-shotgun-button")
|
|
148
273
|
def _on_shotgun_pressed(self) -> None:
|
|
149
274
|
"""Handle Shotgun Account button press."""
|
|
150
275
|
self.run_worker(self._start_shotgun_auth(), exclusive=True)
|
|
151
276
|
|
|
152
277
|
@on(Button.Pressed, "#byok-button")
|
|
278
|
+
@on(Button.Pressed, "#tiny-byok-button")
|
|
153
279
|
def _on_byok_pressed(self) -> None:
|
|
154
280
|
"""Handle BYOK button press."""
|
|
155
281
|
self.run_worker(self._start_byok_config(), exclusive=True)
|
|
156
282
|
|
|
157
283
|
async def _start_byok_config(self) -> None:
|
|
158
284
|
"""Launch BYOK provider configuration flow."""
|
|
159
|
-
self._mark_welcome_shown()
|
|
285
|
+
await self._mark_welcome_shown()
|
|
160
286
|
|
|
161
287
|
app = cast("ShotgunApp", self.app)
|
|
162
288
|
|
|
163
289
|
# If user already has providers, just dismiss and continue to chat
|
|
164
|
-
if app.config_manager.has_any_provider_key():
|
|
290
|
+
if await app.config_manager.has_any_provider_key():
|
|
165
291
|
self.dismiss()
|
|
166
292
|
return
|
|
167
293
|
|
|
@@ -171,7 +297,7 @@ class WelcomeScreen(Screen[None]):
|
|
|
171
297
|
await self.app.push_screen_wait(ProviderConfigScreen())
|
|
172
298
|
|
|
173
299
|
# Dismiss welcome screen after config if providers are now configured
|
|
174
|
-
if app.config_manager.has_any_provider_key():
|
|
300
|
+
if await app.config_manager.has_any_provider_key():
|
|
175
301
|
self.dismiss()
|
|
176
302
|
|
|
177
303
|
async def _start_shotgun_auth(self) -> None:
|
|
@@ -179,7 +305,7 @@ class WelcomeScreen(Screen[None]):
|
|
|
179
305
|
from .shotgun_auth import ShotgunAuthScreen
|
|
180
306
|
|
|
181
307
|
# Mark welcome screen as shown before auth
|
|
182
|
-
self._mark_welcome_shown()
|
|
308
|
+
await self._mark_welcome_shown()
|
|
183
309
|
|
|
184
310
|
# Push the auth screen and wait for result
|
|
185
311
|
await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
@@ -187,9 +313,9 @@ class WelcomeScreen(Screen[None]):
|
|
|
187
313
|
# Dismiss welcome screen after auth
|
|
188
314
|
self.dismiss()
|
|
189
315
|
|
|
190
|
-
def _mark_welcome_shown(self) -> None:
|
|
316
|
+
async def _mark_welcome_shown(self) -> None:
|
|
191
317
|
"""Mark the welcome screen as shown in config."""
|
|
192
318
|
app = cast("ShotgunApp", self.app)
|
|
193
|
-
config = app.config_manager.load()
|
|
319
|
+
config = await app.config_manager.load()
|
|
194
320
|
config.shown_welcome_screen = True
|
|
195
|
-
app.config_manager.save(config)
|
|
321
|
+
await app.config_manager.save(config)
|
|
@@ -0,0 +1,187 @@
|
|
|
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 import (
|
|
14
|
+
ConversationHistory,
|
|
15
|
+
ConversationManager,
|
|
16
|
+
ConversationState,
|
|
17
|
+
)
|
|
18
|
+
from shotgun.agents.models import AgentType
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from shotgun.agents.agent_manager import AgentManager
|
|
22
|
+
from shotgun.agents.usage_manager import SessionUsageManager
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConversationService:
|
|
28
|
+
"""Handles conversation persistence and restoration.
|
|
29
|
+
|
|
30
|
+
This service provides:
|
|
31
|
+
- Save current conversation to disk
|
|
32
|
+
- Load conversation from disk
|
|
33
|
+
- Restore conversation state to agent manager
|
|
34
|
+
- Handle corrupted conversations gracefully
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
conversation_manager: ConversationManager | None = None,
|
|
40
|
+
conversation_path: Path | None = None,
|
|
41
|
+
):
|
|
42
|
+
"""Initialize the conversation service.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
conversation_manager: Optional conversation manager. If not provided,
|
|
46
|
+
creates a default one.
|
|
47
|
+
conversation_path: Optional custom path for conversation storage.
|
|
48
|
+
"""
|
|
49
|
+
if conversation_manager:
|
|
50
|
+
self.conversation_manager = conversation_manager
|
|
51
|
+
elif conversation_path:
|
|
52
|
+
self.conversation_manager = ConversationManager(conversation_path)
|
|
53
|
+
else:
|
|
54
|
+
self.conversation_manager = ConversationManager()
|
|
55
|
+
|
|
56
|
+
async def save_conversation(self, agent_manager: "AgentManager") -> bool:
|
|
57
|
+
"""Save the current conversation to persistent storage.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
agent_manager: The agent manager containing conversation state.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if save was successful, False otherwise.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
# Get conversation state from agent manager
|
|
67
|
+
state = agent_manager.get_conversation_state()
|
|
68
|
+
|
|
69
|
+
# Create conversation history object
|
|
70
|
+
conversation = ConversationHistory(
|
|
71
|
+
last_agent_model=state.agent_type,
|
|
72
|
+
)
|
|
73
|
+
conversation.set_agent_messages(state.agent_messages)
|
|
74
|
+
conversation.set_ui_messages(state.ui_messages)
|
|
75
|
+
|
|
76
|
+
# Save to file (now async)
|
|
77
|
+
await self.conversation_manager.save(conversation)
|
|
78
|
+
logger.debug("Conversation saved successfully")
|
|
79
|
+
return True
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.exception(f"Failed to save conversation: {e}")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
async def load_conversation(self) -> ConversationHistory | None:
|
|
85
|
+
"""Load conversation from persistent storage.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The loaded conversation history, or None if no conversation exists
|
|
89
|
+
or if loading failed.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
conversation = await self.conversation_manager.load()
|
|
93
|
+
if conversation is None:
|
|
94
|
+
logger.debug("No conversation file found")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
logger.debug("Conversation loaded successfully")
|
|
98
|
+
return conversation
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.exception(f"Failed to load conversation: {e}")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
async def check_for_corrupted_conversation(self) -> bool:
|
|
104
|
+
"""Check if a conversation backup exists (indicating corruption).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if a backup exists (conversation was corrupted), False otherwise.
|
|
108
|
+
"""
|
|
109
|
+
backup_path = self.conversation_manager.conversation_path.with_suffix(
|
|
110
|
+
".json.backup"
|
|
111
|
+
)
|
|
112
|
+
return await aiofiles.os.path.exists(str(backup_path))
|
|
113
|
+
|
|
114
|
+
async def restore_conversation(
|
|
115
|
+
self,
|
|
116
|
+
agent_manager: "AgentManager",
|
|
117
|
+
usage_manager: "SessionUsageManager | None" = None,
|
|
118
|
+
) -> tuple[bool, str | None, AgentType | None]:
|
|
119
|
+
"""Restore conversation state from disk.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
agent_manager: The agent manager to restore state to.
|
|
123
|
+
usage_manager: Optional usage manager to restore usage state.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Tuple of (success, error_message, restored_agent_type)
|
|
127
|
+
- success: True if restoration succeeded
|
|
128
|
+
- error_message: Error message if restoration failed, None otherwise
|
|
129
|
+
- restored_agent_type: The agent type from restored conversation
|
|
130
|
+
"""
|
|
131
|
+
conversation = await self.load_conversation()
|
|
132
|
+
|
|
133
|
+
if conversation is None:
|
|
134
|
+
# Check for corruption
|
|
135
|
+
if await self.check_for_corrupted_conversation():
|
|
136
|
+
return (
|
|
137
|
+
False,
|
|
138
|
+
"⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation.",
|
|
139
|
+
None,
|
|
140
|
+
)
|
|
141
|
+
return True, None, None # No conversation to restore is not an error
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# Restore agent state
|
|
145
|
+
agent_messages = conversation.get_agent_messages()
|
|
146
|
+
ui_messages = conversation.get_ui_messages()
|
|
147
|
+
|
|
148
|
+
# Create ConversationState for restoration
|
|
149
|
+
state = ConversationState(
|
|
150
|
+
agent_messages=agent_messages,
|
|
151
|
+
ui_messages=ui_messages,
|
|
152
|
+
agent_type=conversation.last_agent_model,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
agent_manager.restore_conversation_state(state)
|
|
156
|
+
|
|
157
|
+
# Restore usage state if manager provided
|
|
158
|
+
if usage_manager:
|
|
159
|
+
await usage_manager.restore_usage_state()
|
|
160
|
+
|
|
161
|
+
restored_type = AgentType(conversation.last_agent_model)
|
|
162
|
+
logger.info(f"Conversation restored successfully (mode: {restored_type})")
|
|
163
|
+
return True, None, restored_type
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.exception(f"Failed to restore conversation state: {e}")
|
|
167
|
+
return (
|
|
168
|
+
False,
|
|
169
|
+
"⚠️ Could not restore previous session. Starting fresh conversation.",
|
|
170
|
+
None,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def clear_conversation(self) -> bool:
|
|
174
|
+
"""Clear the saved conversation file.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
True if clearing succeeded, False otherwise.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
conversation_path = self.conversation_manager.conversation_path
|
|
181
|
+
if await aiofiles.os.path.exists(str(conversation_path)):
|
|
182
|
+
await aiofiles.os.unlink(str(conversation_path))
|
|
183
|
+
logger.info("Conversation file cleared")
|
|
184
|
+
return True
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.exception(f"Failed to clear conversation: {e}")
|
|
187
|
+
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]
|