shotgun-sh 0.2.11.dev1__py3-none-any.whl → 0.2.17.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 +194 -28
- shotgun/agents/common.py +14 -8
- shotgun/agents/config/manager.py +64 -33
- shotgun/agents/config/models.py +25 -1
- shotgun/agents/config/provider.py +2 -2
- shotgun/agents/context_analyzer/analyzer.py +2 -24
- shotgun/agents/conversation_manager.py +35 -19
- shotgun/agents/export.py +2 -2
- shotgun/agents/history/history_processors.py +99 -3
- shotgun/agents/history/token_counting/anthropic.py +17 -1
- shotgun/agents/history/token_counting/base.py +14 -3
- shotgun/agents/history/token_counting/openai.py +11 -1
- 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/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -2
- shotgun/agents/tools/codebase/file_read.py +5 -2
- shotgun/agents/tools/file_management.py +11 -7
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +2 -2
- shotgun/agents/tools/web_search/gemini.py +1 -1
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/build_constants.py +1 -1
- shotgun/cli/clear.py +2 -1
- shotgun/cli/compact.py +3 -3
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +2 -2
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +4 -2
- 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/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 +3 -3
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/exceptions.py +32 -0
- shotgun/logging_config.py +10 -17
- shotgun/main.py +3 -1
- shotgun/posthog_telemetry.py +28 -25
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sentry_telemetry.py +160 -2
- shotgun/telemetry.py +3 -1
- shotgun/tui/app.py +71 -65
- shotgun/tui/components/context_indicator.py +43 -0
- shotgun/tui/containers.py +15 -17
- shotgun/tui/dependencies.py +2 -2
- shotgun/tui/screens/chat/chat_screen.py +189 -45
- shotgun/tui/screens/chat/help_text.py +16 -15
- shotgun/tui/screens/chat_screen/command_providers.py +10 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
- shotgun/tui/screens/feedback.py +4 -4
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +21 -20
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/provider_config.py +50 -27
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +14 -11
- shotgun/tui/services/conversation_service.py +16 -14
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/widget_coordinator.py +18 -2
- shotgun/utils/file_system_utils.py +19 -0
- shotgun/utils/marketing.py +110 -0
- shotgun_sh-0.2.17.dev1.dist-info/METADATA +465 -0
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/RECORD +75 -71
- shotgun_sh-0.2.11.dev1.dist-info/METADATA +0 -129
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/logging_config.py
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
import logging.handlers
|
|
5
5
|
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from shotgun.settings import settings
|
|
9
10
|
from shotgun.utils.env_utils import is_truthy
|
|
10
11
|
|
|
12
|
+
# Generate a single timestamp for this run to be used across all loggers
|
|
13
|
+
_RUN_TIMESTAMP = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
def get_log_directory() -> Path:
|
|
13
17
|
"""Get the log directory path, creating it if necessary.
|
|
@@ -66,10 +70,7 @@ def setup_logger(
|
|
|
66
70
|
logger = logging.getLogger(name)
|
|
67
71
|
|
|
68
72
|
# Check if we already have a file handler
|
|
69
|
-
has_file_handler = any(
|
|
70
|
-
isinstance(h, logging.handlers.TimedRotatingFileHandler)
|
|
71
|
-
for h in logger.handlers
|
|
72
|
-
)
|
|
73
|
+
has_file_handler = any(isinstance(h, logging.FileHandler) for h in logger.handlers)
|
|
73
74
|
|
|
74
75
|
# If we already have a file handler, just return the logger
|
|
75
76
|
if has_file_handler:
|
|
@@ -120,21 +121,16 @@ def setup_logger(
|
|
|
120
121
|
|
|
121
122
|
if file_logging_enabled:
|
|
122
123
|
try:
|
|
123
|
-
# Create file handler with
|
|
124
|
+
# Create file handler with ISO8601 timestamp for each run
|
|
124
125
|
log_dir = get_log_directory()
|
|
125
|
-
log_file = log_dir / "shotgun.log"
|
|
126
|
+
log_file = log_dir / f"shotgun-{_RUN_TIMESTAMP}.log"
|
|
126
127
|
|
|
127
|
-
# Use
|
|
128
|
-
file_handler = logging.
|
|
128
|
+
# Use regular FileHandler - each run gets its own isolated log file
|
|
129
|
+
file_handler = logging.FileHandler(
|
|
129
130
|
filename=log_file,
|
|
130
|
-
when="midnight", # Rotate at midnight
|
|
131
|
-
interval=1, # Every 1 day
|
|
132
|
-
backupCount=7, # Keep 7 days of logs
|
|
133
131
|
encoding="utf-8",
|
|
134
132
|
)
|
|
135
133
|
|
|
136
|
-
# Also set max file size (10MB) using RotatingFileHandler as fallback
|
|
137
|
-
# Note: We'll use TimedRotatingFileHandler which handles both time and size
|
|
138
134
|
file_handler.setLevel(getattr(logging, log_level))
|
|
139
135
|
|
|
140
136
|
# Use standard formatter for file (no colors)
|
|
@@ -189,10 +185,7 @@ def get_logger(name: str) -> logging.Logger:
|
|
|
189
185
|
logger = logging.getLogger(name)
|
|
190
186
|
|
|
191
187
|
# Check if we have a file handler already
|
|
192
|
-
has_file_handler = any(
|
|
193
|
-
isinstance(h, logging.handlers.TimedRotatingFileHandler)
|
|
194
|
-
for h in logger.handlers
|
|
195
|
-
)
|
|
188
|
+
has_file_handler = any(isinstance(h, logging.FileHandler) for h in logger.handlers)
|
|
196
189
|
|
|
197
190
|
# If no file handler, set up the logger (will add file handler)
|
|
198
191
|
if not has_file_handler:
|
shotgun/main.py
CHANGED
|
@@ -56,8 +56,10 @@ logger.debug("Logfire observability enabled: %s", _logfire_enabled)
|
|
|
56
56
|
|
|
57
57
|
# Initialize configuration
|
|
58
58
|
try:
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
59
61
|
config_manager = get_config_manager()
|
|
60
|
-
config_manager.load() # Ensure config is loaded at startup
|
|
62
|
+
asyncio.run(config_manager.load()) # Ensure config is loaded at startup
|
|
61
63
|
except Exception as e:
|
|
62
64
|
logger.debug("Configuration initialization warning: %s", e)
|
|
63
65
|
|
shotgun/posthog_telemetry.py
CHANGED
|
@@ -18,6 +18,9 @@ logger = get_early_logger(__name__)
|
|
|
18
18
|
# Global PostHog client instance
|
|
19
19
|
_posthog_client = None
|
|
20
20
|
|
|
21
|
+
# Cache the shotgun instance ID to avoid async calls during event tracking
|
|
22
|
+
_shotgun_instance_id: str | None = None
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
def setup_posthog_observability() -> bool:
|
|
23
26
|
"""Set up PostHog analytics for usage tracking.
|
|
@@ -25,7 +28,7 @@ def setup_posthog_observability() -> bool:
|
|
|
25
28
|
Returns:
|
|
26
29
|
True if PostHog was successfully set up, False otherwise
|
|
27
30
|
"""
|
|
28
|
-
global _posthog_client
|
|
31
|
+
global _posthog_client, _shotgun_instance_id
|
|
29
32
|
|
|
30
33
|
try:
|
|
31
34
|
# Check if PostHog is already initialized
|
|
@@ -57,29 +60,20 @@ def setup_posthog_observability() -> bool:
|
|
|
57
60
|
# Store the client for later use
|
|
58
61
|
_posthog_client = posthog
|
|
59
62
|
|
|
60
|
-
#
|
|
63
|
+
# Cache the shotgun instance ID for later use (avoids async issues)
|
|
61
64
|
try:
|
|
62
|
-
|
|
63
|
-
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
64
|
-
|
|
65
|
-
# Identify the user in PostHog
|
|
66
|
-
posthog.identify( # type: ignore[attr-defined]
|
|
67
|
-
distinct_id=shotgun_instance_id,
|
|
68
|
-
properties={
|
|
69
|
-
"version": __version__,
|
|
70
|
-
"environment": environment,
|
|
71
|
-
},
|
|
72
|
-
)
|
|
65
|
+
import asyncio
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
posthog.personal_api_key = None # Not needed for event tracking
|
|
67
|
+
config_manager = get_config_manager()
|
|
68
|
+
_shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
|
|
77
69
|
|
|
78
70
|
logger.debug(
|
|
79
|
-
"PostHog
|
|
71
|
+
"PostHog initialized with shotgun instance ID: %s",
|
|
72
|
+
_shotgun_instance_id,
|
|
80
73
|
)
|
|
81
74
|
except Exception as e:
|
|
82
|
-
logger.warning("Failed to
|
|
75
|
+
logger.warning("Failed to load shotgun instance ID: %s", e)
|
|
76
|
+
# Continue anyway - we'll try to get it during event tracking
|
|
83
77
|
|
|
84
78
|
logger.debug(
|
|
85
79
|
"PostHog analytics configured successfully (environment: %s, version: %s)",
|
|
@@ -100,16 +94,19 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
|
|
|
100
94
|
event_name: Name of the event to track
|
|
101
95
|
properties: Optional properties to include with the event
|
|
102
96
|
"""
|
|
103
|
-
global _posthog_client
|
|
97
|
+
global _posthog_client, _shotgun_instance_id
|
|
104
98
|
|
|
105
99
|
if _posthog_client is None:
|
|
106
100
|
logger.debug("PostHog not initialized, skipping event: %s", event_name)
|
|
107
101
|
return
|
|
108
102
|
|
|
109
103
|
try:
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
# Use cached instance ID (loaded during setup)
|
|
105
|
+
if _shotgun_instance_id is None:
|
|
106
|
+
logger.warning(
|
|
107
|
+
"Shotgun instance ID not available, skipping event: %s", event_name
|
|
108
|
+
)
|
|
109
|
+
return
|
|
113
110
|
|
|
114
111
|
# Add version and environment to properties
|
|
115
112
|
if properties is None:
|
|
@@ -124,7 +121,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
|
|
|
124
121
|
|
|
125
122
|
# Track the event using PostHog's capture method
|
|
126
123
|
_posthog_client.capture(
|
|
127
|
-
distinct_id=
|
|
124
|
+
distinct_id=_shotgun_instance_id, event=event_name, properties=properties
|
|
128
125
|
)
|
|
129
126
|
logger.debug("Tracked PostHog event: %s", event_name)
|
|
130
127
|
except Exception as e:
|
|
@@ -168,10 +165,16 @@ def submit_feedback_survey(feedback: Feedback) -> None:
|
|
|
168
165
|
logger.debug("PostHog not initialized, skipping feedback survey")
|
|
169
166
|
return
|
|
170
167
|
|
|
168
|
+
import asyncio
|
|
169
|
+
|
|
171
170
|
config_manager = get_config_manager()
|
|
172
|
-
config = config_manager.load()
|
|
171
|
+
config = asyncio.run(config_manager.load())
|
|
173
172
|
conversation_manager = ConversationManager()
|
|
174
|
-
conversation =
|
|
173
|
+
conversation = None
|
|
174
|
+
try:
|
|
175
|
+
conversation = asyncio.run(conversation_manager.load())
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.debug(f"Failed to load conversation history: {e}")
|
|
175
178
|
last_10_messages = []
|
|
176
179
|
if conversation is not None:
|
|
177
180
|
last_10_messages = conversation.get_agent_messages()[:10]
|
|
@@ -7,10 +7,11 @@ Your extensive expertise spans, among other things:
|
|
|
7
7
|
## KEY RULES
|
|
8
8
|
|
|
9
9
|
{% if interactive_mode %}
|
|
10
|
-
0. Always ask CLARIFYING QUESTIONS using structured output
|
|
10
|
+
0. Always ask CLARIFYING QUESTIONS using structured output before doing work.
|
|
11
11
|
- Return your response with the clarifying_questions field populated
|
|
12
|
-
- Do not make assumptions about what the user wants
|
|
12
|
+
- Do not make assumptions about what the user wants, get a clear understanding first.
|
|
13
13
|
- Questions should be clear, specific, and answerable
|
|
14
|
+
- Do not ask too many questions that might overwhelm the user; prioritize the most important ones.
|
|
14
15
|
{% endif %}
|
|
15
16
|
1. Above all, prefer using tools to do the work and NEVER respond with text.
|
|
16
17
|
2. IMPORTANT: Always ask for review and go ahead to move forward after using write_file().
|
shotgun/sentry_telemetry.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Sentry observability setup for Shotgun."""
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
3
6
|
from shotgun import __version__
|
|
4
7
|
from shotgun.logging_config import get_early_logger
|
|
5
8
|
from shotgun.settings import settings
|
|
@@ -8,6 +11,122 @@ from shotgun.settings import settings
|
|
|
8
11
|
logger = get_early_logger(__name__)
|
|
9
12
|
|
|
10
13
|
|
|
14
|
+
def _scrub_path(path: str) -> str:
|
|
15
|
+
"""Scrub sensitive information from file paths.
|
|
16
|
+
|
|
17
|
+
Removes home directory and current working directory prefixes to prevent
|
|
18
|
+
leaking usernames that might be part of the path.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: The file path to scrub
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The scrubbed path with sensitive prefixes removed
|
|
25
|
+
"""
|
|
26
|
+
if not path:
|
|
27
|
+
return path
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Get home and cwd as Path objects for comparison
|
|
31
|
+
home = Path.home()
|
|
32
|
+
cwd = Path.cwd()
|
|
33
|
+
|
|
34
|
+
# Convert path to Path object
|
|
35
|
+
path_obj = Path(path)
|
|
36
|
+
|
|
37
|
+
# Try to make path relative to cwd first (most common case)
|
|
38
|
+
try:
|
|
39
|
+
relative_to_cwd = path_obj.relative_to(cwd)
|
|
40
|
+
return str(relative_to_cwd)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Try to replace home directory with ~
|
|
45
|
+
try:
|
|
46
|
+
relative_to_home = path_obj.relative_to(home)
|
|
47
|
+
return f"~/{relative_to_home}"
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# If path is absolute but not under cwd or home, just return filename
|
|
52
|
+
if path_obj.is_absolute():
|
|
53
|
+
return path_obj.name
|
|
54
|
+
|
|
55
|
+
# Return as-is if already relative
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
except Exception:
|
|
59
|
+
# If anything goes wrong, return the original path
|
|
60
|
+
# Better to leak a path than break error reporting
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scrub_sensitive_paths(event: dict[str, Any]) -> None:
|
|
65
|
+
"""Scrub sensitive paths from Sentry event data.
|
|
66
|
+
|
|
67
|
+
Modifies the event in-place to remove:
|
|
68
|
+
- Home directory paths (might contain usernames)
|
|
69
|
+
- Current working directory paths (might contain usernames)
|
|
70
|
+
- Server name/hostname
|
|
71
|
+
- Paths in sys.argv
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
event: The Sentry event dictionary to scrub
|
|
75
|
+
"""
|
|
76
|
+
extra = event.get("extra", {})
|
|
77
|
+
if "sys.argv" in extra:
|
|
78
|
+
argv = extra["sys.argv"]
|
|
79
|
+
if isinstance(argv, list):
|
|
80
|
+
extra["sys.argv"] = [
|
|
81
|
+
_scrub_path(arg) if isinstance(arg, str) else arg for arg in argv
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Scrub server name if present
|
|
85
|
+
if "server_name" in event:
|
|
86
|
+
event["server_name"] = ""
|
|
87
|
+
|
|
88
|
+
# Scrub contexts that might contain paths
|
|
89
|
+
if "contexts" in event:
|
|
90
|
+
contexts = event["contexts"]
|
|
91
|
+
# Remove runtime context if it has CWD
|
|
92
|
+
if "runtime" in contexts:
|
|
93
|
+
if "cwd" in contexts["runtime"]:
|
|
94
|
+
del contexts["runtime"]["cwd"]
|
|
95
|
+
# Scrub sys.argv to remove paths
|
|
96
|
+
if "sys.argv" in contexts["runtime"]:
|
|
97
|
+
argv = contexts["runtime"]["sys.argv"]
|
|
98
|
+
if isinstance(argv, list):
|
|
99
|
+
contexts["runtime"]["sys.argv"] = [
|
|
100
|
+
_scrub_path(arg) if isinstance(arg, str) else arg
|
|
101
|
+
for arg in argv
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Scrub exception stack traces
|
|
105
|
+
if "exception" in event and "values" in event["exception"]:
|
|
106
|
+
for exception in event["exception"]["values"]:
|
|
107
|
+
if "stacktrace" in exception and "frames" in exception["stacktrace"]:
|
|
108
|
+
for frame in exception["stacktrace"]["frames"]:
|
|
109
|
+
# Scrub file paths
|
|
110
|
+
if "abs_path" in frame:
|
|
111
|
+
frame["abs_path"] = _scrub_path(frame["abs_path"])
|
|
112
|
+
if "filename" in frame:
|
|
113
|
+
frame["filename"] = _scrub_path(frame["filename"])
|
|
114
|
+
|
|
115
|
+
# Scrub local variables that might contain paths
|
|
116
|
+
if "vars" in frame:
|
|
117
|
+
for var_name, var_value in frame["vars"].items():
|
|
118
|
+
if isinstance(var_value, str):
|
|
119
|
+
frame["vars"][var_name] = _scrub_path(var_value)
|
|
120
|
+
|
|
121
|
+
# Scrub breadcrumbs that might contain paths
|
|
122
|
+
if "breadcrumbs" in event and "values" in event["breadcrumbs"]:
|
|
123
|
+
for breadcrumb in event["breadcrumbs"]["values"]:
|
|
124
|
+
if "data" in breadcrumb:
|
|
125
|
+
for key, value in breadcrumb["data"].items():
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
breadcrumb["data"][key] = _scrub_path(value)
|
|
128
|
+
|
|
129
|
+
|
|
11
130
|
def setup_sentry_observability() -> bool:
|
|
12
131
|
"""Set up Sentry observability for error tracking.
|
|
13
132
|
|
|
@@ -32,28 +151,67 @@ def setup_sentry_observability() -> bool:
|
|
|
32
151
|
logger.debug("Using Sentry DSN from settings, proceeding with setup")
|
|
33
152
|
|
|
34
153
|
# Determine environment based on version
|
|
35
|
-
# Dev versions contain "dev", "rc", "alpha",
|
|
154
|
+
# Dev versions contain "dev", "rc", "alpha", "beta"
|
|
36
155
|
if any(marker in __version__ for marker in ["dev", "rc", "alpha", "beta"]):
|
|
37
156
|
environment = "development"
|
|
38
157
|
else:
|
|
39
158
|
environment = "production"
|
|
40
159
|
|
|
160
|
+
def before_send(event: Any, hint: dict[str, Any]) -> Any:
|
|
161
|
+
"""Filter out user-actionable errors and scrub sensitive paths.
|
|
162
|
+
|
|
163
|
+
User-actionable errors (like context size limits) are expected conditions
|
|
164
|
+
that users need to resolve, not bugs that need tracking.
|
|
165
|
+
|
|
166
|
+
Also scrubs sensitive information like usernames from file paths and
|
|
167
|
+
working directories to protect user privacy.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
log_record = hint.get("log_record")
|
|
171
|
+
if log_record:
|
|
172
|
+
# Scrub pathname using the helper function
|
|
173
|
+
log_record.pathname = _scrub_path(log_record.pathname)
|
|
174
|
+
|
|
175
|
+
# Scrub traceback text if it exists
|
|
176
|
+
if hasattr(log_record, "exc_text") and isinstance(
|
|
177
|
+
log_record.exc_text, str
|
|
178
|
+
):
|
|
179
|
+
# Replace home directory in traceback text
|
|
180
|
+
home = Path.home()
|
|
181
|
+
log_record.exc_text = log_record.exc_text.replace(str(home), "~")
|
|
182
|
+
|
|
183
|
+
if "exc_info" in hint:
|
|
184
|
+
_, exc_value, _ = hint["exc_info"]
|
|
185
|
+
from shotgun.exceptions import ErrorNotPickedUpBySentry
|
|
186
|
+
|
|
187
|
+
if isinstance(exc_value, ErrorNotPickedUpBySentry):
|
|
188
|
+
# Don't send to Sentry - this is user-actionable, not a bug
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
# Scrub sensitive paths from the event
|
|
192
|
+
_scrub_sensitive_paths(event)
|
|
193
|
+
return event
|
|
194
|
+
|
|
41
195
|
# Initialize Sentry
|
|
42
196
|
sentry_sdk.init(
|
|
43
197
|
dsn=dsn,
|
|
44
198
|
release=f"shotgun-sh@{__version__}",
|
|
45
199
|
environment=environment,
|
|
46
200
|
send_default_pii=False, # Privacy-first: never send PII
|
|
201
|
+
server_name="", # Privacy: don't send hostname (may contain username)
|
|
47
202
|
traces_sample_rate=0.1 if environment == "production" else 1.0,
|
|
48
203
|
profiles_sample_rate=0.1 if environment == "production" else 1.0,
|
|
204
|
+
before_send=before_send,
|
|
49
205
|
)
|
|
50
206
|
|
|
51
207
|
# Set user context with anonymous shotgun instance ID from config
|
|
52
208
|
try:
|
|
209
|
+
import asyncio
|
|
210
|
+
|
|
53
211
|
from shotgun.agents.config import get_config_manager
|
|
54
212
|
|
|
55
213
|
config_manager = get_config_manager()
|
|
56
|
-
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
214
|
+
shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
|
|
57
215
|
sentry_sdk.set_user({"id": shotgun_instance_id})
|
|
58
216
|
logger.debug("Sentry user context set with anonymous ID")
|
|
59
217
|
except Exception as e:
|
shotgun/telemetry.py
CHANGED
|
@@ -50,12 +50,14 @@ def setup_logfire_observability() -> bool:
|
|
|
50
50
|
|
|
51
51
|
# Set user context using baggage for all logs and spans
|
|
52
52
|
try:
|
|
53
|
+
import asyncio
|
|
54
|
+
|
|
53
55
|
from opentelemetry import baggage, context
|
|
54
56
|
|
|
55
57
|
from shotgun.agents.config import get_config_manager
|
|
56
58
|
|
|
57
59
|
config_manager = get_config_manager()
|
|
58
|
-
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
60
|
+
shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
|
|
59
61
|
|
|
60
62
|
# Set shotgun_instance_id as baggage in global context - this will be included in all logs/spans
|
|
61
63
|
ctx = baggage.set_baggage("shotgun_instance_id", shotgun_instance_id)
|
shotgun/tui/app.py
CHANGED
|
@@ -5,6 +5,7 @@ from textual.app import App, SystemCommand
|
|
|
5
5
|
from textual.binding import Binding
|
|
6
6
|
from textual.screen import Screen
|
|
7
7
|
|
|
8
|
+
from shotgun.agents.agent_manager import AgentManager
|
|
8
9
|
from shotgun.agents.config import ConfigManager, get_config_manager
|
|
9
10
|
from shotgun.agents.models import AgentType
|
|
10
11
|
from shotgun.logging_config import get_logger
|
|
@@ -18,7 +19,7 @@ from shotgun.utils.update_checker import (
|
|
|
18
19
|
|
|
19
20
|
from .screens.chat import ChatScreen
|
|
20
21
|
from .screens.directory_setup import DirectorySetupScreen
|
|
21
|
-
from .screens.
|
|
22
|
+
from .screens.github_issue import GitHubIssueScreen
|
|
22
23
|
from .screens.model_picker import ModelPickerScreen
|
|
23
24
|
from .screens.pipx_migration import PipxMigrationScreen
|
|
24
25
|
from .screens.provider_config import ProviderConfigScreen
|
|
@@ -34,7 +35,7 @@ class ShotgunApp(App[None]):
|
|
|
34
35
|
"provider_config": ProviderConfigScreen,
|
|
35
36
|
"model_picker": ModelPickerScreen,
|
|
36
37
|
"directory_setup": DirectorySetupScreen,
|
|
37
|
-
"
|
|
38
|
+
"github_issue": GitHubIssueScreen,
|
|
38
39
|
}
|
|
39
40
|
BINDINGS = [
|
|
40
41
|
Binding("ctrl+c", "quit", "Quit the app"),
|
|
@@ -95,65 +96,75 @@ class ShotgunApp(App[None]):
|
|
|
95
96
|
)
|
|
96
97
|
return
|
|
97
98
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
# Run async config loading in worker
|
|
100
|
+
async def _check_config() -> None:
|
|
101
|
+
# Show welcome screen if no providers are configured OR if user hasn't seen it yet
|
|
102
|
+
config = await self.config_manager.load()
|
|
103
|
+
has_any_key = await self.config_manager.has_any_provider_key()
|
|
104
|
+
if not has_any_key or not config.shown_welcome_screen:
|
|
105
|
+
if isinstance(self.screen, WelcomeScreen):
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
self.push_screen(
|
|
109
|
+
WelcomeScreen(),
|
|
110
|
+
callback=lambda _arg: self.refresh_startup_screen(),
|
|
111
|
+
)
|
|
105
112
|
return
|
|
106
113
|
|
|
107
|
-
self.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
if not self.check_local_shotgun_directory_exists():
|
|
115
|
+
if isinstance(self.screen, DirectorySetupScreen):
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
self.push_screen(
|
|
119
|
+
DirectorySetupScreen(),
|
|
120
|
+
callback=lambda _arg: self.refresh_startup_screen(),
|
|
121
|
+
)
|
|
122
|
+
return
|
|
112
123
|
|
|
113
|
-
|
|
114
|
-
if isinstance(self.screen, DirectorySetupScreen):
|
|
124
|
+
if isinstance(self.screen, ChatScreen):
|
|
115
125
|
return
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
# Create ChatScreen with all dependencies injected from container
|
|
128
|
+
# Get the default agent mode (RESEARCH)
|
|
129
|
+
agent_mode = AgentType.RESEARCH
|
|
130
|
+
|
|
131
|
+
# Create AgentDeps asynchronously (get_provider_model is now async)
|
|
132
|
+
from shotgun.tui.dependencies import create_default_tui_deps
|
|
133
|
+
|
|
134
|
+
agent_deps = await create_default_tui_deps()
|
|
135
|
+
|
|
136
|
+
# Create AgentManager with async initialization
|
|
137
|
+
agent_manager = AgentManager(deps=agent_deps, initial_type=agent_mode)
|
|
138
|
+
|
|
139
|
+
# Create ProcessingStateManager - we'll pass the screen after creation
|
|
140
|
+
# For now, create with None and the ChatScreen will set itself
|
|
141
|
+
chat_screen = ChatScreen(
|
|
142
|
+
agent_manager=agent_manager,
|
|
143
|
+
conversation_manager=self.container.conversation_manager(),
|
|
144
|
+
conversation_service=self.container.conversation_service(),
|
|
145
|
+
widget_coordinator=self.container.widget_coordinator_factory(
|
|
146
|
+
screen=None
|
|
147
|
+
),
|
|
148
|
+
processing_state=self.container.processing_state_factory(
|
|
149
|
+
screen=None, # Will be set after ChatScreen is created
|
|
150
|
+
telemetry_context={"agent_mode": agent_mode.value},
|
|
151
|
+
),
|
|
152
|
+
command_handler=self.container.command_handler(),
|
|
153
|
+
placeholder_hints=self.container.placeholder_hints(),
|
|
154
|
+
codebase_sdk=self.container.codebase_sdk(),
|
|
155
|
+
deps=agent_deps,
|
|
156
|
+
continue_session=self.continue_session,
|
|
157
|
+
force_reindex=self.force_reindex,
|
|
120
158
|
)
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
if isinstance(self.screen, ChatScreen):
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
# Create ChatScreen with all dependencies injected from container
|
|
127
|
-
# Get the default agent mode (RESEARCH)
|
|
128
|
-
agent_mode = AgentType.RESEARCH
|
|
129
|
-
|
|
130
|
-
# Create AgentManager with the correct mode
|
|
131
|
-
agent_manager = self.container.agent_manager_factory(initial_type=agent_mode)
|
|
132
|
-
|
|
133
|
-
# Create ProcessingStateManager - we'll pass the screen after creation
|
|
134
|
-
# For now, create with None and the ChatScreen will set itself
|
|
135
|
-
chat_screen = ChatScreen(
|
|
136
|
-
agent_manager=agent_manager,
|
|
137
|
-
conversation_manager=self.container.conversation_manager(),
|
|
138
|
-
conversation_service=self.container.conversation_service(),
|
|
139
|
-
widget_coordinator=self.container.widget_coordinator_factory(screen=None),
|
|
140
|
-
processing_state=self.container.processing_state_factory(
|
|
141
|
-
screen=None, # Will be set after ChatScreen is created
|
|
142
|
-
telemetry_context={"agent_mode": agent_mode.value},
|
|
143
|
-
),
|
|
144
|
-
command_handler=self.container.command_handler(),
|
|
145
|
-
placeholder_hints=self.container.placeholder_hints(),
|
|
146
|
-
codebase_sdk=self.container.codebase_sdk(),
|
|
147
|
-
deps=self.container.agent_deps(),
|
|
148
|
-
continue_session=self.continue_session,
|
|
149
|
-
force_reindex=self.force_reindex,
|
|
150
|
-
)
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
# Update the ProcessingStateManager and WidgetCoordinator with the actual ChatScreen instance
|
|
161
|
+
chat_screen.processing_state.screen = chat_screen
|
|
162
|
+
chat_screen.widget_coordinator.screen = chat_screen
|
|
155
163
|
|
|
156
|
-
|
|
164
|
+
self.push_screen(chat_screen)
|
|
165
|
+
|
|
166
|
+
# Run the async config check in a worker
|
|
167
|
+
self.run_worker(_check_config(), exclusive=False)
|
|
157
168
|
|
|
158
169
|
def check_local_shotgun_directory_exists(self) -> bool:
|
|
159
170
|
shotgun_dir = get_shotgun_base_path()
|
|
@@ -170,20 +181,15 @@ class ShotgunApp(App[None]):
|
|
|
170
181
|
def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
|
|
171
182
|
return [
|
|
172
183
|
SystemCommand(
|
|
173
|
-
"
|
|
184
|
+
"New Issue",
|
|
185
|
+
"Report a bug or request a feature on GitHub",
|
|
186
|
+
self.action_new_issue,
|
|
174
187
|
)
|
|
175
|
-
]
|
|
176
|
-
|
|
177
|
-
def action_feedback(self) -> None:
|
|
178
|
-
"""Open feedback screen and submit feedback."""
|
|
179
|
-
from shotgun.posthog_telemetry import Feedback, submit_feedback_survey
|
|
180
|
-
|
|
181
|
-
def handle_feedback(feedback: Feedback | None) -> None:
|
|
182
|
-
if feedback is not None:
|
|
183
|
-
submit_feedback_survey(feedback)
|
|
184
|
-
self.notify("Feedback sent. Thank you!")
|
|
188
|
+
]
|
|
185
189
|
|
|
186
|
-
|
|
190
|
+
def action_new_issue(self) -> None:
|
|
191
|
+
"""Open GitHub issue screen to guide users to create an issue."""
|
|
192
|
+
self.push_screen(GitHubIssueScreen())
|
|
187
193
|
|
|
188
194
|
|
|
189
195
|
def run(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Context window indicator component for showing model usage."""
|
|
2
2
|
|
|
3
3
|
from textual.reactive import reactive
|
|
4
|
+
from textual.timer import Timer
|
|
4
5
|
from textual.widgets import Static
|
|
5
6
|
|
|
6
7
|
from shotgun.agents.config.models import MODEL_SPECS, ModelName
|
|
@@ -20,6 +21,10 @@ class ContextIndicator(Static):
|
|
|
20
21
|
|
|
21
22
|
context_analysis: reactive[ContextAnalysis | None] = reactive(None)
|
|
22
23
|
model_name: reactive[ModelName | None] = reactive(None)
|
|
24
|
+
is_streaming: reactive[bool] = reactive(False)
|
|
25
|
+
|
|
26
|
+
_animation_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
27
|
+
_animation_index = 0
|
|
23
28
|
|
|
24
29
|
def __init__(
|
|
25
30
|
self,
|
|
@@ -29,6 +34,7 @@ class ContextIndicator(Static):
|
|
|
29
34
|
classes: str | None = None,
|
|
30
35
|
) -> None:
|
|
31
36
|
super().__init__(name=name, id=id, classes=classes)
|
|
37
|
+
self._animation_timer: Timer | None = None
|
|
32
38
|
|
|
33
39
|
def update_context(
|
|
34
40
|
self, analysis: ContextAnalysis | None, model: ModelName | None
|
|
@@ -43,6 +49,38 @@ class ContextIndicator(Static):
|
|
|
43
49
|
self.model_name = model
|
|
44
50
|
self._refresh_display()
|
|
45
51
|
|
|
52
|
+
def set_streaming(self, streaming: bool) -> None:
|
|
53
|
+
"""Enable or disable streaming animation.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
streaming: Whether to show streaming animation
|
|
57
|
+
"""
|
|
58
|
+
self.is_streaming = streaming
|
|
59
|
+
if streaming:
|
|
60
|
+
self._start_animation()
|
|
61
|
+
else:
|
|
62
|
+
self._stop_animation()
|
|
63
|
+
|
|
64
|
+
def _start_animation(self) -> None:
|
|
65
|
+
"""Start the pulsing animation."""
|
|
66
|
+
if self._animation_timer is None:
|
|
67
|
+
self._animation_timer = self.set_interval(0.1, self._animate_frame)
|
|
68
|
+
|
|
69
|
+
def _stop_animation(self) -> None:
|
|
70
|
+
"""Stop the pulsing animation."""
|
|
71
|
+
if self._animation_timer is not None:
|
|
72
|
+
self._animation_timer.stop()
|
|
73
|
+
self._animation_timer = None
|
|
74
|
+
self._animation_index = 0
|
|
75
|
+
self._refresh_display()
|
|
76
|
+
|
|
77
|
+
def _animate_frame(self) -> None:
|
|
78
|
+
"""Advance the animation frame."""
|
|
79
|
+
self._animation_index = (self._animation_index + 1) % len(
|
|
80
|
+
self._animation_frames
|
|
81
|
+
)
|
|
82
|
+
self._refresh_display()
|
|
83
|
+
|
|
46
84
|
def _get_percentage_color(self, percentage: float) -> str:
|
|
47
85
|
"""Get color for percentage based on threshold.
|
|
48
86
|
|
|
@@ -112,6 +150,11 @@ class ContextIndicator(Static):
|
|
|
112
150
|
f"[{color}]{percentage}% ({current_tokens}/{max_tokens})[/]",
|
|
113
151
|
]
|
|
114
152
|
|
|
153
|
+
# Add streaming animation indicator if streaming
|
|
154
|
+
if self.is_streaming:
|
|
155
|
+
animation_char = self._animation_frames[self._animation_index]
|
|
156
|
+
parts.append(f"[bold cyan]{animation_char}[/]")
|
|
157
|
+
|
|
115
158
|
# Add model name if available
|
|
116
159
|
if self.model_name:
|
|
117
160
|
model_spec = MODEL_SPECS.get(self.model_name)
|