shotgun-sh 0.3.3.dev1__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 (58) hide show
  1. shotgun/agents/agent_manager.py +191 -23
  2. shotgun/agents/common.py +78 -77
  3. shotgun/agents/config/manager.py +42 -1
  4. shotgun/agents/config/models.py +16 -0
  5. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  6. shotgun/agents/export.py +12 -13
  7. shotgun/agents/models.py +66 -1
  8. shotgun/agents/plan.py +12 -13
  9. shotgun/agents/research.py +13 -10
  10. shotgun/agents/router/__init__.py +47 -0
  11. shotgun/agents/router/models.py +376 -0
  12. shotgun/agents/router/router.py +185 -0
  13. shotgun/agents/router/tools/__init__.py +18 -0
  14. shotgun/agents/router/tools/delegation_tools.py +503 -0
  15. shotgun/agents/router/tools/plan_tools.py +322 -0
  16. shotgun/agents/specify.py +12 -13
  17. shotgun/agents/tasks.py +12 -13
  18. shotgun/agents/tools/file_management.py +49 -1
  19. shotgun/agents/tools/registry.py +2 -0
  20. shotgun/agents/tools/web_search/__init__.py +1 -2
  21. shotgun/agents/tools/web_search/gemini.py +1 -3
  22. shotgun/codebase/core/change_detector.py +1 -1
  23. shotgun/codebase/core/ingestor.py +1 -1
  24. shotgun/codebase/core/manager.py +1 -1
  25. shotgun/prompts/agents/export.j2 +2 -0
  26. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -10
  27. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  28. shotgun/prompts/agents/plan.j2 +24 -12
  29. shotgun/prompts/agents/research.j2 +70 -31
  30. shotgun/prompts/agents/router.j2 +440 -0
  31. shotgun/prompts/agents/specify.j2 +39 -16
  32. shotgun/prompts/agents/state/system_state.j2 +15 -6
  33. shotgun/prompts/agents/tasks.j2 +58 -34
  34. shotgun/tui/app.py +5 -6
  35. shotgun/tui/components/mode_indicator.py +120 -25
  36. shotgun/tui/components/status_bar.py +2 -2
  37. shotgun/tui/dependencies.py +64 -9
  38. shotgun/tui/protocols.py +37 -0
  39. shotgun/tui/screens/chat/chat.tcss +9 -1
  40. shotgun/tui/screens/chat/chat_screen.py +643 -11
  41. shotgun/tui/screens/chat_screen/command_providers.py +0 -87
  42. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  43. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  44. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  45. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  46. shotgun/tui/screens/chat_screen/messages.py +219 -0
  47. shotgun/tui/screens/onboarding.py +30 -26
  48. shotgun/tui/utils/mode_progress.py +20 -86
  49. shotgun/tui/widgets/__init__.py +2 -1
  50. shotgun/tui/widgets/approval_widget.py +152 -0
  51. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  52. shotgun/tui/widgets/plan_panel.py +129 -0
  53. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  54. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +3 -3
  55. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/RECORD +58 -45
  56. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +0 -0
  57. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  58. {shotgun_sh-0.3.3.dev1.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,6 +36,8 @@ 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
@@ -46,6 +51,13 @@ from shotgun.agents.models import (
46
51
  AgentType,
47
52
  FileOperationTracker,
48
53
  )
54
+ from shotgun.agents.router.models import (
55
+ CascadeScope,
56
+ ExecutionPlan,
57
+ PlanApprovalStatus,
58
+ RouterDeps,
59
+ RouterMode,
60
+ )
49
61
  from shotgun.agents.runner import AgentRunner
50
62
  from shotgun.codebase.core.manager import (
51
63
  CodebaseAlreadyIndexedError,
@@ -83,6 +95,22 @@ from shotgun.tui.screens.chat_screen.command_providers import (
83
95
  )
84
96
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
85
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
+ )
86
114
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
87
115
  from shotgun.tui.screens.onboarding import OnboardingModal
88
116
  from shotgun.tui.screens.shared_specs import (
@@ -94,6 +122,10 @@ from shotgun.tui.screens.shared_specs import (
94
122
  from shotgun.tui.services.conversation_service import ConversationService
95
123
  from shotgun.tui.state.processing_state import ProcessingStateManager
96
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
97
129
  from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
98
130
  from shotgun.utils import get_shotgun_home
99
131
  from shotgun.utils.file_system_utils import get_shotgun_base_path
@@ -161,6 +193,18 @@ class ChatScreen(Screen[None]):
161
193
  _last_context_update: float = 0.0
162
194
  _context_update_throttle: float = 5.0 # 5 seconds
163
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
+
164
208
  def __init__(
165
209
  self,
166
210
  agent_manager: AgentManager,
@@ -199,6 +243,11 @@ class ChatScreen(Screen[None]):
199
243
 
200
244
  # All dependencies are now required and injected
201
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
+
202
251
  self.codebase_sdk = codebase_sdk
203
252
  self.agent_manager = agent_manager
204
253
  self.command_handler = command_handler
@@ -211,6 +260,10 @@ class ChatScreen(Screen[None]):
211
260
  self.force_reindex = force_reindex
212
261
  self.show_pull_hint = show_pull_hint
213
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
266
+
214
267
  def on_mount(self) -> None:
215
268
  # Use widget coordinator to focus input
216
269
  self.widget_coordinator.update_prompt_input(focus=True)
@@ -220,6 +273,9 @@ class ChatScreen(Screen[None]):
220
273
  # Bind spinner to processing state manager
221
274
  self.processing_state.bind_spinner(self.query_one("#spinner", Spinner))
222
275
 
276
+ # Load saved router mode if using Router agent
277
+ self.call_later(self._load_saved_router_mode)
278
+
223
279
  # Load conversation history if --continue flag was provided
224
280
  # Use call_later to handle async exists() check
225
281
  if self.continue_session:
@@ -331,7 +387,36 @@ class ChatScreen(Screen[None]):
331
387
  # Use widget coordinator for all widget updates
332
388
  self.widget_coordinator.update_messages(messages)
333
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
+
334
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
+
335
420
  # Prevent mode switching during Q&A
336
421
  if self.qa_mode:
337
422
  self.agent_manager.add_hint_message(
@@ -339,18 +424,72 @@ class ChatScreen(Screen[None]):
339
424
  )
340
425
  return
341
426
 
342
- modes = [
343
- AgentType.RESEARCH,
344
- AgentType.SPECIFY,
345
- AgentType.PLAN,
346
- AgentType.TASKS,
347
- AgentType.EXPORT,
348
- ]
349
- self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
350
- self.agent_manager.set_agent(self.mode)
351
- # 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)
352
467
  self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
353
468
 
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
+
354
493
  async def action_show_usage(self) -> None:
355
494
  usage_hint = self.agent_manager.get_usage_hint()
356
495
  logger.info(f"Usage hint: {usage_hint}")
@@ -786,6 +925,12 @@ class ChatScreen(Screen[None]):
786
925
  if has_file_write:
787
926
  return # Skip context update for file writes
788
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
+
789
934
  # Throttle context indicator updates to improve performance during streaming
790
935
  # Only update at most once per 5 seconds to avoid excessive token calculations
791
936
  current_time = time.time()
@@ -913,6 +1058,29 @@ class ChatScreen(Screen[None]):
913
1058
  # Use widget coordinator to update spinner text
914
1059
  self.widget_coordinator.update_spinner_text("Processing...")
915
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
+
916
1084
  async def handle_model_selected(self, result: ModelConfigUpdated | None) -> None:
917
1085
  """Handle model selection from ModelPickerScreen.
918
1086
 
@@ -1449,6 +1617,12 @@ class ChatScreen(Screen[None]):
1449
1617
  if self.deps.llm_model.is_shotgun_account:
1450
1618
  await self._check_low_balance_warning()
1451
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
+
1452
1626
  # Save conversation after each interaction
1453
1627
  self._save_conversation()
1454
1628
 
@@ -1529,3 +1703,461 @@ class ChatScreen(Screen[None]):
1529
1703
  # Mark as shown in config with current timestamp
1530
1704
  config.shown_onboarding_popup = datetime.now(timezone.utc)
1531
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))