shotgun-sh 0.2.23.dev1__py3-none-any.whl → 0.2.29.dev2__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 (86) hide show
  1. shotgun/agents/agent_manager.py +3 -3
  2. shotgun/agents/common.py +1 -1
  3. shotgun/agents/config/manager.py +36 -21
  4. shotgun/agents/config/models.py +30 -0
  5. shotgun/agents/config/provider.py +27 -14
  6. shotgun/agents/context_analyzer/analyzer.py +6 -2
  7. shotgun/agents/conversation/__init__.py +18 -0
  8. shotgun/agents/conversation/filters.py +164 -0
  9. shotgun/agents/conversation/history/chunking.py +278 -0
  10. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  11. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  12. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  13. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  14. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  15. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  16. shotgun/agents/tools/web_search/openai.py +1 -1
  17. shotgun/cli/clear.py +1 -1
  18. shotgun/cli/compact.py +5 -3
  19. shotgun/cli/context.py +1 -1
  20. shotgun/cli/spec/__init__.py +5 -0
  21. shotgun/cli/spec/backup.py +81 -0
  22. shotgun/cli/spec/commands.py +130 -0
  23. shotgun/cli/spec/models.py +30 -0
  24. shotgun/cli/spec/pull_service.py +165 -0
  25. shotgun/codebase/core/ingestor.py +153 -7
  26. shotgun/codebase/models.py +2 -0
  27. shotgun/exceptions.py +5 -3
  28. shotgun/main.py +2 -0
  29. shotgun/posthog_telemetry.py +1 -1
  30. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -3
  31. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  32. shotgun/prompts/agents/research.j2 +0 -3
  33. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  34. shotgun/prompts/history/combine_summaries.j2 +53 -0
  35. shotgun/shotgun_web/__init__.py +67 -1
  36. shotgun/shotgun_web/client.py +42 -1
  37. shotgun/shotgun_web/constants.py +46 -0
  38. shotgun/shotgun_web/exceptions.py +29 -0
  39. shotgun/shotgun_web/models.py +390 -0
  40. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  41. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  42. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  43. shotgun/shotgun_web/shared_specs/models.py +71 -0
  44. shotgun/shotgun_web/shared_specs/upload_pipeline.py +291 -0
  45. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  46. shotgun/shotgun_web/specs_client.py +703 -0
  47. shotgun/shotgun_web/supabase_client.py +31 -0
  48. shotgun/tui/app.py +39 -0
  49. shotgun/tui/containers.py +1 -1
  50. shotgun/tui/layout.py +5 -0
  51. shotgun/tui/screens/chat/chat_screen.py +212 -16
  52. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +147 -19
  53. shotgun/tui/screens/chat_screen/command_providers.py +10 -0
  54. shotgun/tui/screens/chat_screen/history/chat_history.py +0 -36
  55. shotgun/tui/screens/confirmation_dialog.py +40 -0
  56. shotgun/tui/screens/model_picker.py +7 -1
  57. shotgun/tui/screens/onboarding.py +149 -0
  58. shotgun/tui/screens/pipx_migration.py +46 -0
  59. shotgun/tui/screens/provider_config.py +41 -0
  60. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  61. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  62. shotgun/tui/screens/shared_specs/models.py +56 -0
  63. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  64. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  65. shotgun/tui/screens/shotgun_auth.py +60 -6
  66. shotgun/tui/screens/spec_pull.py +286 -0
  67. shotgun/tui/screens/welcome.py +91 -0
  68. shotgun/tui/services/conversation_service.py +5 -2
  69. shotgun/tui/widgets/widget_coordinator.py +1 -1
  70. {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/METADATA +1 -1
  71. {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/RECORD +86 -59
  72. {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/WHEEL +1 -1
  73. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  74. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  75. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  76. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  77. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  78. /shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +0 -0
  79. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  80. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  81. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  82. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  83. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  84. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  85. {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/entry_points.txt +0 -0
  86. {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,31 @@
1
+ """Supabase Storage download utilities."""
2
+
3
+ import httpx
4
+
5
+ from shotgun.logging_config import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ async def download_file_from_url(download_url: str) -> bytes:
11
+ """Download a file from a presigned Supabase Storage URL.
12
+
13
+ The API returns presigned URLs with embedded tokens that don't require
14
+ any authentication headers.
15
+
16
+ Args:
17
+ download_url: Presigned Supabase Storage URL
18
+ (e.g., "https://...supabase.co/storage/v1/object/sign/...?token=...")
19
+
20
+ Returns:
21
+ File contents as bytes
22
+
23
+ Raises:
24
+ httpx.HTTPStatusError: If download fails
25
+ """
26
+ logger.debug("Downloading file from: %s", download_url)
27
+
28
+ async with httpx.AsyncClient(timeout=60.0) as client:
29
+ response = await client.get(download_url)
30
+ response.raise_for_status()
31
+ return response.content
shotgun/tui/app.py CHANGED
@@ -54,12 +54,16 @@ class ShotgunApp(App[None]):
54
54
  no_update_check: bool = False,
55
55
  continue_session: bool = False,
56
56
  force_reindex: bool = False,
57
+ show_pull_hint: bool = False,
58
+ pull_version_id: str | None = None,
57
59
  ) -> None:
58
60
  super().__init__()
59
61
  self.config_manager: ConfigManager = get_config_manager()
60
62
  self.no_update_check = no_update_check
61
63
  self.continue_session = continue_session
62
64
  self.force_reindex = force_reindex
65
+ self.show_pull_hint = show_pull_hint
66
+ self.pull_version_id = pull_version_id
63
67
 
64
68
  # Initialize dependency injection container
65
69
  self.container = TUIContainer()
@@ -77,6 +81,8 @@ class ShotgunApp(App[None]):
77
81
  "tui_started",
78
82
  {
79
83
  "installation_method": detect_installation_method(),
84
+ "terminal_width": self.size.width,
85
+ "terminal_height": self.size.height,
80
86
  },
81
87
  )
82
88
 
@@ -149,6 +155,16 @@ class ShotgunApp(App[None]):
149
155
  if isinstance(self.screen, ChatScreen):
150
156
  return
151
157
 
158
+ # If we have a version to pull, show pull screen first
159
+ if self.pull_version_id:
160
+ from .screens.spec_pull import SpecPullScreen
161
+
162
+ self.push_screen(
163
+ SpecPullScreen(self.pull_version_id),
164
+ callback=self._handle_pull_complete,
165
+ )
166
+ return
167
+
152
168
  # Create ChatScreen with all dependencies injected from container
153
169
  # Get the default agent mode (RESEARCH)
154
170
  agent_mode = AgentType.RESEARCH
@@ -180,6 +196,7 @@ class ShotgunApp(App[None]):
180
196
  deps=agent_deps,
181
197
  continue_session=self.continue_session,
182
198
  force_reindex=self.force_reindex,
199
+ show_pull_hint=self.show_pull_hint,
183
200
  )
184
201
 
185
202
  # Update the ProcessingStateManager and WidgetCoordinator with the actual ChatScreen instance
@@ -195,6 +212,22 @@ class ShotgunApp(App[None]):
195
212
  shotgun_dir = get_shotgun_base_path()
196
213
  return shotgun_dir.exists() and shotgun_dir.is_dir()
197
214
 
215
+ def _handle_pull_complete(self, success: bool | None) -> None:
216
+ """Handle completion of spec pull screen.
217
+
218
+ Args:
219
+ success: Whether the pull was successful, or None if dismissed.
220
+ """
221
+ # Clear version_id so we don't pull again on next refresh
222
+ self.pull_version_id = None
223
+
224
+ if success:
225
+ # Enable hint for ChatScreen
226
+ self.show_pull_hint = True
227
+
228
+ # Continue to ChatScreen
229
+ self.refresh_startup_screen()
230
+
198
231
  async def action_quit(self) -> None:
199
232
  """Quit the application."""
200
233
  # Shut down PostHog client to prevent threading errors
@@ -221,6 +254,8 @@ def run(
221
254
  no_update_check: bool = False,
222
255
  continue_session: bool = False,
223
256
  force_reindex: bool = False,
257
+ show_pull_hint: bool = False,
258
+ pull_version_id: str | None = None,
224
259
  ) -> None:
225
260
  """Run the TUI application.
226
261
 
@@ -228,6 +263,8 @@ def run(
228
263
  no_update_check: If True, disable automatic update checks.
229
264
  continue_session: If True, continue from previous conversation.
230
265
  force_reindex: If True, force re-indexing of codebase (ignores existing index).
266
+ show_pull_hint: If True, show hint about recently pulled spec.
267
+ pull_version_id: If provided, pull this spec version before showing ChatScreen.
231
268
  """
232
269
  # Clean up any corrupted databases BEFORE starting the TUI
233
270
  # This prevents crashes from corrupted databases during initialization
@@ -253,6 +290,8 @@ def run(
253
290
  no_update_check=no_update_check,
254
291
  continue_session=continue_session,
255
292
  force_reindex=force_reindex,
293
+ show_pull_hint=show_pull_hint,
294
+ pull_version_id=pull_version_id,
256
295
  )
257
296
  app.run(inline_no_clear=True)
258
297
 
shotgun/tui/containers.py CHANGED
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
5
5
  from dependency_injector import containers, providers
6
6
  from pydantic_ai import RunContext
7
7
 
8
- from shotgun.agents.conversation_manager import ConversationManager
8
+ from shotgun.agents.conversation import ConversationManager
9
9
  from shotgun.agents.models import AgentDeps
10
10
  from shotgun.sdk.codebase import CodebaseSDK
11
11
  from shotgun.tui.commands import CommandHandler
shotgun/tui/layout.py ADDED
@@ -0,0 +1,5 @@
1
+ """Layout utilities for responsive terminal UI."""
2
+
3
+ # Height thresholds for responsive layouts
4
+ TINY_HEIGHT_THRESHOLD = 25 # Below this: minimal UI, hide most content
5
+ COMPACT_HEIGHT_THRESHOLD = 35 # Below this: reduced padding, hide verbose text
@@ -36,9 +36,11 @@ 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,
@@ -83,16 +85,50 @@ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
83
85
  from shotgun.tui.screens.chat_screen.history import ChatHistory
84
86
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
85
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
+ )
86
94
  from shotgun.tui.services.conversation_service import ConversationService
87
95
  from shotgun.tui.state.processing_state import ProcessingStateManager
88
96
  from shotgun.tui.utils.mode_progress import PlaceholderHints
89
97
  from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
90
98
  from shotgun.utils import get_shotgun_home
99
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
91
100
  from shotgun.utils.marketing import MarketingManager
92
101
 
93
102
  logger = logging.getLogger(__name__)
94
103
 
95
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
+
96
132
  class ChatScreen(Screen[None]):
97
133
  CSS_PATH = "chat.tcss"
98
134
 
@@ -138,6 +174,7 @@ class ChatScreen(Screen[None]):
138
174
  deps: AgentDeps,
139
175
  continue_session: bool = False,
140
176
  force_reindex: bool = False,
177
+ show_pull_hint: bool = False,
141
178
  ) -> None:
