shotgun-sh 0.2.11.dev3__py3-none-any.whl → 0.2.19__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 (39) hide show
  1. shotgun/agents/agent_manager.py +66 -12
  2. shotgun/agents/config/README.md +89 -0
  3. shotgun/agents/config/__init__.py +10 -1
  4. shotgun/agents/config/manager.py +287 -32
  5. shotgun/agents/config/models.py +21 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/agents/conversation_manager.py +14 -7
  9. shotgun/agents/history/history_processors.py +99 -3
  10. shotgun/agents/history/token_counting/openai.py +3 -1
  11. shotgun/build_constants.py +3 -3
  12. shotgun/exceptions.py +32 -0
  13. shotgun/logging_config.py +42 -0
  14. shotgun/main.py +2 -0
  15. shotgun/posthog_telemetry.py +18 -25
  16. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  17. shotgun/sentry_telemetry.py +157 -1
  18. shotgun/settings.py +5 -0
  19. shotgun/tui/app.py +16 -15
  20. shotgun/tui/screens/chat/chat_screen.py +156 -61
  21. shotgun/tui/screens/chat_screen/command_providers.py +13 -2
  22. shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
  23. shotgun/tui/screens/directory_setup.py +14 -5
  24. shotgun/tui/screens/feedback.py +10 -3
  25. shotgun/tui/screens/github_issue.py +111 -0
  26. shotgun/tui/screens/model_picker.py +8 -1
  27. shotgun/tui/screens/onboarding.py +431 -0
  28. shotgun/tui/screens/pipx_migration.py +12 -6
  29. shotgun/tui/screens/provider_config.py +25 -8
  30. shotgun/tui/screens/shotgun_auth.py +0 -10
  31. shotgun/tui/screens/welcome.py +32 -0
  32. shotgun/tui/services/conversation_service.py +8 -6
  33. shotgun/tui/widgets/widget_coordinator.py +3 -2
  34. shotgun_sh-0.2.19.dist-info/METADATA +465 -0
  35. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +38 -33
  36. shotgun_sh-0.2.11.dev3.dist-info/METADATA +0 -130
  37. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
  38. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
  39. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/licenses/LICENSE +0 -0
@@ -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,20 +151,57 @@ 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
shotgun/settings.py CHANGED
@@ -108,6 +108,11 @@ class LoggingSettings(BaseSettings):
108
108
  default=True,
109
109
  description="Enable file logging output",
110
110
  )
111
+ max_log_files: int = Field(
112
+ default=10,
113
+ description="Maximum number of log files to keep (older files are deleted)",
114
+ ge=1,
115
+ )
111
116
 
