shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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 (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -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 +239 -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/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,10 @@ import logging
5
5
  import time
6
6
  from datetime import datetime, timezone
7
7
  from pathlib import Path
8
- from typing import cast
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ if TYPE_CHECKING:
11
+ from shotgun.agents.router.models import ExecutionStep
9
12
 
10
13
  from pydantic_ai.messages import (
11
14
  ModelMessage,
@@ -33,23 +36,39 @@ from shotgun.agents.agent_manager import (
33
36
  MessageHistoryUpdated,
34
37
  ModelConfigUpdated,
35
38
  PartialResponseMessage,
39
+ ToolExecutionStartedMessage,
40
+ ToolStreamingProgressMessage,
36
41
  )
37
42
  from shotgun.agents.config import get_config_manager
38
43
  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
44
+ from shotgun.agents.conversation import ConversationManager
45
+ from shotgun.agents.conversation.history.compaction import apply_persistent_compaction
46
+ from shotgun.agents.conversation.history.token_estimation import (
47
+ estimate_tokens_from_messages,
48
+ )
42
49
  from shotgun.agents.models import (
43
50
  AgentDeps,
44
51
  AgentType,
45
52
  FileOperationTracker,
46
53
  )
54
+ from shotgun.agents.router.models import (
55
+ CascadeScope,
56
+ ExecutionPlan,
57
+ PlanApprovalStatus,
58
+ RouterDeps,
59
+ RouterMode,
60
+ )
61
+ from shotgun.agents.runner import AgentRunner
47
62
  from shotgun.codebase.core.manager import (
48
63
  CodebaseAlreadyIndexedError,
49
64
  CodebaseGraphManager,
50
65
  )
51
66
  from shotgun.codebase.models import IndexProgress, ProgressPhase
52
- from shotgun.exceptions import ContextSizeLimitExceeded
67
+ from shotgun.exceptions import (
68
+ SHOTGUN_CONTACT_EMAIL,
69
+ ErrorNotPickedUpBySentry,
70
+ ShotgunAccountException,
71
+ )
53
72
  from shotgun.posthog_telemetry import track_event
54
73
  from shotgun.sdk.codebase import CodebaseSDK
55
74
  from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
@@ -59,6 +78,8 @@ from shotgun.tui.components.mode_indicator import ModeIndicator
59
78
  from shotgun.tui.components.prompt_input import PromptInput
60
79
  from shotgun.tui.components.spinner import Spinner
61
80
  from shotgun.tui.components.status_bar import StatusBar
81
+
82
+ # TUIErrorHandler removed - exceptions now caught directly
62
83
  from shotgun.tui.screens.chat.codebase_index_prompt_screen import (
63
84
  CodebaseIndexPromptScreen,
64
85
  )
@@ -74,18 +95,72 @@ from shotgun.tui.screens.chat_screen.command_providers import (
74
95
  )
75
96
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
76
97
  from shotgun.tui.screens.chat_screen.history import ChatHistory
98
+ from shotgun.tui.screens.chat_screen.messages import (
99
+ CascadeConfirmationRequired,
100
+ CascadeConfirmed,
101
+ CascadeDeclined,
102
+ CheckpointContinue,
103
+ CheckpointModify,
104
+ CheckpointStop,
105
+ PlanApprovalRequired,
106
+ PlanApproved,
107
+ PlanPanelClosed,
108
+ PlanRejected,
109
+ PlanUpdated,
110
+ StepCompleted,
111
+ SubAgentCompleted,
112
+ SubAgentStarted,
113
+ )
77
114
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
78
115
  from shotgun.tui.screens.onboarding import OnboardingModal
116
+ from shotgun.tui.screens.shared_specs import (
117
+ CreateSpecDialog,
118
+ ShareSpecsAction,
119
+ ShareSpecsDialog,
120
+ UploadProgressScreen,
121
+ )
79
122
  from shotgun.tui.services.conversation_service import ConversationService
80
123
  from shotgun.tui.state.processing_state import ProcessingStateManager
81
124
  from shotgun.tui.utils.mode_progress import PlaceholderHints
125
+ from shotgun.tui.widgets.approval_widget import PlanApprovalWidget
126
+ from shotgun.tui.widgets.cascade_confirmation_widget import CascadeConfirmationWidget
127
+ from shotgun.tui.widgets.plan_panel import PlanPanelWidget
128
+ from shotgun.tui.widgets.step_checkpoint_widget import StepCheckpointWidget
82
129
  from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
83
130
  from shotgun.utils import get_shotgun_home
131
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
84
132
  from shotgun.utils.marketing import MarketingManager
85
133
 
86
134
  logger = logging.getLogger(__name__)
87
135
 
88
136
 
137
+ def _format_duration(seconds: float) -> str:
138
+ """Format duration in natural language."""
139
+ if seconds < 60:
140
+ return f"{int(seconds)} seconds"
141
+ minutes = int(seconds // 60)
142
+ secs = int(seconds % 60)
143
+ if secs == 0:
144
+ return f"{minutes} minute{'s' if minutes != 1 else ''}"
145
+ return f"{minutes} minute{'s' if minutes != 1 else ''} {secs} seconds"
146
+
147
+
148
+ def _format_count(count: int) -> str:
149
+ """Format count in natural language (e.g., '5 thousand')."""
150
+ if count < 1000:
151
+ return str(count)
152
+ elif count < 1_000_000:
153
+ thousands = count / 1000
154
+ if thousands == int(thousands):
155
+ return f"{int(thousands)} thousand"
156
+ return f"{thousands:.1f} thousand"
157
+ else:
158
+ millions = count / 1_000_000
159
+ if millions == int(millions):
160
+ return f"{int(millions)} million"
161
+ return f"{millions:.1f} million"
162
+
163
+
89
164
  class ChatScreen(Screen[None]):
90
165
  CSS_PATH = "chat.tcss"
91
166
 
@@ -118,6 +193,18 @@ class ChatScreen(Screen[None]):
118
193
  _last_context_update: float = 0.0
119
194
  _context_update_throttle: float = 5.0 # 5 seconds
120
195
 
196
+ # Step checkpoint widget (Planning mode)
197
+ _checkpoint_widget: StepCheckpointWidget | None = None
198
+
199
+ # Cascade confirmation widget (Planning mode)
200
+ _cascade_widget: CascadeConfirmationWidget | None = None
201
+
202
+ # Plan approval widget (Planning mode)
203
+ _approval_widget: PlanApprovalWidget | None = None
204
+
205
+ # Plan panel widget (Stage 11)
206
+ _plan_panel: PlanPanelWidget | None = None
207
+
121
208
  def __init__(
122
209
  self,
123
210
  agent_manager: AgentManager,
@@ -131,6 +218,7 @@ class ChatScreen(Screen[None]):
131
218
  deps: AgentDeps,
132
219
  continue_session: bool = False,
133
220
  force_reindex: bool = False,
221
+ show_pull_hint: bool = False,
134
222
  ) -> None:
135
223
  """Initialize the ChatScreen.
136
224
 
@@ -149,11 +237,17 @@ class ChatScreen(Screen[None]):
149
237
  deps: AgentDeps configuration for agent dependencies
150
238
  continue_session: Whether to continue a previous session
151
239
  force_reindex: Whether to force reindexing of codebases
240
+ show_pull_hint: Whether to show hint about recently pulled spec
152
241
  """
153
242
  super().__init__()
154
243
 
155
244
  # All dependencies are now required and injected
156
245
  self.deps = deps
246
+
247
+ # Wire up plan change callback for Plan Panel (Stage 11)
248
+ if isinstance(deps, RouterDeps):
249
+ deps.on_plan_changed = self._on_plan_changed
250
+
157
251
  self.codebase_sdk = codebase_sdk
158
252
  self.agent_manager = agent_manager
159
253
  self.command_handler = command_handler
@@ -164,6 +258,11 @@ class ChatScreen(Screen[None]):
164
258
  self.processing_state = processing_state
165
259
  self.continue_session = continue_session
166
260
  self.force_reindex = force_reindex
261
+ self.show_pull_hint = show_pull_hint
262
+
263
+ # Initialize mode from agent_manager before compose() runs
264
+ # This ensures ModeIndicator shows correct mode on first render
265
+ self.mode = agent_manager._current_agent_type
167
266
 
168
267
  def on_mount(self) -> None:
169
268
  # Use widget coordinator to focus input
@@ -174,11 +273,18 @@ class ChatScreen(Screen[None]):
174
273
  # Bind spinner to processing state manager
175
274
  self.processing_state.bind_spinner(self.query_one("#spinner", Spinner))
176
275
 
276
+ # Load saved router mode if using Router agent
277
+ self.call_later(self._load_saved_router_mode)
278
+
177
279
  # Load conversation history if --continue flag was provided
178
280
  # Use call_later to handle async exists() check
179
281
  if self.continue_session:
180
282
  self.call_later(self._check_and_load_conversation)
181
283
 
284
+ # Show pull hint if launching after spec pull
285
+ if self.show_pull_hint:
286
+ self.call_later(self._show_pull_hint)
287
+
182
288
  self.call_later(self.check_if_codebase_is_indexed)
183
289
  # Initial update of context indicator
184
290
  self.update_context_indicator()
@@ -281,42 +387,193 @@ class ChatScreen(Screen[None]):
281
387
  # Use widget coordinator for all widget updates
282
388
  self.widget_coordinator.update_messages(messages)
283
389
 
390
+ # =========================================================================
391
+ # Router State Properties (for Protocol compliance)
392
+ # =========================================================================
393
+
394
+ @property
395
+ def router_mode(self) -> str | None:
396
+ """Get the current router mode for RouterModeProvider protocol.
397
+
398
+ Returns:
399
+ 'planning' or 'drafting' if using router agent, None otherwise.
400
+ """
401
+ if isinstance(self.deps, RouterDeps):
402
+ return self.deps.router_mode.value
403
+ return None
404
+
405
+ @property
406
+ def active_sub_agent(self) -> str | None:
407
+ """Get the active sub-agent for ActiveSubAgentProvider protocol.
408
+
409
+ Returns:
410
+ The sub-agent type string if executing, None otherwise.
411
+ """
412
+ if isinstance(self.deps, RouterDeps) and self.deps.active_sub_agent:
413
+ return self.deps.active_sub_agent.value
414
+ return None
415
+
284
416
  def action_toggle_mode(self) -> None:
417
+ """Toggle between Planning and Drafting modes for Router."""
418
+ from shotgun.agents.router.models import RouterDeps, RouterMode
419
+
285
420
  # Prevent mode switching during Q&A
286
421
  if self.qa_mode:
287
- self.notify(
288
- "Cannot switch modes while answering questions",
289
- severity="warning",
290
- timeout=3,
422
+ self.agent_manager.add_hint_message(
423
+ HintMessage(message="⚠️ Cannot switch modes while answering questions")
291
424
  )
292
425
  return
293
426
 
294
- modes = [
295
- AgentType.RESEARCH,
296
- AgentType.SPECIFY,
297
- AgentType.PLAN,
298
- AgentType.TASKS,
299
- AgentType.EXPORT,
300
- ]
301
- self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
302
- self.agent_manager.set_agent(self.mode)
303
- # Re-focus input after mode change
427
+ if not isinstance(self.deps, RouterDeps):
428
+ return
429
+
430
+ # Prevent mode switching during execution
431
+ if self.deps.is_executing:
432
+ self.agent_manager.add_hint_message(
433
+ HintMessage(message="⚠️ Cannot switch modes during plan execution")
434
+ )
435
+ return
436
+
437
+ # Prevent mode switching while sub-agent is active
438
+ if self.deps.active_sub_agent is not None:
439
+ self.agent_manager.add_hint_message(
440
+ HintMessage(message="⚠️ Cannot switch modes while sub-agent is running")
441
+ )
442
+ return
443
+
444
+ # Toggle mode
445
+ if self.deps.router_mode == RouterMode.PLANNING:
446
+ self.deps.router_mode = RouterMode.DRAFTING
447
+ mode_name = "Drafting"
448
+ else:
449
+ self.deps.router_mode = RouterMode.PLANNING
450
+ mode_name = "Planning"
451
+ # Clear plan when switching back to Planning mode
452
+ # This forces the agent to create a new plan for the next request
453
+ self.deps.current_plan = None
454
+ self.deps.approval_status = PlanApprovalStatus.SKIPPED
455
+ self.deps.is_executing = False
456
+
457
+ # Persist mode (fire-and-forget)
458
+ self._save_router_mode(self.deps.router_mode.value)
459
+
460
+ # Show mode change feedback
461
+ self.agent_manager.add_hint_message(
462
+ HintMessage(message=f"Switched to {mode_name} mode")
463
+ )
464
+
465
+ # Update UI
466
+ self.widget_coordinator.update_for_mode_change(self.mode)
304
467
  self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
305
468
 
306
- def action_show_usage(self) -> None:
469
+ def _save_router_mode(self, mode: str) -> None:
470
+ """Save router mode to config (fire-and-forget)."""
471
+
472
+ async def _save() -> None:
473
+ config_manager = get_config_manager()
474
+ await config_manager.set_router_mode(mode)
475
+
476
+ asyncio.create_task(_save())
477
+
478
+ async def _load_saved_router_mode(self) -> None:
479
+ """Load saved router mode from config."""
480
+ from shotgun.agents.router.models import RouterDeps, RouterMode
481
+
482
+ if isinstance(self.deps, RouterDeps):
483
+ config_manager = get_config_manager()
484
+ saved_mode = await config_manager.get_router_mode()
485
+
486
+ if saved_mode == "drafting":
487
+ self.deps.router_mode = RouterMode.DRAFTING
488
+ else:
489
+ self.deps.router_mode = RouterMode.PLANNING
490
+
491
+ logger.debug("Loaded router mode from config: %s", saved_mode)
492
+
493
+ async def action_show_usage(self) -> None:
307
494
  usage_hint = self.agent_manager.get_usage_hint()
308
495
  logger.info(f"Usage hint: {usage_hint}")
496
+
497
+ # Add budget info for Shotgun Account users
498
+ if self.deps.llm_model.is_shotgun_account:
499
+ try:
500
+ from shotgun.llm_proxy import LiteLLMProxyClient
501
+
502
+ logger.debug("Fetching budget info for Shotgun Account")
503
+ client = LiteLLMProxyClient(self.deps.llm_model.api_key)
504
+ budget_info = await client.get_budget_info()
505
+
506
+ # Format budget section
507
+ source_label = "Key" if budget_info.source == "key" else "Team"
508
+ budget_section = f"""## Shotgun Account Budget
509
+
510
+ * Max Budget: ${budget_info.max_budget:.2f}
511
+ * Current Spend: ${budget_info.spend:.2f}
512
+ * Remaining: ${budget_info.remaining:.2f} ({100 - budget_info.percentage_used:.1f}%)
513
+ * Budget Source: {source_label}-level
514
+
515
+ **Questions or need help?**"""
516
+
517
+ # Build markdown_before (usage + budget info before email)
518
+ if usage_hint:
519
+ markdown_before = f"{usage_hint}\n\n{budget_section}"
520
+ else:
521
+ markdown_before = budget_section
522
+
523
+ markdown_after = (
524
+ "\n\n_Reach out anytime for billing questions "
525
+ "or to increase your budget._"
526
+ )
527
+
528
+ # Mount with email copy button
529
+ self.mount_hint_with_email(
530
+ markdown_before=markdown_before,
531
+ email="contact@shotgun.sh",
532
+ markdown_after=markdown_after,
533
+ )
534
+ logger.debug("Successfully added budget info to usage hint")
535
+ return # Exit early since we've already mounted
536
+
537
+ except Exception as e:
538
+ logger.warning(f"Failed to fetch budget info: {e}")
539
+ # For Shotgun Account, show budget fetch error
540
+ # If we have usage data, still show it
541
+ if usage_hint:
542
+ # Show usage even though budget fetch failed
543
+ self.mount_hint(usage_hint)
544
+ else:
545
+ # No usage and budget fetch failed - show specific error with email
546
+ markdown_before = (
547
+ "⚠️ **Unable to fetch budget information**\n\n"
548
+ "There was an error retrieving your budget data."
549
+ )
550
+ markdown_after = (
551
+ "\n\n_Try the command again in a moment. "
552
+ "If the issue persists, reach out for help._"
553
+ )
554
+ self.mount_hint_with_email(
555
+ markdown_before=markdown_before,
556
+ email="contact@shotgun.sh",
557
+ markdown_after=markdown_after,
558
+ )
559
+ return # Exit early
560
+
561
+ # Fallback for non-Shotgun Account users
309
562
  if usage_hint:
310
563
  self.mount_hint(usage_hint)
311
564
  else:
312
- self.notify("No usage hint available", severity="error")
565
+ self.agent_manager.add_hint_message(
566
+ HintMessage(message="⚠️ No usage hint available")
567
+ )
313
568
 
314
569
  async def action_show_context(self) -> None:
315
570
  context_hint = await self.agent_manager.get_context_hint()
316
571
  if context_hint:
317
572
  self.mount_hint(context_hint)
318
573
  else:
319
- self.notify("No context analysis available", severity="error")
574
+ self.agent_manager.add_hint_message(
575
+ HintMessage(message="⚠️ No context analysis available")
576
+ )
320
577
 
321
578
  def action_view_onboarding(self) -> None:
322
579
  """Show the onboarding modal."""
@@ -441,7 +698,9 @@ class ChatScreen(Screen[None]):
441
698
 
442
699
  except Exception as e:
443
700
  logger.error(f"Failed to compact conversation: {e}", exc_info=True)
444
- self.notify(f"Failed to compact: {e}", severity="error")
701
+ self.agent_manager.add_hint_message(
702
+ HintMessage(message=f"❌ Failed to compact: {e}")
703
+ )
445
704
  finally:
446
705
  # Hide spinner
447
706
  self.processing_state.stop_processing()
@@ -489,7 +748,9 @@ class ChatScreen(Screen[None]):
489
748
 
490
749
  except Exception as e:
491
750
  logger.error(f"Failed to clear conversation: {e}", exc_info=True)
492
- self.notify(f"Failed to clear: {e}", severity="error")
751
+ self.agent_manager.add_hint_message(
752
+ HintMessage(message=f"❌ Failed to clear: {e}")
753
+ )
493
754
 
494
755
  @work(exclusive=False)
495
756
  async def update_context_indicator(self) -> None:
@@ -576,6 +837,53 @@ class ChatScreen(Screen[None]):
576
837
  hint = HintMessage(message=markdown)
577
838
  self.agent_manager.add_hint_message(hint)
578
839
 
840
+ def _show_pull_hint(self) -> None:
841
+ """Show hint about recently pulled spec from meta.json."""
842
+ # Import at runtime to avoid circular import (CLI -> TUI dependency)
843
+ from shotgun.cli.spec.models import SpecMeta
844
+
845
+ shotgun_dir = get_shotgun_base_path()
846
+ meta_path = shotgun_dir / "meta.json"
847
+ if not meta_path.exists():
848
+ return
849
+
850
+ try:
851
+ meta: SpecMeta = SpecMeta.model_validate_json(meta_path.read_text())
852
+ # Only show if pulled within last 60 seconds
853
+ age_seconds = (datetime.now(timezone.utc) - meta.pulled_at).total_seconds()
854
+ if age_seconds > 60:
855
+ return
856
+
857
+ hint_parts = [f"You just pulled **{meta.spec_name}** from the cloud."]
858
+ if meta.web_url:
859
+ hint_parts.append(f"[View in browser]({meta.web_url})")
860
+ hint_parts.append(
861
+ f"The specs are now located at `{shotgun_dir}` so Shotgun has access to them."
862
+ )
863
+ if meta.backup_path:
864
+ hint_parts.append(
865
+ f"Previous files were backed up to: `{meta.backup_path}`"
866
+ )
867
+ self.mount_hint("\n\n".join(hint_parts))
868
+ except Exception:
869
+ # Ignore errors reading meta.json - this is optional UI feedback
870
+ logger.debug("Failed to read meta.json for pull hint", exc_info=True)
871
+
872
+ def mount_hint_with_email(
873
+ self, markdown_before: str, email: str, markdown_after: str = ""
874
+ ) -> None:
875
+ """Mount a hint with inline email copy button.
876
+
877
+ Args:
878
+ markdown_before: Markdown content to display before the email line
879
+ email: Email address to display with copy button
880
+ markdown_after: Optional markdown content to display after the email line
881
+ """
882
+ hint = HintMessage(
883
+ message=markdown_before, email=email, markdown_after=markdown_after
884
+ )
885
+ self.agent_manager.add_hint_message(hint)
886
+
579
887
  @on(PartialResponseMessage)
580
888
  def handle_partial_response(self, event: PartialResponseMessage) -> None:
581
889
  # Filter event.messages to exclude ModelRequest with only ToolReturnPart
@@ -617,6 +925,12 @@ class ChatScreen(Screen[None]):
617
925
  if has_file_write:
618
926
  return # Skip context update for file writes
619
927
 
928
+ # Skip context updates when a sub-agent is streaming
929
+ # Sub-agents run with isolated message history, so their streaming doesn't
930
+ # represent the router's actual context usage
931
+ if isinstance(self.deps, RouterDeps) and self.deps.active_sub_agent is not None:
932
+ return # Skip context update for sub-agent streaming
933
+
620
934
  # Throttle context indicator updates to improve performance during streaming
621
935
  # Only update at most once per 5 seconds to avoid excessive token calculations
622
936
  current_time = time.time()
@@ -744,6 +1058,29 @@ class ChatScreen(Screen[None]):
744
1058
  # Use widget coordinator to update spinner text
745
1059
  self.widget_coordinator.update_spinner_text("Processing...")
746
1060
 
1061
+ @on(ToolExecutionStartedMessage)
1062
+ def handle_tool_execution_started(self, event: ToolExecutionStartedMessage) -> None:
1063
+ """Update spinner text when a tool starts executing.
1064
+
1065
+ This provides visual feedback during long-running tool executions
1066
+ like web search, so the UI doesn't appear frozen.
1067
+ """
1068
+ self.widget_coordinator.update_spinner_text(event.spinner_text)
1069
+
1070
+ @on(ToolStreamingProgressMessage)
1071
+ def handle_tool_streaming_progress(
1072
+ self, event: ToolStreamingProgressMessage
1073
+ ) -> None:
1074
+ """Update spinner text with token count during tool streaming.
1075
+
1076
+ Shows progress while tool arguments are streaming in,
1077
+ particularly useful for long file writes.
1078
+ """
1079
+ text = f"{event.spinner_text} (~{event.streamed_tokens:,} tokens)"
1080
+ self.widget_coordinator.update_spinner_text(text)
1081
+ # Force immediate refresh to show progress
1082
+ self.refresh()
1083
+
747
1084
  async def handle_model_selected(self, result: ModelConfigUpdated | None) -> None:
748
1085
  """Handle model selection from ModelPickerScreen.
749
1086
 
@@ -762,6 +1099,19 @@ class ChatScreen(Screen[None]):
762
1099
  # Update the agent manager's model configuration
763
1100
  self.agent_manager.deps.llm_model = result.model_config
764
1101
 
1102
+ # Reset agents so they get recreated with new model
1103
+ self.agent_manager._agents_initialized = False
1104
+ self.agent_manager._research_agent = None
1105
+ self.agent_manager._plan_agent = None
1106
+ self.agent_manager._tasks_agent = None
1107
+ self.agent_manager._specify_agent = None
1108
+ self.agent_manager._export_agent = None
1109
+ self.agent_manager._research_deps = None
1110
+ self.agent_manager._plan_deps = None
1111
+ self.agent_manager._tasks_deps = None
1112
+ self.agent_manager._specify_deps = None
1113
+ self.agent_manager._export_deps = None
1114
+
765
1115
  # Get current analysis and update context indicator via coordinator
766
1116
  analysis = await self.agent_manager.get_context_analysis()
767
1117
  self.widget_coordinator.update_context_indicator(analysis, result.new_model)
@@ -928,6 +1278,71 @@ class ChatScreen(Screen[None]):
928
1278
  )
929
1279
  )
930
1280
 
1281
+ def share_specs_command(self) -> None:
1282
+ """Launch the share specs workflow."""
1283
+ self.call_later(lambda: self._start_share_specs_flow())
1284
+
1285
+ @work
1286
+ async def _start_share_specs_flow(self) -> None:
1287
+ """Main workflow for sharing specs to workspace."""
1288
+ # 1. Check preconditions (instant check, no API call)
1289
+ shotgun_dir = Path.cwd() / ".shotgun"
1290
+ if not shotgun_dir.exists():
1291
+ self.mount_hint("No .shotgun/ directory found in current directory")
1292
+ return
1293
+
1294
+ # 2. Show spec selection dialog (handles workspace fetch, permissions, and spec loading)
1295
+ result = await self.app.push_screen_wait(ShareSpecsDialog())
1296
+ if result is None or result.action is None:
1297
+ return # User cancelled or error
1298
+
1299
+ workspace_id = result.workspace_id
1300
+ if not workspace_id:
1301
+ self.mount_hint("Failed to get workspace")
1302
+ return
1303
+
1304
+ # 3. Handle create vs add version
1305
+ if result.action == ShareSpecsAction.CREATE:
1306
+ # Show create spec dialog
1307
+ create_result = await self.app.push_screen_wait(CreateSpecDialog())
1308
+ if create_result is None:
1309
+ return # User cancelled
1310
+
1311
+ # Pass spec creation info to UploadProgressScreen
1312
+ # It will create the spec/version and then upload
1313
+ upload_result = await self.app.push_screen_wait(
1314
+ UploadProgressScreen(
1315
+ workspace_id,
1316
+ spec_name=create_result.name,
1317
+ spec_description=create_result.description,
1318
+ spec_is_public=create_result.is_public,
1319
+ )
1320
+ )
1321
+
1322
+ else: # add_version
1323
+ spec_id = result.spec_id
1324
+ if not spec_id:
1325
+ self.mount_hint("No spec selected")
1326
+ return
1327
+
1328
+ # Pass spec_id to UploadProgressScreen
1329
+ # It will create the version and then upload
1330
+ upload_result = await self.app.push_screen_wait(
1331
+ UploadProgressScreen(workspace_id, spec_id=spec_id)
1332
+ )
1333
+
1334
+ # 7. Show result
1335
+ if upload_result and upload_result.success:
1336
+ if upload_result.web_url:
1337
+ self.mount_hint(
1338
+ f"Specs shared successfully!\n\nView at: {upload_result.web_url}"
1339
+ )
1340
+ else:
1341
+ self.mount_hint("Specs shared successfully!")
1342
+ elif upload_result and upload_result.cancelled:
1343
+ self.mount_hint("Upload cancelled")
1344
+ # Error case is handled by the upload screen
1345
+
931
1346
  def delete_codebase_from_palette(self, graph_id: str) -> None:
932
1347
  stack = getattr(self.app, "screen_stack", None)
933
1348
  if stack and isinstance(stack[-1], CommandPalette):
@@ -939,11 +1354,15 @@ class ChatScreen(Screen[None]):
939
1354
  async def delete_codebase(self, graph_id: str) -> None:
940
1355
  try:
941
1356
  await self.codebase_sdk.delete_codebase(graph_id)
942
- self.notify(f"Deleted codebase: {graph_id}", severity="information")
1357
+ self.agent_manager.add_hint_message(
1358
+ HintMessage(message=f"✓ Deleted codebase: {graph_id}")
1359
+ )
943
1360
  except CodebaseNotFoundError as exc:
944
- self.notify(str(exc), severity="error")
1361
+ self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
945
1362
  except Exception as exc: # pragma: no cover - defensive UI path
946
- self.notify(f"Failed to delete codebase: {exc}", severity="error")
1363
+ self.agent_manager.add_hint_message(
1364
+ HintMessage(message=f"❌ Failed to delete codebase: {exc}")
1365
+ )
947
1366
 
948
1367
  def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
949
1368
  """Check if error is related to kuzu database corruption.
@@ -969,6 +1388,8 @@ class ChatScreen(Screen[None]):
969
1388
 
970
1389
  @work
971
1390
  async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
1391
+ index_start_time = time.time()
1392
+
972
1393
  label = self.query_one("#indexing-job-display", Static)
973
1394
  label.update(
974
1395
  f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
@@ -1008,24 +1429,40 @@ class ChatScreen(Screen[None]):
1008
1429
 
1009
1430
  def progress_callback(progress_info: IndexProgress) -> None:
1010
1431
  """Update progress state (timer renders it independently)."""
1011
- # Calculate overall percentage (0-95%, reserve 95-100% for finalization)
1432
+ # Calculate overall percentage with weights based on actual timing:
1433
+ # Structure: 0-2%, Definitions: 2-18%, Relationships: 18-20%
1434
+ # Flush nodes: 20-28%, Flush relationships: 28-100%
1012
1435
  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
1436
+ # Phase 1: 0-2% (actual: ~0%)
1437
+ overall_pct = 2.0 if progress_info.phase_complete else 1.0
1015
1438
  elif progress_info.phase == ProgressPhase.DEFINITIONS:
1016
- # Phase 2: 10-80% based on files processed
1439
+ # Phase 2: 2-18% based on files processed (actual: ~16%)
1017
1440
  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
1441
+ phase_pct = (progress_info.current / progress_info.total) * 16.0
1442
+ overall_pct = 2.0 + phase_pct
1020
1443
  else:
1021
- overall_pct = 10.0
1444
+ overall_pct = 2.0
1022
1445
  elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
1023
- # Phase 3: 80-95% based on relationships processed (cap at 95%)
1446
+ # Phase 3: 18-20% based on relationships processed (actual: ~0.3%)
1024
1447
  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
1448
+ phase_pct = (progress_info.current / progress_info.total) * 2.0
1449
+ overall_pct = 18.0 + phase_pct
1027
1450
  else:
1028
- overall_pct = 80.0
1451
+ overall_pct = 18.0
1452
+ elif progress_info.phase == ProgressPhase.FLUSH_NODES:
1453
+ # Phase 4: 20-28% based on nodes flushed (actual: ~7.5%)
1454
+ if progress_info.total and progress_info.total > 0:
1455
+ phase_pct = (progress_info.current / progress_info.total) * 8.0
1456
+ overall_pct = 20.0 + phase_pct
1457
+ else:
1458
+ overall_pct = 20.0
1459
+ elif progress_info.phase == ProgressPhase.FLUSH_RELATIONSHIPS:
1460
+ # Phase 5: 28-100% based on relationships flushed (actual: ~76%)
1461
+ if progress_info.total and progress_info.total > 0:
1462
+ phase_pct = (progress_info.current / progress_info.total) * 72.0
1463
+ overall_pct = 28.0 + phase_pct
1464
+ else:
1465
+ overall_pct = 28.0
1029
1466
  else:
1030
1467
  overall_pct = 0.0
1031
1468
 
@@ -1050,9 +1487,10 @@ class ChatScreen(Screen[None]):
1050
1487
  )
1051
1488
  cleaned = await manager.cleanup_corrupted_databases()
1052
1489
  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",
1490
+ self.agent_manager.add_hint_message(
1491
+ HintMessage(
1492
+ message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
1493
+ )
1056
1494
  )
1057
1495
 
1058
1496
  # Pass the current working directory as the indexed_from_cwd
@@ -1077,25 +1515,32 @@ class ChatScreen(Screen[None]):
1077
1515
  )
1078
1516
  label.refresh()
1079
1517
 
1518
+ # Calculate duration and format message
1519
+ duration = time.time() - index_start_time
1520
+ duration_str = _format_duration(duration)
1521
+ entity_count = result.node_count + result.relationship_count
1522
+ entity_str = _format_count(entity_count)
1523
+
1080
1524
  logger.info(
1081
- f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
1525
+ f"Successfully indexed codebase '{result.name}' in {duration_str} "
1526
+ f"({entity_count} entities)"
1082
1527
  )
1083
- self.notify(
1084
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
1085
- severity="information",
1086
- timeout=8,
1528
+ self.agent_manager.add_hint_message(
1529
+ HintMessage(
1530
+ message=f"✓ Indexed '{result.name}' in {duration_str} ({entity_str} entities)"
1531
+ )
1087
1532
  )
1088
1533
  break # Success - exit retry loop
1089
1534
 
1090
1535
  except CodebaseAlreadyIndexedError as exc:
1091
1536
  progress_timer.stop()
1092
1537
  logger.warning(f"Codebase already indexed: {exc}")
1093
- self.notify(str(exc), severity="warning")
1538
+ self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
1094
1539
  return
1095
1540
  except InvalidPathError as exc:
1096
1541
  progress_timer.stop()
1097
1542
  logger.error(f"Invalid path error: {exc}")
1098
- self.notify(str(exc), severity="error")
1543
+ self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
1099
1544
  return
1100
1545
 
1101
1546
  except Exception as exc: # pragma: no cover - defensive UI path
@@ -1114,10 +1559,10 @@ class ChatScreen(Screen[None]):
1114
1559
  f"Failed to index codebase after {attempt + 1} attempts - "
1115
1560
  f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
1116
1561
  )
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
1562
+ self.agent_manager.add_hint_message(
1563
+ HintMessage(
1564
+ message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
1565
+ )
1121
1566
  )
1122
1567
  break
1123
1568
 
@@ -1128,8 +1573,6 @@ class ChatScreen(Screen[None]):
1128
1573
 
1129
1574
  @work
1130
1575
  async def run_agent(self, message: str) -> None:
1131
- prompt = None
1132
-
1133
1576
  # Start processing with spinner
1134
1577
  from textual.worker import get_current_worker
1135
1578
 
@@ -1139,65 +1582,47 @@ class ChatScreen(Screen[None]):
1139
1582
  # Start context indicator animation immediately
1140
1583
  self.widget_coordinator.set_context_streaming(True)
1141
1584
 
1142
- prompt = message
1143
-
1144
1585
  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}"
1586
+ # Use unified agent runner - exceptions propagate for handling
1587
+ runner = AgentRunner(self.agent_manager)
1588
+ await runner.run(message)
1589
+ except ShotgunAccountException as e:
1590
+ # Shotgun Account errors show contact email UI
1591
+ message_parts = e.to_markdown().split("**Need help?**")
1592
+ if len(message_parts) == 2:
1593
+ markdown_before = message_parts[0] + "**Need help?**"
1594
+ markdown_after = message_parts[1].strip()
1595
+ self.mount_hint_with_email(
1596
+ markdown_before=markdown_before,
1597
+ email=SHOTGUN_CONTACT_EMAIL,
1598
+ markdown_after=markdown_after,
1599
+ )
1192
1600
  else:
1193
- hint = f"⚠️ An error occurred: {error_message}\n\nCheck logs at ~/.shotgun-sh/logs/shotgun.log"
1194
-
1195
- self.mount_hint(hint)
1601
+ # Fallback if message format is unexpected
1602
+ self.mount_hint(e.to_markdown())
1603
+ except ErrorNotPickedUpBySentry as e:
1604
+ # All other user-actionable errors - display with markdown
1605
+ self.mount_hint(e.to_markdown())
1606
+ except Exception as e:
1607
+ # Unexpected errors that weren't wrapped (shouldn't happen)
1608
+ logger.exception("Unexpected error in run_agent")
1609
+ self.mount_hint(f"⚠️ An unexpected error occurred: {str(e)}")
1196
1610
  finally:
1197
1611
  self.processing_state.stop_processing()
1198
1612
  # Stop context indicator animation
1199
1613
  self.widget_coordinator.set_context_streaming(False)
1200
1614
 
1615
+ # Check for low balance after agent loop completes (only for Shotgun Account)
1616
+ # This runs after processing but doesn't interfere with Q&A mode
1617
+ if self.deps.llm_model.is_shotgun_account:
1618
+ await self._check_low_balance_warning()
1619
+
1620
+ # Check for pending approval (Planning mode multi-step plan creation)
1621
+ self._check_pending_approval()
1622
+
1623
+ # Check for pending checkpoint (Planning mode step completion)
1624
+ self._check_pending_checkpoint()
1625
+
1201
1626
  # Save conversation after each interaction
1202
1627
  self._save_conversation()
1203
1628
 
@@ -1212,6 +1637,32 @@ class ChatScreen(Screen[None]):
1212
1637
  exclusive=True,
1213
1638
  )
1214
1639
 
1640
+ async def _check_low_balance_warning(self) -> None:
1641
+ """Check account balance and show warning if $2.50 or less remaining.
1642
+
1643
+ This runs after every agent loop completion for Shotgun Account users.
1644
+ Errors are silently caught to avoid disrupting user workflow.
1645
+ """
1646
+ try:
1647
+ from shotgun.llm_proxy import LiteLLMProxyClient
1648
+
1649
+ client = LiteLLMProxyClient(self.deps.llm_model.api_key)
1650
+ budget_info = await client.get_budget_info()
1651
+
1652
+ # Show warning if remaining balance is $2.50 or less
1653
+ if budget_info.remaining <= 2.50:
1654
+ warning_message = (
1655
+ f"⚠️ **Low Balance Warning**\n\n"
1656
+ f"Your Shotgun Account has **${budget_info.remaining:.2f}** remaining.\n\n"
1657
+ f"👉 **[Top Up Now at https://app.shotgun.sh/dashboard](https://app.shotgun.sh/dashboard)**"
1658
+ )
1659
+ self.agent_manager.add_hint_message(
1660
+ HintMessage(message=warning_message)
1661
+ )
1662
+ except Exception as e:
1663
+ # Silently log and continue - don't block user workflow
1664
+ logger.debug(f"Failed to check low balance warning: {e}")
1665
+
1215
1666
  async def _check_and_load_conversation(self) -> None:
1216
1667
  """Check if conversation exists and load it if it does."""
1217
1668
  if await self.conversation_manager.exists():
@@ -1252,3 +1703,461 @@ class ChatScreen(Screen[None]):
1252
1703
  # Mark as shown in config with current timestamp
1253
1704
  config.shown_onboarding_popup = datetime.now(timezone.utc)
1254
1705
  await config_manager.save(config)
1706
+
1707
+ # =========================================================================
1708
+ # Step Checkpoint Handlers (Planning Mode)
1709
+ # =========================================================================
1710
+
1711
+ @on(StepCompleted)
1712
+ def handle_step_completed(self, event: StepCompleted) -> None:
1713
+ """Show checkpoint widget when a step completes in Planning mode.
1714
+
1715
+ This handler is triggered after mark_step_done is called and sets
1716
+ up a pending checkpoint. It shows the StepCheckpointWidget to let
1717
+ the user decide whether to continue, modify, or stop.
1718
+ """
1719
+ if not isinstance(self.deps, RouterDeps):
1720
+ return
1721
+ if self.deps.router_mode != RouterMode.PLANNING:
1722
+ return
1723
+
1724
+ # Show checkpoint widget
1725
+ self._show_checkpoint_widget(event.step, event.next_step)
1726
+
1727
+ @on(CheckpointContinue)
1728
+ def handle_checkpoint_continue(self) -> None:
1729
+ """Continue to next step when user approves at checkpoint."""
1730
+ self._hide_checkpoint_widget()
1731
+ self._execute_next_step()
1732
+
1733
+ @on(CheckpointModify)
1734
+ def handle_checkpoint_modify(self) -> None:
1735
+ """Return to prompt input for plan modification."""
1736
+ self._hide_checkpoint_widget()
1737
+
1738
+ if isinstance(self.deps, RouterDeps):
1739
+ self.deps.is_executing = False
1740
+
1741
+ self.widget_coordinator.update_prompt_input(focus=True)
1742
+
1743
+ @on(CheckpointStop)
1744
+ def handle_checkpoint_stop(self) -> None:
1745
+ """Stop execution, keep remaining steps as pending."""
1746
+ self._hide_checkpoint_widget()
1747
+
1748
+ if isinstance(self.deps, RouterDeps):
1749
+ self.deps.is_executing = False
1750
+
1751
+ # Show confirmation message
1752
+ self.mount_hint("⏸️ Execution stopped. Remaining steps are still in the plan.")
1753
+ self.widget_coordinator.update_prompt_input(focus=True)
1754
+
1755
+ def _show_checkpoint_widget(
1756
+ self,
1757
+ step: "ExecutionStep",
1758
+ next_step: "ExecutionStep | None",
1759
+ ) -> None:
1760
+ """Replace PromptInput with StepCheckpointWidget.
1761
+
1762
+ Args:
1763
+ step: The step that was just completed.
1764
+ next_step: The next step to execute, or None if last step.
1765
+ """
1766
+ # Create the checkpoint widget
1767
+ self._checkpoint_widget = StepCheckpointWidget(step, next_step)
1768
+
1769
+ # Hide PromptInput
1770
+ prompt_input = self.query_one(PromptInput)
1771
+ prompt_input.display = False
1772
+
1773
+ # Mount checkpoint widget in footer
1774
+ footer = self.query_one("#footer")
1775
+ footer.mount(self._checkpoint_widget, after=prompt_input)
1776
+
1777
+ def _hide_checkpoint_widget(self) -> None:
1778
+ """Remove checkpoint widget, restore PromptInput."""
1779
+ if hasattr(self, "_checkpoint_widget") and self._checkpoint_widget:
1780
+ self._checkpoint_widget.remove()
1781
+ self._checkpoint_widget = None
1782
+
1783
+ # Show PromptInput
1784
+ prompt_input = self.query_one(PromptInput)
1785
+ prompt_input.display = True
1786
+
1787
+ def _execute_next_step(self) -> None:
1788
+ """Execute the next step in the plan."""
1789
+ if not isinstance(self.deps, RouterDeps) or not self.deps.current_plan:
1790
+ return
1791
+
1792
+ # Advance to next step
1793
+ plan = self.deps.current_plan
1794
+ plan.current_step_index += 1
1795
+
1796
+ next_step = plan.current_step()
1797
+ if next_step:
1798
+ # Resume router execution for the next step
1799
+ self.run_agent(f"Continue with next step: {next_step.title}")
1800
+ else:
1801
+ # Plan complete
1802
+ self.deps.is_executing = False
1803
+ self.mount_hint("✅ All plan steps completed!")
1804
+ self.widget_coordinator.update_prompt_input(focus=True)
1805
+
1806
+ def _check_pending_checkpoint(self) -> None:
1807
+ """Check if there's a pending checkpoint and post StepCompleted if so.
1808
+
1809
+ This is called after each agent run to check if mark_step_done
1810
+ set a pending checkpoint in Planning mode.
1811
+ """
1812
+ if not isinstance(self.deps, RouterDeps):
1813
+ return
1814
+
1815
+ if self.deps.pending_checkpoint is None:
1816
+ return
1817
+
1818
+ # Extract checkpoint data and clear the pending state
1819
+ checkpoint = self.deps.pending_checkpoint
1820
+ self.deps.pending_checkpoint = None
1821
+
1822
+ # Post the StepCompleted message to trigger the checkpoint UI
1823
+ self.post_message(
1824
+ StepCompleted(
1825
+ step=checkpoint.completed_step, next_step=checkpoint.next_step
1826
+ )
1827
+ )
1828
+
1829
+ # =========================================================================
1830
+ # Sub-Agent Lifecycle Handlers (Stage 8)
1831
+ # =========================================================================
1832
+
1833
+ @on(SubAgentStarted)
1834
+ def handle_sub_agent_started(self, event: SubAgentStarted) -> None:
1835
+ """Update mode indicator when router delegates to a sub-agent.
1836
+
1837
+ Sets the active_sub_agent in RouterDeps and refreshes the mode
1838
+ indicator to show "📋 Planning → Research" format.
1839
+ """
1840
+ if isinstance(self.deps, RouterDeps):
1841
+ self.deps.active_sub_agent = event.agent_type
1842
+ self.widget_coordinator.refresh_mode_indicator()
1843
+
1844
+ @on(SubAgentCompleted)
1845
+ def handle_sub_agent_completed(self, event: SubAgentCompleted) -> None:
1846
+ """Clear sub-agent display when delegation completes.
1847
+
1848
+ Clears the active_sub_agent in RouterDeps and refreshes the mode
1849
+ indicator to show just the mode name.
1850
+ """
1851
+ if isinstance(self.deps, RouterDeps):
1852
+ self.deps.active_sub_agent = None
1853
+ self.widget_coordinator.refresh_mode_indicator()
1854
+
1855
+ # =========================================================================
1856
+ # Cascade Confirmation Handlers (Planning Mode)
1857
+ # =========================================================================
1858
+
1859
+ @on(CascadeConfirmationRequired)
1860
+ def handle_cascade_confirmation_required(
1861
+ self, event: CascadeConfirmationRequired
1862
+ ) -> None:
1863
+ """Show cascade confirmation widget when a file with dependents is updated.
1864
+
1865
+ In Planning mode, after updating a file like specification.md that has
1866
+ dependent files, this shows the CascadeConfirmationWidget to let the
1867
+ user decide which dependent files should also be updated.
1868
+ """
1869
+ if not isinstance(self.deps, RouterDeps):
1870
+ return
1871
+ if self.deps.router_mode != RouterMode.PLANNING:
1872
+ # In Drafting mode, auto-cascade without confirmation
1873
+ self._execute_cascade(CascadeScope.ALL, event.dependent_files)
1874
+ return
1875
+
1876
+ # Show cascade confirmation widget
1877
+ self._show_cascade_widget(event.updated_file, event.dependent_files)
1878
+
1879
+ @on(CascadeConfirmed)
1880
+ def handle_cascade_confirmed(self, event: CascadeConfirmed) -> None:
1881
+ """Execute cascade update based on user's selected scope."""
1882
+ # Get dependent files from the widget before hiding it
1883
+ dependent_files: list[str] = []
1884
+ if self._cascade_widget:
1885
+ dependent_files = self._cascade_widget.dependent_files
1886
+
1887
+ self._hide_cascade_widget()
1888
+ self._execute_cascade(event.scope, dependent_files)
1889
+
1890
+ @on(CascadeDeclined)
1891
+ def handle_cascade_declined(self) -> None:
1892
+ """Handle user declining cascade update."""
1893
+ self._hide_cascade_widget()
1894
+ self.mount_hint(
1895
+ "ℹ️ Cascade update skipped. You can update dependent files manually."
1896
+ )
1897
+ self.widget_coordinator.update_prompt_input(focus=True)
1898
+
1899
+ def _show_cascade_widget(
1900
+ self,
1901
+ updated_file: str,
1902
+ dependent_files: list[str],
1903
+ ) -> None:
1904
+ """Replace PromptInput with CascadeConfirmationWidget.
1905
+
1906
+ Args:
1907
+ updated_file: The file that was just updated.
1908
+ dependent_files: List of files that depend on the updated file.
1909
+ """
1910
+ # Create the cascade confirmation widget
1911
+ self._cascade_widget = CascadeConfirmationWidget(updated_file, dependent_files)
1912
+
1913
+ # Hide PromptInput
1914
+ prompt_input = self.query_one(PromptInput)
1915
+ prompt_input.display = False
1916
+
1917
+ # Mount cascade widget in footer
1918
+ footer = self.query_one("#footer")
1919
+ footer.mount(self._cascade_widget, after=prompt_input)
1920
+
1921
+ def _hide_cascade_widget(self) -> None:
1922
+ """Remove cascade widget, restore PromptInput."""
1923
+ if self._cascade_widget:
1924
+ self._cascade_widget.remove()
1925
+ self._cascade_widget = None
1926
+
1927
+ # Show PromptInput
1928
+ prompt_input = self.query_one(PromptInput)
1929
+ prompt_input.display = True
1930
+
1931
+ def _execute_cascade(self, scope: CascadeScope, dependent_files: list[str]) -> None:
1932
+ """Execute cascade updates based on the selected scope.
1933
+
1934
+ Args:
1935
+ scope: The scope of files to update.
1936
+ dependent_files: List of dependent files that could be updated.
1937
+
1938
+ Note:
1939
+ Actual cascade execution (calling sub-agents) requires Stage 9's
1940
+ delegation tools. For now, this shows a hint about what would happen.
1941
+ """
1942
+ if scope == CascadeScope.NONE:
1943
+ return
1944
+
1945
+ # Determine which files will be updated based on scope
1946
+ files_to_update: list[str] = []
1947
+ if scope == CascadeScope.ALL:
1948
+ files_to_update = dependent_files
1949
+ elif scope == CascadeScope.PLAN_ONLY:
1950
+ files_to_update = [f for f in dependent_files if "plan.md" in f]
1951
+ elif scope == CascadeScope.TASKS_ONLY:
1952
+ files_to_update = [f for f in dependent_files if "tasks.md" in f]
1953
+
1954
+ if files_to_update:
1955
+ file_names = ", ".join(f.split("/")[-1] for f in files_to_update)
1956
+ # TODO: Stage 9 will implement actual delegation to sub-agents
1957
+ self.mount_hint(f"📋 Cascade update queued for: {file_names}")
1958
+
1959
+ self.widget_coordinator.update_prompt_input(focus=True)
1960
+
1961
+ def _check_pending_cascade(self) -> None:
1962
+ """Check if there's a pending cascade and post CascadeConfirmationRequired if so.
1963
+
1964
+ This is called after each agent run to check if a file modification
1965
+ set a pending cascade in Planning mode.
1966
+ """
1967
+ if not isinstance(self.deps, RouterDeps):
1968
+ return
1969
+
1970
+ if self.deps.pending_cascade is None:
1971
+ return
1972
+
1973
+ # Extract cascade data and clear the pending state
1974
+ cascade = self.deps.pending_cascade
1975
+ self.deps.pending_cascade = None
1976
+
1977
+ # Post the CascadeConfirmationRequired message to trigger the cascade UI
1978
+ self.post_message(
1979
+ CascadeConfirmationRequired(
1980
+ updated_file=cascade.updated_file,
1981
+ dependent_files=cascade.dependent_files,
1982
+ )
1983
+ )
1984
+
1985
+ # =========================================================================
1986
+ # Plan Approval Handlers (Planning Mode - Stage 7)
1987
+ # =========================================================================
1988
+
1989
+ @on(PlanApprovalRequired)
1990
+ def handle_plan_approval_required(self, event: PlanApprovalRequired) -> None:
1991
+ """Show approval widget when a multi-step plan is created.
1992
+
1993
+ In Planning mode, after creating a plan with multiple steps,
1994
+ this shows the PlanApprovalWidget to let the user decide
1995
+ whether to proceed or clarify.
1996
+ """
1997
+ logger.debug(
1998
+ "[PLAN] handle_plan_approval_required - plan=%s",
1999
+ f"'{event.plan.goal}' with {len(event.plan.steps)} steps",
2000
+ )
2001
+ if not isinstance(self.deps, RouterDeps):
2002
+ logger.debug("[PLAN] Not RouterDeps, skipping approval widget")
2003
+ return
2004
+ if self.deps.router_mode != RouterMode.PLANNING:
2005
+ logger.debug(
2006
+ "[PLAN] Not in PLANNING mode (%s), skipping approval widget",
2007
+ self.deps.router_mode,
2008
+ )
2009
+ return
2010
+
2011
+ # Show approval widget
2012
+ logger.debug("[PLAN] Showing approval widget")
2013
+ self._show_approval_widget(event.plan)
2014
+
2015
+ @on(PlanApproved)
2016
+ def handle_plan_approved(self) -> None:
2017
+ """Begin plan execution when user approves."""
2018
+ self._hide_approval_widget()
2019
+
2020
+ if isinstance(self.deps, RouterDeps):
2021
+ self.deps.approval_status = PlanApprovalStatus.APPROVED
2022
+ self.deps.is_executing = True
2023
+
2024
+ # Switch to Drafting mode when plan is approved
2025
+ self.deps.router_mode = RouterMode.DRAFTING
2026
+ self._save_router_mode(RouterMode.DRAFTING.value)
2027
+ self.widget_coordinator.update_for_mode_change(self.mode)
2028
+
2029
+ # Begin execution of the first step
2030
+ plan = self.deps.current_plan
2031
+ if plan and plan.current_step():
2032
+ first_step = plan.current_step()
2033
+ if first_step:
2034
+ self.run_agent(f"Execute step: {first_step.title}")
2035
+ else:
2036
+ self.widget_coordinator.update_prompt_input(focus=True)
2037
+
2038
+ @on(PlanRejected)
2039
+ def handle_plan_rejected(self) -> None:
2040
+ """Return to prompt input for clarification when user rejects plan."""
2041
+ self._hide_approval_widget()
2042
+
2043
+ if isinstance(self.deps, RouterDeps):
2044
+ self.deps.approval_status = PlanApprovalStatus.REJECTED
2045
+ # Clear the plan since user wants to modify
2046
+ self.deps.current_plan = None
2047
+
2048
+ self.mount_hint("ℹ️ Plan cancelled. Please clarify what you'd like to do.")
2049
+ self.widget_coordinator.update_prompt_input(focus=True)
2050
+
2051
+ def _show_approval_widget(self, plan: ExecutionPlan) -> None:
2052
+ """Replace PromptInput with PlanApprovalWidget.
2053
+
2054
+ Args:
2055
+ plan: The execution plan that needs user approval.
2056
+ """
2057
+ # Create the approval widget
2058
+ self._approval_widget = PlanApprovalWidget(plan)
2059
+
2060
+ # Hide PromptInput
2061
+ prompt_input = self.query_one(PromptInput)
2062
+ prompt_input.display = False
2063
+
2064
+ # Mount approval widget in footer
2065
+ footer = self.query_one("#footer")
2066
+ footer.mount(self._approval_widget, after=prompt_input)
2067
+
2068
+ def _hide_approval_widget(self) -> None:
2069
+ """Remove approval widget, restore PromptInput."""
2070
+ if self._approval_widget:
2071
+ self._approval_widget.remove()
2072
+ self._approval_widget = None
2073
+
2074
+ # Show PromptInput
2075
+ prompt_input = self.query_one(PromptInput)
2076
+ prompt_input.display = True
2077
+
2078
+ def _check_pending_approval(self) -> None:
2079
+ """Check if there's a pending approval and post PlanApprovalRequired if so.
2080
+
2081
+ This is called after each agent run to check if create_plan
2082
+ set a pending approval in Planning mode.
2083
+ """
2084
+ logger.debug("[PLAN] _check_pending_approval called")
2085
+ if not isinstance(self.deps, RouterDeps):
2086
+ logger.debug("[PLAN] Not RouterDeps, skipping pending approval check")
2087
+ return
2088
+
2089
+ if self.deps.pending_approval is None:
2090
+ logger.debug("[PLAN] No pending approval")
2091
+ return
2092
+
2093
+ # Extract approval data and clear the pending state
2094
+ approval = self.deps.pending_approval
2095
+ self.deps.pending_approval = None
2096
+
2097
+ logger.debug(
2098
+ "[PLAN] Found pending approval for plan: '%s' with %d steps",
2099
+ approval.plan.goal,
2100
+ len(approval.plan.steps),
2101
+ )
2102
+
2103
+ # Post the PlanApprovalRequired message to trigger the approval UI
2104
+ self.post_message(PlanApprovalRequired(plan=approval.plan))
2105
+
2106
+ # =========================================================================
2107
+ # Plan Panel (Stage 11)
2108
+ # =========================================================================
2109
+
2110
+ @on(PlanUpdated)
2111
+ def handle_plan_updated(self, event: PlanUpdated) -> None:
2112
+ """Auto-show/hide plan panel when plan changes.
2113
+
2114
+ The plan panel automatically shows when a plan is created or
2115
+ modified, and hides when the plan is cleared.
2116
+ """
2117
+ if event.plan is not None:
2118
+ # Show panel (auto-reopens when plan changes)
2119
+ self._show_plan_panel(event.plan)
2120
+ else:
2121
+ # Plan cleared - hide panel
2122
+ self._hide_plan_panel()
2123
+
2124
+ @on(PlanPanelClosed)
2125
+ def handle_plan_panel_closed(self, event: PlanPanelClosed) -> None:
2126
+ """Handle user closing the plan panel with × button."""
2127
+ self._hide_plan_panel()
2128
+
2129
+ def _show_plan_panel(self, plan: ExecutionPlan) -> None:
2130
+ """Show the plan panel with the given plan.
2131
+
2132
+ Args:
2133
+ plan: The execution plan to display.
2134
+ """
2135
+ if self._plan_panel is None:
2136
+ self._plan_panel = PlanPanelWidget(plan)
2137
+ # Mount in window container, before footer
2138
+ window = self.query_one("#window")
2139
+ footer = self.query_one("#footer")
2140
+ window.mount(self._plan_panel, before=footer)
2141
+ else:
2142
+ self._plan_panel.update_plan(plan)
2143
+
2144
+ def _hide_plan_panel(self) -> None:
2145
+ """Hide the plan panel."""
2146
+ if self._plan_panel:
2147
+ self._plan_panel.remove()
2148
+ self._plan_panel = None
2149
+
2150
+ def _on_plan_changed(self, plan: ExecutionPlan | None) -> None:
2151
+ """Handle plan changes from router tools.
2152
+
2153
+ This callback is set on RouterDeps to receive plan updates
2154
+ and post PlanUpdated messages to update the plan panel.
2155
+
2156
+ Args:
2157
+ plan: The updated plan or None if plan was cleared.
2158
+ """
2159
+ logger.debug(
2160
+ "[PLAN] _on_plan_changed called - plan=%s",
2161
+ f"'{plan.goal}' with {len(plan.steps)} steps" if plan else "None",
2162
+ )
2163
+ self.post_message(PlanUpdated(plan))