shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +524 -58
- shotgun/agents/common.py +62 -62
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +14 -3
- shotgun/agents/config/models.py +16 -0
- shotgun/agents/config/provider.py +68 -13
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +493 -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 +24 -2
- shotgun/agents/export.py +4 -5
- 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 +32 -10
- shotgun/agents/models.py +50 -2
- shotgun/agents/plan.py +4 -5
- shotgun/agents/research.py +4 -5
- shotgun/agents/specify.py +4 -5
- shotgun/agents/tasks.py +4 -5
- 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 +6 -0
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +71 -9
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +24 -12
- shotgun/agents/tools/web_search/anthropic.py +24 -3
- shotgun/agents/tools/web_search/gemini.py +22 -10
- shotgun/agents/tools/web_search/openai.py +21 -12
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +1 -1
- shotgun/cli/clear.py +52 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/context.py +111 -0
- shotgun/cli/models.py +1 -0
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/manager.py +10 -1
- shotgun/llm_proxy/__init__.py +5 -2
- shotgun/llm_proxy/clients.py +12 -7
- shotgun/logging_config.py +8 -10
- shotgun/main.py +70 -10
- shotgun/posthog_telemetry.py +9 -3
- 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 +4 -15
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +15 -32
- shotgun/tui/app.py +203 -9
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +136 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +93 -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 +1110 -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 +39 -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 +68 -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/model_picker.py +30 -6
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/welcome.py +24 -5
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +182 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +247 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/file_system_utils.py +3 -2
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dev1.dist-info/METADATA +129 -0
- shotgun_sh-0.2.11.dev1.dist-info/RECORD +190 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.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.dev1.dist-info}/WHEEL +0 -0
|
@@ -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}")
|
|
@@ -0,0 +1,247 @@
|
|
|
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}")
|
|
@@ -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,9 @@
|
|
|
1
1
|
"""File system utility functions."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
5
|
+
from shotgun.settings import settings
|
|
6
|
+
|
|
6
7
|
|
|
7
8
|
def get_shotgun_base_path() -> Path:
|
|
8
9
|
"""Get the absolute path to the .shotgun directory."""
|
|
@@ -18,7 +19,7 @@ def get_shotgun_home() -> Path:
|
|
|
18
19
|
Path to shotgun home directory (default: ~/.shotgun-sh/)
|
|
19
20
|
"""
|
|
20
21
|
# Allow override via environment variable (useful for testing)
|
|
21
|
-
if custom_home :=
|
|
22
|
+
if custom_home := settings.dev.home:
|
|
22
23
|
return Path(custom_home)
|
|
23
24
|
|
|
24
25
|
return Path.home() / ".shotgun-sh"
|
shotgun/utils/update_checker.py
CHANGED
|
@@ -10,6 +10,7 @@ from packaging import version
|
|
|
10
10
|
|
|
11
11
|
from shotgun import __version__
|
|
12
12
|
from shotgun.logging_config import get_logger
|
|
13
|
+
from shotgun.settings import settings
|
|
13
14
|
|
|
14
15
|
logger = get_logger(__name__)
|
|
15
16
|
|
|
@@ -18,8 +19,34 @@ def detect_installation_method() -> str:
|
|
|
18
19
|
"""Detect how shotgun-sh was installed.
|
|
19
20
|
|
|
20
21
|
Returns:
|
|
21
|
-
Installation method: 'pipx', 'pip', 'venv', or 'unknown'.
|
|
22
|
+
Installation method: 'uvx', 'uv-tool', 'pipx', 'pip', 'venv', or 'unknown'.
|
|
22
23
|
"""
|
|
24
|
+
# Check for simulation environment variable (for testing)
|
|
25
|
+
if settings.dev.pipx_simulate:
|
|
26
|
+
logger.debug("SHOTGUN_PIPX_SIMULATE enabled, simulating pipx installation")
|
|
27
|
+
return "pipx"
|
|
28
|
+
|
|
29
|
+
# Check for uvx (ephemeral execution) by looking at executable path
|
|
30
|
+
# uvx runs from a temporary cache directory
|
|
31
|
+
executable = Path(sys.executable)
|
|
32
|
+
if ".cache/uv" in str(executable) or "uv/cache" in str(executable):
|
|
33
|
+
logger.debug("Detected uvx (ephemeral) execution")
|
|
34
|
+
return "uvx"
|
|
35
|
+
|
|
36
|
+
# Check for uv tool installation
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["uv", "tool", "list"], # noqa: S607, S603
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=5,
|
|
43
|
+
)
|
|
44
|
+
if result.returncode == 0 and "shotgun-sh" in result.stdout:
|
|
45
|
+
logger.debug("Detected uv tool installation")
|
|
46
|
+
return "uv-tool"
|
|
47
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
23
50
|
# Check for pipx installation
|
|
24
51
|
try:
|
|
25
52
|
result = subprocess.run(
|
|
@@ -59,7 +86,7 @@ def detect_installation_method() -> str:
|
|
|
59
86
|
|
|
60
87
|
|
|
61
88
|
def perform_auto_update(no_update_check: bool = False) -> None:
|
|
62
|
-
"""Perform automatic update if installed via pipx.
|
|
89
|
+
"""Perform automatic update if installed via pipx or uv tool.
|
|
63
90
|
|
|
64
91
|
Args:
|
|
65
92
|
no_update_check: If True, skip the update.
|
|
@@ -68,23 +95,40 @@ def perform_auto_update(no_update_check: bool = False) -> None:
|
|
|
68
95
|
return
|
|
69
96
|
|
|
70
97
|
try:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
method = detect_installation_method()
|
|
99
|
+
|
|
100
|
+
# Skip auto-update for ephemeral uvx executions
|
|
101
|
+
if method == "uvx":
|
|
102
|
+
logger.debug("uvx (ephemeral) execution, skipping auto-update")
|
|
74
103
|
return
|
|
75
104
|
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
# Only auto-update for pipx and uv-tool installations
|
|
106
|
+
if method not in ["pipx", "uv-tool"]:
|
|
107
|
+
logger.debug(f"Installation method '{method}', skipping auto-update")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Determine the appropriate upgrade command
|
|
111
|
+
if method == "pipx":
|
|
112
|
+
command = ["pipx", "upgrade", "shotgun-sh", "--quiet"]
|
|
113
|
+
logger.debug("Running pipx upgrade shotgun-sh --quiet")
|
|
114
|
+
elif method == "uv-tool":
|
|
115
|
+
command = ["uv", "tool", "upgrade", "shotgun-sh"]
|
|
116
|
+
logger.debug("Running uv tool upgrade shotgun-sh")
|
|
117
|
+
else:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# Run upgrade command
|
|
121
|
+
result = subprocess.run( # noqa: S603, S607
|
|
122
|
+
command,
|
|
80
123
|
capture_output=True,
|
|
81
124
|
text=True,
|
|
82
125
|
timeout=30,
|
|
83
126
|
)
|
|
84
127
|
|
|
85
128
|
if result.returncode == 0:
|
|
86
|
-
# Check if there was an actual update
|
|
87
|
-
|
|
129
|
+
# Check if there was an actual update
|
|
130
|
+
output = result.stdout.lower()
|
|
131
|
+
if "upgraded" in output or "updated" in output:
|
|
88
132
|
logger.info("Shotgun-sh has been updated to the latest version")
|
|
89
133
|
else:
|
|
90
134
|
# Only log errors at debug level to not annoy users
|
|
@@ -166,16 +210,18 @@ def compare_versions(current: str, latest: str) -> bool:
|
|
|
166
210
|
return False
|
|
167
211
|
|
|
168
212
|
|
|
169
|
-
def get_update_command(method: str) -> list[str]:
|
|
213
|
+
def get_update_command(method: str) -> list[str] | None:
|
|
170
214
|
"""Get the appropriate update command based on installation method.
|
|
171
215
|
|
|
172
216
|
Args:
|
|
173
|
-
method: Installation method ('pipx', 'pip', 'venv', or 'unknown').
|
|
217
|
+
method: Installation method ('uvx', 'uv-tool', 'pipx', 'pip', 'venv', or 'unknown').
|
|
174
218
|
|
|
175
219
|
Returns:
|
|
176
|
-
Command list to execute for updating.
|
|
220
|
+
Command list to execute for updating, or None for uvx (ephemeral).
|
|
177
221
|
"""
|
|
178
222
|
commands = {
|
|
223
|
+
"uvx": None, # uvx is ephemeral, no update command
|
|
224
|
+
"uv-tool": ["uv", "tool", "upgrade", "shotgun-sh"],
|
|
179
225
|
"pipx": ["pipx", "upgrade", "shotgun-sh"],
|
|
180
226
|
"pip": [sys.executable, "-m", "pip", "install", "--upgrade", "shotgun-sh"],
|
|
181
227
|
"venv": [sys.executable, "-m", "pip", "install", "--upgrade", "shotgun-sh"],
|
|
@@ -210,6 +256,15 @@ def perform_update(force: bool = False) -> tuple[bool, str]:
|
|
|
210
256
|
method = detect_installation_method()
|
|
211
257
|
command = get_update_command(method)
|
|
212
258
|
|
|
259
|
+
# Handle uvx (ephemeral) installations
|
|
260
|
+
if method == "uvx" or command is None:
|
|
261
|
+
return (
|
|
262
|
+
False,
|
|
263
|
+
"You're running shotgun-sh via uvx (ephemeral mode). "
|
|
264
|
+
"To get the latest version, simply run 'uvx shotgun-sh' again, "
|
|
265
|
+
"or install permanently with 'uv tool install shotgun-sh'.",
|
|
266
|
+
)
|
|
267
|
+
|
|
213
268
|
# Perform update
|
|
214
269
|
try:
|
|
215
270
|
logger.info(f"Updating shotgun-sh using {method}...")
|