112
117
  model_config = SettingsConfigDict(
113
118
  env_prefix="SHOTGUN_",
shotgun/tui/app.py CHANGED
@@ -6,7 +6,10 @@ from textual.binding import Binding
6
6
  from textual.screen import Screen
7
7
 
8
8
  from shotgun.agents.agent_manager import AgentManager
9
- from shotgun.agents.config import ConfigManager, get_config_manager
9
+ from shotgun.agents.config import (
10
+ ConfigManager,
11
+ get_config_manager,
12
+ )
10
13
  from shotgun.agents.models import AgentType
11
14
  from shotgun.logging_config import get_logger
12
15
  from shotgun.tui.containers import TUIContainer
@@ -19,7 +22,7 @@ from shotgun.utils.update_checker import (
19
22
 
20
23
  from .screens.chat import ChatScreen
21
24
  from .screens.directory_setup import DirectorySetupScreen
22
- from .screens.feedback import FeedbackScreen
25
+ from .screens.github_issue import GitHubIssueScreen
23
26
  from .screens.model_picker import ModelPickerScreen
24
27
  from .screens.pipx_migration import PipxMigrationScreen
25
28
  from .screens.provider_config import ProviderConfigScreen
@@ -35,7 +38,7 @@ class ShotgunApp(App[None]):
35
38
  "provider_config": ProviderConfigScreen,
36
39
  "model_picker": ModelPickerScreen,
37
40
  "directory_setup": DirectorySetupScreen,
38
- "feedback": FeedbackScreen,
41
+ "github_issue": GitHubIssueScreen,
39
42
  }
40
43
  BINDINGS = [
41
44
  Binding("ctrl+c", "quit", "Quit the app"),
@@ -99,7 +102,10 @@ class ShotgunApp(App[None]):
99
102
  # Run async config loading in worker
100
103
  async def _check_config() -> None:
101
104
  # Show welcome screen if no providers are configured OR if user hasn't seen it yet
105
+ # Note: If config migration fails, ConfigManager will auto-create fresh config
106
+ # and set migration_failed flag, which WelcomeScreen will display
102
107
  config = await self.config_manager.load()
108
+
103
109
  has_any_key = await self.config_manager.has_any_provider_key()
104
110
  if not has_any_key or not config.shown_welcome_screen:
105
111
  if isinstance(self.screen, WelcomeScreen):
@@ -181,20 +187,15 @@ class ShotgunApp(App[None]):
181
187
  def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
182
188
  return [
183
189
  SystemCommand(
184
- "Feedback", "Send us feedback or report a bug", self.action_feedback
190
+ "New Issue",
191
+ "Report a bug or request a feature on GitHub",
192
+ self.action_new_issue,
185
193
  )
186
- ] # we don't want any system commands
187
-
188
- def action_feedback(self) -> None:
189
- """Open feedback screen and submit feedback."""
190
- from shotgun.posthog_telemetry import Feedback, submit_feedback_survey
191
-
192
- def handle_feedback(feedback: Feedback | None) -> None:
193
- if feedback is not None:
194
- submit_feedback_survey(feedback)
195
- self.notify("Feedback sent. Thank you!")
194
+ ]
196
195
 
197
- self.push_screen(FeedbackScreen(), callback=handle_feedback)
196
+ def action_new_issue(self) -> None:
197
+ """Open GitHub issue screen to guide users to create an issue."""
198
+ self.push_screen(GitHubIssueScreen())
198
199
 
199
200
 
200
201
  def run(
@@ -2,6 +2,8 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import time
6
+ from datetime import datetime, timezone
5
7
  from pathlib import Path
6
8
  from typing import cast
7
9
 
@@ -10,6 +12,7 @@ from pydantic_ai.messages import (
10
12
  ModelRequest,
11
13
  ModelResponse,
12
14
  TextPart,
15
+ ToolCallPart,
13
16
  ToolReturnPart,
14
17
  UserPromptPart,
15
18
  )
@@ -31,6 +34,7 @@ from shotgun.agents.agent_manager import (
31
34
  ModelConfigUpdated,
32
35
  PartialResponseMessage,
33
36
  )
37
+ from shotgun.agents.config import get_config_manager
34
38
  from shotgun.agents.config.models import MODEL_SPECS
35
39
  from shotgun.agents.conversation_manager import ConversationManager
36
40
  from shotgun.agents.history.compaction import apply_persistent_compaction
@@ -45,6 +49,7 @@ from shotgun.codebase.core.manager import (
45
49
  CodebaseGraphManager,
46
50
  )
47
51
  from shotgun.codebase.models import IndexProgress, ProgressPhase
52
+ from shotgun.exceptions import ContextSizeLimitExceeded
48
53
  from shotgun.posthog_telemetry import track_event
49
54
  from shotgun.sdk.codebase import CodebaseSDK
50
55
  from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
@@ -70,6 +75,7 @@ from shotgun.tui.screens.chat_screen.command_providers import (
70
75
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
71
76
  from shotgun.tui.screens.chat_screen.history import ChatHistory
72
77
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
78
+ from shotgun.tui.screens.onboarding import OnboardingModal
73
79
  from shotgun.tui.services.conversation_service import ConversationService
74
80
  from shotgun.tui.state.processing_state import ProcessingStateManager
75
81
  from shotgun.tui.utils.mode_progress import PlaceholderHints
@@ -98,7 +104,6 @@ class ChatScreen(Screen[None]):
98
104
  history: PromptHistory = PromptHistory()
99
105
  messages = reactive(list[ModelMessage | HintMessage]())
100
106
  indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
101
- partial_message: reactive[ModelMessage | None] = reactive(None)
102
107
 
103
108
  # Q&A mode state (for structured output clarifying questions)
104
109
  qa_mode = reactive(False)
@@ -109,6 +114,10 @@ class ChatScreen(Screen[None]):
109
114
  # Working state - keep reactive for Textual watchers
110
115
  working = reactive(False)
111
116
 
117
+ # Throttle context indicator updates (in seconds)
118
+ _last_context_update: float = 0.0
119
+ _context_update_throttle: float = 5.0 # 5 seconds
120
+
112
121
  def __init__(
113
122
  self,
114
123
  agent_manager: AgentManager,
@@ -166,13 +175,17 @@ class ChatScreen(Screen[None]):
166
175
  self.processing_state.bind_spinner(self.query_one("#spinner", Spinner))
167
176
 
168
177
  # Load conversation history if --continue flag was provided
169
- if self.continue_session and self.conversation_manager.exists():
170
- self._load_conversation()
178
+ # Use call_later to handle async exists() check
179
+ if self.continue_session:
180
+ self.call_later(self._check_and_load_conversation)
171
181
 
172
182
  self.call_later(self.check_if_codebase_is_indexed)
173
183
  # Initial update of context indicator
174
184
  self.update_context_indicator()
175
185
 
186
+ # Show onboarding popup if not shown before
187
+ self.call_later(self._check_and_show_onboarding)
188
+
176
189
  async def on_key(self, event: events.Key) -> None:
177
190
  """Handle key presses for cancellation."""
178
191
  # If escape is pressed during Q&A mode, exit Q&A
@@ -271,10 +284,8 @@ class ChatScreen(Screen[None]):
271
284
  def action_toggle_mode(self) -> None:
272
285
  # Prevent mode switching during Q&A
273
286
  if self.qa_mode:
274
- self.notify(
275
- "Cannot switch modes while answering questions",
276
- severity="warning",
277
- timeout=3,
287
+ self.agent_manager.add_hint_message(
288
+ HintMessage(message="⚠️ Cannot switch modes while answering questions")
278
289
  )
279
290
  return
280
291
 
@@ -296,14 +307,22 @@ class ChatScreen(Screen[None]):
296
307
  if usage_hint:
297
308
  self.mount_hint(usage_hint)
298
309
  else:
299
- self.notify("No usage hint available", severity="error")
310
+ self.agent_manager.add_hint_message(
311
+ HintMessage(message="⚠️ No usage hint available")
312
+ )
300
313
 
301
314
  async def action_show_context(self) -> None:
302
315
  context_hint = await self.agent_manager.get_context_hint()
303
316
  if context_hint:
304
317
  self.mount_hint(context_hint)
305
318
  else:
306
- self.notify("No context analysis available", severity="error")
319
+ self.agent_manager.add_hint_message(
320
+ HintMessage(message="⚠️ No context analysis available")
321
+ )
322
+
323
+ def action_view_onboarding(self) -> None:
324
+ """Show the onboarding modal."""
325
+ self.app.push_screen(OnboardingModal())
307
326
 
308
327
  @work
309
328
  async def action_compact_conversation(self) -> None:
@@ -424,7 +443,9 @@ class ChatScreen(Screen[None]):
424
443
 
425
444
  except Exception as e:
426
445
  logger.error(f"Failed to compact conversation: {e}", exc_info=True)
427
- self.notify(f"Failed to compact: {e}", severity="error")
446
+ self.agent_manager.add_hint_message(
447
+ HintMessage(message=f"❌ Failed to compact: {e}")
448
+ )
428
449
  finally:
429
450
  # Hide spinner
430
451
  self.processing_state.stop_processing()
@@ -456,7 +477,7 @@ class ChatScreen(Screen[None]):
456
477
  self.agent_manager.ui_message_history = []
457
478
 
458
479
  # Use conversation service to clear conversation
459
- self.conversation_service.clear_conversation()
480
+ await self.conversation_service.clear_conversation()
460
481
 
461
482
  # Post message history updated event to refresh UI
462
483
  self.agent_manager.post_message(
@@ -472,7 +493,9 @@ class ChatScreen(Screen[None]):
472
493
 
473
494
  except Exception as e:
474
495
  logger.error(f"Failed to clear conversation: {e}", exc_info=True)
475
- self.notify(f"Failed to clear: {e}", severity="error")
496
+ self.agent_manager.add_hint_message(
497
+ HintMessage(message=f"❌ Failed to clear: {e}")
498
+ )
476
499
 
477
500
  @work(exclusive=False)
478
501
  async def update_context_indicator(self) -> None:
@@ -561,8 +584,6 @@ class ChatScreen(Screen[None]):
561
584
 
562
585
  @on(PartialResponseMessage)
563
586
  def handle_partial_response(self, event: PartialResponseMessage) -> None:
564
- self.partial_message = event.message
565
-
566
587
  # Filter event.messages to exclude ModelRequest with only ToolReturnPart
567
588
  # These are intermediate tool results that would render as empty (UserQuestionWidget
568
589
  # filters out ToolReturnPart in format_prompt_parts), causing user messages to disappear
@@ -586,16 +607,33 @@ class ChatScreen(Screen[None]):
586
607
  )
587
608
 
588
609
  # Use widget coordinator to set partial response
589
- self.widget_coordinator.set_partial_response(
590
- self.partial_message, new_message_list
610
+ self.widget_coordinator.set_partial_response(event.message, new_message_list)
611
+
612
+ # Skip context updates for file write operations (they don't add to input context)
613
+ has_file_write = any(
614
+ isinstance(msg, ModelResponse)
615
+ and any(
616
+ isinstance(part, ToolCallPart)
617
+ and part.tool_name in ("write_file", "append_file")
618
+ for part in msg.parts
619
+ )
620
+ for msg in event.messages
591
621
  )
592
622
 
593
- # Update context indicator with full message history including streaming messages
594
- # Combine existing agent history with new streaming messages for accurate token count
595
- combined_agent_history = self.agent_manager.message_history + event.messages
596
- self.update_context_indicator_with_messages(
597
- combined_agent_history, new_message_list
598
- )
623
+ if has_file_write:
624
+ return # Skip context update for file writes
625
+
626
+ # Throttle context indicator updates to improve performance during streaming
627
+ # Only update at most once per 5 seconds to avoid excessive token calculations
628
+ current_time = time.time()
629
+ if current_time - self._last_context_update >= self._context_update_throttle:
630
+ self._last_context_update = current_time
631
+ # Update context indicator with full message history including streaming messages
632
+ # Combine existing agent history with new streaming messages for accurate token count
633
+ combined_agent_history = self.agent_manager.message_history + event.messages
634
+ self.update_context_indicator_with_messages(
635
+ combined_agent_history, new_message_list
636
+ )
599
637
 
600
638
  def _clear_partial_response(self) -> None:
601
639
  # Use widget coordinator to clear partial response
@@ -655,32 +693,42 @@ class ChatScreen(Screen[None]):
655
693
  self.update_context_indicator()
656
694
 
657
695
  # If there are file operations, add a message showing the modified files
696
+ # Skip if hint was already added by agent_manager (e.g., in QA mode)
658
697
  if event.file_operations:
659
- chat_history = self.query_one(ChatHistory)
660
- if chat_history.vertical_tail:
661
- tracker = FileOperationTracker(operations=event.file_operations)
662
- display_path = tracker.get_display_path()
663
-
664
- if display_path:
665
- # Create a simple markdown message with the file path
666
- # The terminal emulator will make this clickable automatically
667
- path_obj = Path(display_path)
668
-
669
- if len(event.file_operations) == 1:
670
- message = f"📝 Modified: `{display_path}`"
671
- else:
672
- num_files = len({op.file_path for op in event.file_operations})
673
- if path_obj.is_dir():
674
- message = (
675
- f"📁 Modified {num_files} files in: `{display_path}`"
676
- )
698
+ # Check if file operation hint already exists in recent messages
699
+ file_hint_exists = any(
700
+ isinstance(msg, HintMessage)
701
+ and (
702
+ msg.message.startswith("📝 Modified:")
703
+ or msg.message.startswith("📁 Modified")
704
+ )
705
+ for msg in event.messages[-5:] # Check last 5 messages
706
+ )
707
+
708
+ if not file_hint_exists:
709
+ chat_history = self.query_one(ChatHistory)
710
+ if chat_history.vertical_tail:
711
+ tracker = FileOperationTracker(operations=event.file_operations)
712
+ display_path = tracker.get_display_path()
713
+
714
+ if display_path:
715
+ # Create a simple markdown message with the file path
716
+ # The terminal emulator will make this clickable automatically
717
+ path_obj = Path(display_path)
718
+
719
+ if len(event.file_operations) == 1:
720
+ message = f"📝 Modified: `{display_path}`"
677
721
  else:
678
- # Common path is a file, show parent directory
679
- message = (
680
- f"📁 Modified {num_files} files in: `{path_obj.parent}`"
722
+ num_files = len(
723
+ {op.file_path for op in event.file_operations}
681
724
  )
725
+ if path_obj.is_dir():
726
+ message = f"📁 Modified {num_files} files in: `{display_path}`"
727
+ else:
728
+ # Common path is a file, show parent directory
729
+ message = f"📁 Modified {num_files} files in: `{path_obj.parent}`"
682
730
 
683
- self.mount_hint(message)
731
+ self.mount_hint(message)
684
732
 
685
733
  # Check and display any marketing messages
686
734
  from shotgun.tui.app import ShotgunApp
@@ -897,11 +945,15 @@ class ChatScreen(Screen[None]):
897
945
  async def delete_codebase(self, graph_id: str) -> None:
898
946
  try:
899
947
  await self.codebase_sdk.delete_codebase(graph_id)
900
- self.notify(f"Deleted codebase: {graph_id}", severity="information")
948
+ self.agent_manager.add_hint_message(
949
+ HintMessage(message=f"✓ Deleted codebase: {graph_id}")
950
+ )
901
951
  except CodebaseNotFoundError as exc:
902
- self.notify(str(exc), severity="error")
952
+ self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
903
953
  except Exception as exc: # pragma: no cover - defensive UI path
904
- self.notify(f"Failed to delete codebase: {exc}", severity="error")
954
+ self.agent_manager.add_hint_message(
955
+ HintMessage(message=f"❌ Failed to delete codebase: {exc}")
956
+ )
905
957
 
906
958
  def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
907
959
  """Check if error is related to kuzu database corruption.
@@ -1008,9 +1060,10 @@ class ChatScreen(Screen[None]):
1008
1060
  )
1009
1061
  cleaned = await manager.cleanup_corrupted_databases()
1010
1062
  logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
1011
- self.notify(
1012
- f"Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})...",
1013
- severity="information",
1063
+ self.agent_manager.add_hint_message(
1064
+ HintMessage(
1065
+ message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
1066
+ )
1014
1067
  )
1015
1068
 
1016
1069
  # Pass the current working directory as the indexed_from_cwd
@@ -1038,22 +1091,22 @@ class ChatScreen(Screen[None]):
1038
1091
  logger.info(
1039
1092
  f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
1040
1093
  )
1041
- self.notify(
1042
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
1043
- severity="information",
1044
- timeout=8,
1094
+ self.agent_manager.add_hint_message(
1095
+ HintMessage(
1096
+ message=f"✓ Indexed codebase '{result.name}' (ID: {result.graph_id})"
1097
+ )
1045
1098
  )
1046
1099
  break # Success - exit retry loop
1047
1100
 
1048
1101
  except CodebaseAlreadyIndexedError as exc:
1049
1102
  progress_timer.stop()
1050
1103
  logger.warning(f"Codebase already indexed: {exc}")
1051
- self.notify(str(exc), severity="warning")
1104
+ self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
1052
1105
  return
1053
1106
  except InvalidPathError as exc:
1054
1107
  progress_timer.stop()
1055
1108
  logger.error(f"Invalid path error: {exc}")
1056
- self.notify(str(exc), severity="error")
1109
+ self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
1057
1110
  return
1058
1111
 
1059
1112
  except Exception as exc: # pragma: no cover - defensive UI path
@@ -1072,10 +1125,10 @@ class ChatScreen(Screen[None]):
1072
1125
  f"Failed to index codebase after {attempt + 1} attempts - "
1073
1126
  f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
1074
1127
  )
1075
- self.notify(
1076
- f"Failed to index codebase after {attempt + 1} attempts: {exc}",
1077
- severity="error",
1078
- timeout=30, # Keep error visible for 30 seconds
1128
+ self.agent_manager.add_hint_message(
1129
+ HintMessage(
1130
+ message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
1131
+ )
1079
1132
  )
1080
1133
  break
1081
1134
 
@@ -1106,6 +1159,27 @@ class ChatScreen(Screen[None]):
1106
1159
  except asyncio.CancelledError:
1107
1160
  # Handle cancellation gracefully - DO NOT re-raise
1108
1161
  self.mount_hint("⚠️ Operation cancelled by user")
1162
+ except ContextSizeLimitExceeded as e:
1163
+ # User-friendly error with actionable options
1164
+ hint = (
1165
+ f"⚠️ **Context too large for {e.model_name}**\n\n"
1166
+ f"Your conversation history exceeds this model's limit ({e.max_tokens:,} tokens).\n\n"
1167
+ f"**Choose an action:**\n\n"
1168
+ f"1. Switch to a larger model (`Ctrl+P` → Change Model)\n"
1169
+ f"2. Switch to a larger model, compact (`/compact`), then switch back to {e.model_name}\n"
1170
+ f"3. Clear conversation (`/clear`)\n"
1171
+ )
1172
+
1173
+ self.mount_hint(hint)
1174
+
1175
+ # Log for debugging (won't send to Sentry due to ErrorNotPickedUpBySentry)
1176
+ logger.info(
1177
+ "Context size limit exceeded",
1178
+ extra={
1179
+ "max_tokens": e.max_tokens,
1180
+ "model_name": e.model_name,
1181
+ },
1182
+ )
1109
1183
  except Exception as e:
1110
1184
  # Log with full stack trace to shotgun.log
1111
1185
  logger.exception(
@@ -1143,11 +1217,17 @@ class ChatScreen(Screen[None]):
1143
1217
  def _save_conversation(self) -> None:
1144
1218
  """Save the current conversation to persistent storage."""
1145
1219
  # Use conversation service for saving (run async in background)
1220
+ # Use exclusive=True to prevent concurrent saves that can cause file contention
1146
1221
  self.run_worker(
1147
1222
  self.conversation_service.save_conversation(self.agent_manager),
1148
- exclusive=False,
1223
+ exclusive=True,
1149
1224
  )
1150
1225
 
1226
+ async def _check_and_load_conversation(self) -> None:
1227
+ """Check if conversation exists and load it if it does."""
1228
+ if await self.conversation_manager.exists():
1229
+ self._load_conversation()
1230
+
1151
1231
  def _load_conversation(self) -> None:
1152
1232
  """Load conversation from persistent storage."""
1153
1233
 
@@ -1168,3 +1248,18 @@ class ChatScreen(Screen[None]):
1168
1248
  self.mode = restored_type
1169
1249
 
1170
1250
  self.run_worker(_do_load(), exclusive=False)
1251
+
1252
+ @work
1253
+ async def _check_and_show_onboarding(self) -> None:
1254
+ """Check if onboarding should be shown and display modal if needed."""
1255
+ config_manager = get_config_manager()
1256
+ config = await config_manager.load()
1257
+
1258
+ # Only show onboarding if it hasn't been shown before
1259
+ if config.shown_onboarding_popup is None:
1260
+ # Show the onboarding modal
1261
+ await self.app.push_screen_wait(OnboardingModal())
1262
+
1263
+ # Mark as shown in config with current timestamp
1264
+ config.shown_onboarding_popup = datetime.now(timezone.utc)
1265
+ await config_manager.save(config)