142
179
  """Initialize the ChatScreen.
143
180
 
@@ -156,6 +193,7 @@ class ChatScreen(Screen[None]):
156
193
  deps: AgentDeps configuration for agent dependencies
157
194
  continue_session: Whether to continue a previous session
158
195
  force_reindex: Whether to force reindexing of codebases
196
+ show_pull_hint: Whether to show hint about recently pulled spec
159
197
  """
160
198
  super().__init__()
161
199
 
@@ -171,6 +209,7 @@ class ChatScreen(Screen[None]):
171
209
  self.processing_state = processing_state
172
210
  self.continue_session = continue_session
173
211
  self.force_reindex = force_reindex
212
+ self.show_pull_hint = show_pull_hint
174
213
 
175
214
  def on_mount(self) -> None:
176
215
  # Use widget coordinator to focus input
@@ -186,6 +225,10 @@ class ChatScreen(Screen[None]):
186
225
  if self.continue_session:
187
226
  self.call_later(self._check_and_load_conversation)
188
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
+
189
232
  self.call_later(self.check_if_codebase_is_indexed)
190
233
  # Initial update of context indicator
191
234
  self.update_context_indicator()
@@ -655,6 +698,38 @@ class ChatScreen(Screen[None]):
655
698
  hint = HintMessage(message=markdown)
