shotgun-sh 0.2.11.dev2__py3-none-any.whl → 0.2.11.dev7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (72) hide show
  1. shotgun/agents/agent_manager.py +194 -28
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/manager.py +64 -33
  4. shotgun/agents/config/models.py +25 -1
  5. shotgun/agents/config/provider.py +2 -2
  6. shotgun/agents/context_analyzer/analyzer.py +2 -24
  7. shotgun/agents/conversation_manager.py +35 -19
  8. shotgun/agents/export.py +2 -2
  9. shotgun/agents/history/history_processors.py +99 -3
  10. shotgun/agents/history/token_counting/anthropic.py +17 -1
  11. shotgun/agents/history/token_counting/base.py +14 -3
  12. shotgun/agents/history/token_counting/openai.py +11 -1
  13. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  14. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  15. shotgun/agents/history/token_counting/utils.py +0 -3
  16. shotgun/agents/plan.py +2 -2
  17. shotgun/agents/research.py +3 -3
  18. shotgun/agents/specify.py +2 -2
  19. shotgun/agents/tasks.py +2 -2
  20. shotgun/agents/tools/codebase/file_read.py +5 -2
  21. shotgun/agents/tools/file_management.py +11 -7
  22. shotgun/agents/tools/web_search/__init__.py +8 -8
  23. shotgun/agents/tools/web_search/anthropic.py +2 -2
  24. shotgun/agents/tools/web_search/gemini.py +1 -1
  25. shotgun/agents/tools/web_search/openai.py +1 -1
  26. shotgun/agents/tools/web_search/utils.py +2 -2
  27. shotgun/agents/usage_manager.py +16 -11
  28. shotgun/cli/clear.py +2 -1
  29. shotgun/cli/compact.py +3 -3
  30. shotgun/cli/config.py +8 -5
  31. shotgun/cli/context.py +2 -2
  32. shotgun/cli/export.py +1 -1
  33. shotgun/cli/feedback.py +4 -2
  34. shotgun/cli/plan.py +1 -1
  35. shotgun/cli/research.py +1 -1
  36. shotgun/cli/specify.py +1 -1
  37. shotgun/cli/tasks.py +1 -1
  38. shotgun/codebase/core/change_detector.py +5 -3
  39. shotgun/codebase/core/code_retrieval.py +4 -2
  40. shotgun/codebase/core/ingestor.py +10 -8
  41. shotgun/codebase/core/manager.py +3 -3
  42. shotgun/codebase/core/nl_query.py +1 -1
  43. shotgun/exceptions.py +32 -0
  44. shotgun/logging_config.py +10 -17
  45. shotgun/main.py +3 -1
  46. shotgun/posthog_telemetry.py +14 -4
  47. shotgun/sentry_telemetry.py +22 -2
  48. shotgun/telemetry.py +3 -1
  49. shotgun/tui/app.py +71 -65
  50. shotgun/tui/components/context_indicator.py +43 -0
  51. shotgun/tui/containers.py +15 -17
  52. shotgun/tui/dependencies.py +2 -2
  53. shotgun/tui/screens/chat/chat_screen.py +164 -40
  54. shotgun/tui/screens/chat/help_text.py +16 -15
  55. shotgun/tui/screens/chat_screen/command_providers.py +10 -0
  56. shotgun/tui/screens/feedback.py +4 -4
  57. shotgun/tui/screens/github_issue.py +102 -0
  58. shotgun/tui/screens/model_picker.py +21 -20
  59. shotgun/tui/screens/onboarding.py +431 -0
  60. shotgun/tui/screens/provider_config.py +50 -27
  61. shotgun/tui/screens/shotgun_auth.py +2 -2
  62. shotgun/tui/screens/welcome.py +14 -11
  63. shotgun/tui/services/conversation_service.py +16 -14
  64. shotgun/tui/utils/mode_progress.py +14 -7
  65. shotgun/tui/widgets/widget_coordinator.py +15 -0
  66. shotgun/utils/file_system_utils.py +19 -0
  67. shotgun/utils/marketing.py +110 -0
  68. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/METADATA +2 -1
  69. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/RECORD +72 -68
  70. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/WHEEL +0 -0
  71. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/entry_points.txt +0 -0
  72. {shotgun_sh-0.2.11.dev2.dist-info → shotgun_sh-0.2.11.dev7.dist-info}/licenses/LICENSE +0 -0
