shotgun-sh 0.2.17__py3-none-any.whl → 0.3.3.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.
Files changed (112) hide show
  1. shotgun/agents/agent_manager.py +28 -14
  2. shotgun/agents/common.py +1 -1
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +323 -53
  6. shotgun/agents/config/models.py +85 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/runner.py +230 -0
  23. shotgun/agents/tools/web_search/openai.py +1 -1
  24. shotgun/build_constants.py +2 -2
  25. shotgun/cli/clear.py +1 -1
  26. shotgun/cli/compact.py +5 -3
  27. shotgun/cli/context.py +44 -1
  28. shotgun/cli/error_handler.py +24 -0
  29. shotgun/cli/export.py +34 -34
  30. shotgun/cli/plan.py +34 -34
  31. shotgun/cli/research.py +17 -9
  32. shotgun/cli/spec/__init__.py +5 -0
  33. shotgun/cli/spec/backup.py +81 -0
  34. shotgun/cli/spec/commands.py +132 -0
  35. shotgun/cli/spec/models.py +48 -0
  36. shotgun/cli/spec/pull_service.py +219 -0
  37. shotgun/cli/specify.py +20 -19
  38. shotgun/cli/tasks.py +34 -34
  39. shotgun/codebase/core/ingestor.py +153 -7
  40. shotgun/codebase/models.py +2 -0
  41. shotgun/exceptions.py +325 -0
  42. shotgun/llm_proxy/__init__.py +17 -0
  43. shotgun/llm_proxy/client.py +215 -0
  44. shotgun/llm_proxy/models.py +137 -0
  45. shotgun/logging_config.py +42 -0
  46. shotgun/main.py +4 -0
  47. shotgun/posthog_telemetry.py +1 -1
  48. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
  49. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  50. shotgun/prompts/agents/plan.j2 +16 -0
  51. shotgun/prompts/agents/research.j2 +16 -3
  52. shotgun/prompts/agents/specify.j2 +54 -1
  53. shotgun/prompts/agents/state/system_state.j2 +0 -2
  54. shotgun/prompts/agents/tasks.j2 +16 -0
  55. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  56. shotgun/prompts/history/combine_summaries.j2 +53 -0
  57. shotgun/sdk/codebase.py +14 -3
  58. shotgun/settings.py +5 -0
  59. shotgun/shotgun_web/__init__.py +67 -1
  60. shotgun/shotgun_web/client.py +42 -1
  61. shotgun/shotgun_web/constants.py +46 -0
  62. shotgun/shotgun_web/exceptions.py +29 -0
  63. shotgun/shotgun_web/models.py +390 -0
  64. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  65. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  66. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  67. shotgun/shotgun_web/shared_specs/models.py +71 -0
  68. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  69. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  70. shotgun/shotgun_web/specs_client.py +703 -0
  71. shotgun/shotgun_web/supabase_client.py +31 -0
  72. shotgun/tui/app.py +73 -9
  73. shotgun/tui/containers.py +1 -1
  74. shotgun/tui/layout.py +5 -0
  75. shotgun/tui/screens/chat/chat_screen.py +372 -95
  76. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  77. shotgun/tui/screens/chat_screen/command_providers.py +13 -2
  78. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  79. shotgun/tui/screens/confirmation_dialog.py +40 -0
  80. shotgun/tui/screens/directory_setup.py +45 -41
  81. shotgun/tui/screens/feedback.py +10 -3
  82. shotgun/tui/screens/github_issue.py +11 -2
  83. shotgun/tui/screens/model_picker.py +28 -8
  84. shotgun/tui/screens/onboarding.py +149 -0
  85. shotgun/tui/screens/pipx_migration.py +58 -6
  86. shotgun/tui/screens/provider_config.py +66 -8
  87. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  88. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  89. shotgun/tui/screens/shared_specs/models.py +56 -0
  90. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  91. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  92. shotgun/tui/screens/shotgun_auth.py +110 -16
  93. shotgun/tui/screens/spec_pull.py +288 -0
  94. shotgun/tui/screens/welcome.py +123 -0
  95. shotgun/tui/services/conversation_service.py +5 -2
  96. shotgun/tui/widgets/widget_coordinator.py +1 -1
  97. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
  98. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
  99. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  100. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  101. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  102. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  103. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  104. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  105. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  106. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  107. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  108. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  109. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  110. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  111. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
  112. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -36,20 +36,27 @@ from shotgun.agents.agent_manager import (
36
36
  )
37
37
  from shotgun.agents.config import get_config_manager
