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.
Files changed (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -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
- # Update BYOK button text based on whether user has existing providers
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,5 @@
1
+ """Services for TUI business logic."""
2
+
3
+ from shotgun.tui.services.conversation_service import ConversationService
4
+
5
+ __all__ = ["ConversationService"]
@@ -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,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}")
@@ -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
- content = item.read_text(encoding="utf-8")
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
- content = file_path.read_text(encoding="utf-8")
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(self, current_mode: AgentType) -> AgentType | None:
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
- # Determine if mode has content
226
- has_content = self.progress_checker.has_mode_content(current_mode)
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]
@@ -0,0 +1,5 @@
1
+ """Widget utilities and coordinators for TUI."""
2
+
3
+ from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
4
+
5
+ __all__ = ["WidgetCoordinator"]