@@ -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)
@@ -8,6 +8,8 @@ import logging
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ import aiofiles.os
12
+
11
13
  from shotgun.agents.conversation_history import ConversationHistory, ConversationState
12
14
  from shotgun.agents.conversation_manager import ConversationManager
13
15
  from shotgun.agents.models import AgentType
@@ -48,7 +50,7 @@ class ConversationService:
48
50
  else:
49
51
  self.conversation_manager = ConversationManager()
50
52
 
51
- def save_conversation(self, agent_manager: "AgentManager") -> bool:
53
+ async def save_conversation(self, agent_manager: "AgentManager") -> bool:
52
54
  """Save the current conversation to persistent storage.
53
55
 
54
56
  Args:
@@ -68,15 +70,15 @@ class ConversationService:
68
70
  conversation.set_agent_messages(state.agent_messages)
69
71
  conversation.set_ui_messages(state.ui_messages)
70
72
 
71
- # Save to file
72
- self.conversation_manager.save(conversation)
73
+ # Save to file (now async)
74
+ await self.conversation_manager.save(conversation)
73
75
  logger.debug("Conversation saved successfully")
74
76
  return True
75
77
  except Exception as e:
76
78
  logger.exception(f"Failed to save conversation: {e}")
77
79
  return False
78
80
 
79
- def load_conversation(self) -> ConversationHistory | None:
81
+ async def load_conversation(self) -> ConversationHistory | None:
80
82
  """Load conversation from persistent storage.
81
83
 
82
84
  Returns:
@@ -84,7 +86,7 @@ class ConversationService:
84
86
  or if loading failed.
85
87
  """
86
88
  try:
87
- conversation = self.conversation_manager.load()
89
+ conversation = await self.conversation_manager.load()
88
90
  if conversation is None:
89
91
  logger.debug("No conversation file found")
90
92
  return None
@@ -95,7 +97,7 @@ class ConversationService:
95
97
  logger.exception(f"Failed to load conversation: {e}")
96
98
  return None
97
99
 
98
- def check_for_corrupted_conversation(self) -> bool:
100
+ async def check_for_corrupted_conversation(self) -> bool:
99
101
  """Check if a conversation backup exists (indicating corruption).
100
102
 
101
103
  Returns:
@@ -104,9 +106,9 @@ class ConversationService:
104
106
  backup_path = self.conversation_manager.conversation_path.with_suffix(
105
107
  ".json.backup"
106
108
  )
107
- return backup_path.exists()
109
+ return await aiofiles.os.path.exists(str(backup_path))
108
110
 