38
38
  from shotgun.agents.config.models import MODEL_SPECS
39
- from shotgun.agents.conversation_manager import ConversationManager
40
- from shotgun.agents.history.compaction import apply_persistent_compaction
41
- from shotgun.agents.history.token_estimation import estimate_tokens_from_messages
39
+ from shotgun.agents.conversation import ConversationManager
40
+ from shotgun.agents.conversation.history.compaction import apply_persistent_compaction
41
+ from shotgun.agents.conversation.history.token_estimation import (
42
+ estimate_tokens_from_messages,
43
+ )
42
44
  from shotgun.agents.models import (
43
45
  AgentDeps,
44
46
  AgentType,
45
47
  FileOperationTracker,
46
48
  )
49
+ from shotgun.agents.runner import AgentRunner
47
50
  from shotgun.codebase.core.manager import (
48
51
  CodebaseAlreadyIndexedError,
49
52
  CodebaseGraphManager,
50
53
  )
51
54
  from shotgun.codebase.models import IndexProgress, ProgressPhase
52
- from shotgun.exceptions import ContextSizeLimitExceeded
55
+ from shotgun.exceptions import (
56
+ SHOTGUN_CONTACT_EMAIL,
57
+ ErrorNotPickedUpBySentry,
58
+ ShotgunAccountException,
59
+ )
53
60
  from shotgun.posthog_telemetry import track_event
54
61
  from shotgun.sdk.codebase import CodebaseSDK
55
62
  from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
@@ -59,6 +66,8 @@ from shotgun.tui.components.mode_indicator import ModeIndicator
59
66
  from shotgun.tui.components.prompt_input import PromptInput
60
67
  from shotgun.tui.components.spinner import Spinner
61
68
  from shotgun.tui.components.status_bar import StatusBar
69
+
70
+ # TUIErrorHandler removed - exceptions now caught directly
62
71
  from shotgun.tui.screens.chat.codebase_index_prompt_screen import (
63
72
  CodebaseIndexPromptScreen,
64
73
  )
@@ -76,16 +85,50 @@ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
76
85
  from shotgun.tui.screens.chat_screen.history import ChatHistory
77
86
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
78
87
  from shotgun.tui.screens.onboarding import OnboardingModal
88
+ from shotgun.tui.screens.shared_specs import (
89
+ CreateSpecDialog,
90
+ ShareSpecsAction,
91
+ ShareSpecsDialog,
92
+ UploadProgressScreen,
93
+ )
79
94
  from shotgun.tui.services.conversation_service import ConversationService
80
95
  from shotgun.tui.state.processing_state import ProcessingStateManager
81
96
  from shotgun.tui.utils.mode_progress import PlaceholderHints
82
97
  from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
83
98
  from shotgun.utils import get_shotgun_home
99
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
84
100
  from shotgun.utils.marketing import MarketingManager
85
101
 
86
102
  logger = logging.getLogger(__name__)
87
103
 
88
104
 
105
+ def _format_duration(seconds: float) -> str:
106
+ """Format duration in natural language."""
107
+ if seconds < 60:
108
+ return f"{int(seconds)} seconds"
109
+ minutes = int(seconds // 60)
110
+ secs = int(seconds % 60)
111
+ if secs == 0:
112
+ return f"{minutes} minute{'s' if minutes != 1 else ''}"
113
+ return f"{minutes} minute{'s' if minutes != 1 else ''} {secs} seconds"
114
+
115
+
116
+ def _format_count(count: int) -> str:
117
+ """Format count in natural language (e.g., '5 thousand')."""
118
+ if count < 1000:
119
+ return str(count)
120
+ elif count < 1_000_000:
121
+ thousands = count / 1000
122
+ if thousands == int(thousands):
123
+ return f"{int(thousands)} thousand"
124
+ return f"{thousands:.1f} thousand"
125
+ else:
126
+ millions = count / 1_000_000
127
+ if millions == int(millions):
128
+ return f"{int(millions)} million"
129
+ return f"{millions:.1f} million"
130
+
131
+
89
132
  class ChatScreen(Screen[None]):
90
133
  CSS_PATH = "chat.tcss"
91
134
 
@@ -131,6 +174,7 @@ class ChatScreen(Screen[None]):
131
174
  deps: AgentDeps,
132
175
  continue_session: bool = False,
133
176
  force_reindex: bool = False,
177
+ show_pull_hint: bool = False,
134
178
  ) -> None:
