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.
- shotgun/agents/agent_manager.py +219 -37
- shotgun/agents/common.py +79 -78
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +364 -53
- shotgun/agents/config/models.py +101 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- 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/runner.py +230 -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/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +154 -8
- shotgun/codebase/core/manager.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +29 -1
- shotgun/prompts/agents/research.j2 +75 -23
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +80 -4
- shotgun/prompts/agents/state/system_state.j2 +15 -8
- shotgun/prompts/agents/tasks.j2 +63 -23
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +78 -15
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/containers.py +1 -1
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +1015 -106
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -89
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- 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/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +179 -26
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- 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/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
- shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
- shotgun_sh-0.2.17.dist-info/RECORD +0 -194
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {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.
|
|
40
|
-
from shotgun.agents.history.compaction import apply_persistent_compaction
|
|
41
|
-
from shotgun.agents.history.token_estimation import
|
|
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
|
|
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.
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1357
|
+
self.agent_manager.add_hint_message(
|
|
1358
|
+
HintMessage(message=f"✓ Deleted codebase: {graph_id}")
|
|
1359
|
+
)
|
|
943
1360
|
except CodebaseNotFoundError as exc:
|
|
944
|
-
self.
|
|
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.
|
|
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
|
|
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-
|
|
1014
|
-
overall_pct =
|
|
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:
|
|
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) *
|
|
1019
|
-
overall_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 =
|
|
1444
|
+
overall_pct = 2.0
|
|
1022
1445
|
elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
|
|
1023
|
-
# Phase 3:
|
|
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) *
|
|
1026
|
-
overall_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 =
|
|
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.
|
|
1054
|
-
|
|
1055
|
-
|
|
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}'
|
|
1525
|
+
f"Successfully indexed codebase '{result.name}' in {duration_str} "
|
|
1526
|
+
f"({entity_count} entities)"
|
|
1082
1527
|
)
|
|
1083
|
-
self.
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
)
|
|
1148
|
-
except
|
|
1149
|
-
#
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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))
|