shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +664 -75
- shotgun/agents/common.py +76 -70
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +78 -36
- shotgun/agents/config/models.py +41 -1
- shotgun/agents/config/provider.py +70 -15
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +9 -4
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +14 -2
- shotgun/agents/history/token_counting/anthropic.py +49 -11
- shotgun/agents/history/token_counting/base.py +14 -3
- shotgun/agents/history/token_counting/openai.py +8 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/history/token_counting/utils.py +0 -3
- shotgun/agents/models.py +50 -2
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +30 -18
- shotgun/agents/tools/web_search/anthropic.py +26 -5
- shotgun/agents/tools/web_search/gemini.py +23 -11
- shotgun/agents/tools/web_search/openai.py +22 -13
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +1 -1
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +10 -8
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/llm_proxy/__init__.py +5 -2
- shotgun/llm_proxy/clients.py +12 -7
- shotgun/logging_config.py +18 -27
- shotgun/main.py +73 -11
- shotgun/posthog_telemetry.py +23 -7
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sentry_telemetry.py +7 -16
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +18 -33
- shotgun/tui/app.py +243 -43
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1202 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +78 -2
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/feedback.py +4 -4
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +49 -24
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +50 -27
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +32 -10
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dev5.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dev5.dist-info/RECORD +193 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -804
- shotgun/tui/screens/chat_screen/history.py +0 -352
- shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
- shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/WHEEL +0 -0
|
@@ -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]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# mypy: disable-error-code="import-not-found"
|
|
2
|
+
"""Widget coordinator to centralize widget queries and updates.
|
|
3
|
+
|
|
4
|
+
This module eliminates scattered `query_one()` calls throughout ChatScreen
|
|
5
|
+
by providing a single place for all widget updates. This improves:
|
|
6
|
+
- Testability (can test update logic in isolation)
|
|
7
|
+
- Maintainability (clear update contracts)
|
|
8
|
+
- Performance (can batch updates if needed)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pydantic_ai.messages import ModelMessage
|
|
15
|
+
|
|
16
|
+
from shotgun.agents.config.models import ModelName
|
|
17
|
+
from shotgun.agents.models import AgentType
|
|
18
|
+
from shotgun.tui.components.context_indicator import ContextIndicator
|
|
19
|
+
from shotgun.tui.components.mode_indicator import ModeIndicator
|
|
20
|
+
from shotgun.tui.components.prompt_input import PromptInput
|
|
21
|
+
from shotgun.tui.components.spinner import Spinner
|
|
22
|
+
from shotgun.tui.components.status_bar import StatusBar
|
|
23
|
+
from shotgun.tui.screens.chat_screen.history.chat_history import ChatHistory
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from shotgun.agents.context_analyzer.models import ContextAnalysis
|
|
27
|
+
from shotgun.agents.conversation_history import HintMessage
|
|
28
|
+
from shotgun.tui.screens.chat import ChatScreen
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WidgetCoordinator:
|
|
34
|
+
"""Coordinates updates to all widgets in ChatScreen.
|
|
35
|
+
|
|
36
|
+
This class centralizes all `query_one()` calls and widget manipulations,
|
|
37
|
+
providing clear update methods instead of scattered direct queries.
|
|
38
|
+
|
|
39
|
+
Benefits:
|
|
40
|
+
- Single place for all widget updates
|
|
41
|
+
- Testable without full TUI
|
|
42
|
+
- Clear update contracts
|
|
43
|
+
- Can add batching/debouncing easily
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, screen: "ChatScreen"):
|
|
47
|
+
"""Initialize the coordinator with a reference to the screen.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
screen: The ChatScreen instance containing the widgets.
|
|
51
|
+
"""
|
|
52
|
+
self.screen = screen
|
|
53
|
+
|
|
54
|
+
def update_for_mode_change(
|
|
55
|
+
self, new_mode: AgentType, placeholder: str | None = None
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Update all widgets when agent mode changes.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
new_mode: The new agent mode.
|
|
61
|
+
placeholder: Optional placeholder text for input. If not provided,
|
|
62
|
+
will use the screen's _placeholder_for_mode method.
|
|
63
|
+
"""
|
|
64
|
+
if not self.screen.is_mounted:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Update mode indicator
|
|
68
|
+
try:
|
|
69
|
+
mode_indicator = self.screen.query_one(ModeIndicator)
|
|
70
|
+
mode_indicator.mode = new_mode
|
|
71
|
+
mode_indicator.refresh()
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.exception(f"Failed to update mode indicator: {e}")
|
|
74
|
+
|
|
75
|
+
# Update prompt input placeholder
|
|
76
|
+
try:
|
|
77
|
+
prompt_input = self.screen.query_one(PromptInput)
|
|
78
|
+
if placeholder is None:
|
|
79
|
+
placeholder = self.screen._placeholder_for_mode(
|
|
80
|
+
new_mode, force_new=True
|
|
81
|
+
)
|
|
82
|
+
prompt_input.placeholder = placeholder
|
|
83
|
+
prompt_input.refresh()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.exception(f"Failed to update prompt input: {e}")
|
|
86
|
+
|
|
87
|
+
def update_for_processing_state(
|
|
88
|
+
self, is_processing: bool, spinner_text: str | None = None
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Update widgets when processing state changes.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
is_processing: Whether processing is active.
|
|
94
|
+
spinner_text: Optional text to display in spinner.
|
|
95
|
+
"""
|
|
96
|
+
if not self.screen.is_mounted:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Update spinner visibility
|
|
100
|
+
try:
|
|
101
|
+
spinner = self.screen.query_one("#spinner", Spinner)
|
|
102
|
+
spinner.set_classes("" if is_processing else "hidden")
|
|
103
|
+
spinner.display = is_processing
|
|
104
|
+
if spinner_text and is_processing:
|
|
105
|
+
spinner.text = spinner_text
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.exception(f"Failed to update spinner: {e}")
|
|
108
|
+
|
|
109
|
+
# Update status bar
|
|
110
|
+
try:
|
|
111
|
+
status_bar = self.screen.query_one(StatusBar)
|
|
112
|
+
status_bar.working = is_processing
|
|
113
|
+
status_bar.refresh()
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.exception(f"Failed to update status bar: {e}")
|
|
116
|
+
|
|
117
|
+
def update_for_qa_mode(self, qa_mode_active: bool) -> None:
|
|
118
|
+
"""Update widgets when Q&A mode changes.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
qa_mode_active: Whether Q&A mode is active.
|
|
122
|
+
"""
|
|
123
|
+
if not self.screen.is_mounted:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Update status bar
|
|
127
|
+
try:
|
|
128
|
+
status_bar = self.screen.query_one(StatusBar)
|
|
129
|
+
status_bar.refresh()
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.exception(f"Failed to update status bar for Q&A: {e}")
|
|
132
|
+
|
|
133
|
+
# Update mode indicator
|
|
134
|
+
try:
|
|
135
|
+
mode_indicator = self.screen.query_one(ModeIndicator)
|
|
136
|
+
mode_indicator.refresh()
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.exception(f"Failed to update mode indicator for Q&A: {e}")
|
|
139
|
+
|
|
140
|
+
def update_messages(self, messages: list[ModelMessage | "HintMessage"]) -> None:
|
|
141
|
+
"""Update chat history with new messages.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
messages: The messages to display.
|
|
145
|
+
"""
|
|
146
|
+
if not self.screen.is_mounted:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
chat_history = self.screen.query_one(ChatHistory)
|
|
151
|
+
chat_history.update_messages(messages)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.exception(f"Failed to update messages: {e}")
|
|
154
|
+
|
|
155
|
+
def set_partial_response(
|
|
156
|
+
self, message: ModelMessage | None, messages: list[ModelMessage | "HintMessage"]
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Update chat history with partial streaming response.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
message: The partial message being streamed.
|
|
162
|
+
messages: The full message history.
|
|
163
|
+
"""
|
|
164
|
+
if not self.screen.is_mounted:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
chat_history = self.screen.query_one(ChatHistory)
|
|
169
|
+
if message:
|
|
170
|
+
chat_history.partial_response = message
|
|
171
|
+
chat_history.update_messages(messages)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.exception(f"Failed to set partial response: {e}")
|
|
174
|
+
|
|
175
|
+
def update_context_indicator(
|
|
176
|
+
self, analysis: "ContextAnalysis | None", model_name: str
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Update context indicator with new analysis.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
analysis: The context analysis results.
|
|
182
|
+
model_name: The current model name.
|
|
183
|
+
"""
|
|
184
|
+
if not self.screen.is_mounted:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
context_indicator = self.screen.query_one(ContextIndicator)
|
|
189
|
+
# Cast the string model name to ModelName type
|
|
190
|
+
model = ModelName(model_name) if model_name else None
|
|
191
|
+
context_indicator.update_context(analysis, model)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.exception(f"Failed to update context indicator: {e}")
|
|
194
|
+
|
|
195
|
+
def update_prompt_input(
|
|
196
|
+
self,
|
|
197
|
+
placeholder: str | None = None,
|
|
198
|
+
clear: bool = False,
|
|
199
|
+
focus: bool = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Update prompt input widget.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
placeholder: New placeholder text.
|
|
205
|
+
clear: Whether to clear the input.
|
|
206
|
+
focus: Whether to focus the input.
|
|
207
|
+
"""
|
|
208
|
+
if not self.screen.is_mounted:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
prompt_input = self.screen.query_one(PromptInput)
|
|
213
|
+
if placeholder is not None:
|
|
214
|
+
prompt_input.placeholder = placeholder
|
|
215
|
+
if clear:
|
|
216
|
+
prompt_input.clear()
|
|
217
|
+
if focus:
|
|
218
|
+
prompt_input.focus()
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.exception(f"Failed to update prompt input: {e}")
|
|
221
|
+
|
|
222
|
+
def refresh_mode_indicator(self) -> None:
|
|
223
|
+
"""Refresh mode indicator without changing mode."""
|
|
224
|
+
if not self.screen.is_mounted:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
mode_indicator = self.screen.query_one(ModeIndicator)
|
|
229
|
+
mode_indicator.refresh()
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.exception(f"Failed to refresh mode indicator: {e}")
|
|
232
|
+
|
|
233
|
+
def update_spinner_text(self, text: str) -> None:
|
|
234
|
+
"""Update spinner text without changing visibility.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
text: The new spinner text.
|
|
238
|
+
"""
|
|
239
|
+
if not self.screen.is_mounted:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
spinner = self.screen.query_one("#spinner", Spinner)
|
|
244
|
+
if spinner.display: # Only update if visible
|
|
245
|
+
spinner.text = text
|
|
246
|
+
except Exception as e:
|
|
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}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Datetime utilities for consistent datetime formatting across the application."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DateTimeContext(BaseModel):
|
|
9
|
+
"""Structured datetime context with timezone information.
|
|
10
|
+
|
|
11
|
+
This model provides consistently formatted datetime information
|
|
12
|
+
for use in prompts, templates, and UI display.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
datetime_formatted: Human-readable datetime string
|
|
16
|
+
timezone_name: Short timezone name (e.g., "PST", "UTC")
|
|
17
|
+
utc_offset: UTC offset formatted with colon (e.g., "UTC-08:00")
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> dt_context = get_datetime_context()
|
|
21
|
+
>>> print(dt_context.datetime_formatted)
|
|
22
|
+
'Monday, January 13, 2025 at 3:45:30 PM'
|
|
23
|
+
>>> print(dt_context.timezone_name)
|
|
24
|
+
'PST'
|
|
25
|
+
>>> print(dt_context.utc_offset)
|
|
26
|
+
'UTC-08:00'
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
datetime_formatted: str = Field(
|
|
30
|
+
description="Human-readable datetime string in format: 'Day, Month DD, YYYY at HH:MM:SS AM/PM'"
|
|
31
|
+
)
|
|
32
|
+
timezone_name: str = Field(description="Short timezone name (e.g., PST, EST, UTC)")
|
|
33
|
+
utc_offset: str = Field(
|
|
34
|
+
description="UTC offset formatted with colon (e.g., UTC-08:00, UTC+05:30)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_datetime_context() -> DateTimeContext:
|
|
39
|
+
"""Get formatted datetime context with timezone information.
|
|
40
|
+
|
|
41
|
+
Returns a Pydantic model containing consistently formatted datetime
|
|
42
|
+
information suitable for use in prompts and templates.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
DateTimeContext: Structured datetime context with formatted strings
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> dt_context = get_datetime_context()
|
|
49
|
+
>>> dt_context.datetime_formatted
|
|
50
|
+
'Monday, January 13, 2025 at 3:45:30 PM'
|
|
51
|
+
>>> dt_context.timezone_name
|
|
52
|
+
'PST'
|
|
53
|
+
>>> dt_context.utc_offset
|
|
54
|
+
'UTC-08:00'
|
|
55
|
+
"""
|
|
56
|
+
# Get current datetime with timezone information
|
|
57
|
+
now = datetime.now().astimezone()
|
|
58
|
+
|
|
59
|
+
# Format datetime in plain English
|
|
60
|
+
# Example: "Monday, January 13, 2025 at 3:45:30 PM"
|
|
61
|
+
datetime_formatted = now.strftime("%A, %B %d, %Y at %I:%M:%S %p")
|
|
62
|
+
|
|
63
|
+
# Get timezone name and UTC offset
|
|
64
|
+
# Example: "PST" and "UTC-08:00"
|
|
65
|
+
timezone_name = now.strftime("%Z")
|
|
66
|
+
utc_offset = now.strftime("%z") # Format: +0800 or -0500
|
|
67
|
+
|
|
68
|
+
# Reformat UTC offset to include colon: +08:00 or -05:00
|
|
69
|
+
utc_offset_formatted = (
|
|
70
|
+
f"UTC{utc_offset[:3]}:{utc_offset[3:]}" if utc_offset else "UTC"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return DateTimeContext(
|
|
74
|
+
datetime_formatted=datetime_formatted,
|
|
75
|
+
timezone_name=timezone_name,
|
|
76
|
+
utc_offset=utc_offset_formatted,
|
|
77
|
+
)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""File system utility functions."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
5
|
+
import aiofiles
|
|
6
|
+
|
|
7
|
+
from shotgun.settings import settings
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
def get_shotgun_base_path() -> Path:
|
|
8
11
|
"""Get the absolute path to the .shotgun directory."""
|
|
@@ -18,7 +21,7 @@ def get_shotgun_home() -> Path:
|
|
|
18
21
|
Path to shotgun home directory (default: ~/.shotgun-sh/)
|
|
19
22
|
"""
|
|
20
23
|
# Allow override via environment variable (useful for testing)
|
|
21
|
-
if custom_home :=
|
|
24
|
+
if custom_home := settings.dev.home:
|
|
22
25
|
return Path(custom_home)
|
|
23
26
|
|
|
24
27
|
return Path.home() / ".shotgun-sh"
|
|
@@ -34,3 +37,20 @@ def ensure_shotgun_directory_exists() -> Path:
|
|
|
34
37
|
shotgun_dir.mkdir(exist_ok=True)
|
|
35
38
|
# Note: Removed logger to avoid circular dependency with logging_config
|
|
36
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)
|