135
179
  """Initialize the ChatScreen.
136
180
 
@@ -149,6 +193,7 @@ class ChatScreen(Screen[None]):
149
193
  deps: AgentDeps configuration for agent dependencies
150
194
  continue_session: Whether to continue a previous session
151
195
  force_reindex: Whether to force reindexing of codebases
196
+ show_pull_hint: Whether to show hint about recently pulled spec
152
197
  """
153
198
  super().__init__()
154
199
 
@@ -164,6 +209,7 @@ class ChatScreen(Screen[None]):
164
209
  self.processing_state = processing_state
165
210
  self.continue_session = continue_session
166
211
  self.force_reindex = force_reindex
212
+ self.show_pull_hint = show_pull_hint
167
213
 
168
214
  def on_mount(self) -> None:
169
215
  # Use widget coordinator to focus input
@@ -179,6 +225,10 @@ class ChatScreen(Screen[None]):
179
225
  if self.continue_session:
180
226
  self.call_later(self._check_and_load_conversation)
181
227
 
228
+ # Show pull hint if launching after spec pull
229
+ if self.show_pull_hint:
230
+ self.call_later(self._show_pull_hint)
231
+
182
232
  self.call_later(self.check_if_codebase_is_indexed)
183
233
  # Initial update of context indicator
184
234
  self.update_context_indicator()
@@ -284,10 +334,8 @@ class ChatScreen(Screen[None]):
284
334
  def action_toggle_mode(self) -> None:
285
335
  # Prevent mode switching during Q&A
286
336
  if self.qa_mode:
287
- self.notify(
288
- "Cannot switch modes while answering questions",
289
- severity="warning",
290
- timeout=3,
337
+ self.agent_manager.add_hint_message(
338
+ HintMessage(message="⚠️ Cannot switch modes while answering questions")
291
339
  )
292
340
  return
293
341
 
@@ -303,20 +351,90 @@ class ChatScreen(Screen[None]):
303
351
  # Re-focus input after mode change
304
352
  self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
305
353
 
306
- def action_show_usage(self) -> None:
354
+ async def action_show_usage(self) -> None:
307
355
  usage_hint = self.agent_manager.get_usage_hint()
308
356
  logger.info(f"Usage hint: {usage_hint}")
357
+
358
+ # Add budget info for Shotgun Account users
359
+ if self.deps.llm_model.is_shotgun_account:
360
+ try:
361
+ from shotgun.llm_proxy import LiteLLMProxyClient
362
+
363
+ logger.debug("Fetching budget info for Shotgun Account")
364
+ client = LiteLLMProxyClient(self.deps.llm_model.api_key)
365
+ budget_info = await client.get_budget_info()
366
+
367
+ # Format budget section
368
+ source_label = "Key" if budget_info.source == "key" else "Team"
369
+ budget_section = f"""## Shotgun Account Budget
370
+
371
+ * Max Budget: ${budget_info.max_budget:.2f}
372
+ * Current Spend: ${budget_info.spend:.2f}
373
+ * Remaining: ${budget_info.remaining:.2f} ({100 - budget_info.percentage_used:.1f}%)
374
+ * Budget Source: {source_label}-level
375
+
376
+ **Questions or need help?**"""
377
+
378
+ # Build markdown_before (usage + budget info before email)
379
+ if usage_hint:
380
+ markdown_before = f"{usage_hint}\n\n{budget_section}"
381
+ else:
382
+ markdown_before = budget_section
383
+
384
+ markdown_after = (
385
+ "\n\n_Reach out anytime for billing questions "
386
+ "or to increase your budget._"
387
+ )
388
+
389
+ # Mount with email copy button
390
+ self.mount_hint_with_email(
391
+ markdown_before=markdown_before,
392
+ email="contact@shotgun.sh",
393
+ markdown_after=markdown_after,
394
+ )
395
+ logger.debug("Successfully added budget info to usage hint")
396
+ return # Exit early since we've already mounted
397
+
398
+ except Exception as e:
399
+ logger.warning(f"Failed to fetch budget info: {e}")
400
+ # For Shotgun Account, show budget fetch error
401
+ # If we have usage data, still show it
402
+ if usage_hint:
403
+ # Show usage even though budget fetch failed
404
+ self.mount_hint(usage_hint)
405
+ else:
406
+ # No usage and budget fetch failed - show specific error with email
407
+ markdown_before = (
408
+ "⚠️ **Unable to fetch budget information**\n\n"
409
+ "There was an error retrieving your budget data."
410
+ )
411
+ markdown_after = (
412
+ "\n\n_Try the command again in a moment. "
413
+ "If the issue persists, reach out for help._"
414
+ )
415
+ self.mount_hint_with_email(
416
+ markdown_before=markdown_before,
417
+ email="contact@shotgun.sh",
418
+ markdown_after=markdown_after,
419
+ )
420
+ return # Exit early
421
+
422
+ # Fallback for non-Shotgun Account users
309
423
  if usage_hint:
310
424
  self.mount_hint(usage_hint)
311
425
  else:
312
- self.notify("No usage hint available", severity="error")
426
+ self.agent_manager.add_hint_message(
427
+ HintMessage(message="⚠️ No usage hint available")
428
+ )
313
429
 
314
430
  async def action_show_context(self) -> None:
315
431
  context_hint = await self.agent_manager.get_context_hint()
316
432
  if context_hint:
317
433
  self.mount_hint(context_hint)
318
434
  else:
319
- self.notify("No context analysis available", severity="error")
435
+ self.agent_manager.add_hint_message(
436
+ HintMessage(message="⚠️ No context analysis available")
437
+ )
320
438
 
321
439
  def action_view_onboarding(self) -> None:
322
440
  """Show the onboarding modal."""
@@ -441,7 +559,9 @@ class ChatScreen(Screen[None]):
441
559
 
442
560
  except Exception as e:
443
561
  logger.error(f"Failed to compact conversation: {e}", exc_info=True)
444
- self.notify(f"Failed to compact: {e}", severity="error")
562
+ self.agent_manager.add_hint_message(
563
+ HintMessage(message=f"❌ Failed to compact: {e}")
564
+ )
445
565
  finally:
446
566
  # Hide spinner
447
567
  self.processing_state.stop_processing()
@@ -489,7 +609,9 @@ class ChatScreen(Screen[None]):
489
609
 
490
610
  except Exception as e:
491
611
  logger.error(f"Failed to clear conversation: {e}", exc_info=True)
492
- self.notify(f"Failed to clear: {e}", severity="error")
612
+ self.agent_manager.add_hint_message(
613
+ HintMessage(message=f"❌ Failed to clear: {e}")
614
+ )
493
615
 
494
616
  @work(exclusive=False)
495
617
  async def update_context_indicator(self) -> None:
@@ -576,6 +698,53 @@ class ChatScreen(Screen[None]):
576
698
  hint = HintMessage(message=markdown)
577
699
  self.agent_manager.add_hint_message(hint)
578
700
 
701
+ def _show_pull_hint(self) -> None:
702
+ """Show hint about recently pulled spec from meta.json."""
703
+ # Import at runtime to avoid circular import (CLI -> TUI dependency)
704
+ from shotgun.cli.spec.models import SpecMeta
705
+
706
+ shotgun_dir = get_shotgun_base_path()
707
+ meta_path = shotgun_dir / "meta.json"
708
+ if not meta_path.exists():
709
+ return
710
+
711
+ try:
712
+ meta: SpecMeta = SpecMeta.model_validate_json(meta_path.read_text())
713
+ # Only show if pulled within last 60 seconds
714
+ age_seconds = (datetime.now(timezone.utc) - meta.pulled_at).total_seconds()
715
+ if age_seconds > 60:
716
+ return
717
+
718
+ hint_parts = [f"You just pulled **{meta.spec_name}** from the cloud."]
719
+ if meta.web_url:
720
+ hint_parts.append(f"[View in browser]({meta.web_url})")
721
+ hint_parts.append(
722
+ f"The specs are now located at `{shotgun_dir}` so Shotgun has access to them."
723
+ )
724
+ if meta.backup_path:
725
+ hint_parts.append(
726
+ f"Previous files were backed up to: `{meta.backup_path}`"
727
+ )
728
+ self.mount_hint("\n\n".join(hint_parts))
729
+ except Exception:
730
+ # Ignore errors reading meta.json - this is optional UI feedback
731
+ logger.debug("Failed to read meta.json for pull hint", exc_info=True)
732
+
733
+ def mount_hint_with_email(
734
+ self, markdown_before: str, email: str, markdown_after: str = ""
735
+ ) -> None:
736
+ """Mount a hint with inline email copy button.
737
+
738
+ Args:
739
+ markdown_before: Markdown content to display before the email line
740
+ email: Email address to display with copy button
741
+ markdown_after: Optional markdown content to display after the email line
742
+ """
743
+ hint = HintMessage(
744
+ message=markdown_before, email=email, markdown_after=markdown_after
745
+ )
746
+ self.agent_manager.add_hint_message(hint)
747
+
579
748
  @on(PartialResponseMessage)
580
749
  def handle_partial_response(self, event: PartialResponseMessage) -> None:
581
750
  # Filter event.messages to exclude ModelRequest with only ToolReturnPart
@@ -762,6 +931,19 @@ class ChatScreen(Screen[None]):
762
931
  # Update the agent manager's model configuration
763
932
  self.agent_manager.deps.llm_model = result.model_config
764
933
 
934
+ # Reset agents so they get recreated with new model
935
+ self.agent_manager._agents_initialized = False
936
+ self.agent_manager._research_agent = None
937
+ self.agent_manager._plan_agent = None
938
+ self.agent_manager._tasks_agent = None
939
+ self.agent_manager._specify_agent = None
940
+ self.agent_manager._export_agent = None
941
+ self.agent_manager._research_deps = None
942
+ self.agent_manager._plan_deps = None
943
+ self.agent_manager._tasks_deps = None
944
+ self.agent_manager._specify_deps = None
945
+ self.agent_manager._export_deps = None
946
+
765
947
  # Get current analysis and update context indicator via coordinator
766
948
  analysis = await self.agent_manager.get_context_analysis()
767
949
  self.widget_coordinator.update_context_indicator(analysis, result.new_model)
@@ -928,6 +1110,71 @@ class ChatScreen(Screen[None]):
928
1110
  )