109
- def restore_conversation(
111
+ async def restore_conversation(
110
112
  self,
111
113
  agent_manager: "AgentManager",
112
114
  usage_manager: "SessionUsageManager | None" = None,
@@ -123,11 +125,11 @@ class ConversationService:
123
125
  - error_message: Error message if restoration failed, None otherwise
124
126
  - restored_agent_type: The agent type from restored conversation
125
127
  """
126
- conversation = self.load_conversation()
128
+ conversation = await self.load_conversation()
127
129
 
128
130
  if conversation is None:
129
131
  # Check for corruption
130
- if self.check_for_corrupted_conversation():
132
+ if await self.check_for_corrupted_conversation():
131
133
  return (
132
134
  False,
133
135
  "⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation.",
@@ -151,7 +153,7 @@ class ConversationService:
151
153
 
152
154
  # Restore usage state if manager provided
153
155
  if usage_manager:
154
- usage_manager.restore_usage_state()
156
+ await usage_manager.restore_usage_state()
155
157
 
156
158
  restored_type = AgentType(conversation.last_agent_model)
157
159
  logger.info(f"Conversation restored successfully (mode: {restored_type})")
@@ -165,7 +167,7 @@ class ConversationService:
165
167
  None,
166
168
  )
167
169
 
168
- def clear_conversation(self) -> bool:
170
+ async def clear_conversation(self) -> bool:
169
171
  """Clear the saved conversation file.
170
172
 
171
173
  Returns:
@@ -173,8 +175,8 @@ class ConversationService:
173
175
  """
174
176
  try:
175
177
  conversation_path = self.conversation_manager.conversation_path
176
- if conversation_path.exists():
177
- conversation_path.unlink()
178
+ if await aiofiles.os.path.exists(str(conversation_path)):
179
+ await aiofiles.os.unlink(str(conversation_path))
178
180
  logger.info("Conversation file cleared")
179
181
  return True
180
182
  except Exception as e:
@@ -3,6 +3,8 @@
3
3
  import random
4
4
  from pathlib import Path
5
5
 
6
+ import aiofiles
7
+
6
8
  from shotgun.agents.models import AgentType
7
9
  from shotgun.utils.file_system_utils import get_shotgun_base_path
8
10
 
@@ -30,7 +32,7 @@ class ModeProgressChecker:
30
32
  """
31
33
  self.base_path = base_path or get_shotgun_base_path()
32
34
 
33
- def has_mode_content(self, mode: AgentType) -> bool:
35
+ async def has_mode_content(self, mode: AgentType) -> bool:
34
36
  """Check if a mode has meaningful content.
35
37
 
36
38
  Args:
@@ -52,7 +54,8 @@ class ModeProgressChecker:
52
54
  for item in export_path.glob("*"):
53
55
  if item.is_file() and not item.name.startswith("."):
54
56
  try:
55
- 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]
@@ -245,3 +245,18 @@ class WidgetCoordinator:
245
245
  spinner.text = text
246
246
  except Exception as e:
247
247
  logger.exception(f"Failed to update spinner text: {e}")
248
+
249
+ def set_context_streaming(self, streaming: bool) -> None:
250
+ """Enable or disable context indicator streaming animation.
251
+
252
+ Args:
253
+ streaming: Whether to show streaming animation.
254
+ """
255
+ if not self.screen.is_mounted:
256
+ return
257
+
258
+ try:
259
+ context_indicator = self.screen.query_one(ContextIndicator)
260
+ context_indicator.set_streaming(streaming)
261
+ except Exception as e:
262
+ logger.exception(f"Failed to set context streaming: {e}")
@@ -2,6 +2,8 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ import aiofiles
6
+
5
7
  from shotgun.settings import settings
6
8
 
7
9
 
@@ -35,3 +37,20 @@ def ensure_shotgun_directory_exists() -> Path:
35
37
  shotgun_dir.mkdir(exist_ok=True)
36
38
  # Note: Removed logger to avoid circular dependency with logging_config
37
39
  return shotgun_dir
40
+
41
+
42
+ async def async_copy_file(src: Path, dst: Path) -> None:
43
+ """Asynchronously copy a file from src to dst.
44
+
45
+ Args:
46
+ src: Source file path
47
+ dst: Destination file path
48
+
49
+ Raises:
50
+ FileNotFoundError: If source file doesn't exist
51
+ OSError: If copy operation fails
52
+ """
53
+ async with aiofiles.open(src, "rb") as src_file:
54
+ content = await src_file.read()
55
+ async with aiofiles.open(dst, "wb") as dst_file:
56
+ await dst_file.write(content)
@@ -0,0 +1,110 @@
1
+ """Marketing message management for Shotgun CLI."""
2
+
3
+ from collections.abc import Callable
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shotgun.agents.config.models import MarketingConfig, MarketingMessageRecord
9
+ from shotgun.agents.models import FileOperation
10
+
11
+ if TYPE_CHECKING:
12
+ from shotgun.agents.config.manager import ConfigManager
13
+
14
+ # Marketing message IDs
15
+ GITHUB_STAR_MESSAGE_ID = "github_star_v1"
16
+
17
+ # Spec files that trigger the GitHub star message
18
+ SPEC_FILES = {"research.md", "specification.md", "plan.md", "tasks.md"}
19
+
20
+
21
+ class MarketingManager:
22
+ """Manages marketing messages shown to users."""
23
+
24
+ @staticmethod
25
+ def should_show_github_star_message(
26
+ marketing_config: MarketingConfig, file_operations: list[FileOperation]
27
+ ) -> bool:
28
+ """
29
+ Check if the GitHub star message should be shown.
30
+
31
+ Args:
32
+ marketing_config: Current marketing configuration
33
+ file_operations: List of file operations from the current agent run
34
+
35
+ Returns:
36
+ True if message should be shown, False otherwise
37
+ """
38
+ # Check if message has already been shown
39
+ if GITHUB_STAR_MESSAGE_ID in marketing_config.messages:
40
+ return False
41
+
42
+ # Check if any spec file was written
43
+ for operation in file_operations:
44
+ # operation.file_path is a string, so we convert to Path to get the filename
45
+ file_name = Path(operation.file_path).name
46
+ if file_name in SPEC_FILES:
47
+ return True
48
+
49
+ return False
50
+
51
+ @staticmethod
52
+ def mark_message_shown(
53
+ marketing_config: MarketingConfig, message_id: str
54
+ ) -> MarketingConfig:
55
+ """
56
+ Mark a marketing message as shown.
57
+
58
+ Args:
59
+ marketing_config: Current marketing configuration
60
+ message_id: ID of the message to mark as shown
61
+
62
+ Returns:
63
+ Updated marketing configuration
64
+ """
65
+ # Create a new record with current timestamp
66
+ record = MarketingMessageRecord(shown_at=datetime.now(timezone.utc))
67
+
68
+ # Update the messages dict
69
+ marketing_config.messages[message_id] = record
70
+
71
+ return marketing_config
72
+
73
+ @staticmethod
74
+ def get_github_star_message() -> str:
75
+ """Get the GitHub star marketing message text."""
76
+ return "⭐ Enjoying Shotgun? Star us on GitHub: https://github.com/shotgun-sh/shotgun"
77
+
78
+ @staticmethod
79
+ async def check_and_display_messages(
80
+ config_manager: "ConfigManager",
81
+ file_operations: list[FileOperation],
82
+ display_callback: Callable[[str], None],
83
+ ) -> None:
84
+ """
85
+ Check if any marketing messages should be shown and display them.
86
+
87
+ This is the main entry point for marketing message handling. It checks
88
+ all configured messages, displays them if appropriate, and updates the
89
+ config to mark them as shown.
90
+
91
+ Args:
92
+ config_manager: Config manager to load/save configuration
93
+ file_operations: List of file operations from the current agent run
94
+ display_callback: Callback function to display messages to the user
95
+ """
96
+ config = await config_manager.load()
97
+
98
+ # Check GitHub star message
99
+ if MarketingManager.should_show_github_star_message(
100
+ config.marketing, file_operations
101
+ ):
102
+ # Display the message
103
+ message = MarketingManager.get_github_star_message()
104
+ display_callback(message)
105
+
106
+ # Mark as shown and save
107
+ MarketingManager.mark_message_shown(
108
+ config.marketing, GITHUB_STAR_MESSAGE_ID
109
+ )
110
+ await config_manager.save(config)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shotgun-sh
3
- Version: 0.2.11.dev2
3
+ Version: 0.2.11.dev7
4
4
  Summary: AI-powered research, planning, and task management CLI tool
5
5
  Project-URL: Homepage, https://shotgun.sh/
6
6
  Project-URL: Repository, https://github.com/shotgun-sh/shotgun
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Classifier: Topic :: Utilities
23
23
  Requires-Python: >=3.11
24
+ Requires-Dist: aiofiles>=24.0.0
24
25
  Requires-Dist: anthropic>=0.39.0
25
26
  Requires-Dist: dependency-injector>=4.41.0
26
27
  Requires-Dist: genai-prices>=0.0.27