shotgun-sh 0.2.11.dev7__py3-none-any.whl → 0.2.23.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 +25 -11
- 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 +26 -1
- shotgun/agents/config/provider.py +27 -0
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/history/token_counting/anthropic.py +8 -0
- shotgun/agents/runner.py +230 -0
- shotgun/build_constants.py +1 -1
- shotgun/cli/context.py +43 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/exceptions.py +323 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -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/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +140 -2
- shotgun/settings.py +5 -0
- shotgun/tui/app.py +35 -10
- shotgun/tui/screens/chat/chat_screen.py +192 -91
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +62 -11
- shotgun/tui/screens/chat_screen/command_providers.py +3 -2
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/chat_history.py +37 -2
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +8 -1
- 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/widgets/widget_coordinator.py +3 -2
- shotgun_sh-0.2.23.dev1.dist-info/METADATA +472 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/RECORD +50 -42
- shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import time
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import cast
|
|
@@ -11,6 +12,7 @@ from pydantic_ai.messages import (
|
|
|
11
12
|
ModelRequest,
|
|
12
13
|
ModelResponse,
|
|
13
14
|
TextPart,
|
|
15
|
+
ToolCallPart,
|
|
14
16
|
ToolReturnPart,
|
|
15
17
|
UserPromptPart,
|
|
16
18
|
)
|
|
@@ -42,12 +44,17 @@ from shotgun.agents.models import (
|
|
|
42
44
|
AgentType,
|
|
43
45
|
FileOperationTracker,
|
|
44
46
|
)
|
|
47
|
+
from shotgun.agents.runner import AgentRunner
|
|
45
48
|
from shotgun.codebase.core.manager import (
|
|
46
49
|
CodebaseAlreadyIndexedError,
|
|
47
50
|
CodebaseGraphManager,
|
|
48
51
|
)
|
|
49
52
|
from shotgun.codebase.models import IndexProgress, ProgressPhase
|
|
50
|
-
from shotgun.exceptions import
|
|
53
|
+
from shotgun.exceptions import (
|
|
54
|
+
SHOTGUN_CONTACT_EMAIL,
|
|
55
|
+
ErrorNotPickedUpBySentry,
|
|
56
|
+
ShotgunAccountException,
|
|
57
|
+
)
|
|
51
58
|
from shotgun.posthog_telemetry import track_event
|
|
52
59
|
from shotgun.sdk.codebase import CodebaseSDK
|
|
53
60
|
from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
|
|
@@ -57,6 +64,8 @@ from shotgun.tui.components.mode_indicator import ModeIndicator
|
|
|
57
64
|
from shotgun.tui.components.prompt_input import PromptInput
|
|
58
65
|
from shotgun.tui.components.spinner import Spinner
|
|
59
66
|
from shotgun.tui.components.status_bar import StatusBar
|
|
67
|
+
|
|
68
|
+
# TUIErrorHandler removed - exceptions now caught directly
|
|
60
69
|
from shotgun.tui.screens.chat.codebase_index_prompt_screen import (
|
|
61
70
|
CodebaseIndexPromptScreen,
|
|
62
71
|
)
|
|
@@ -102,7 +111,6 @@ class ChatScreen(Screen[None]):
|
|
|
102
111
|
history: PromptHistory = PromptHistory()
|
|
103
112
|
messages = reactive(list[ModelMessage | HintMessage]())
|
|
104
113
|
indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
|
|
105
|
-
partial_message: reactive[ModelMessage | None] = reactive(None)
|
|
106
114
|
|
|
107
115
|
# Q&A mode state (for structured output clarifying questions)
|
|
108
116
|
qa_mode = reactive(False)
|
|
@@ -113,6 +121,10 @@ class ChatScreen(Screen[None]):
|
|
|
113
121
|
# Working state - keep reactive for Textual watchers
|
|
114
122
|
working = reactive(False)
|
|
115
123
|
|
|
124
|
+
# Throttle context indicator updates (in seconds)
|
|
125
|
+
_last_context_update: float = 0.0
|
|
126
|
+
_context_update_throttle: float = 5.0 # 5 seconds
|
|
127
|
+
|
|
116
128
|
def __init__(
|
|
117
129
|
self,
|
|
118
130
|
agent_manager: AgentManager,
|
|
@@ -279,10 +291,8 @@ class ChatScreen(Screen[None]):
|
|
|
279
291
|
def action_toggle_mode(self) -> None:
|
|
280
292
|
# Prevent mode switching during Q&A
|
|
281
293
|
if self.qa_mode:
|
|
282
|
-
self.
|
|
283
|
-
"Cannot switch modes while answering questions"
|
|
284
|
-
severity="warning",
|
|
285
|
-
timeout=3,
|
|
294
|
+
self.agent_manager.add_hint_message(
|
|
295
|
+
HintMessage(message="⚠️ Cannot switch modes while answering questions")
|
|
286
296
|
)
|
|
287
297
|
return
|
|
288
298
|
|
|
@@ -298,20 +308,90 @@ class ChatScreen(Screen[None]):
|
|
|
298
308
|
# Re-focus input after mode change
|
|
299
309
|
self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
|
|
300
310
|
|
|
301
|
-
def action_show_usage(self) -> None:
|
|
311
|
+
async def action_show_usage(self) -> None:
|
|
302
312
|
usage_hint = self.agent_manager.get_usage_hint()
|
|
303
313
|
logger.info(f"Usage hint: {usage_hint}")
|
|
314
|
+
|
|
315
|
+
# Add budget info for Shotgun Account users
|
|
316
|
+
if self.deps.llm_model.is_shotgun_account:
|
|
317
|
+
try:
|
|
318
|
+
from shotgun.llm_proxy import LiteLLMProxyClient
|
|
319
|
+
|
|
320
|
+
logger.debug("Fetching budget info for Shotgun Account")
|
|
321
|
+
client = LiteLLMProxyClient(self.deps.llm_model.api_key)
|
|
322
|
+
budget_info = await client.get_budget_info()
|
|
323
|
+
|
|
324
|
+
# Format budget section
|
|
325
|
+
source_label = "Key" if budget_info.source == "key" else "Team"
|
|
326
|
+
budget_section = f"""## Shotgun Account Budget
|
|
327
|
+
|
|
328
|
+
* Max Budget: ${budget_info.max_budget:.2f}
|
|
329
|
+
* Current Spend: ${budget_info.spend:.2f}
|
|
330
|
+
* Remaining: ${budget_info.remaining:.2f} ({100 - budget_info.percentage_used:.1f}%)
|
|
331
|
+
* Budget Source: {source_label}-level
|
|
332
|
+
|
|
333
|
+
**Questions or need help?**"""
|
|
334
|
+
|
|
335
|
+
# Build markdown_before (usage + budget info before email)
|
|
336
|
+
if usage_hint:
|
|
337
|
+
markdown_before = f"{usage_hint}\n\n{budget_section}"
|
|
338
|
+
else:
|
|
339
|
+
markdown_before = budget_section
|
|
340
|
+
|
|
341
|
+
markdown_after = (
|
|
342
|
+
"\n\n_Reach out anytime for billing questions "
|
|
343
|
+
"or to increase your budget._"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Mount with email copy button
|
|
347
|
+
self.mount_hint_with_email(
|
|
348
|
+
markdown_before=markdown_before,
|
|
349
|
+
email="contact@shotgun.sh",
|
|
350
|
+
markdown_after=markdown_after,
|
|
351
|
+
)
|
|
352
|
+
logger.debug("Successfully added budget info to usage hint")
|
|
353
|
+
return # Exit early since we've already mounted
|
|
354
|
+
|
|
355
|
+
except Exception as e:
|
|
356
|
+
logger.warning(f"Failed to fetch budget info: {e}")
|
|
357
|
+
# For Shotgun Account, show budget fetch error
|
|
358
|
+
# If we have usage data, still show it
|
|
359
|
+
if usage_hint:
|
|
360
|
+
# Show usage even though budget fetch failed
|
|
361
|
+
self.mount_hint(usage_hint)
|
|
362
|
+
else:
|
|
363
|
+
# No usage and budget fetch failed - show specific error with email
|
|
364
|
+
markdown_before = (
|
|
365
|
+
"⚠️ **Unable to fetch budget information**\n\n"
|
|
366
|
+
"There was an error retrieving your budget data."
|
|
367
|
+
)
|
|
368
|
+
markdown_after = (
|
|
369
|
+
"\n\n_Try the command again in a moment. "
|
|
370
|
+
"If the issue persists, reach out for help._"
|
|
371
|
+
)
|
|
372
|
+
self.mount_hint_with_email(
|
|
373
|
+
markdown_before=markdown_before,
|
|
374
|
+
email="contact@shotgun.sh",
|
|
375
|
+
markdown_after=markdown_after,
|
|
376
|
+
)
|
|
377
|
+
return # Exit early
|
|
378
|
+
|
|
379
|
+
# Fallback for non-Shotgun Account users
|
|
304
380
|
if usage_hint:
|
|
305
381
|
self.mount_hint(usage_hint)
|
|
306
382
|
else:
|
|
307
|
-
self.
|
|
383
|
+
self.agent_manager.add_hint_message(
|
|
384
|
+
HintMessage(message="⚠️ No usage hint available")
|
|
385
|
+
)
|
|
308
386
|
|
|
309
387
|
async def action_show_context(self) -> None:
|
|
310
388
|
context_hint = await self.agent_manager.get_context_hint()
|
|
311
389
|
if context_hint:
|
|
312
390
|
self.mount_hint(context_hint)
|
|
313
391
|
else:
|
|
314
|
-
self.
|
|
392
|
+
self.agent_manager.add_hint_message(
|
|
393
|
+
HintMessage(message="⚠️ No context analysis available")
|
|
394
|
+
)
|
|
315
395
|
|
|
316
396
|
def action_view_onboarding(self) -> None:
|
|
317
397
|
"""Show the onboarding modal."""
|
|
@@ -436,7 +516,9 @@ class ChatScreen(Screen[None]):
|
|
|
436
516
|
|
|
437
517
|
except Exception as e:
|
|
438
518
|
logger.error(f"Failed to compact conversation: {e}", exc_info=True)
|
|
439
|
-
self.
|
|
519
|
+
self.agent_manager.add_hint_message(
|
|
520
|
+
HintMessage(message=f"❌ Failed to compact: {e}")
|
|
521
|
+
)
|
|
440
522
|
finally:
|
|
441
523
|
# Hide spinner
|
|
442
524
|
self.processing_state.stop_processing()
|
|
@@ -484,7 +566,9 @@ class ChatScreen(Screen[None]):
|
|
|
484
566
|
|
|
485
567
|
except Exception as e:
|
|
486
568
|
logger.error(f"Failed to clear conversation: {e}", exc_info=True)
|
|
487
|
-
self.
|
|
569
|
+
self.agent_manager.add_hint_message(
|
|
570
|
+
HintMessage(message=f"❌ Failed to clear: {e}")
|
|
571
|
+
)
|
|
488
572
|
|
|
489
573
|
@work(exclusive=False)
|
|
490
574
|
async def update_context_indicator(self) -> None:
|
|
@@ -571,10 +655,23 @@ class ChatScreen(Screen[None]):
|
|
|
571
655
|
hint = HintMessage(message=markdown)
|
|
572
656
|
self.agent_manager.add_hint_message(hint)
|
|
573
657
|
|
|
658
|
+
def mount_hint_with_email(
|
|
659
|
+
self, markdown_before: str, email: str, markdown_after: str = ""
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Mount a hint with inline email copy button.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
markdown_before: Markdown content to display before the email line
|
|
665
|
+
email: Email address to display with copy button
|
|
666
|
+
markdown_after: Optional markdown content to display after the email line
|
|
667
|
+
"""
|
|
668
|
+
hint = HintMessage(
|
|
669
|
+
message=markdown_before, email=email, markdown_after=markdown_after
|
|
670
|
+
)
|
|
671
|
+
self.agent_manager.add_hint_message(hint)
|
|
672
|
+
|
|
574
673
|
@on(PartialResponseMessage)
|
|
575
674
|
def handle_partial_response(self, event: PartialResponseMessage) -> None:
|
|
576
|
-
self.partial_message = event.message
|
|
577
|
-
|
|
578
675
|
# Filter event.messages to exclude ModelRequest with only ToolReturnPart
|
|
579
676
|
# These are intermediate tool results that would render as empty (UserQuestionWidget
|
|
580
677
|
# filters out ToolReturnPart in format_prompt_parts), causing user messages to disappear
|
|
@@ -598,16 +695,33 @@ class ChatScreen(Screen[None]):
|
|
|
598
695
|
)
|
|
599
696
|
|
|
600
697
|
# Use widget coordinator to set partial response
|
|
601
|
-
self.widget_coordinator.set_partial_response(
|
|
602
|
-
|
|
698
|
+
self.widget_coordinator.set_partial_response(event.message, new_message_list)
|
|
699
|
+
|
|
700
|
+
# Skip context updates for file write operations (they don't add to input context)
|
|
701
|
+
has_file_write = any(
|
|
702
|
+
isinstance(msg, ModelResponse)
|
|
703
|
+
and any(
|
|
704
|
+
isinstance(part, ToolCallPart)
|
|
705
|
+
and part.tool_name in ("write_file", "append_file")
|
|
706
|
+
for part in msg.parts
|
|
707
|
+
)
|
|
708
|
+
for msg in event.messages
|
|
603
709
|
)
|
|
604
710
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
)
|
|
711
|
+
if has_file_write:
|
|
712
|
+
return # Skip context update for file writes
|
|
713
|
+
|
|
714
|
+
# Throttle context indicator updates to improve performance during streaming
|
|
715
|
+
# Only update at most once per 5 seconds to avoid excessive token calculations
|
|
716
|
+
current_time = time.time()
|
|
717
|
+
if current_time - self._last_context_update >= self._context_update_throttle:
|
|
718
|
+
self._last_context_update = current_time
|
|
719
|
+
# Update context indicator with full message history including streaming messages
|
|
720
|
+
# Combine existing agent history with new streaming messages for accurate token count
|
|
721
|
+
combined_agent_history = self.agent_manager.message_history + event.messages
|
|
722
|
+
self.update_context_indicator_with_messages(
|
|
723
|
+
combined_agent_history, new_message_list
|
|
724
|
+
)
|
|
611
725
|
|
|
612
726
|
def _clear_partial_response(self) -> None:
|
|
613
727
|
# Use widget coordinator to clear partial response
|
|
@@ -742,6 +856,19 @@ class ChatScreen(Screen[None]):
|
|
|
742
856
|
# Update the agent manager's model configuration
|
|
743
857
|
self.agent_manager.deps.llm_model = result.model_config
|
|
744
858
|
|
|
859
|
+
# Reset agents so they get recreated with new model
|
|
860
|
+
self.agent_manager._agents_initialized = False
|
|
861
|
+
self.agent_manager._research_agent = None
|
|
862
|
+
self.agent_manager._plan_agent = None
|
|
863
|
+
self.agent_manager._tasks_agent = None
|
|
864
|
+
self.agent_manager._specify_agent = None
|
|
865
|
+
self.agent_manager._export_agent = None
|
|
866
|
+
self.agent_manager._research_deps = None
|
|
867
|
+
self.agent_manager._plan_deps = None
|
|
868
|
+
self.agent_manager._tasks_deps = None
|
|
869
|
+
self.agent_manager._specify_deps = None
|
|
870
|
+
self.agent_manager._export_deps = None
|
|
871
|
+
|
|
745
872
|
# Get current analysis and update context indicator via coordinator
|
|
746
873
|
analysis = await self.agent_manager.get_context_analysis()
|
|
747
874
|
self.widget_coordinator.update_context_indicator(analysis, result.new_model)
|
|
@@ -919,11 +1046,15 @@ class ChatScreen(Screen[None]):
|
|
|
919
1046
|
async def delete_codebase(self, graph_id: str) -> None:
|
|
920
1047
|
try:
|
|
921
1048
|
await self.codebase_sdk.delete_codebase(graph_id)
|
|
922
|
-
self.
|
|
1049
|
+
self.agent_manager.add_hint_message(
|
|
1050
|
+
HintMessage(message=f"✓ Deleted codebase: {graph_id}")
|
|
1051
|
+
)
|
|
923
1052
|
except CodebaseNotFoundError as exc:
|
|
924
|
-
self.
|
|
1053
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
925
1054
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
926
|
-
self.
|
|
1055
|
+
self.agent_manager.add_hint_message(
|
|
1056
|
+
HintMessage(message=f"❌ Failed to delete codebase: {exc}")
|
|
1057
|
+
)
|
|
927
1058
|
|
|
928
1059
|
def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
|
|
929
1060
|
"""Check if error is related to kuzu database corruption.
|
|
@@ -1030,9 +1161,10 @@ class ChatScreen(Screen[None]):
|
|
|
1030
1161
|
)
|
|
1031
1162
|
cleaned = await manager.cleanup_corrupted_databases()
|
|
1032
1163
|
logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
|
|
1033
|
-
self.
|
|
1034
|
-
|
|
1035
|
-
|
|
1164
|
+
self.agent_manager.add_hint_message(
|
|
1165
|
+
HintMessage(
|
|
1166
|
+
message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
|
|
1167
|
+
)
|
|
1036
1168
|
)
|
|
1037
1169
|
|
|
1038
1170
|
# Pass the current working directory as the indexed_from_cwd
|
|
@@ -1060,22 +1192,22 @@ class ChatScreen(Screen[None]):
|
|
|
1060
1192
|
logger.info(
|
|
1061
1193
|
f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
|
|
1062
1194
|
)
|
|
1063
|
-
self.
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1195
|
+
self.agent_manager.add_hint_message(
|
|
1196
|
+
HintMessage(
|
|
1197
|
+
message=f"✓ Indexed codebase '{result.name}' (ID: {result.graph_id})"
|
|
1198
|
+
)
|
|
1067
1199
|
)
|
|
1068
1200
|
break # Success - exit retry loop
|
|
1069
1201
|
|
|
1070
1202
|
except CodebaseAlreadyIndexedError as exc:
|
|
1071
1203
|
progress_timer.stop()
|
|
1072
1204
|
logger.warning(f"Codebase already indexed: {exc}")
|
|
1073
|
-
self.
|
|
1205
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
|
|
1074
1206
|
return
|
|
1075
1207
|
except InvalidPathError as exc:
|
|
1076
1208
|
progress_timer.stop()
|
|
1077
1209
|
logger.error(f"Invalid path error: {exc}")
|
|
1078
|
-
self.
|
|
1210
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
1079
1211
|
return
|
|
1080
1212
|
|
|
1081
1213
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
@@ -1094,10 +1226,10 @@ class ChatScreen(Screen[None]):
|
|
|
1094
1226
|
f"Failed to index codebase after {attempt + 1} attempts - "
|
|
1095
1227
|
f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
|
|
1096
1228
|
)
|
|
1097
|
-
self.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1229
|
+
self.agent_manager.add_hint_message(
|
|
1230
|
+
HintMessage(
|
|
1231
|
+
message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
|
|
1232
|
+
)
|
|
1101
1233
|
)
|
|
1102
1234
|
break
|
|
1103
1235
|
|
|
@@ -1108,8 +1240,6 @@ class ChatScreen(Screen[None]):
|
|
|
1108
1240
|
|
|
1109
1241
|
@work
|
|
1110
1242
|
async def run_agent(self, message: str) -> None:
|
|
1111
|
-
prompt = None
|
|
1112
|
-
|
|
1113
1243
|
# Start processing with spinner
|
|
1114
1244
|
from textual.worker import get_current_worker
|
|
1115
1245
|
|
|
@@ -1119,60 +1249,31 @@ class ChatScreen(Screen[None]):
|
|
|
1119
1249
|
# Start context indicator animation immediately
|
|
1120
1250
|
self.widget_coordinator.set_context_streaming(True)
|
|
1121
1251
|
|
|
1122
|
-
prompt = message
|
|
1123
|
-
|
|
1124
1252
|
try:
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
)
|
|
1128
|
-
except
|
|
1129
|
-
#
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
f"3. Clear conversation (`/clear`)\n"
|
|
1140
|
-
)
|
|
1141
|
-
|
|
1142
|
-
self.mount_hint(hint)
|
|
1143
|
-
|
|
1144
|
-
# Log for debugging (won't send to Sentry due to ErrorNotPickedUpBySentry)
|
|
1145
|
-
logger.info(
|
|
1146
|
-
"Context size limit exceeded",
|
|
1147
|
-
extra={
|
|
1148
|
-
"max_tokens": e.max_tokens,
|
|
1149
|
-
"model_name": e.model_name,
|
|
1150
|
-
},
|
|
1151
|
-
)
|
|
1152
|
-
except Exception as e:
|
|
1153
|
-
# Log with full stack trace to shotgun.log
|
|
1154
|
-
logger.exception(
|
|
1155
|
-
"Agent run failed",
|
|
1156
|
-
extra={
|
|
1157
|
-
"agent_mode": self.mode.value,
|
|
1158
|
-
"error_type": type(e).__name__,
|
|
1159
|
-
},
|
|
1160
|
-
)
|
|
1161
|
-
|
|
1162
|
-
# Determine user-friendly message based on error type
|
|
1163
|
-
error_name = type(e).__name__
|
|
1164
|
-
error_message = str(e)
|
|
1165
|
-
|
|
1166
|
-
if "APIStatusError" in error_name and "overload" in error_message.lower():
|
|
1167
|
-
hint = "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
|
|
1168
|
-
elif "APIStatusError" in error_name and "rate" in error_message.lower():
|
|
1169
|
-
hint = "⚠️ Rate limit reached. Please wait before trying again."
|
|
1170
|
-
elif "APIStatusError" in error_name:
|
|
1171
|
-
hint = f"⚠️ AI service error: {error_message}"
|
|
1253
|
+
# Use unified agent runner - exceptions propagate for handling
|
|
1254
|
+
runner = AgentRunner(self.agent_manager)
|
|
1255
|
+
await runner.run(message)
|
|
1256
|
+
except ShotgunAccountException as e:
|
|
1257
|
+
# Shotgun Account errors show contact email UI
|
|
1258
|
+
message_parts = e.to_markdown().split("**Need help?**")
|
|
1259
|
+
if len(message_parts) == 2:
|
|
1260
|
+
markdown_before = message_parts[0] + "**Need help?**"
|
|
1261
|
+
markdown_after = message_parts[1].strip()
|
|
1262
|
+
self.mount_hint_with_email(
|
|
1263
|
+
markdown_before=markdown_before,
|
|
1264
|
+
email=SHOTGUN_CONTACT_EMAIL,
|
|
1265
|
+
markdown_after=markdown_after,
|
|
1266
|
+
)
|
|
1172
1267
|
else:
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1268
|
+
# Fallback if message format is unexpected
|
|
1269
|
+
self.mount_hint(e.to_markdown())
|
|
1270
|
+
except ErrorNotPickedUpBySentry as e:
|
|
1271
|
+
# All other user-actionable errors - display with markdown
|
|
1272
|
+
self.mount_hint(e.to_markdown())
|
|
1273
|
+
except Exception as e:
|
|
1274
|
+
# Unexpected errors that weren't wrapped (shouldn't happen)
|
|
1275
|
+
logger.exception("Unexpected error in run_agent")
|
|
1276
|
+
self.mount_hint(f"⚠️ An unexpected error occurred: {str(e)}")
|
|
1176
1277
|
finally:
|
|
1177
1278
|
self.processing_state.stop_processing()
|
|
1178
1279
|
# Stop context indicator animation
|
|
@@ -4,9 +4,11 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from textual import on
|
|
6
6
|
from textual.app import ComposeResult
|
|
7
|
-
from textual.containers import Container
|
|
7
|
+
from textual.containers import Container, VerticalScroll
|
|
8
8
|
from textual.screen import ModalScreen
|
|
9
|
-
from textual.widgets import Button, Label,
|
|
9
|
+
from textual.widgets import Button, Label, Markdown
|
|
10
|
+
|
|
11
|
+
from shotgun.utils.file_system_utils import get_shotgun_home
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class CodebaseIndexPromptScreen(ModalScreen[bool]):
|
|
@@ -19,39 +21,88 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
CodebaseIndexPromptScreen > #index-prompt-dialog {
|
|
22
|
-
width:
|
|
23
|
-
max-width:
|
|
24
|
+
width: 80%;
|
|
25
|
+
max-width: 90;
|
|
24
26
|
height: auto;
|
|
27
|
+
max-height: 85%;
|
|
25
28
|
border: wide $primary;
|
|
26
29
|
padding: 1 2;
|
|
27
30
|
layout: vertical;
|
|
28
31
|
background: $surface;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#index-prompt-title {
|
|
35
|
+
text-style: bold;
|
|
36
|
+
color: $text-accent;
|
|
37
|
+
text-align: center;
|
|
38
|
+
padding-bottom: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#index-prompt-content {
|
|
29
42
|
height: auto;
|
|
43
|
+
max-height: 1fr;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#index-prompt-info {
|
|
47
|
+
padding: 0 1;
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
#index-prompt-buttons {
|
|
33
51
|
layout: horizontal;
|
|
34
52
|
align-horizontal: right;
|
|
35
53
|
height: auto;
|
|
54
|
+
padding-top: 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#index-prompt-buttons Button {
|
|
58
|
+
margin: 0 1;
|
|
59
|
+
min-width: 12;
|
|
36
60
|
}
|
|
37
61
|
"""
|
|
38
62
|
|
|
39
63
|
def compose(self) -> ComposeResult:
|
|
64
|
+
storage_path = get_shotgun_home() / "codebases"
|
|
65
|
+
cwd = Path.cwd()
|
|
66
|
+
|
|
67
|
+
# Build the markdown content with privacy-first messaging
|
|
68
|
+
content = f"""
|
|
69
|
+
## 🔒 Your code never leaves your computer
|
|
70
|
+
|
|
71
|
+
Shotgun will index the codebase at:
|
|
72
|
+
**`{cwd}`**
|
|
73
|
+
_(This is the current working directory where you started Shotgun)_
|
|
74
|
+
|
|
75
|
+
### What happens during indexing:
|
|
76
|
+
|
|
77
|
+
- **Stays on your computer**: Index is stored locally at `{storage_path}` - it will not be stored on a server
|
|
78
|
+
- **Zero cost**: Indexing runs entirely on your machine
|
|
79
|
+
- **Runs in the background**: Usually takes 1-3 minutes, and you can continue using Shotgun while it indexes
|
|
80
|
+
- **Enable code understanding**: Allows Shotgun to answer questions about your codebase
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
If you're curious, you can review how Shotgun indexes/queries code by taking a look at the [source code](https://github.com/shotgun-sh/shotgun).
|
|
85
|
+
|
|
86
|
+
We take your privacy seriously. You can read our full [privacy policy](https://app.shotgun.sh/privacy) for more details.
|
|
87
|
+
"""
|
|
88
|
+
|
|
40
89
|
with Container(id="index-prompt-dialog"):
|
|
41
|
-
yield Label(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"This is required for the agent to understand your code and answer "
|
|
45
|
-
"questions about it. Without indexing, the agent cannot analyze "
|
|
46
|
-
"your codebase."
|
|
90
|
+
yield Label(
|
|
91
|
+
"Want to index your codebase so Shotgun can understand it?",
|
|
92
|
+
id="index-prompt-title",
|
|
47
93
|
)
|
|
94
|
+
with VerticalScroll(id="index-prompt-content"):
|
|
95
|
+
yield Markdown(content, id="index-prompt-info")
|
|
48
96
|
with Container(id="index-prompt-buttons"):
|
|
97
|
+
yield Button(
|
|
98
|
+
"Not now",
|
|
99
|
+
id="index-prompt-cancel",
|
|
100
|
+
)
|
|
49
101
|
yield Button(
|
|
50
102
|
"Index now",
|
|
51
103
|
id="index-prompt-confirm",
|
|
52
104
|
variant="primary",
|
|
53
105
|
)
|
|
54
|
-
yield Button("Not now", id="index-prompt-cancel")
|
|
55
106
|
|
|
56
107
|
@on(Button.Pressed, "#index-prompt-cancel")
|
|
57
108
|
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
@@ -5,6 +5,7 @@ from textual.command import DiscoveryHit, Hit, Provider
|
|
|
5
5
|
|
|
6
6
|
from shotgun.agents.models import AgentType
|
|
7
7
|
from shotgun.codebase.models import CodebaseGraph
|
|
8
|
+
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
8
9
|
from shotgun.tui.screens.model_picker import ModelPickerScreen
|
|
9
10
|
from shotgun.tui.screens.provider_config import ProviderConfigScreen
|
|
10
11
|
|
|
@@ -271,8 +272,8 @@ class DeleteCodebasePaletteProvider(Provider):
|
|
|
271
272
|
try:
|
|
272
273
|
result = await self.chat_screen.codebase_sdk.list_codebases()
|
|
273
274
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
274
|
-
self.chat_screen.
|
|
275
|
-
f"Unable to load codebases: {exc}"
|
|
275
|
+
self.chat_screen.agent_manager.add_hint_message(
|
|
276
|
+
HintMessage(message=f"❌ Unable to load codebases: {exc}")
|
|
276
277
|
)
|
|
277
278
|
return []
|
|
278
279
|
return result.graphs
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
from typing import Literal
|
|
2
2
|
|
|
3
3
|
from pydantic import BaseModel
|
|
4
|
+
from textual import on
|
|
4
5
|
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal
|
|
5
7
|
from textual.widget import Widget
|
|
6
|
-
from textual.widgets import Markdown
|
|
8
|
+
from textual.widgets import Button, Label, Markdown, Static
|
|
9
|
+
|
|
10
|
+
from shotgun.logging_config import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
7
13
|
|
|
8
14
|
|
|
9
15
|
class HintMessage(BaseModel):
|
|
10
16
|
message: str
|
|
11
17
|
kind: Literal["hint"] = "hint"
|
|
18
|
+
# Optional email copy functionality
|
|
19
|
+
email: str | None = None
|
|
20
|
+
markdown_after: str | None = None
|
|
12
21
|
|
|
13
22
|
|
|
14
23
|
class HintMessageWidget(Widget):
|
|
@@ -30,6 +39,30 @@ class HintMessageWidget(Widget):
|
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
HintMessageWidget .email-copy-row {
|
|
43
|
+
width: auto;
|
|
44
|
+
height: auto;
|
|
45
|
+
margin: 1 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
HintMessageWidget .email-text {
|
|
49
|
+
width: auto;
|
|
50
|
+
margin-right: 1;
|
|
51
|
+
content-align: left middle;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
HintMessageWidget .copy-btn {
|
|
55
|
+
width: auto;
|
|
56
|
+
min-width: 12;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
HintMessageWidget #copy-status {
|
|
60
|
+
height: 1;
|
|
61
|
+
width: 100%;
|
|
62
|
+
margin-top: 1;
|
|
63
|
+
content-align: left middle;
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
"""
|
|
34
67
|
|
|
35
68
|
def __init__(self, message: HintMessage) -> None:
|
|
@@ -37,4 +70,46 @@ class HintMessageWidget(Widget):
|
|
|
37
70
|
self.message = message
|
|
38
71
|
|
|
39
72
|
def compose(self) -> ComposeResult:
|
|
73
|
+
# Main message markdown
|
|
40
74
|
yield Markdown(markdown=f"{self.message.message}")
|
|
75
|
+
|
|
76
|
+
# Optional email copy section
|
|
77
|
+
if self.message.email:
|
|
78
|
+
# Email + copy button on same line
|
|
79
|
+
with Horizontal(classes="email-copy-row"):
|
|
80
|
+
yield Static(f"Contact: {self.message.email}", classes="email-text")
|
|
81
|
+
yield Button("Copy email", id="copy-email-btn", classes="copy-btn")
|
|
82
|
+
|
|
83
|
+
# Status feedback label
|
|
84
|
+
yield Label("", id="copy-status")
|
|
85
|
+
|
|
86
|
+
# Optional markdown after email
|
|
87
|
+
if self.message.markdown_after:
|
|
88
|
+
yield Markdown(self.message.markdown_after)
|
|
89
|
+
|
|
90
|
+
@on(Button.Pressed, "#copy-email-btn")
|
|
91
|
+
def _copy_email(self) -> None:
|
|
92
|
+
"""Copy email address to clipboard when button is pressed."""
|
|
93
|
+
if not self.message.email:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
status_label = self.query_one("#copy-status", Label)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import pyperclip # type: ignore[import-untyped] # noqa: PGH003
|
|
100
|
+
|
|
101
|
+
pyperclip.copy(self.message.email)
|
|
102
|
+
status_label.update("✓ Copied to clipboard!")
|
|
103
|
+
logger.debug(
|
|
104
|
+
f"Successfully copied email to clipboard: {self.message.email}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except ImportError:
|
|
108
|
+
status_label.update(
|
|
109
|
+
f"⚠️ Clipboard unavailable. Please manually copy: {self.message.email}"
|
|
110
|
+
)
|
|
111
|
+
logger.warning("pyperclip not available for clipboard operations")
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
status_label.update(f"⚠️ Copy failed: {e}")
|
|
115
|
+
logger.error(f"Failed to copy email to clipboard: {e}", exc_info=True)
|