929
1111
  )
930
1112
 
1113
+ def share_specs_command(self) -> None:
1114
+ """Launch the share specs workflow."""
1115
+ self.call_later(lambda: self._start_share_specs_flow())
1116
+
1117
+ @work
1118
+ async def _start_share_specs_flow(self) -> None:
1119
+ """Main workflow for sharing specs to workspace."""
1120
+ # 1. Check preconditions (instant check, no API call)
1121
+ shotgun_dir = Path.cwd() / ".shotgun"
1122
+ if not shotgun_dir.exists():
1123
+ self.mount_hint("No .shotgun/ directory found in current directory")
1124
+ return
1125
+
1126
+ # 2. Show spec selection dialog (handles workspace fetch, permissions, and spec loading)
1127
+ result = await self.app.push_screen_wait(ShareSpecsDialog())
1128
+ if result is None or result.action is None:
1129
+ return # User cancelled or error
1130
+
1131
+ workspace_id = result.workspace_id
1132
+ if not workspace_id:
1133
+ self.mount_hint("Failed to get workspace")
1134
+ return
1135
+
1136
+ # 3. Handle create vs add version
1137
+ if result.action == ShareSpecsAction.CREATE:
1138
+ # Show create spec dialog
1139
+ create_result = await self.app.push_screen_wait(CreateSpecDialog())
1140
+ if create_result is None:
1141
+ return # User cancelled
1142
+
1143
+ # Pass spec creation info to UploadProgressScreen
1144
+ # It will create the spec/version and then upload
1145
+ upload_result = await self.app.push_screen_wait(
1146
+ UploadProgressScreen(
1147
+ workspace_id,
1148
+ spec_name=create_result.name,
1149
+ spec_description=create_result.description,
1150
+ spec_is_public=create_result.is_public,
1151
+ )
1152
+ )
1153
+
1154
+ else: # add_version
1155
+ spec_id = result.spec_id
1156
+ if not spec_id:
1157
+ self.mount_hint("No spec selected")
1158
+ return
1159
+
1160
+ # Pass spec_id to UploadProgressScreen
1161
+ # It will create the version and then upload
1162
+ upload_result = await self.app.push_screen_wait(
1163
+ UploadProgressScreen(workspace_id, spec_id=spec_id)
1164
+ )
1165
+
1166
+ # 7. Show result
1167
+ if upload_result and upload_result.success:
1168
+ if upload_result.web_url:
1169
+ self.mount_hint(
1170
+ f"Specs shared successfully!\n\nView at: {upload_result.web_url}"
1171
+ )
1172
+ else:
1173
+ self.mount_hint("Specs shared successfully!")
1174
+ elif upload_result and upload_result.cancelled:
1175
+ self.mount_hint("Upload cancelled")
1176
+ # Error case is handled by the upload screen
1177
+
931
1178
  def delete_codebase_from_palette(self, graph_id: str) -> None:
932
1179
  stack = getattr(self.app, "screen_stack", None)
933
1180
  if stack and isinstance(stack[-1], CommandPalette):
@@ -939,11 +1186,15 @@ class ChatScreen(Screen[None]):
939
1186
  async def delete_codebase(self, graph_id: str) -> None:
940
1187
  try:
941
1188
  await self.codebase_sdk.delete_codebase(graph_id)
