shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.2.17__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.
Files changed (117) hide show
  1. shotgun/agents/agent_manager.py +354 -46
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +66 -35
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +33 -5
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +2 -0
  13. shotgun/agents/conversation_manager.py +35 -19
  14. shotgun/agents/export.py +2 -2
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/history_processors.py +113 -5
  17. shotgun/agents/history/token_counting/anthropic.py +17 -1
  18. shotgun/agents/history/token_counting/base.py +14 -3
  19. shotgun/agents/history/token_counting/openai.py +11 -1
  20. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  21. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  22. shotgun/agents/history/token_counting/utils.py +0 -3
  23. shotgun/agents/plan.py +2 -2
  24. shotgun/agents/research.py +3 -3
  25. shotgun/agents/specify.py +2 -2
  26. shotgun/agents/tasks.py +2 -2
  27. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  28. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  29. shotgun/agents/tools/codebase/file_read.py +11 -2
  30. shotgun/agents/tools/codebase/query_graph.py +6 -0
  31. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  32. shotgun/agents/tools/file_management.py +27 -7
  33. shotgun/agents/tools/registry.py +217 -0
  34. shotgun/agents/tools/web_search/__init__.py +8 -8
  35. shotgun/agents/tools/web_search/anthropic.py +8 -2
  36. shotgun/agents/tools/web_search/gemini.py +7 -1
  37. shotgun/agents/tools/web_search/openai.py +7 -1
  38. shotgun/agents/tools/web_search/utils.py +2 -2
  39. shotgun/agents/usage_manager.py +16 -11
  40. shotgun/api_endpoints.py +7 -3
  41. shotgun/build_constants.py +3 -3
  42. shotgun/cli/clear.py +53 -0
  43. shotgun/cli/compact.py +186 -0
  44. shotgun/cli/config.py +8 -5
  45. shotgun/cli/context.py +111 -0
  46. shotgun/cli/export.py +1 -1
  47. shotgun/cli/feedback.py +4 -2
  48. shotgun/cli/models.py +1 -0
  49. shotgun/cli/plan.py +1 -1
  50. shotgun/cli/research.py +1 -1
  51. shotgun/cli/specify.py +1 -1
  52. shotgun/cli/tasks.py +1 -1
  53. shotgun/cli/update.py +16 -2
  54. shotgun/codebase/core/change_detector.py +5 -3
  55. shotgun/codebase/core/code_retrieval.py +4 -2
  56. shotgun/codebase/core/ingestor.py +10 -8
  57. shotgun/codebase/core/manager.py +13 -4
  58. shotgun/codebase/core/nl_query.py +1 -1
  59. shotgun/exceptions.py +32 -0
  60. shotgun/logging_config.py +18 -27
  61. shotgun/main.py +73 -11
  62. shotgun/posthog_telemetry.py +37 -28
  63. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  64. shotgun/sentry_telemetry.py +163 -16
  65. shotgun/settings.py +238 -0
  66. shotgun/telemetry.py +10 -33
  67. shotgun/tui/app.py +243 -43
  68. shotgun/tui/commands/__init__.py +1 -1
  69. shotgun/tui/components/context_indicator.py +179 -0
  70. shotgun/tui/components/mode_indicator.py +70 -0
  71. shotgun/tui/components/status_bar.py +48 -0
  72. shotgun/tui/containers.py +91 -0
  73. shotgun/tui/dependencies.py +39 -0
  74. shotgun/tui/protocols.py +45 -0
  75. shotgun/tui/screens/chat/__init__.py +5 -0
  76. shotgun/tui/screens/chat/chat.tcss +54 -0
  77. shotgun/tui/screens/chat/chat_screen.py +1254 -0
  78. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  79. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  80. shotgun/tui/screens/chat/help_text.py +40 -0
  81. shotgun/tui/screens/chat/prompt_history.py +48 -0
  82. shotgun/tui/screens/chat.tcss +11 -0
  83. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  84. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  85. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  86. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  87. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  88. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  89. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  90. shotgun/tui/screens/confirmation_dialog.py +151 -0
  91. shotgun/tui/screens/feedback.py +4 -4
  92. shotgun/tui/screens/github_issue.py +102 -0
  93. shotgun/tui/screens/model_picker.py +49 -24
  94. shotgun/tui/screens/onboarding.py +431 -0
  95. shotgun/tui/screens/pipx_migration.py +153 -0
  96. shotgun/tui/screens/provider_config.py +50 -27
  97. shotgun/tui/screens/shotgun_auth.py +2 -2
  98. shotgun/tui/screens/welcome.py +14 -11
  99. shotgun/tui/services/__init__.py +5 -0
  100. shotgun/tui/services/conversation_service.py +184 -0
  101. shotgun/tui/state/__init__.py +7 -0
  102. shotgun/tui/state/processing_state.py +185 -0
  103. shotgun/tui/utils/mode_progress.py +14 -7
  104. shotgun/tui/widgets/__init__.py +5 -0
  105. shotgun/tui/widgets/widget_coordinator.py +263 -0
  106. shotgun/utils/file_system_utils.py +22 -2
  107. shotgun/utils/marketing.py +110 -0
  108. shotgun/utils/update_checker.py +69 -14
  109. shotgun_sh-0.2.17.dist-info/METADATA +465 -0
  110. shotgun_sh-0.2.17.dist-info/RECORD +194 -0
  111. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
  112. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/licenses/LICENSE +1 -1
  113. shotgun/tui/screens/chat.py +0 -996
  114. shotgun/tui/screens/chat_screen/history.py +0 -335
  115. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  116. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  117. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.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._build_provider_items(), id="provider-list")
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.refresh_provider_status()
130
- self._update_done_button_visibility()
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 _build_provider_items(self) -> list[ListItem]:
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
- label = Label(self._provider_label(provider_id), id=f"label-{provider_id}")
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
- status = (
231
- "Configured" if self._has_provider_key(provider_id) else "Not configured"
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
  )
@@ -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
- # Update BYOK button text based on whether user has existing providers
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)
@@ -0,0 +1,5 @@
1
+ """Services for TUI business logic."""
2
+
3
+ from shotgun.tui.services.conversation_service import ConversationService
4
+
5
+ __all__ = ["ConversationService"]
@@ -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,7 @@
1
+ """State management utilities for TUI."""
2
+
3
+ from .processing_state import ProcessingStateManager
4
+
5
+ __all__ = [
6
+ "ProcessingStateManager",
7
+ ]
@@ -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}")