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.
- shotgun/agents/agent_manager.py +66 -12
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +287 -32
- shotgun/agents/config/models.py +21 -1
- shotgun/agents/config/provider.py +27 -0
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/conversation_manager.py +14 -7
- shotgun/agents/history/history_processors.py +99 -3
- shotgun/agents/history/token_counting/openai.py +3 -1
- shotgun/build_constants.py +3 -3
- shotgun/exceptions.py +32 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +2 -0
- shotgun/posthog_telemetry.py +18 -25
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sentry_telemetry.py +157 -1
- shotgun/settings.py +5 -0
- shotgun/tui/app.py +16 -15
- shotgun/tui/screens/chat/chat_screen.py +156 -61
- shotgun/tui/screens/chat_screen/command_providers.py +13 -2
- shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
- shotgun/tui/screens/directory_setup.py +14 -5
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +111 -0
- shotgun/tui/screens/model_picker.py +8 -1
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +12 -6
- shotgun/tui/screens/provider_config.py +25 -8
- shotgun/tui/screens/shotgun_auth.py +0 -10
- shotgun/tui/screens/welcome.py +32 -0
- shotgun/tui/services/conversation_service.py +8 -6
- shotgun/tui/widgets/widget_coordinator.py +3 -2
- shotgun_sh-0.2.19.dist-info/METADATA +465 -0
- {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +38 -33
- shotgun_sh-0.2.11.dev3.dist-info/METADATA +0 -130
- {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/licenses/LICENSE +0 -0
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,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",
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
190
|
+
"New Issue",
|
|
191
|
+
"Report a bug or request a feature on GitHub",
|
|
192
|
+
self.action_new_issue,
|
|
185
193
|
)
|
|
186
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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.
|
|
948
|
+
self.agent_manager.add_hint_message(
|
|
949
|
+
HintMessage(message=f"✓ Deleted codebase: {graph_id}")
|
|
950
|
+
)
|
|
901
951
|
except CodebaseNotFoundError as exc:
|
|
902
|
-
self.
|
|
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.
|
|
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.
|
|
1012
|
-
|
|
1013
|
-
|
|
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.
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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=
|
|
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)
|