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.

Files changed (76) hide show
  1. shotgun/agents/agent_manager.py +194 -28
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/manager.py +64 -33
  4. shotgun/agents/config/models.py +25 -1
  5. shotgun/agents/config/provider.py +2 -2
  6. shotgun/agents/context_analyzer/analyzer.py +2 -24
  7. shotgun/agents/conversation_manager.py +35 -19
  8. shotgun/agents/export.py +2 -2
  9. shotgun/agents/history/history_processors.py +99 -3
  10. shotgun/agents/history/token_counting/anthropic.py +17 -1
  11. shotgun/agents/history/token_counting/base.py +14 -3
  12. shotgun/agents/history/token_counting/openai.py +11 -1
  13. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  14. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  15. shotgun/agents/history/token_counting/utils.py +0 -3
  16. shotgun/agents/plan.py +2 -2
  17. shotgun/agents/research.py +3 -3
  18. shotgun/agents/specify.py +2 -2
  19. shotgun/agents/tasks.py +2 -2
  20. shotgun/agents/tools/codebase/file_read.py +5 -2
  21. shotgun/agents/tools/file_management.py +11 -7
  22. shotgun/agents/tools/web_search/__init__.py +8 -8
  23. shotgun/agents/tools/web_search/anthropic.py +2 -2
  24. shotgun/agents/tools/web_search/gemini.py +1 -1
  25. shotgun/agents/tools/web_search/openai.py +1 -1
  26. shotgun/agents/tools/web_search/utils.py +2 -2
  27. shotgun/agents/usage_manager.py +16 -11
  28. shotgun/build_constants.py +1 -1
  29. shotgun/cli/clear.py +2 -1
  30. shotgun/cli/compact.py +3 -3
  31. shotgun/cli/config.py +8 -5
  32. shotgun/cli/context.py +2 -2
  33. shotgun/cli/export.py +1 -1
  34. shotgun/cli/feedback.py +4 -2
  35. shotgun/cli/plan.py +1 -1
  36. shotgun/cli/research.py +1 -1
  37. shotgun/cli/specify.py +1 -1
  38. shotgun/cli/tasks.py +1 -1
  39. shotgun/codebase/core/change_detector.py +5 -3
  40. shotgun/codebase/core/code_retrieval.py +4 -2
  41. shotgun/codebase/core/ingestor.py +10 -8
  42. shotgun/codebase/core/manager.py +3 -3
  43. shotgun/codebase/core/nl_query.py +1 -1
  44. shotgun/exceptions.py +32 -0
  45. shotgun/logging_config.py +10 -17
  46. shotgun/main.py +3 -1
  47. shotgun/posthog_telemetry.py +28 -25
  48. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  49. shotgun/sentry_telemetry.py +160 -2
  50. shotgun/telemetry.py +3 -1
  51. shotgun/tui/app.py +71 -65
  52. shotgun/tui/components/context_indicator.py +43 -0
  53. shotgun/tui/containers.py +15 -17
  54. shotgun/tui/dependencies.py +2 -2
  55. shotgun/tui/screens/chat/chat_screen.py +189 -45
  56. shotgun/tui/screens/chat/help_text.py +16 -15
  57. shotgun/tui/screens/chat_screen/command_providers.py +10 -0
  58. shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
  59. shotgun/tui/screens/feedback.py +4 -4
  60. shotgun/tui/screens/github_issue.py +102 -0
  61. shotgun/tui/screens/model_picker.py +21 -20
  62. shotgun/tui/screens/onboarding.py +431 -0
  63. shotgun/tui/screens/provider_config.py +50 -27
  64. shotgun/tui/screens/shotgun_auth.py +2 -2
  65. shotgun/tui/screens/welcome.py +14 -11
  66. shotgun/tui/services/conversation_service.py +16 -14
  67. shotgun/tui/utils/mode_progress.py +14 -7
  68. shotgun/tui/widgets/widget_coordinator.py +18 -2
  69. shotgun/utils/file_system_utils.py +19 -0
  70. shotgun/utils/marketing.py +110 -0
  71. shotgun_sh-0.2.17.dev1.dist-info/METADATA +465 -0
  72. {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/RECORD +75 -71
  73. shotgun_sh-0.2.11.dev1.dist-info/METADATA +0 -129
  74. {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/WHEEL +0 -0
  75. {shotgun_sh-0.2.11.dev1.dist-info → shotgun_sh-0.2.17.dev1.dist-info}/entry_points.txt +0 -0
  76. {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 rotation
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 TimedRotatingFileHandler - rotates daily and keeps 7 days of logs
128
- file_handler = logging.handlers.TimedRotatingFileHandler(
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
 
@@ -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
- # Set user context with anonymous shotgun instance ID from config
63
+ # Cache the shotgun instance ID for later use (avoids async issues)
61
64
  try:
62
- config_manager = get_config_manager()
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
- # Set default properties for all events
75
- posthog.disabled = False
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 user identified with anonymous ID: %s", shotgun_instance_id
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 set user context: %s", e)
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
- # Get shotgun instance ID for tracking
111
- config_manager = get_config_manager()
112
- shotgun_instance_id = config_manager.get_shotgun_instance_id()
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=shotgun_instance_id, event=event_name, properties=properties
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 = conversation_manager.load()
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 if the user's request is ambiguous or lacks sufficient detail.
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().
@@ -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", or "beta"
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.feedback import FeedbackScreen
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
- "feedback": FeedbackScreen,
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
- # Show welcome screen if no providers are configured OR if user hasn't seen it yet
99
- config = self.config_manager.load()
100
- if (
101
- not self.config_manager.has_any_provider_key()
102
- or not config.shown_welcome_screen
103
- ):
104
- if isinstance(self.screen, WelcomeScreen):
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.push_screen(
108
- WelcomeScreen(),
109
- callback=lambda _arg: self.refresh_startup_screen(),
110
- )
111
- return
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
- if not self.check_local_shotgun_directory_exists():
114
- if isinstance(self.screen, DirectorySetupScreen):
124
+ if isinstance(self.screen, ChatScreen):
115
125
  return
116
126
 
117
- self.push_screen(
118
- DirectorySetupScreen(),
119
- callback=lambda _arg: self.refresh_startup_screen(),
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
- # Update the ProcessingStateManager and WidgetCoordinator with the actual ChatScreen instance
153
- chat_screen.processing_state.screen = chat_screen
154
- chat_screen.widget_coordinator.screen = chat_screen
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
- self.push_screen(chat_screen)
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
- "Feedback", "Send us feedback or report a bug", self.action_feedback
184
+ "New Issue",
185
+ "Report a bug or request a feature on GitHub",
186
+ self.action_new_issue,
174
187
  )
175
- ] # we don't want any system commands
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
- self.push_screen(FeedbackScreen(), callback=handle_feedback)
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)