942
- self.notify(f"Deleted codebase: {graph_id}", severity="information")
1189
+ self.agent_manager.add_hint_message(
1190
+ HintMessage(message=f"✓ Deleted codebase: {graph_id}")
1191
+ )
943
1192
  except CodebaseNotFoundError as exc:
944
- self.notify(str(exc), severity="error")
1193
+ self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
945
1194
  except Exception as exc: # pragma: no cover - defensive UI path
946
- self.notify(f"Failed to delete codebase: {exc}", severity="error")
1195
+ self.agent_manager.add_hint_message(
1196
+ HintMessage(message=f"❌ Failed to delete codebase: {exc}")
1197
+ )
947
1198
 
948
1199
  def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
949
1200
  """Check if error is related to kuzu database corruption.
@@ -969,6 +1220,8 @@ class ChatScreen(Screen[None]):
969
1220
 
970
1221
  @work
971
1222
  async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
1223
+ index_start_time = time.time()
1224
+
972
1225
  label = self.query_one("#indexing-job-display", Static)
973
1226
  label.update(
974
1227
  f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
@@ -1008,24 +1261,40 @@ class ChatScreen(Screen[None]):
1008
1261
 
1009
1262
  def progress_callback(progress_info: IndexProgress) -> None:
1010
1263
  """Update progress state (timer renders it independently)."""
1011
- # Calculate overall percentage (0-95%, reserve 95-100% for finalization)
1264
+ # Calculate overall percentage with weights based on actual timing:
1265
+ # Structure: 0-2%, Definitions: 2-18%, Relationships: 18-20%
1266
+ # Flush nodes: 20-28%, Flush relationships: 28-100%
1012
1267
  if progress_info.phase == ProgressPhase.STRUCTURE:
1013
- # Phase 1: 0-10%, always show 5% while running, 10% when complete
1014
- overall_pct = 10.0 if progress_info.phase_complete else 5.0
1268
+ # Phase 1: 0-2% (actual: ~0%)
1269
+ overall_pct = 2.0 if progress_info.phase_complete else 1.0
1015
1270
  elif progress_info.phase == ProgressPhase.DEFINITIONS:
1016
- # Phase 2: 10-80% based on files processed
1271
+ # Phase 2: 2-18% based on files processed (actual: ~16%)
1017
1272
  if progress_info.total and progress_info.total > 0:
1018
- phase_pct = (progress_info.current / progress_info.total) * 70.0
1019
- overall_pct = 10.0 + phase_pct
1273
+ phase_pct = (progress_info.current / progress_info.total) * 16.0
1274
+ overall_pct = 2.0 + phase_pct
1020
1275
  else:
1021
- overall_pct = 10.0
1276
+ overall_pct = 2.0
1022
1277
  elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
1023
- # Phase 3: 80-95% based on relationships processed (cap at 95%)
1278
+ # Phase 3: 18-20% based on relationships processed (actual: ~0.3%)
1279
+ if progress_info.total and progress_info.total > 0:
1280
+ phase_pct = (progress_info.current / progress_info.total) * 2.0
1281
+ overall_pct = 18.0 + phase_pct
1282
+ else:
1283
+ overall_pct = 18.0
1284
+ elif progress_info.phase == ProgressPhase.FLUSH_NODES:
1285
+ # Phase 4: 20-28% based on nodes flushed (actual: ~7.5%)
1286
+ if progress_info.total and progress_info.total > 0:
1287
+ phase_pct = (progress_info.current / progress_info.total) * 8.0
1288
+ overall_pct = 20.0 + phase_pct
1289
+ else:
1290
+ overall_pct = 20.0
1291
+ elif progress_info.phase == ProgressPhase.FLUSH_RELATIONSHIPS:
1292
+ # Phase 5: 28-100% based on relationships flushed (actual: ~76%)
1024
1293
  if progress_info.total and progress_info.total > 0:
1025
- phase_pct = (progress_info.current / progress_info.total) * 15.0
1026
- overall_pct = 80.0 + phase_pct
1294
+ phase_pct = (progress_info.current / progress_info.total) * 72.0
1295
+ overall_pct = 28.0 + phase_pct
1027
1296
  else:
1028
- overall_pct = 80.0
1297
+ overall_pct = 28.0
1029
1298
  else:
1030
1299
  overall_pct = 0.0
1031
1300
 
@@ -1050,9 +1319,10 @@ class ChatScreen(Screen[None]):
1050
1319
  )
1051
1320
  cleaned = await manager.cleanup_corrupted_databases()
1052
1321
  logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
1053
- self.notify(
1054
- f"Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})...",
1055
- severity="information",
1322
+ self.agent_manager.add_hint_message(
1323
+ HintMessage(
1324
+ message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
1325
+ )
1056
1326
  )
1057
1327
 
1058
1328
  # Pass the current working directory as the indexed_from_cwd
@@ -1077,25 +1347,32 @@ class ChatScreen(Screen[None]):
1077
1347
  )
1078
1348
  label.refresh()
1079
1349
 
1350
+ # Calculate duration and format message
1351
+ duration = time.time() - index_start_time
1352
+ duration_str = _format_duration(duration)
1353
+ entity_count = result.node_count + result.relationship_count
1354
+ entity_str = _format_count(entity_count)
1355
+
1080
1356
  logger.info(
1081
- f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
1357
+ f"Successfully indexed codebase '{result.name}' in {duration_str} "
1358
+ f"({entity_count} entities)"
1082
1359
  )
1083
- self.notify(
1084
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
1085
- severity="information",
1086
- timeout=8,
1360
+ self.agent_manager.add_hint_message(
1361
+ HintMessage(
1362
+ message=f"✓ Indexed '{result.name}' in {duration_str} ({entity_str} entities)"
1363
+ )
1087
1364
  )
1088
1365
  break # Success - exit retry loop
1089
1366
 
1090
1367
  except CodebaseAlreadyIndexedError as exc:
1091
1368
  progress_timer.stop()
1092
1369
  logger.warning(f"Codebase already indexed: {exc}")
1093
- self.notify(str(exc), severity="warning")
1370
+ self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
1094
1371
  return
1095
1372
  except InvalidPathError as exc:
1096
1373
  progress_timer.stop()
1097
1374
  logger.error(f"Invalid path error: {exc}")
1098
- self.notify(str(exc), severity="error")
1375
+ self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
1099
1376
  return
1100
1377
 
1101
1378
  except Exception as exc: # pragma: no cover - defensive UI path
@@ -1114,10 +1391,10 @@ class ChatScreen(Screen[None]):
1114
1391
  f"Failed to index codebase after {attempt + 1} attempts - "
1115
1392
  f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
1116
1393
  )
1117
- self.notify(
1118
- f"Failed to index codebase after {attempt + 1} attempts: {exc}",
1119
- severity="error",
1120
- timeout=30, # Keep error visible for 30 seconds
1394
+ self.agent_manager.add_hint_message(
1395
+ HintMessage(
1396
+ message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
1397
+ )
1121
1398
  )
1122
1399
  break
1123
1400
 
@@ -1128,8 +1405,6 @@ class ChatScreen(Screen[None]):
1128
1405
 
1129
1406
  @work
1130
1407
  async def run_agent(self, message: str) -> None:
1131
- prompt = None
1132
-
1133
1408
  # Start processing with spinner
1134
1409
  from textual.worker import get_current_worker
1135
1410
 
@@ -1139,65 +1414,41 @@ class ChatScreen(Screen[None]):
1139
1414
  # Start context indicator animation immediately
1140
1415
  self.widget_coordinator.set_context_streaming(True)
1141
1416
 
1142
- prompt = message
1143
-
1144
1417
  try:
1145
- await self.agent_manager.run(
1146
- prompt=prompt,
1147
- )
1148
- except asyncio.CancelledError:
1149
- # Handle cancellation gracefully - DO NOT re-raise
1150
- self.mount_hint("⚠️ Operation cancelled by user")
1151
- except ContextSizeLimitExceeded as e:
1152
- # User-friendly error with actionable options
1153
- hint = (
1154
- f"⚠️ **Context too large for {e.model_name}**\n\n"
1155
- f"Your conversation history exceeds this model's limit ({e.max_tokens:,} tokens).\n\n"
1156
- f"**Choose an action:**\n\n"
1157
- f"1. Switch to a larger model (`Ctrl+P` → Change Model)\n"
1158
- f"2. Switch to a larger model, compact (`/compact`), then switch back to {e.model_name}\n"
1159
- f"3. Clear conversation (`/clear`)\n"
1160
- )
1161
-
1162
- self.mount_hint(hint)
1163
-
1164
- # Log for debugging (won't send to Sentry due to ErrorNotPickedUpBySentry)
1165
- logger.info(
1166
- "Context size limit exceeded",
1167
- extra={
1168
- "max_tokens": e.max_tokens,
1169
- "model_name": e.model_name,
1170
- },
1171
- )
1172
- except Exception as e:
1173
- # Log with full stack trace to shotgun.log
1174
- logger.exception(
1175
- "Agent run failed",
1176
- extra={
1177
- "agent_mode": self.mode.value,
1178
- "error_type": type(e).__name__,
1179
- },
1180
- )
1181
-
1182
- # Determine user-friendly message based on error type
1183
- error_name = type(e).__name__
1184
- error_message = str(e)
1185
-
1186
- if "APIStatusError" in error_name and "overload" in error_message.lower():
1187
- hint = "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
1188
- elif "APIStatusError" in error_name and "rate" in error_message.lower():
1189
- hint = "⚠️ Rate limit reached. Please wait before trying again."
1190
- elif "APIStatusError" in error_name:
1191
- hint = f"⚠️ AI service error: {error_message}"
1418
+ # Use unified agent runner - exceptions propagate for handling
1419
+ runner = AgentRunner(self.agent_manager)
1420
+ await runner.run(message)
1421
+ except ShotgunAccountException as e:
1422
+ # Shotgun Account errors show contact email UI
1423
+ message_parts = e.to_markdown().split("**Need help?**")
1424
+ if len(message_parts) == 2:
1425
+ markdown_before = message_parts[0] + "**Need help?**"
1426
+ markdown_after = message_parts[1].strip()
1427
+ self.mount_hint_with_email(
1428
+ markdown_before=markdown_before,
1429
+ email=SHOTGUN_CONTACT_EMAIL,
1430
+ markdown_after=markdown_after,
1431
+ )
1192
1432
  else:
1193
- hint = f"⚠️ An error occurred: {error_message}\n\nCheck logs at ~/.shotgun-sh/logs/shotgun.log"
1194
-
1195
- self.mount_hint(hint)
1433
+ # Fallback if message format is unexpected
1434
+ self.mount_hint(e.to_markdown())
1435
+ except ErrorNotPickedUpBySentry as e:
1436
+ # All other user-actionable errors - display with markdown
1437
+ self.mount_hint(e.to_markdown())
1438
+ except Exception as e:
1439
+ # Unexpected errors that weren't wrapped (shouldn't happen)
1440
+ logger.exception("Unexpected error in run_agent")
1441
+ self.mount_hint(f"⚠️ An unexpected error occurred: {str(e)}")
1196
1442
  finally:
1197
1443
  self.processing_state.stop_processing()
1198
1444
  # Stop context indicator animation
1199
1445
  self.widget_coordinator.set_context_streaming(False)
1200
1446
 
1447
+ # Check for low balance after agent loop completes (only for Shotgun Account)
1448
+ # This runs after processing but doesn't interfere with Q&A mode
1449
+ if self.deps.llm_model.is_shotgun_account:
1450
+ await self._check_low_balance_warning()
1451
+
1201
1452
  # Save conversation after each interaction
1202
1453
  self._save_conversation()
1203
1454
 
@@ -1212,6 +1463,32 @@ class ChatScreen(Screen[None]):
1212
1463
  exclusive=True,
1213
1464
  )
1214
1465
 
1466
+ async def _check_low_balance_warning(self) -> None:
1467
+ """Check account balance and show warning if $2.50 or less remaining.
1468
+
1469
+ This runs after every agent loop completion for Shotgun Account users.
1470
+ Errors are silently caught to avoid disrupting user workflow.
1471
+ """
1472
+ try:
1473
+ from shotgun.llm_proxy import LiteLLMProxyClient
1474
+
1475
+ client = LiteLLMProxyClient(self.deps.llm_model.api_key)
1476
+ budget_info = await client.get_budget_info()
1477
+
1478
+ # Show warning if remaining balance is $2.50 or less
1479
+ if budget_info.remaining <= 2.50:
1480
+ warning_message = (
1481
+ f"⚠️ **Low Balance Warning**\n\n"
1482
+ f"Your Shotgun Account has **${budget_info.remaining:.2f}** remaining.\n\n"
1483
+ f"👉 **[Top Up Now at https://app.shotgun.sh/dashboard](https://app.shotgun.sh/dashboard)**"
1484
+ )
1485
+ self.agent_manager.add_hint_message(
1486
+ HintMessage(message=warning_message)
1487
+ )
1488
+ except Exception as e:
1489
+ # Silently log and continue - don't block user workflow
1490
+ logger.debug(f"Failed to check low balance warning: {e}")
1491
+
1215
1492
  async def _check_and_load_conversation(self) -> None:
1216
1493
  """Check if conversation exists and load it if it does."""
1217
1494
  if await self.conversation_manager.exists():