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.

Files changed (51) hide show
  1. shotgun/agents/agent_manager.py +25 -11
  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 +26 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/agents/error/__init__.py +11 -0
  9. shotgun/agents/error/models.py +19 -0
  10. shotgun/agents/history/token_counting/anthropic.py +8 -0
  11. shotgun/agents/runner.py +230 -0
  12. shotgun/build_constants.py +1 -1
  13. shotgun/cli/context.py +43 -0
  14. shotgun/cli/error_handler.py +24 -0
  15. shotgun/cli/export.py +34 -34
  16. shotgun/cli/plan.py +34 -34
  17. shotgun/cli/research.py +17 -9
  18. shotgun/cli/specify.py +20 -19
  19. shotgun/cli/tasks.py +34 -34
  20. shotgun/exceptions.py +323 -0
  21. shotgun/llm_proxy/__init__.py +17 -0
  22. shotgun/llm_proxy/client.py +215 -0
  23. shotgun/llm_proxy/models.py +137 -0
  24. shotgun/logging_config.py +42 -0
  25. shotgun/main.py +2 -0
  26. shotgun/posthog_telemetry.py +18 -25
  27. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  28. shotgun/sdk/codebase.py +14 -3
  29. shotgun/sentry_telemetry.py +140 -2
  30. shotgun/settings.py +5 -0
  31. shotgun/tui/app.py +35 -10
  32. shotgun/tui/screens/chat/chat_screen.py +192 -91
  33. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +62 -11
  34. shotgun/tui/screens/chat_screen/command_providers.py +3 -2
  35. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  36. shotgun/tui/screens/chat_screen/history/chat_history.py +37 -2
  37. shotgun/tui/screens/directory_setup.py +45 -41
  38. shotgun/tui/screens/feedback.py +10 -3
  39. shotgun/tui/screens/github_issue.py +11 -2
  40. shotgun/tui/screens/model_picker.py +8 -1
  41. shotgun/tui/screens/pipx_migration.py +12 -6
  42. shotgun/tui/screens/provider_config.py +25 -8
  43. shotgun/tui/screens/shotgun_auth.py +0 -10
  44. shotgun/tui/screens/welcome.py +32 -0
  45. shotgun/tui/widgets/widget_coordinator.py +3 -2
  46. shotgun_sh-0.2.23.dev1.dist-info/METADATA +472 -0
  47. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/RECORD +50 -42
  48. shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
  49. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/WHEEL +0 -0
  50. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/entry_points.txt +0 -0
  51. {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 ContextSizeLimitExceeded
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.notify(
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.notify("No usage hint available", severity="error")
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.notify("No context analysis available", severity="error")
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.notify(f"Failed to compact: {e}", severity="error")
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.notify(f"Failed to clear: {e}", severity="error")
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
- self.partial_message, new_message_list
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
- # Update context indicator with full message history including streaming messages
606
- # Combine existing agent history with new streaming messages for accurate token count
607
- combined_agent_history = self.agent_manager.message_history + event.messages
608
- self.update_context_indicator_with_messages(
609
- combined_agent_history, new_message_list
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.notify(f"Deleted codebase: {graph_id}", severity="information")
1049
+ self.agent_manager.add_hint_message(
1050
+ HintMessage(message=f"✓ Deleted codebase: {graph_id}")
1051
+ )
923
1052
  except CodebaseNotFoundError as exc:
924
- self.notify(str(exc), severity="error")
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.notify(f"Failed to delete codebase: {exc}", severity="error")
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.notify(
1034
- f"Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})...",
1035
- severity="information",
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.notify(
1064
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
1065
- severity="information",
1066
- timeout=8,
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.notify(str(exc), severity="warning")
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.notify(str(exc), severity="error")
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.notify(
1098
- f"Failed to index codebase after {attempt + 1} attempts: {exc}",
1099
- severity="error",
1100
- timeout=30, # Keep error visible for 30 seconds
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
- await self.agent_manager.run(
1126
- prompt=prompt,
1127
- )
1128
- except asyncio.CancelledError:
1129
- # Handle cancellation gracefully - DO NOT re-raise
1130
- self.mount_hint("⚠️ Operation cancelled by user")
1131
- except ContextSizeLimitExceeded as e:
1132
- # User-friendly error with actionable options
1133
- hint = (
1134
- f"⚠️ **Context too large for {e.model_name}**\n\n"
1135
- f"Your conversation history exceeds this model's limit ({e.max_tokens:,} tokens).\n\n"
1136
- f"**Choose an action:**\n\n"
1137
- f"1. Switch to a larger model (`Ctrl+P` → Change Model)\n"
1138
- f"2. Switch to a larger model, compact (`/compact`), then switch back to {e.model_name}\n"
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
- hint = f"⚠️ An error occurred: {error_message}\n\nCheck logs at ~/.shotgun-sh/logs/shotgun.log"
1174
-
1175
- self.mount_hint(hint)
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, Static
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: 60%;
23
- max-width: 60;
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("Index this codebase?", id="index-prompt-title")
42
- yield Static(
43
- f"Would you like to index the codebase at:\n{Path.cwd()}\n\n"
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.notify(
275
- f"Unable to load codebases: {exc}", severity="error"
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)