656
699
  self.agent_manager.add_hint_message(hint)
657
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
+
658
733
  def mount_hint_with_email(
659
734
  self, markdown_before: str, email: str, markdown_after: str = ""
660
735
  ) -> None:
@@ -1035,6 +1110,71 @@ class ChatScreen(Screen[None]):
1035
1110
  )
1036
1111
  )
1037
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
+
1038
1178
  def delete_codebase_from_palette(self, graph_id: str) -> None:
1039
1179
  stack = getattr(self.app, "screen_stack", None)
1040
1180
  if stack and isinstance(stack[-1], CommandPalette):
@@ -1080,6 +1220,8 @@ class ChatScreen(Screen[None]):
1080
1220
 
1081
1221
  @work
1082
1222
  async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
1223
+ index_start_time = time.time()
1224
+
1083
1225
  label = self.query_one("#indexing-job-display", Static)
1084
1226
  label.update(
1085
1227
  f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
@@ -1119,24 +1261,40 @@ class ChatScreen(Screen[None]):
1119
1261
 
1120
1262
  def progress_callback(progress_info: IndexProgress) -> None:
1121
1263
  """Update progress state (timer renders it independently)."""
1122
- # 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%
1123
1267
  if progress_info.phase == ProgressPhase.STRUCTURE:
1124
- # Phase 1: 0-10%, always show 5% while running, 10% when complete
1125
- 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
1126
1270
  elif progress_info.phase == ProgressPhase.DEFINITIONS:
1127
- # Phase 2: 10-80% based on files processed
1271
+ # Phase 2: 2-18% based on files processed (actual: ~16%)
1128
1272
  if progress_info.total and progress_info.total > 0:
1129
- phase_pct = (progress_info.current / progress_info.total) * 70.0
1130
- overall_pct = 10.0 + phase_pct
1273
+ phase_pct = (progress_info.current / progress_info.total) * 16.0
1274
+ overall_pct = 2.0 + phase_pct
1131
1275
  else:
1132
- overall_pct = 10.0
1276
+ overall_pct = 2.0
1133
1277
  elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
1134
- # 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%)
1135
1293
  if progress_info.total and progress_info.total > 0:
1136
- phase_pct = (progress_info.current / progress_info.total) * 15.0
1137
- overall_pct = 80.0 + phase_pct
1294
+ phase_pct = (progress_info.current / progress_info.total) * 72.0
1295
+ overall_pct = 28.0 + phase_pct
1138
1296
  else:
1139
- overall_pct = 80.0
1297
+ overall_pct = 28.0
1140
1298
  else:
1141
1299
  overall_pct = 0.0
1142
1300
 
@@ -1189,12 +1347,19 @@ class ChatScreen(Screen[None]):
1189
1347
  )
1190
1348
  label.refresh()
1191
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
+
1192
1356
  logger.info(
1193
- 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)"
1194
1359
  )
1195
1360
  self.agent_manager.add_hint_message(
1196
1361
  HintMessage(
1197
- message=f"✓ Indexed codebase '{result.name}' (ID: {result.graph_id})"
1362
+ message=f"✓ Indexed '{result.name}' in {duration_str} ({entity_str} entities)"
1198
1363
  )
1199
1364
  )
1200
1365
  break # Success - exit retry loop
@@ -1279,6 +1444,11 @@ class ChatScreen(Screen[None]):
1279
1444
  # Stop context indicator animation
1280
1445
  self.widget_coordinator.set_context_streaming(False)
1281
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
+
1282
1452
  # Save conversation after each interaction
1283
1453
  self._save_conversation()
1284
1454
 
@@ -1293,6 +1463,32 @@ class ChatScreen(Screen[None]):
1293
1463
  exclusive=True,
1294
1464
  )
1295
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
+
1296
1492
  async def _check_and_load_conversation(self) -> None:
1297
1493
  """Check if conversation exists and load it if it does."""
1298
1494
  if await self.conversation_manager.exists():