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.
- shotgun/agents/agent_manager.py +191 -23
- shotgun/agents/common.py +78 -77
- shotgun/agents/config/manager.py +42 -1
- shotgun/agents/config/models.py +16 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/export.py +12 -13
- shotgun/agents/models.py +66 -1
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +376 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +503 -0
- shotgun/agents/router/tools/plan_tools.py +322 -0
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/file_management.py +49 -1
- shotgun/agents/tools/registry.py +2 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +1 -1
- shotgun/codebase/core/manager.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -10
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +24 -12
- shotgun/prompts/agents/research.j2 +70 -31
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +39 -16
- shotgun/prompts/agents/state/system_state.j2 +15 -6
- shotgun/prompts/agents/tasks.j2 +58 -34
- shotgun/tui/app.py +5 -6
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +643 -11
- shotgun/tui/screens/chat_screen/command_providers.py +0 -87
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/onboarding.py +30 -26
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +3 -3
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/RECORD +58 -45
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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))
|