shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +48 -45
- shotgun/agents/config/provider.py +44 -29
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +81 -3
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +41 -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 +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/cli/spec/commands.py +2 -0
- shotgun/cli/spec/models.py +18 -0
- shotgun/cli/spec/pull_service.py +122 -68
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +1 -1
- shotgun/main.py +2 -10
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
- shotgun/prompts/agents/plan.j2 +43 -1
- shotgun/prompts/agents/research.j2 +75 -20
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +94 -4
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -15
- shotgun/prompts/agents/tasks.j2 +77 -23
- shotgun/settings.py +44 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
- shotgun/tui/app.py +90 -23
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +23 -28
- shotgun/tui/components/status_bar.py +5 -4
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1374 -211
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +14 -9
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/screens/shotgun_auth.py +50 -0
- shotgun/tui/screens/spec_pull.py +2 -0
- shotgun/tui/state/processing_state.py +19 -0
- 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 +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
- shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/tui/screens/onboarding.py +0 -580
- shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,8 +5,12 @@ 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
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from shotgun.agents.router.models import ExecutionStep
|
|
12
|
+
|
|
13
|
+
from pydantic_ai import BinaryContent
|
|
10
14
|
from pydantic_ai.messages import (
|
|
11
15
|
ModelMessage,
|
|
12
16
|
ModelRequest,
|
|
@@ -30,11 +34,13 @@ from shotgun.agents.agent_manager import (
|
|
|
30
34
|
ClarifyingQuestionsMessage,
|
|
31
35
|
CompactionCompletedMessage,
|
|
32
36
|
CompactionStartedMessage,
|
|
37
|
+
FileRequestPendingMessage,
|
|
33
38
|
MessageHistoryUpdated,
|
|
34
39
|
ModelConfigUpdated,
|
|
35
40
|
PartialResponseMessage,
|
|
41
|
+
ToolExecutionStartedMessage,
|
|
42
|
+
ToolStreamingProgressMessage,
|
|
36
43
|
)
|
|
37
|
-
from shotgun.agents.config import get_config_manager
|
|
38
44
|
from shotgun.agents.config.models import MODEL_SPECS
|
|
39
45
|
from shotgun.agents.conversation import ConversationManager
|
|
40
46
|
from shotgun.agents.conversation.history.compaction import apply_persistent_compaction
|
|
@@ -46,7 +52,25 @@ from shotgun.agents.models import (
|
|
|
46
52
|
AgentType,
|
|
47
53
|
FileOperationTracker,
|
|
48
54
|
)
|
|
55
|
+
from shotgun.agents.router.models import (
|
|
56
|
+
CascadeScope,
|
|
57
|
+
ExecutionPlan,
|
|
58
|
+
PlanApprovalStatus,
|
|
59
|
+
RouterDeps,
|
|
60
|
+
RouterMode,
|
|
61
|
+
)
|
|
49
62
|
from shotgun.agents.runner import AgentRunner
|
|
63
|
+
from shotgun.attachments import (
|
|
64
|
+
FileAttachment,
|
|
65
|
+
parse_attachment_reference,
|
|
66
|
+
process_attachment,
|
|
67
|
+
)
|
|
68
|
+
from shotgun.codebase.core.errors import (
|
|
69
|
+
DatabaseIssue,
|
|
70
|
+
KuzuErrorType,
|
|
71
|
+
classify_kuzu_error,
|
|
72
|
+
)
|
|
73
|
+
from shotgun.codebase.core.kuzu_compat import KuzuImportError
|
|
50
74
|
from shotgun.codebase.core.manager import (
|
|
51
75
|
CodebaseAlreadyIndexedError,
|
|
52
76
|
CodebaseGraphManager,
|
|
@@ -54,6 +78,7 @@ from shotgun.codebase.core.manager import (
|
|
|
54
78
|
from shotgun.codebase.models import IndexProgress, ProgressPhase
|
|
55
79
|
from shotgun.exceptions import (
|
|
56
80
|
SHOTGUN_CONTACT_EMAIL,
|
|
81
|
+
AgentCancelledException,
|
|
57
82
|
ErrorNotPickedUpBySentry,
|
|
58
83
|
ShotgunAccountException,
|
|
59
84
|
)
|
|
@@ -61,6 +86,7 @@ from shotgun.posthog_telemetry import track_event
|
|
|
61
86
|
from shotgun.sdk.codebase import CodebaseSDK
|
|
62
87
|
from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
|
|
63
88
|
from shotgun.tui.commands import CommandHandler
|
|
89
|
+
from shotgun.tui.components.attachment_bar import AttachmentBar
|
|
64
90
|
from shotgun.tui.components.context_indicator import ContextIndicator
|
|
65
91
|
from shotgun.tui.components.mode_indicator import ModeIndicator
|
|
66
92
|
from shotgun.tui.components.prompt_input import PromptInput
|
|
@@ -83,8 +109,27 @@ from shotgun.tui.screens.chat_screen.command_providers import (
|
|
|
83
109
|
)
|
|
84
110
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
85
111
|
from shotgun.tui.screens.chat_screen.history import ChatHistory
|
|
112
|
+
from shotgun.tui.screens.chat_screen.messages import (
|
|
113
|
+
CascadeConfirmationRequired,
|
|
114
|
+
CascadeConfirmed,
|
|
115
|
+
CascadeDeclined,
|
|
116
|
+
CheckpointContinue,
|
|
117
|
+
CheckpointModify,
|
|
118
|
+
CheckpointStop,
|
|
119
|
+
PlanApprovalRequired,
|
|
120
|
+
PlanApproved,
|
|
121
|
+
PlanPanelClosed,
|
|
122
|
+
PlanRejected,
|
|
123
|
+
PlanUpdated,
|
|
124
|
+
StepCompleted,
|
|
125
|
+
SubAgentCompleted,
|
|
126
|
+
SubAgentStarted,
|
|
127
|
+
)
|
|
86
128
|
from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
|
|
87
|
-
from shotgun.tui.screens.
|
|
129
|
+
from shotgun.tui.screens.database_locked_dialog import DatabaseLockedDialog
|
|
130
|
+
from shotgun.tui.screens.database_timeout_dialog import DatabaseTimeoutDialog
|
|
131
|
+
from shotgun.tui.screens.kuzu_error_dialog import KuzuErrorDialog
|
|
132
|
+
from shotgun.tui.screens.models import LockedDialogAction
|
|
88
133
|
from shotgun.tui.screens.shared_specs import (
|
|
89
134
|
CreateSpecDialog,
|
|
90
135
|
ShareSpecsAction,
|
|
@@ -94,6 +139,10 @@ from shotgun.tui.screens.shared_specs import (
|
|
|
94
139
|
from shotgun.tui.services.conversation_service import ConversationService
|
|
95
140
|
from shotgun.tui.state.processing_state import ProcessingStateManager
|
|
96
141
|
from shotgun.tui.utils.mode_progress import PlaceholderHints
|
|
142
|
+
from shotgun.tui.widgets.approval_widget import PlanApprovalWidget
|
|
143
|
+
from shotgun.tui.widgets.cascade_confirmation_widget import CascadeConfirmationWidget
|
|
144
|
+
from shotgun.tui.widgets.plan_panel import PlanPanelWidget
|
|
145
|
+
from shotgun.tui.widgets.step_checkpoint_widget import StepCheckpointWidget
|
|
97
146
|
from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
|
|
98
147
|
from shotgun.utils import get_shotgun_home
|
|
99
148
|
from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
@@ -133,7 +182,6 @@ class ChatScreen(Screen[None]):
|
|
|
133
182
|
CSS_PATH = "chat.tcss"
|
|
134
183
|
|
|
135
184
|
BINDINGS = [
|
|
136
|
-
("ctrl+p", "command_palette", "Command Palette"),
|
|
137
185
|
("shift+tab", "toggle_mode", "Toggle mode"),
|
|
138
186
|
("ctrl+u", "show_usage", "Show usage"),
|
|
139
187
|
]
|
|
@@ -161,6 +209,18 @@ class ChatScreen(Screen[None]):
|
|
|
161
209
|
_last_context_update: float = 0.0
|
|
162
210
|
_context_update_throttle: float = 5.0 # 5 seconds
|
|
163
211
|
|
|
212
|
+
# Step checkpoint widget (Planning mode)
|
|
213
|
+
_checkpoint_widget: StepCheckpointWidget | None = None
|
|
214
|
+
|
|
215
|
+
# Cascade confirmation widget (Planning mode)
|
|
216
|
+
_cascade_widget: CascadeConfirmationWidget | None = None
|
|
217
|
+
|
|
218
|
+
# Plan approval widget (Planning mode)
|
|
219
|
+
_approval_widget: PlanApprovalWidget | None = None
|
|
220
|
+
|
|
221
|
+
# Plan panel widget (Stage 11)
|
|
222
|
+
_plan_panel: PlanPanelWidget | None = None
|
|
223
|
+
|
|
164
224
|
def __init__(
|
|
165
225
|
self,
|
|
166
226
|
agent_manager: AgentManager,
|
|
@@ -199,6 +259,11 @@ class ChatScreen(Screen[None]):
|
|
|
199
259
|
|
|
200
260
|
# All dependencies are now required and injected
|
|
201
261
|
self.deps = deps
|
|
262
|
+
|
|
263
|
+
# Wire up plan change callback for Plan Panel (Stage 11)
|
|
264
|
+
if isinstance(deps, RouterDeps):
|
|
265
|
+
deps.on_plan_changed = self._on_plan_changed
|
|
266
|
+
|
|
202
267
|
self.codebase_sdk = codebase_sdk
|
|
203
268
|
self.agent_manager = agent_manager
|
|
204
269
|
self.command_handler = command_handler
|
|
@@ -211,6 +276,10 @@ class ChatScreen(Screen[None]):
|
|
|
211
276
|
self.force_reindex = force_reindex
|
|
212
277
|
self.show_pull_hint = show_pull_hint
|
|
213
278
|
|
|
279
|
+
# Initialize mode from agent_manager before compose() runs
|
|
280
|
+
# This ensures ModeIndicator shows correct mode on first render
|
|
281
|
+
self.mode = agent_manager._current_agent_type
|
|
282
|
+
|
|
214
283
|
def on_mount(self) -> None:
|
|
215
284
|
# Use widget coordinator to focus input
|
|
216
285
|
self.widget_coordinator.update_prompt_input(focus=True)
|
|
@@ -233,9 +302,6 @@ class ChatScreen(Screen[None]):
|
|
|
233
302
|
# Initial update of context indicator
|
|
234
303
|
self.update_context_indicator()
|
|
235
304
|
|
|
236
|
-
# Show onboarding popup if not shown before
|
|
237
|
-
self.call_later(self._check_and_show_onboarding)
|
|
238
|
-
|
|
239
305
|
async def on_key(self, event: events.Key) -> None:
|
|
240
306
|
"""Handle key presses for cancellation."""
|
|
241
307
|
# If escape is pressed during Q&A mode, exit Q&A
|
|
@@ -257,8 +323,152 @@ class ChatScreen(Screen[None]):
|
|
|
257
323
|
# Prevent the event from propagating (don't quit the app)
|
|
258
324
|
event.stop()
|
|
259
325
|
|
|
326
|
+
async def _handle_pending_database_issues(self) -> bool:
|
|
327
|
+
"""Handle any database issues detected at startup.
|
|
328
|
+
|
|
329
|
+
This method processes pending database issues (locked, corrupted, timeout)
|
|
330
|
+
and shows appropriate dialogs to the user.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
True if should continue with normal startup, False if should abort
|
|
334
|
+
"""
|
|
335
|
+
from shotgun.codebase.core.manager import CodebaseGraphManager
|
|
336
|
+
from shotgun.utils import get_shotgun_home
|
|
337
|
+
|
|
338
|
+
# Get pending issues from app
|
|
339
|
+
pending_issues: list[DatabaseIssue] = getattr(self.app, "pending_db_issues", [])
|
|
340
|
+
if not pending_issues:
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
storage_dir = get_shotgun_home() / "codebases"
|
|
344
|
+
manager = CodebaseGraphManager(storage_dir)
|
|
345
|
+
|
|
346
|
+
# Handle locked databases first - show ONE dialog for all locked DBs
|
|
347
|
+
locked_issues = [
|
|
348
|
+
i for i in pending_issues if i.error_type == KuzuErrorType.LOCKED
|
|
349
|
+
]
|
|
350
|
+
if locked_issues:
|
|
351
|
+
# Show single locked dialog
|
|
352
|
+
locked_action = await self.app.push_screen_wait(DatabaseLockedDialog())
|
|
353
|
+
|
|
354
|
+
if locked_action == LockedDialogAction.QUIT:
|
|
355
|
+
# User cancelled - exit the app gracefully
|
|
356
|
+
await self.app.action_quit()
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
if locked_action == LockedDialogAction.DELETE:
|
|
360
|
+
# User confirmed deletion of locked databases
|
|
361
|
+
for issue in locked_issues:
|
|
362
|
+
deleted = await manager.delete_database(issue.graph_id)
|
|
363
|
+
if deleted:
|
|
364
|
+
logger.info(f"Deleted locked database: {issue.graph_id}")
|
|
365
|
+
self.agent_manager.add_hint_message(
|
|
366
|
+
HintMessage(
|
|
367
|
+
message=f"Deleted locked database '{issue.graph_id}'. "
|
|
368
|
+
"You can re-index using /index."
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
logger.error(
|
|
373
|
+
f"Failed to delete locked database: {issue.graph_id}"
|
|
374
|
+
)
|
|
375
|
+
# Continue with startup after deletion
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
# locked_action == LockedDialogAction.RETRY - re-detect to see if locks are cleared
|
|
379
|
+
new_issues = await manager.detect_database_issues(timeout_seconds=10.0)
|
|
380
|
+
still_locked = [
|
|
381
|
+
i for i in new_issues if i.error_type == KuzuErrorType.LOCKED
|
|
382
|
+
]
|
|
383
|
+
if still_locked:
|
|
384
|
+
# Still locked - show hint message
|
|
385
|
+
self.agent_manager.add_hint_message(
|
|
386
|
+
HintMessage(
|
|
387
|
+
message="Database is still locked. "
|
|
388
|
+
"Please close the other shotgun instance and restart."
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
await self.app.action_quit()
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
# Process non-locked issues
|
|
395
|
+
for issue in pending_issues:
|
|
396
|
+
if issue.error_type == KuzuErrorType.LOCKED:
|
|
397
|
+
continue # Already handled above
|
|
398
|
+
|
|
399
|
+
if issue.error_type == KuzuErrorType.TIMEOUT:
|
|
400
|
+
# Show timeout dialog
|
|
401
|
+
action = await self.app.push_screen_wait(
|
|
402
|
+
DatabaseTimeoutDialog(
|
|
403
|
+
codebase_name=issue.graph_id,
|
|
404
|
+
timeout_seconds=10.0,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
if action == "retry":
|
|
408
|
+
# Retry with longer timeout (90s)
|
|
409
|
+
new_issues = await manager.detect_database_issues(
|
|
410
|
+
timeout_seconds=90.0
|
|
411
|
+
)
|
|
412
|
+
still_timeout = any(
|
|
413
|
+
i.graph_id == issue.graph_id
|
|
414
|
+
and i.error_type == KuzuErrorType.TIMEOUT
|
|
415
|
+
for i in new_issues
|
|
416
|
+
)
|
|
417
|
+
if still_timeout:
|
|
418
|
+
self.agent_manager.add_hint_message(
|
|
419
|
+
HintMessage(
|
|
420
|
+
message=f"Database '{issue.graph_id}' still not responding. "
|
|
421
|
+
"It may be corrupted or the codebase is extremely large."
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
elif action == "skip":
|
|
425
|
+
# User chose to skip this database
|
|
426
|
+
logger.info(f"User skipped timeout database: {issue.graph_id}")
|
|
427
|
+
# "cancel" - do nothing
|
|
428
|
+
|
|
429
|
+
elif issue.error_type == KuzuErrorType.CORRUPTION:
|
|
430
|
+
# Show corruption confirmation dialog
|
|
431
|
+
should_delete = await self.app.push_screen_wait(
|
|
432
|
+
ConfirmationDialog(
|
|
433
|
+
title="Database Corrupted",
|
|
434
|
+
message=(
|
|
435
|
+
f"The codebase index '{issue.graph_id}' appears to be corrupted.\n\n"
|
|
436
|
+
f"Error: {issue.message}\n\n"
|
|
437
|
+
"Would you like to delete it? You will need to re-index the codebase."
|
|
438
|
+
),
|
|
439
|
+
confirm_label="Delete & Re-index",
|
|
440
|
+
cancel_label="Keep (Skip)",
|
|
441
|
+
confirm_variant="warning",
|
|
442
|
+
danger=True,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
if should_delete:
|
|
446
|
+
deleted = await manager.delete_database(issue.graph_id)
|
|
447
|
+
if deleted:
|
|
448
|
+
self.agent_manager.add_hint_message(
|
|
449
|
+
HintMessage(
|
|
450
|
+
message=f"Deleted corrupted database '{issue.graph_id}'. "
|
|
451
|
+
"You can re-index using /index."
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
logger.error(
|
|
456
|
+
f"Failed to delete corrupted database: {issue.graph_id}"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Clear the pending issues after processing
|
|
460
|
+
if hasattr(self.app, "pending_db_issues"):
|
|
461
|
+
self.app.pending_db_issues = []
|
|
462
|
+
|
|
463
|
+
return True
|
|
464
|
+
|
|
260
465
|
@work
|
|
261
466
|
async def check_if_codebase_is_indexed(self) -> None:
|
|
467
|
+
# Handle any pending database issues from startup first
|
|
468
|
+
should_continue = await self._handle_pending_database_issues()
|
|
469
|
+
if not should_continue:
|
|
470
|
+
return
|
|
471
|
+
|
|
262
472
|
cur_dir = Path.cwd().resolve()
|
|
263
473
|
is_empty = all(
|
|
264
474
|
dir.is_dir() and dir.name in ["__pycache__", ".git", ".shotgun"]
|
|
@@ -331,24 +541,81 @@ class ChatScreen(Screen[None]):
|
|
|
331
541
|
# Use widget coordinator for all widget updates
|
|
332
542
|
self.widget_coordinator.update_messages(messages)
|
|
333
543
|
|
|
544
|
+
# =========================================================================
|
|
545
|
+
# Router State Properties (for Protocol compliance)
|
|
546
|
+
# =========================================================================
|
|
547
|
+
|
|
548
|
+
@property
|
|
549
|
+
def router_mode(self) -> str | None:
|
|
550
|
+
"""Get the current router mode for RouterModeProvider protocol.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
'planning' or 'drafting' if using router agent, None otherwise.
|
|
554
|
+
"""
|
|
555
|
+
if isinstance(self.deps, RouterDeps):
|
|
556
|
+
return self.deps.router_mode.value
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def active_sub_agent(self) -> str | None:
|
|
561
|
+
"""Get the active sub-agent for ActiveSubAgentProvider protocol.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
The sub-agent type string if executing, None otherwise.
|
|
565
|
+
"""
|
|
566
|
+
if isinstance(self.deps, RouterDeps) and self.deps.active_sub_agent:
|
|
567
|
+
return self.deps.active_sub_agent.value
|
|
568
|
+
return None
|
|
569
|
+
|
|
334
570
|
def action_toggle_mode(self) -> None:
|
|
335
|
-
|
|
571
|
+
"""Toggle between Planning and Drafting modes for Router."""
|
|
572
|
+
from shotgun.agents.router.models import RouterDeps, RouterMode
|
|
573
|
+
|
|
574
|
+
# If in Q&A mode, exit it first (SHIFT+TAB escapes Q&A mode)
|
|
336
575
|
if self.qa_mode:
|
|
576
|
+
self._exit_qa_mode()
|
|
337
577
|
self.agent_manager.add_hint_message(
|
|
338
|
-
HintMessage(message="
|
|
578
|
+
HintMessage(message="Exited Q&A mode via Shift+Tab")
|
|
339
579
|
)
|
|
340
580
|
return
|
|
341
581
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
582
|
+
if not isinstance(self.deps, RouterDeps):
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
# Prevent mode switching during execution
|
|
586
|
+
if self.deps.is_executing:
|
|
587
|
+
self.agent_manager.add_hint_message(
|
|
588
|
+
HintMessage(message="⚠️ Cannot switch modes during plan execution")
|
|
589
|
+
)
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
# Prevent mode switching while sub-agent is active
|
|
593
|
+
if self.deps.active_sub_agent is not None:
|
|
594
|
+
self.agent_manager.add_hint_message(
|
|
595
|
+
HintMessage(message="⚠️ Cannot switch modes while sub-agent is running")
|
|
596
|
+
)
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
# Toggle mode
|
|
600
|
+
if self.deps.router_mode == RouterMode.PLANNING:
|
|
601
|
+
self.deps.router_mode = RouterMode.DRAFTING
|
|
602
|
+
mode_name = "Drafting"
|
|
603
|
+
else:
|
|
604
|
+
self.deps.router_mode = RouterMode.PLANNING
|
|
605
|
+
mode_name = "Planning"
|
|
606
|
+
# Clear plan when switching back to Planning mode
|
|
607
|
+
# This forces the agent to create a new plan for the next request
|
|
608
|
+
self.deps.current_plan = None
|
|
609
|
+
self.deps.approval_status = PlanApprovalStatus.SKIPPED
|
|
610
|
+
self.deps.is_executing = False
|
|
611
|
+
|
|
612
|
+
# Show mode change feedback
|
|
613
|
+
self.agent_manager.add_hint_message(
|
|
614
|
+
HintMessage(message=f"Switched to {mode_name} mode")
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Update UI
|
|
618
|
+
self.widget_coordinator.update_for_mode_change(self.mode)
|
|
352
619
|
self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
|
|
353
620
|
|
|
354
621
|
async def action_show_usage(self) -> None:
|
|
@@ -436,10 +703,6 @@ class ChatScreen(Screen[None]):
|
|
|
436
703
|
HintMessage(message="⚠️ No context analysis available")
|
|
437
704
|
)
|
|
438
705
|
|
|
439
|
-
def action_view_onboarding(self) -> None:
|
|
440
|
-
"""Show the onboarding modal."""
|
|
441
|
-
self.app.push_screen(OnboardingModal())
|
|
442
|
-
|
|
443
706
|
@work
|
|
444
707
|
async def action_compact_conversation(self) -> None:
|
|
445
708
|
"""Compact the conversation history to reduce size."""
|
|
@@ -682,6 +945,7 @@ class ChatScreen(Screen[None]):
|
|
|
682
945
|
classes="" if self.working else "hidden",
|
|
683
946
|
)
|
|
684
947
|
yield StatusBar(working=self.working)
|
|
948
|
+
yield AttachmentBar(id="attachment-bar")
|
|
685
949
|
yield PromptInput(
|
|
686
950
|
text=self.value,
|
|
687
951
|
highlight_cursor_line=False,
|
|
@@ -745,6 +1009,72 @@ class ChatScreen(Screen[None]):
|
|
|
745
1009
|
)
|
|
746
1010
|
self.agent_manager.add_hint_message(hint)
|
|
747
1011
|
|
|
1012
|
+
async def execute_shell_command(self, command: str) -> None:
|
|
1013
|
+
"""Execute a shell command and display output.
|
|
1014
|
+
|
|
1015
|
+
This implements the `!`-to-shell behavior for interactive mode.
|
|
1016
|
+
Commands are executed in the current working directory with
|
|
1017
|
+
full shell features (pipes, redirection, etc.).
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
command: The shell command to execute (after stripping the leading `!`)
|
|
1021
|
+
|
|
1022
|
+
Note:
|
|
1023
|
+
- Commands are executed with shell=True for full shell features
|
|
1024
|
+
- Output is streamed to the TUI via hint messages
|
|
1025
|
+
- Errors are displayed but do not crash the application
|
|
1026
|
+
- Commands are NOT added to conversation history
|
|
1027
|
+
"""
|
|
1028
|
+
# Handle empty command (just `!` with nothing after it)
|
|
1029
|
+
if not command or command.isspace():
|
|
1030
|
+
self.mount_hint("⚠️ Empty shell command. Usage: `!<command>`")
|
|
1031
|
+
return
|
|
1032
|
+
|
|
1033
|
+
# Show what command is being executed
|
|
1034
|
+
# Use code block for better visibility
|
|
1035
|
+
self.mount_hint(f"**Running:** `{command}`")
|
|
1036
|
+
|
|
1037
|
+
try:
|
|
1038
|
+
# Execute with shell=True to support pipes, redirection, etc.
|
|
1039
|
+
# Run in current working directory
|
|
1040
|
+
process = await asyncio.create_subprocess_shell(
|
|
1041
|
+
command,
|
|
1042
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1043
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1044
|
+
cwd=Path.cwd(),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
# Wait for command to complete and capture output
|
|
1048
|
+
stdout_bytes, stderr_bytes = await process.communicate()
|
|
1049
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
1050
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
1051
|
+
return_code = process.returncode or 0
|
|
1052
|
+
|
|
1053
|
+
# Build output message
|
|
1054
|
+
output_parts = []
|
|
1055
|
+
|
|
1056
|
+
if stdout:
|
|
1057
|
+
# Show stdout in a code block for proper formatting
|
|
1058
|
+
output_parts.append(f"```\n{stdout.rstrip()}\n```")
|
|
1059
|
+
|
|
1060
|
+
if stderr:
|
|
1061
|
+
# Show stderr with a warning indicator
|
|
1062
|
+
output_parts.append(f"**stderr:**\n```\n{stderr.rstrip()}\n```")
|
|
1063
|
+
|
|
1064
|
+
if return_code != 0:
|
|
1065
|
+
# Show non-zero exit code as error
|
|
1066
|
+
output_parts.append(f"**Exit code:** {return_code}")
|
|
1067
|
+
|
|
1068
|
+
# Display output (or success message if no output)
|
|
1069
|
+
if output_parts:
|
|
1070
|
+
self.mount_hint("\n\n".join(output_parts))
|
|
1071
|
+
elif return_code == 0:
|
|
1072
|
+
self.mount_hint("✓ Command completed successfully (no output)")
|
|
1073
|
+
|
|
1074
|
+
except Exception as e:
|
|
1075
|
+
# Show error message
|
|
1076
|
+
self.mount_hint(f"❌ **Shell command failed:**\n```\n{str(e)}\n```")
|
|
1077
|
+
|
|
748
1078
|
@on(PartialResponseMessage)
|
|
749
1079
|
def handle_partial_response(self, event: PartialResponseMessage) -> None:
|
|
750
1080
|
# Filter event.messages to exclude ModelRequest with only ToolReturnPart
|
|
@@ -786,6 +1116,12 @@ class ChatScreen(Screen[None]):
|
|
|
786
1116
|
if has_file_write:
|
|
787
1117
|
return # Skip context update for file writes
|
|
788
1118
|
|
|
1119
|
+
# Skip context updates when a sub-agent is streaming
|
|
1120
|
+
# Sub-agents run with isolated message history, so their streaming doesn't
|
|
1121
|
+
# represent the router's actual context usage
|
|
1122
|
+
if isinstance(self.deps, RouterDeps) and self.deps.active_sub_agent is not None:
|
|
1123
|
+
return # Skip context update for sub-agent streaming
|
|
1124
|
+
|
|
789
1125
|
# Throttle context indicator updates to improve performance during streaming
|
|
790
1126
|
# Only update at most once per 5 seconds to avoid excessive token calculations
|
|
791
1127
|
current_time = time.time()
|
|
@@ -832,17 +1168,88 @@ class ChatScreen(Screen[None]):
|
|
|
832
1168
|
# Clear any streaming partial response (removes final_result JSON)
|
|
833
1169
|
self._clear_partial_response()
|
|
834
1170
|
|
|
1171
|
+
# Safety check: don't enter Q&A mode if questions array is empty
|
|
1172
|
+
if not event.questions:
|
|
1173
|
+
logger.warning("ClarifyingQuestionsMessage received with empty questions")
|
|
1174
|
+
return
|
|
1175
|
+
|
|
835
1176
|
# Enter Q&A mode
|
|
836
1177
|
self.qa_mode = True
|
|
837
1178
|
self.qa_questions = event.questions
|
|
838
1179
|
self.qa_current_index = 0
|
|
839
1180
|
self.qa_answers = []
|
|
840
1181
|
|
|
1182
|
+
@on(FileRequestPendingMessage)
|
|
1183
|
+
def handle_file_request_pending(self, event: FileRequestPendingMessage) -> None:
|
|
1184
|
+
"""Handle file request from agent structured output.
|
|
1185
|
+
|
|
1186
|
+
When the agent returns file_requests in its response, we load the files
|
|
1187
|
+
and resume the agent with the file contents.
|
|
1188
|
+
"""
|
|
1189
|
+
logger.debug(
|
|
1190
|
+
"[FILE_REQUEST] FileRequestPendingMessage received - %d files",
|
|
1191
|
+
len(event.file_paths),
|
|
1192
|
+
)
|
|
1193
|
+
# Clear any streaming partial response
|
|
1194
|
+
self._clear_partial_response()
|
|
1195
|
+
|
|
1196
|
+
# Process files and resume agent (run as background task)
|
|
1197
|
+
self._process_files_and_resume(event.file_paths)
|
|
1198
|
+
|
|
1199
|
+
@work(exclusive=True, name="process_files_and_resume")
|
|
1200
|
+
async def _process_files_and_resume(self, file_paths: list[str]) -> None:
|
|
1201
|
+
"""Load requested files and resume agent with content.
|
|
1202
|
+
|
|
1203
|
+
This runs as a background worker to avoid blocking the UI.
|
|
1204
|
+
"""
|
|
1205
|
+
logger.debug("[FILE_REQUEST] Processing %d file requests", len(file_paths))
|
|
1206
|
+
|
|
1207
|
+
# Set working state
|
|
1208
|
+
self.working = True
|
|
1209
|
+
self.widget_coordinator.update_spinner_text("Loading files...")
|
|
1210
|
+
|
|
1211
|
+
try:
|
|
1212
|
+
# Load files via agent manager
|
|
1213
|
+
file_contents = self.agent_manager.process_file_requests()
|
|
1214
|
+
|
|
1215
|
+
if not file_contents:
|
|
1216
|
+
logger.warning("[FILE_REQUEST] No files were successfully loaded")
|
|
1217
|
+
self.mount_hint("⚠️ Could not load any of the requested files.")
|
|
1218
|
+
self.working = False
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
logger.info(
|
|
1222
|
+
"[FILE_REQUEST] Loaded %d files, resuming agent",
|
|
1223
|
+
len(file_contents),
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
# Resume agent with file contents
|
|
1227
|
+
# Note: We call run_agent directly since we're already in a worker
|
|
1228
|
+
runner = AgentRunner(self.agent_manager)
|
|
1229
|
+
await runner.run(
|
|
1230
|
+
prompt=(
|
|
1231
|
+
"The files you requested are now loaded and included below. "
|
|
1232
|
+
"Analyze the file contents and respond to the user's original question. "
|
|
1233
|
+
"DO NOT use file_requests - the files are already provided in this message."
|
|
1234
|
+
),
|
|
1235
|
+
file_contents=file_contents,
|
|
1236
|
+
)
|
|
1237
|
+
# Mark work as complete after successful file processing
|
|
1238
|
+
self.working = False
|
|
1239
|
+
except Exception as e:
|
|
1240
|
+
logger.error("[FILE_REQUEST] Error processing files: %s", e)
|
|
1241
|
+
self.mount_hint(f"⚠️ Error loading files: {e}")
|
|
1242
|
+
self.working = False
|
|
1243
|
+
|
|
841
1244
|
@on(MessageHistoryUpdated)
|
|
842
1245
|
async def handle_message_history_updated(
|
|
843
1246
|
self, event: MessageHistoryUpdated
|
|
844
1247
|
) -> None:
|
|
845
1248
|
"""Handle message history updates from the agent manager."""
|
|
1249
|
+
logger.debug(
|
|
1250
|
+
"[MSG_HISTORY] MessageHistoryUpdated received - %d messages",
|
|
1251
|
+
len(event.messages),
|
|
1252
|
+
)
|
|
846
1253
|
self._clear_partial_response()
|
|
847
1254
|
self.messages = event.messages
|
|
848
1255
|
|
|
@@ -913,6 +1320,29 @@ class ChatScreen(Screen[None]):
|
|
|
913
1320
|
# Use widget coordinator to update spinner text
|
|
914
1321
|
self.widget_coordinator.update_spinner_text("Processing...")
|
|
915
1322
|
|
|
1323
|
+
@on(ToolExecutionStartedMessage)
|
|
1324
|
+
def handle_tool_execution_started(self, event: ToolExecutionStartedMessage) -> None:
|
|
1325
|
+
"""Update spinner text when a tool starts executing.
|
|
1326
|
+
|
|
1327
|
+
This provides visual feedback during long-running tool executions
|
|
1328
|
+
like web search, so the UI doesn't appear frozen.
|
|
1329
|
+
"""
|
|
1330
|
+
self.widget_coordinator.update_spinner_text(event.spinner_text)
|
|
1331
|
+
|
|
1332
|
+
@on(ToolStreamingProgressMessage)
|
|
1333
|
+
def handle_tool_streaming_progress(
|
|
1334
|
+
self, event: ToolStreamingProgressMessage
|
|
1335
|
+
) -> None:
|
|
1336
|
+
"""Update spinner text with token count during tool streaming.
|
|
1337
|
+
|
|
1338
|
+
Shows progress while tool arguments are streaming in,
|
|
1339
|
+
particularly useful for long file writes.
|
|
1340
|
+
"""
|
|
1341
|
+
text = f"{event.spinner_text} (~{event.streamed_tokens:,} tokens)"
|
|
1342
|
+
self.widget_coordinator.update_spinner_text(text)
|
|
1343
|
+
# Force immediate refresh to show progress
|
|
1344
|
+
self.refresh()
|
|
1345
|
+
|
|
916
1346
|
async def handle_model_selected(self, result: ModelConfigUpdated | None) -> None:
|
|
917
1347
|
"""Handle model selection from ModelPickerScreen.
|
|
918
1348
|
|
|
@@ -984,8 +1414,27 @@ class ChatScreen(Screen[None]):
|
|
|
984
1414
|
HintMessage(message=f"⚠ Failed to update model configuration: {e}")
|
|
985
1415
|
)
|
|
986
1416
|
|
|
1417
|
+
@on(PromptInput.OpenCommandPalette)
|
|
1418
|
+
def _on_open_command_palette(self, event: PromptInput.OpenCommandPalette) -> None:
|
|
1419
|
+
"""Open command palette when triggered by '/' prefix."""
|
|
1420
|
+
self.app.action_command_palette()
|
|
1421
|
+
|
|
987
1422
|
@on(PromptInput.Submitted)
|
|
988
1423
|
async def handle_submit(self, message: PromptInput.Submitted) -> None:
|
|
1424
|
+
"""Handle user input submission from the prompt.
|
|
1425
|
+
|
|
1426
|
+
This is the main interactive loop entrypoint for shotgun-cli TUI.
|
|
1427
|
+
Input classification:
|
|
1428
|
+
1. Lines starting with `!` (after trimming whitespace) are shell commands
|
|
1429
|
+
2. Lines starting with `/` are internal commands
|
|
1430
|
+
3. All other lines are sent to the LLM
|
|
1431
|
+
|
|
1432
|
+
Shell command behavior (`!`-to-shell):
|
|
1433
|
+
- Lines like `!ls` or ` !git status` execute as shell commands
|
|
1434
|
+
- Shell commands are NOT sent to LLM
|
|
1435
|
+
- Shell commands are NOT added to conversation history
|
|
1436
|
+
- Implementation: v1 limitation - no history expansion (!!, !$, etc.)
|
|
1437
|
+
"""
|
|
989
1438
|
text = message.text.strip()
|
|
990
1439
|
|
|
991
1440
|
# If empty text, just clear input and return
|
|
@@ -994,6 +1443,23 @@ class ChatScreen(Screen[None]):
|
|
|
994
1443
|
self.value = ""
|
|
995
1444
|
return
|
|
996
1445
|
|
|
1446
|
+
# Stage 1: Classify input - check if line starts with `!` (shell command)
|
|
1447
|
+
# Trim leading whitespace and check first character
|
|
1448
|
+
trimmed = message.text.lstrip()
|
|
1449
|
+
if trimmed.startswith("!"):
|
|
1450
|
+
# This is a shell command - extract the command by removing exactly one `!`
|
|
1451
|
+
# Note: `!!ls` becomes `!ls` in v1 (no special history expansion)
|
|
1452
|
+
shell_command = trimmed[1:] # Remove the leading `!`
|
|
1453
|
+
|
|
1454
|
+
# Execute shell command (do NOT forward to LLM or add to history)
|
|
1455
|
+
await self.execute_shell_command(shell_command)
|
|
1456
|
+
|
|
1457
|
+
# Clear input and return (do not proceed to LLM handling)
|
|
1458
|
+
# This ensures shell commands are never added to conversation history
|
|
1459
|
+
self.widget_coordinator.update_prompt_input(clear=True)
|
|
1460
|
+
self.value = ""
|
|
1461
|
+
return
|
|
1462
|
+
|
|
997
1463
|
# Handle Q&A mode (from structured output clarifying questions)
|
|
998
1464
|
if self.qa_mode and self.qa_questions:
|
|
999
1465
|
# Collect answer
|
|
@@ -1069,6 +1535,33 @@ class ChatScreen(Screen[None]):
|
|
|
1069
1535
|
return
|
|
1070
1536
|
|
|
1071
1537
|
# Not a command, process as normal
|
|
1538
|
+
|
|
1539
|
+
# Parse for @path attachment references
|
|
1540
|
+
parse_result = parse_attachment_reference(text)
|
|
1541
|
+
|
|
1542
|
+
if parse_result.error_message:
|
|
1543
|
+
self.mount_hint(parse_result.error_message)
|
|
1544
|
+
self.widget_coordinator.update_prompt_input(clear=True)
|
|
1545
|
+
self.value = ""
|
|
1546
|
+
return
|
|
1547
|
+
|
|
1548
|
+
# Process attachment if found (encode to base64, validate size)
|
|
1549
|
+
attachment: FileAttachment | None = None
|
|
1550
|
+
if parse_result.attachment:
|
|
1551
|
+
processed_attachment, error = await process_attachment(
|
|
1552
|
+
parse_result.attachment,
|
|
1553
|
+
self.deps.llm_model.provider,
|
|
1554
|
+
)
|
|
1555
|
+
if error:
|
|
1556
|
+
self.mount_hint(error)
|
|
1557
|
+
self.widget_coordinator.update_prompt_input(clear=True)
|
|
1558
|
+
self.value = ""
|
|
1559
|
+
return
|
|
1560
|
+
attachment = processed_attachment
|
|
1561
|
+
|
|
1562
|
+
# Show attachment in the attachment bar
|
|
1563
|
+
self.widget_coordinator.update_attachment_bar(attachment)
|
|
1564
|
+
|
|
1072
1565
|
self.history.append(message.text)
|
|
1073
1566
|
|
|
1074
1567
|
# Add user message to agent_manager's history BEFORE running the agent
|
|
@@ -1077,9 +1570,10 @@ class ChatScreen(Screen[None]):
|
|
|
1077
1570
|
self.agent_manager.ui_message_history.append(user_message)
|
|
1078
1571
|
self.messages = self.agent_manager.ui_message_history.copy()
|
|
1079
1572
|
|
|
1080
|
-
# Clear the input
|
|
1573
|
+
# Clear the input and attachment bar
|
|
1081
1574
|
self.value = ""
|
|
1082
|
-
self.
|
|
1575
|
+
self.widget_coordinator.update_attachment_bar(None)
|
|
1576
|
+
self.run_agent(text, attachment=attachment) # Use stripped text
|
|
1083
1577
|
|
|
1084
1578
|
self.widget_coordinator.update_prompt_input(clear=True)
|
|
1085
1579
|
|
|
@@ -1196,6 +1690,17 @@ class ChatScreen(Screen[None]):
|
|
|
1196
1690
|
HintMessage(message=f"❌ Failed to delete codebase: {exc}")
|
|
1197
1691
|
)
|
|
1198
1692
|
|
|
1693
|
+
def _classify_kuzu_error(self, exception: Exception) -> KuzuErrorType:
|
|
1694
|
+
"""Classify a Kuzu database error.
|
|
1695
|
+
|
|
1696
|
+
Args:
|
|
1697
|
+
exception: The exception to classify
|
|
1698
|
+
|
|
1699
|
+
Returns:
|
|
1700
|
+
KuzuErrorType indicating the category of error
|
|
1701
|
+
"""
|
|
1702
|
+
return classify_kuzu_error(exception)
|
|
1703
|
+
|
|
1199
1704
|
def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
|
|
1200
1705
|
"""Check if error is related to kuzu database corruption.
|
|
1201
1706
|
|
|
@@ -1203,221 +1708,275 @@ class ChatScreen(Screen[None]):
|
|
|
1203
1708
|
exception: The exception to check
|
|
1204
1709
|
|
|
1205
1710
|
Returns:
|
|
1206
|
-
True if the error indicates kuzu database corruption
|
|
1711
|
+
True if the error indicates kuzu database corruption or lock issues
|
|
1207
1712
|
"""
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
"unordered_map", # C++ STL map errors from kuzu
|
|
1216
|
-
"key not found", # unordered_map::at errors
|
|
1217
|
-
"std::exception", # Generic C++ exceptions from kuzu
|
|
1218
|
-
]
|
|
1219
|
-
return any(indicator in error_str for indicator in error_indicators)
|
|
1713
|
+
error_type = classify_kuzu_error(exception)
|
|
1714
|
+
# Consider corruption and lock errors as "kuzu errors" that need special handling
|
|
1715
|
+
return error_type in (
|
|
1716
|
+
KuzuErrorType.CORRUPTION,
|
|
1717
|
+
KuzuErrorType.LOCKED,
|
|
1718
|
+
KuzuErrorType.SCHEMA,
|
|
1719
|
+
)
|
|
1220
1720
|
|
|
1221
|
-
@work
|
|
1721
|
+
@work(group="indexing", exit_on_error=False)
|
|
1222
1722
|
async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
|
|
1723
|
+
logger.debug(f"index_codebase worker starting for {selection.repo_path}")
|
|
1223
1724
|
index_start_time = time.time()
|
|
1224
1725
|
|
|
1726
|
+
# Compute graph_id to track indexing state
|
|
1727
|
+
graph_id = self.codebase_sdk.service.compute_graph_id(selection.repo_path)
|
|
1728
|
+
|
|
1729
|
+
# Mark indexing as started and show hint
|
|
1730
|
+
await self.codebase_sdk.service.indexing.start(graph_id)
|
|
1731
|
+
self.agent_manager.add_hint_message(
|
|
1732
|
+
HintMessage(
|
|
1733
|
+
message="Indexing has started. The codebase graph will be "
|
|
1734
|
+
"inaccessible to the AI Agents until this is completed."
|
|
1735
|
+
)
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1225
1738
|
label = self.query_one("#indexing-job-display", Static)
|
|
1226
1739
|
label.update(
|
|
1227
1740
|
f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
|
|
1228
1741
|
)
|
|
1229
1742
|
label.refresh()
|
|
1230
1743
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
filled = int((percentage / 100) * width)
|
|
1234
|
-
empty = width - filled
|
|
1235
|
-
return "▓" * filled + "░" * empty
|
|
1236
|
-
|
|
1237
|
-
# Spinner animation frames
|
|
1238
|
-
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1239
|
-
|
|
1240
|
-
# Progress state (shared between timer and progress callback)
|
|
1241
|
-
progress_state: dict[str, int | float] = {
|
|
1242
|
-
"frame_index": 0,
|
|
1243
|
-
"percentage": 0.0,
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
def update_progress_display() -> None:
|
|
1247
|
-
"""Update progress bar on timer - runs every 100ms."""
|
|
1248
|
-
# Advance spinner frame
|
|
1249
|
-
frame_idx = int(progress_state["frame_index"])
|
|
1250
|
-
progress_state["frame_index"] = (frame_idx + 1) % len(spinner_frames)
|
|
1251
|
-
spinner = spinner_frames[frame_idx]
|
|
1252
|
-
|
|
1253
|
-
# Get current state
|
|
1254
|
-
pct = float(progress_state["percentage"])
|
|
1255
|
-
bar = create_progress_bar(pct)
|
|
1256
|
-
|
|
1257
|
-
# Update label
|
|
1258
|
-
label.update(
|
|
1259
|
-
f"[$foreground-muted]Indexing codebase: {spinner} {bar} {pct:.0f}%[/]"
|
|
1260
|
-
)
|
|
1744
|
+
# Track progress timer for cleanup
|
|
1745
|
+
progress_timer = None
|
|
1261
1746
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1747
|
+
try:
|
|
1748
|
+
|
|
1749
|
+
def create_progress_bar(percentage: float, width: int = 20) -> str:
|
|
1750
|
+
"""Create a visual progress bar using Unicode block characters."""
|
|
1751
|
+
filled = int((percentage / 100) * width)
|
|
1752
|
+
empty = width - filled
|
|
1753
|
+
return "▓" * filled + "░" * empty
|
|
1754
|
+
|
|
1755
|
+
# Spinner animation frames
|
|
1756
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1757
|
+
|
|
1758
|
+
# Progress state (shared between timer and progress callback)
|
|
1759
|
+
progress_state: dict[str, int | float] = {
|
|
1760
|
+
"frame_index": 0,
|
|
1761
|
+
"percentage": 0.0,
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
def update_progress_display() -> None:
|
|
1765
|
+
"""Update progress bar on timer - runs every 100ms."""
|
|
1766
|
+
# Advance spinner frame
|
|
1767
|
+
frame_idx = int(progress_state["frame_index"])
|
|
1768
|
+
progress_state["frame_index"] = (frame_idx + 1) % len(spinner_frames)
|
|
1769
|
+
spinner = spinner_frames[frame_idx]
|
|
1770
|
+
|
|
1771
|
+
# Get current state
|
|
1772
|
+
pct = float(progress_state["percentage"])
|
|
1773
|
+
bar = create_progress_bar(pct)
|
|
1774
|
+
|
|
1775
|
+
# Update label
|
|
1776
|
+
label.update(
|
|
1777
|
+
f"[$foreground-muted]Indexing codebase: {spinner} {bar} {pct:.0f}%[/]"
|
|
1778
|
+
)
|
|
1779
|
+
|
|
1780
|
+
def progress_callback(progress_info: IndexProgress) -> None:
|
|
1781
|
+
"""Update progress state (timer renders it independently)."""
|
|
1782
|
+
# Calculate overall percentage with weights based on actual timing:
|
|
1783
|
+
# Structure: 0-2%, Definitions: 2-18%, Relationships: 18-20%
|
|
1784
|
+
# Flush nodes: 20-28%, Flush relationships: 28-100%
|
|
1785
|
+
if progress_info.phase == ProgressPhase.STRUCTURE:
|
|
1786
|
+
# Phase 1: 0-2% (actual: ~0%)
|
|
1787
|
+
overall_pct = 2.0 if progress_info.phase_complete else 1.0
|
|
1788
|
+
elif progress_info.phase == ProgressPhase.DEFINITIONS:
|
|
1789
|
+
# Phase 2: 2-18% based on files processed (actual: ~16%)
|
|
1790
|
+
if progress_info.total and progress_info.total > 0:
|
|
1791
|
+
phase_pct = (progress_info.current / progress_info.total) * 16.0
|
|
1792
|
+
overall_pct = 2.0 + phase_pct
|
|
1793
|
+
else:
|
|
1794
|
+
overall_pct = 2.0
|
|
1795
|
+
elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
|
|
1796
|
+
# Phase 3: 18-20% based on relationships processed (actual: ~0.3%)
|
|
1797
|
+
if progress_info.total and progress_info.total > 0:
|
|
1798
|
+
phase_pct = (progress_info.current / progress_info.total) * 2.0
|
|
1799
|
+
overall_pct = 18.0 + phase_pct
|
|
1800
|
+
else:
|
|
1801
|
+
overall_pct = 18.0
|
|
1802
|
+
elif progress_info.phase == ProgressPhase.FLUSH_NODES:
|
|
1803
|
+
# Phase 4: 20-28% based on nodes flushed (actual: ~7.5%)
|
|
1804
|
+
if progress_info.total and progress_info.total > 0:
|
|
1805
|
+
phase_pct = (progress_info.current / progress_info.total) * 8.0
|
|
1806
|
+
overall_pct = 20.0 + phase_pct
|
|
1807
|
+
else:
|
|
1808
|
+
overall_pct = 20.0
|
|
1809
|
+
elif progress_info.phase == ProgressPhase.FLUSH_RELATIONSHIPS:
|
|
1810
|
+
# Phase 5: 28-100% based on relationships flushed (actual: ~76%)
|
|
1811
|
+
if progress_info.total and progress_info.total > 0:
|
|
1812
|
+
phase_pct = (progress_info.current / progress_info.total) * 72.0
|
|
1813
|
+
overall_pct = 28.0 + phase_pct
|
|
1814
|
+
else:
|
|
1815
|
+
overall_pct = 28.0
|
|
1296
1816
|
else:
|
|
1297
|
-
overall_pct =
|
|
1298
|
-
else:
|
|
1299
|
-
overall_pct = 0.0
|
|
1817
|
+
overall_pct = 0.0
|
|
1300
1818
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1819
|
+
# Update shared state (timer will render it)
|
|
1820
|
+
progress_state["percentage"] = overall_pct
|
|
1303
1821
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1822
|
+
# Start progress animation timer (10 fps = 100ms interval)
|
|
1823
|
+
progress_timer = self.set_interval(0.1, update_progress_display)
|
|
1306
1824
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1825
|
+
# Retry logic for handling kuzu corruption
|
|
1826
|
+
max_retries = 3
|
|
1309
1827
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1828
|
+
for attempt in range(max_retries):
|
|
1829
|
+
try:
|
|
1830
|
+
# Clean up corrupted DBs before retry (skip on first attempt)
|
|
1831
|
+
if attempt > 0:
|
|
1832
|
+
logger.info(
|
|
1833
|
+
f"Retry attempt {attempt + 1}/{max_retries} - cleaning up corrupted databases"
|
|
1834
|
+
)
|
|
1835
|
+
manager = CodebaseGraphManager(
|
|
1836
|
+
self.codebase_sdk.service.storage_dir
|
|
1837
|
+
)
|
|
1838
|
+
cleaned = await manager.cleanup_corrupted_databases()
|
|
1839
|
+
logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
|
|
1840
|
+
self.agent_manager.add_hint_message(
|
|
1841
|
+
HintMessage(
|
|
1842
|
+
message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
|
|
1843
|
+
)
|
|
1325
1844
|
)
|
|
1845
|
+
|
|
1846
|
+
# Pass the current working directory as the indexed_from_cwd
|
|
1847
|
+
logger.debug(
|
|
1848
|
+
f"Starting indexing - repo_path: {selection.repo_path}, "
|
|
1849
|
+
f"name: {selection.name}, cwd: {Path.cwd().resolve()}"
|
|
1850
|
+
)
|
|
1851
|
+
result = await self.codebase_sdk.index_codebase(
|
|
1852
|
+
selection.repo_path,
|
|
1853
|
+
selection.name,
|
|
1854
|
+
indexed_from_cwd=str(Path.cwd().resolve()),
|
|
1855
|
+
progress_callback=progress_callback,
|
|
1326
1856
|
)
|
|
1857
|
+
logger.debug("index_codebase SDK call completed successfully")
|
|
1327
1858
|
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
f"Starting indexing - repo_path: {selection.repo_path}, "
|
|
1331
|
-
f"name: {selection.name}, cwd: {Path.cwd().resolve()}"
|
|
1332
|
-
)
|
|
1333
|
-
result = await self.codebase_sdk.index_codebase(
|
|
1334
|
-
selection.repo_path,
|
|
1335
|
-
selection.name,
|
|
1336
|
-
indexed_from_cwd=str(Path.cwd().resolve()),
|
|
1337
|
-
progress_callback=progress_callback,
|
|
1338
|
-
)
|
|
1859
|
+
# Success! Stop progress animation
|
|
1860
|
+
progress_timer.stop()
|
|
1339
1861
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
f"[$foreground-muted]Indexing codebase: {final_bar} 100%[/]"
|
|
1347
|
-
)
|
|
1348
|
-
label.refresh()
|
|
1862
|
+
# Show 100% completion after indexing finishes
|
|
1863
|
+
final_bar = create_progress_bar(100.0)
|
|
1864
|
+
label.update(
|
|
1865
|
+
f"[$foreground-muted]Indexing codebase: {final_bar} 100%[/]"
|
|
1866
|
+
)
|
|
1867
|
+
label.refresh()
|
|
1349
1868
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1869
|
+
# Calculate duration and format message
|
|
1870
|
+
duration = time.time() - index_start_time
|
|
1871
|
+
duration_str = _format_duration(duration)
|
|
1872
|
+
entity_count = result.node_count + result.relationship_count
|
|
1873
|
+
entity_str = _format_count(entity_count)
|
|
1355
1874
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
)
|
|
1360
|
-
self.agent_manager.add_hint_message(
|
|
1361
|
-
HintMessage(
|
|
1362
|
-
message=f"✓ Indexed '{result.name}' in {duration_str} ({entity_str} entities)"
|
|
1875
|
+
logger.info(
|
|
1876
|
+
f"Successfully indexed codebase '{result.name}' in {duration_str} "
|
|
1877
|
+
f"({entity_count} entities)"
|
|
1363
1878
|
)
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
return
|
|
1372
|
-
except InvalidPathError as exc:
|
|
1373
|
-
progress_timer.stop()
|
|
1374
|
-
logger.error(f"Invalid path error: {exc}")
|
|
1375
|
-
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
1376
|
-
return
|
|
1879
|
+
self.agent_manager.add_hint_message(
|
|
1880
|
+
HintMessage(
|
|
1881
|
+
message=f"✓ Indexed '{result.name}' in {duration_str} ({entity_str} entities). "
|
|
1882
|
+
"Codebase graph is now accessible."
|
|
1883
|
+
)
|
|
1884
|
+
)
|
|
1885
|
+
break # Success - exit retry loop
|
|
1377
1886
|
|
|
1378
|
-
|
|
1379
|
-
# Check if this is a kuzu corruption error and we have retries left
|
|
1380
|
-
if attempt < max_retries - 1 and self._is_kuzu_corruption_error(exc):
|
|
1887
|
+
except asyncio.CancelledError:
|
|
1381
1888
|
logger.warning(
|
|
1382
|
-
|
|
1383
|
-
f"Will retry after cleanup..."
|
|
1889
|
+
"index_codebase worker was cancelled - this should not happen"
|
|
1384
1890
|
)
|
|
1385
|
-
#
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
message=f"❌
|
|
1891
|
+
raise # Re-raise to let finally block clean up
|
|
1892
|
+
|
|
1893
|
+
except CodebaseAlreadyIndexedError as exc:
|
|
1894
|
+
progress_timer.stop()
|
|
1895
|
+
logger.warning(f"Codebase already indexed: {exc}")
|
|
1896
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
|
|
1897
|
+
return
|
|
1898
|
+
except InvalidPathError as exc:
|
|
1899
|
+
progress_timer.stop()
|
|
1900
|
+
logger.error(f"Invalid path error: {exc}")
|
|
1901
|
+
self.agent_manager.add_hint_message(
|
|
1902
|
+
HintMessage(message=f"❌ {exc}")
|
|
1903
|
+
)
|
|
1904
|
+
return
|
|
1905
|
+
except KuzuImportError as exc:
|
|
1906
|
+
progress_timer.stop()
|
|
1907
|
+
logger.error(f"Kuzu import error (Windows DLL issue): {exc}")
|
|
1908
|
+
# Show dialog with copy button for Windows users
|
|
1909
|
+
await self.app.push_screen_wait(KuzuErrorDialog())
|
|
1910
|
+
return
|
|
1911
|
+
|
|
1912
|
+
except Exception as exc: # pragma: no cover - defensive UI path
|
|
1913
|
+
# Check if this is a kuzu corruption error and we have retries left
|
|
1914
|
+
if attempt < max_retries - 1 and self._is_kuzu_corruption_error(
|
|
1915
|
+
exc
|
|
1916
|
+
):
|
|
1917
|
+
logger.warning(
|
|
1918
|
+
f"Kuzu corruption detected on attempt {attempt + 1}/{max_retries}: {exc}. "
|
|
1919
|
+
f"Will retry after cleanup..."
|
|
1920
|
+
)
|
|
1921
|
+
# Exponential backoff: 1s, 2s
|
|
1922
|
+
await asyncio.sleep(2**attempt)
|
|
1923
|
+
continue
|
|
1924
|
+
|
|
1925
|
+
# Either final retry failed OR not a corruption error - show error
|
|
1926
|
+
logger.exception(
|
|
1927
|
+
f"Failed to index codebase after {attempt + 1} attempts - "
|
|
1928
|
+
f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
|
|
1397
1929
|
)
|
|
1398
|
-
)
|
|
1399
|
-
break
|
|
1400
1930
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1931
|
+
# Provide helpful error message with correct path for kuzu errors
|
|
1932
|
+
if self._is_kuzu_corruption_error(exc):
|
|
1933
|
+
storage_dir = self.codebase_sdk.service.storage_dir
|
|
1934
|
+
self.agent_manager.add_hint_message(
|
|
1935
|
+
HintMessage(
|
|
1936
|
+
message=(
|
|
1937
|
+
f"❌ Database error during indexing. "
|
|
1938
|
+
f"Try deleting files in: {storage_dir}"
|
|
1939
|
+
)
|
|
1940
|
+
)
|
|
1941
|
+
)
|
|
1942
|
+
else:
|
|
1943
|
+
self.agent_manager.add_hint_message(
|
|
1944
|
+
HintMessage(message=f"❌ Failed to index codebase: {exc}")
|
|
1945
|
+
)
|
|
1946
|
+
break
|
|
1947
|
+
finally:
|
|
1948
|
+
# Always stop the progress timer, clean up label, and mark indexing complete
|
|
1949
|
+
if progress_timer:
|
|
1950
|
+
progress_timer.stop()
|
|
1951
|
+
label.update("")
|
|
1952
|
+
label.refresh()
|
|
1953
|
+
await self.codebase_sdk.service.indexing.complete(graph_id)
|
|
1405
1954
|
|
|
1406
1955
|
@work
|
|
1407
|
-
async def run_agent(
|
|
1956
|
+
async def run_agent(
|
|
1957
|
+
self,
|
|
1958
|
+
message: str,
|
|
1959
|
+
attachment: FileAttachment | None = None,
|
|
1960
|
+
file_contents: list[tuple[str, BinaryContent]] | None = None,
|
|
1961
|
+
) -> None:
|
|
1408
1962
|
# Start processing with spinner
|
|
1409
1963
|
from textual.worker import get_current_worker
|
|
1410
1964
|
|
|
1411
1965
|
self.processing_state.start_processing("Processing...")
|
|
1412
1966
|
self.processing_state.bind_worker(get_current_worker())
|
|
1413
1967
|
|
|
1968
|
+
# Pass cancellation event to deps for responsive ESC handling
|
|
1969
|
+
self.deps.cancellation_event = self.processing_state.cancellation_event
|
|
1970
|
+
|
|
1414
1971
|
# Start context indicator animation immediately
|
|
1415
1972
|
self.widget_coordinator.set_context_streaming(True)
|
|
1416
1973
|
|
|
1417
1974
|
try:
|
|
1418
1975
|
# Use unified agent runner - exceptions propagate for handling
|
|
1419
1976
|
runner = AgentRunner(self.agent_manager)
|
|
1420
|
-
await runner.run(
|
|
1977
|
+
await runner.run(
|
|
1978
|
+
message, attachment=attachment, file_contents=file_contents
|
|
1979
|
+
)
|
|
1421
1980
|
except ShotgunAccountException as e:
|
|
1422
1981
|
# Shotgun Account errors show contact email UI
|
|
1423
1982
|
message_parts = e.to_markdown().split("**Need help?**")
|
|
@@ -1432,6 +1991,12 @@ class ChatScreen(Screen[None]):
|
|
|
1432
1991
|
else:
|
|
1433
1992
|
# Fallback if message format is unexpected
|
|
1434
1993
|
self.mount_hint(e.to_markdown())
|
|
1994
|
+
except AgentCancelledException as e:
|
|
1995
|
+
# Reset execution state on cancellation so user can switch modes
|
|
1996
|
+
if isinstance(self.deps, RouterDeps):
|
|
1997
|
+
self.deps.is_executing = False
|
|
1998
|
+
self.deps.active_sub_agent = None
|
|
1999
|
+
self.mount_hint(e.to_markdown())
|
|
1435
2000
|
except ErrorNotPickedUpBySentry as e:
|
|
1436
2001
|
# All other user-actionable errors - display with markdown
|
|
1437
2002
|
self.mount_hint(e.to_markdown())
|
|
@@ -1449,6 +2014,18 @@ class ChatScreen(Screen[None]):
|
|
|
1449
2014
|
if self.deps.llm_model.is_shotgun_account:
|
|
1450
2015
|
await self._check_low_balance_warning()
|
|
1451
2016
|
|
|
2017
|
+
# Check for pending approval (Planning mode multi-step plan creation)
|
|
2018
|
+
self._check_pending_approval()
|
|
2019
|
+
|
|
2020
|
+
# Check for pending checkpoint (Planning mode step completion)
|
|
2021
|
+
self._check_pending_checkpoint()
|
|
2022
|
+
|
|
2023
|
+
# Check for plan completion (Drafting mode)
|
|
2024
|
+
self._check_plan_completion()
|
|
2025
|
+
|
|
2026
|
+
# Check if agent stopped with incomplete plan (failsafe)
|
|
2027
|
+
self._check_incomplete_plan()
|
|
2028
|
+
|
|
1452
2029
|
# Save conversation after each interaction
|
|
1453
2030
|
self._save_conversation()
|
|
1454
2031
|
|
|
@@ -1515,17 +2092,603 @@ class ChatScreen(Screen[None]):
|
|
|
1515
2092
|
|
|
1516
2093
|
self.run_worker(_do_load(), exclusive=False)
|
|
1517
2094
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
2095
|
+
# =========================================================================
|
|
2096
|
+
# Step Checkpoint Handlers (Planning Mode)
|
|
2097
|
+
# =========================================================================
|
|
2098
|
+
|
|
2099
|
+
@on(StepCompleted)
|
|
2100
|
+
def handle_step_completed(self, event: StepCompleted) -> None:
|
|
2101
|
+
"""Show checkpoint widget when a step completes in Planning mode.
|
|
2102
|
+
|
|
2103
|
+
This handler is triggered after mark_step_done is called and sets
|
|
2104
|
+
up a pending checkpoint. It shows the StepCheckpointWidget to let
|
|
2105
|
+
the user decide whether to continue, modify, or stop.
|
|
2106
|
+
"""
|
|
2107
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2108
|
+
return
|
|
2109
|
+
if self.deps.router_mode != RouterMode.PLANNING:
|
|
2110
|
+
return
|
|
2111
|
+
|
|
2112
|
+
# Show checkpoint widget
|
|
2113
|
+
self._show_checkpoint_widget(event.step, event.next_step)
|
|
2114
|
+
|
|
2115
|
+
def _track_checkpoint_event(self, event_name: str) -> None:
|
|
2116
|
+
"""Track a checkpoint-related PostHog event.
|
|
2117
|
+
|
|
2118
|
+
Args:
|
|
2119
|
+
event_name: The name of the event to track.
|
|
2120
|
+
"""
|
|
2121
|
+
if isinstance(self.deps, RouterDeps) and self.deps.current_plan:
|
|
2122
|
+
plan = self.deps.current_plan
|
|
2123
|
+
completed_count = sum(1 for s in plan.steps if s.done)
|
|
2124
|
+
track_event(
|
|
2125
|
+
event_name,
|
|
2126
|
+
{
|
|
2127
|
+
"completed_step_position": completed_count,
|
|
2128
|
+
"steps_remaining": len(plan.steps) - completed_count,
|
|
2129
|
+
},
|
|
2130
|
+
)
|
|
2131
|
+
|
|
2132
|
+
@on(CheckpointContinue)
|
|
2133
|
+
def handle_checkpoint_continue(self) -> None:
|
|
2134
|
+
"""Continue to next step when user approves at checkpoint."""
|
|
2135
|
+
self._track_checkpoint_event("checkpoint_continued")
|
|
2136
|
+
self._hide_checkpoint_widget()
|
|
2137
|
+
self._execute_next_step()
|
|
2138
|
+
|
|
2139
|
+
@on(CheckpointModify)
|
|
2140
|
+
def handle_checkpoint_modify(self) -> None:
|
|
2141
|
+
"""Return to prompt input for plan modification."""
|
|
2142
|
+
self._hide_checkpoint_widget()
|
|
2143
|
+
|
|
2144
|
+
if isinstance(self.deps, RouterDeps):
|
|
2145
|
+
self.deps.is_executing = False
|
|
2146
|
+
|
|
2147
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2148
|
+
|
|
2149
|
+
@on(CheckpointStop)
|
|
2150
|
+
def handle_checkpoint_stop(self) -> None:
|
|
2151
|
+
"""Stop execution, keep remaining steps as pending."""
|
|
2152
|
+
self._track_checkpoint_event("checkpoint_stopped")
|
|
2153
|
+
self._hide_checkpoint_widget()
|
|
2154
|
+
|
|
2155
|
+
if isinstance(self.deps, RouterDeps):
|
|
2156
|
+
self.deps.is_executing = False
|
|
2157
|
+
|
|
2158
|
+
# Show confirmation message
|
|
2159
|
+
self.mount_hint("⏸️ Execution stopped. Remaining steps are still in the plan.")
|
|
2160
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2161
|
+
|
|
2162
|
+
def _show_checkpoint_widget(
|
|
2163
|
+
self,
|
|
2164
|
+
step: "ExecutionStep",
|
|
2165
|
+
next_step: "ExecutionStep | None",
|
|
2166
|
+
) -> None:
|
|
2167
|
+
"""Replace PromptInput with StepCheckpointWidget.
|
|
2168
|
+
|
|
2169
|
+
Args:
|
|
2170
|
+
step: The step that was just completed.
|
|
2171
|
+
next_step: The next step to execute, or None if last step.
|
|
2172
|
+
"""
|
|
2173
|
+
# Create the checkpoint widget
|
|
2174
|
+
self._checkpoint_widget = StepCheckpointWidget(step, next_step)
|
|
2175
|
+
|
|
2176
|
+
# Hide PromptInput
|
|
2177
|
+
prompt_input = self.query_one(PromptInput)
|
|
2178
|
+
prompt_input.display = False
|
|
2179
|
+
|
|
2180
|
+
# Mount checkpoint widget in footer
|
|
2181
|
+
footer = self.query_one("#footer")
|
|
2182
|
+
footer.mount(self._checkpoint_widget, after=prompt_input)
|
|
2183
|
+
|
|
2184
|
+
def _hide_checkpoint_widget(self) -> None:
|
|
2185
|
+
"""Remove checkpoint widget, restore PromptInput."""
|
|
2186
|
+
if hasattr(self, "_checkpoint_widget") and self._checkpoint_widget:
|
|
2187
|
+
self._checkpoint_widget.remove()
|
|
2188
|
+
self._checkpoint_widget = None
|
|
2189
|
+
|
|
2190
|
+
# Show PromptInput
|
|
2191
|
+
prompt_input = self.query_one(PromptInput)
|
|
2192
|
+
prompt_input.display = True
|
|
2193
|
+
|
|
2194
|
+
def _execute_next_step(self) -> None:
|
|
2195
|
+
"""Execute the next step in the plan."""
|
|
2196
|
+
if not isinstance(self.deps, RouterDeps) or not self.deps.current_plan:
|
|
2197
|
+
return
|
|
2198
|
+
|
|
2199
|
+
# Advance to next step
|
|
2200
|
+
plan = self.deps.current_plan
|
|
2201
|
+
plan.current_step_index += 1
|
|
2202
|
+
|
|
2203
|
+
next_step = plan.current_step()
|
|
2204
|
+
if next_step:
|
|
2205
|
+
# Resume router execution for the next step
|
|
2206
|
+
self.run_agent(f"Continue with next step: {next_step.title}")
|
|
2207
|
+
else:
|
|
2208
|
+
# Plan complete
|
|
2209
|
+
self.deps.is_executing = False
|
|
2210
|
+
self.mount_hint("✅ All plan steps completed!")
|
|
2211
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2212
|
+
|
|
2213
|
+
def _check_pending_checkpoint(self) -> None:
|
|
2214
|
+
"""Check if there's a pending checkpoint and post StepCompleted if so.
|
|
2215
|
+
|
|
2216
|
+
This is called after each agent run to check if mark_step_done
|
|
2217
|
+
set a pending checkpoint in Planning mode.
|
|
2218
|
+
"""
|
|
2219
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2220
|
+
return
|
|
2221
|
+
|
|
2222
|
+
if self.deps.pending_checkpoint is None:
|
|
2223
|
+
return
|
|
2224
|
+
|
|
2225
|
+
# Extract checkpoint data and clear the pending state
|
|
2226
|
+
checkpoint = self.deps.pending_checkpoint
|
|
2227
|
+
self.deps.pending_checkpoint = None
|
|
2228
|
+
|
|
2229
|
+
# Post the StepCompleted message to trigger the checkpoint UI
|
|
2230
|
+
self.post_message(
|
|
2231
|
+
StepCompleted(
|
|
2232
|
+
step=checkpoint.completed_step, next_step=checkpoint.next_step
|
|
2233
|
+
)
|
|
2234
|
+
)
|
|
2235
|
+
|
|
2236
|
+
def _check_plan_completion(self) -> None:
|
|
2237
|
+
"""Check if a plan was completed in Drafting mode and show completion message.
|
|
2238
|
+
|
|
2239
|
+
This is called after each agent run to check if mark_step_done
|
|
2240
|
+
set pending_completion in Drafting mode.
|
|
2241
|
+
"""
|
|
2242
|
+
logger.debug("[PLAN] _check_plan_completion called")
|
|
2243
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2244
|
+
logger.debug("[PLAN] Not RouterDeps, skipping plan completion check")
|
|
2245
|
+
return
|
|
2246
|
+
|
|
2247
|
+
if not self.deps.pending_completion:
|
|
2248
|
+
logger.debug("[PLAN] No pending completion")
|
|
2249
|
+
return
|
|
2250
|
+
|
|
2251
|
+
# Don't show completion message if Q&A mode is active.
|
|
2252
|
+
# The user needs to answer the clarifying questions first.
|
|
2253
|
+
# Keep pending_completion=True so it shows after Q&A is done.
|
|
2254
|
+
if self.qa_mode:
|
|
2255
|
+
logger.debug("[PLAN] Q&A mode active, deferring plan completion message")
|
|
2256
|
+
return
|
|
2257
|
+
|
|
2258
|
+
# Clear the pending state
|
|
2259
|
+
self.deps.pending_completion = False
|
|
2260
|
+
|
|
2261
|
+
# Show completion message
|
|
2262
|
+
logger.debug("[PLAN] Showing plan completion message for drafting mode")
|
|
2263
|
+
self.mount_hint("✅ All plan steps completed!")
|
|
2264
|
+
|
|
2265
|
+
# Hide the plan panel since the plan is done
|
|
2266
|
+
self._hide_plan_panel()
|
|
2267
|
+
|
|
2268
|
+
def _check_incomplete_plan(self) -> None:
|
|
2269
|
+
"""Check if agent returned with an incomplete plan in Drafting mode.
|
|
2270
|
+
|
|
2271
|
+
This is a failsafe to notify the user if the agent stopped
|
|
2272
|
+
mid-plan without completing all steps.
|
|
2273
|
+
"""
|
|
2274
|
+
logger.debug("[PLAN] _check_incomplete_plan called")
|
|
2275
|
+
|
|
2276
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2277
|
+
logger.debug("[PLAN] Not RouterDeps, skipping incomplete plan check")
|
|
2278
|
+
return
|
|
2279
|
+
|
|
2280
|
+
logger.debug(
|
|
2281
|
+
"[PLAN] router_mode=%s, current_plan=%s",
|
|
2282
|
+
self.deps.router_mode,
|
|
2283
|
+
self.deps.current_plan.goal if self.deps.current_plan else None,
|
|
2284
|
+
)
|
|
2285
|
+
|
|
2286
|
+
if self.deps.router_mode != RouterMode.DRAFTING:
|
|
2287
|
+
logger.debug("[PLAN] Not in DRAFTING mode, skipping incomplete plan check")
|
|
2288
|
+
return
|
|
2289
|
+
|
|
2290
|
+
plan = self.deps.current_plan
|
|
2291
|
+
if plan is None:
|
|
2292
|
+
logger.debug("[PLAN] No current plan")
|
|
2293
|
+
return
|
|
2294
|
+
|
|
2295
|
+
if plan.is_complete():
|
|
2296
|
+
logger.debug("[PLAN] Plan is complete, no incomplete plan hint needed")
|
|
2297
|
+
return
|
|
2298
|
+
|
|
2299
|
+
# Don't show the "continue to resume" hint if Q&A mode is active.
|
|
2300
|
+
# The user needs to answer the clarifying questions first.
|
|
2301
|
+
if self.qa_mode:
|
|
2302
|
+
logger.debug(
|
|
2303
|
+
"[PLAN] Q&A mode active, deferring incomplete plan hint until "
|
|
2304
|
+
"questions are answered"
|
|
2305
|
+
)
|
|
2306
|
+
return
|
|
2307
|
+
|
|
2308
|
+
# Plan exists and is incomplete - show status hint
|
|
2309
|
+
completed = sum(1 for s in plan.steps if s.done)
|
|
2310
|
+
total = len(plan.steps)
|
|
2311
|
+
remaining = [s.title for s in plan.steps if not s.done]
|
|
2312
|
+
|
|
2313
|
+
logger.info(
|
|
2314
|
+
"[PLAN] Agent stopped with incomplete plan: %d/%d steps done, "
|
|
2315
|
+
"remaining: %s",
|
|
2316
|
+
completed,
|
|
2317
|
+
total,
|
|
2318
|
+
remaining,
|
|
2319
|
+
)
|
|
2320
|
+
|
|
2321
|
+
hint = (
|
|
2322
|
+
f"📋 **Plan Status: {completed}/{total} steps complete**\n\n"
|
|
2323
|
+
f"Remaining: {', '.join(remaining)}\n\n"
|
|
2324
|
+
f"_Type 'continue' to resume the plan._"
|
|
2325
|
+
)
|
|
2326
|
+
self.mount_hint(hint)
|
|
2327
|
+
|
|
2328
|
+
# =========================================================================
|
|
2329
|
+
# Sub-Agent Lifecycle Handlers (Stage 8)
|
|
2330
|
+
# =========================================================================
|
|
2331
|
+
|
|
2332
|
+
@on(SubAgentStarted)
|
|
2333
|
+
def handle_sub_agent_started(self, event: SubAgentStarted) -> None:
|
|
2334
|
+
"""Update mode indicator when router delegates to a sub-agent.
|
|
2335
|
+
|
|
2336
|
+
Sets the active_sub_agent in RouterDeps and refreshes the mode
|
|
2337
|
+
indicator to show "📋 Planning → Research" format.
|
|
2338
|
+
"""
|
|
2339
|
+
if isinstance(self.deps, RouterDeps):
|
|
2340
|
+
self.deps.active_sub_agent = event.agent_type
|
|
2341
|
+
self.widget_coordinator.refresh_mode_indicator()
|
|
2342
|
+
|
|
2343
|
+
@on(SubAgentCompleted)
|
|
2344
|
+
def handle_sub_agent_completed(self, event: SubAgentCompleted) -> None:
|
|
2345
|
+
"""Clear sub-agent display when delegation completes.
|
|
2346
|
+
|
|
2347
|
+
Clears the active_sub_agent in RouterDeps and refreshes the mode
|
|
2348
|
+
indicator to show just the mode name.
|
|
2349
|
+
"""
|
|
2350
|
+
if isinstance(self.deps, RouterDeps):
|
|
2351
|
+
self.deps.active_sub_agent = None
|
|
2352
|
+
self.widget_coordinator.refresh_mode_indicator()
|
|
2353
|
+
|
|
2354
|
+
# =========================================================================
|
|
2355
|
+
# Cascade Confirmation Handlers (Planning Mode)
|
|
2356
|
+
# =========================================================================
|
|
2357
|
+
|
|
2358
|
+
@on(CascadeConfirmationRequired)
|
|
2359
|
+
def handle_cascade_confirmation_required(
|
|
2360
|
+
self, event: CascadeConfirmationRequired
|
|
2361
|
+
) -> None:
|
|
2362
|
+
"""Show cascade confirmation widget when a file with dependents is updated.
|
|
2363
|
+
|
|
2364
|
+
In Planning mode, after updating a file like specification.md that has
|
|
2365
|
+
dependent files, this shows the CascadeConfirmationWidget to let the
|
|
2366
|
+
user decide which dependent files should also be updated.
|
|
2367
|
+
"""
|
|
2368
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2369
|
+
return
|
|
2370
|
+
if self.deps.router_mode != RouterMode.PLANNING:
|
|
2371
|
+
# In Drafting mode, auto-cascade without confirmation
|
|
2372
|
+
self._execute_cascade(CascadeScope.ALL, event.dependent_files)
|
|
2373
|
+
return
|
|
2374
|
+
|
|
2375
|
+
# Show cascade confirmation widget
|
|
2376
|
+
self._show_cascade_widget(event.updated_file, event.dependent_files)
|
|
2377
|
+
|
|
2378
|
+
@on(CascadeConfirmed)
|
|
2379
|
+
def handle_cascade_confirmed(self, event: CascadeConfirmed) -> None:
|
|
2380
|
+
"""Execute cascade update based on user's selected scope."""
|
|
2381
|
+
# Get dependent files from the widget before hiding it
|
|
2382
|
+
dependent_files: list[str] = []
|
|
2383
|
+
if self._cascade_widget:
|
|
2384
|
+
dependent_files = self._cascade_widget.dependent_files
|
|
2385
|
+
|
|
2386
|
+
self._hide_cascade_widget()
|
|
2387
|
+
self._execute_cascade(event.scope, dependent_files)
|
|
2388
|
+
|
|
2389
|
+
@on(CascadeDeclined)
|
|
2390
|
+
def handle_cascade_declined(self) -> None:
|
|
2391
|
+
"""Handle user declining cascade update."""
|
|
2392
|
+
self._hide_cascade_widget()
|
|
2393
|
+
self.mount_hint(
|
|
2394
|
+
"ℹ️ Cascade update skipped. You can update dependent files manually."
|
|
2395
|
+
)
|
|
2396
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2397
|
+
|
|
2398
|
+
def _show_cascade_widget(
|
|
2399
|
+
self,
|
|
2400
|
+
updated_file: str,
|
|
2401
|
+
dependent_files: list[str],
|
|
2402
|
+
) -> None:
|
|
2403
|
+
"""Replace PromptInput with CascadeConfirmationWidget.
|
|
2404
|
+
|
|
2405
|
+
Args:
|
|
2406
|
+
updated_file: The file that was just updated.
|
|
2407
|
+
dependent_files: List of files that depend on the updated file.
|
|
2408
|
+
"""
|
|
2409
|
+
# Create the cascade confirmation widget
|
|
2410
|
+
self._cascade_widget = CascadeConfirmationWidget(updated_file, dependent_files)
|
|
2411
|
+
|
|
2412
|
+
# Hide PromptInput
|
|
2413
|
+
prompt_input = self.query_one(PromptInput)
|
|
2414
|
+
prompt_input.display = False
|
|
2415
|
+
|
|
2416
|
+
# Mount cascade widget in footer
|
|
2417
|
+
footer = self.query_one("#footer")
|
|
2418
|
+
footer.mount(self._cascade_widget, after=prompt_input)
|
|
2419
|
+
|
|
2420
|
+
def _hide_cascade_widget(self) -> None:
|
|
2421
|
+
"""Remove cascade widget, restore PromptInput."""
|
|
2422
|
+
if self._cascade_widget:
|
|
2423
|
+
self._cascade_widget.remove()
|
|
2424
|
+
self._cascade_widget = None
|
|
2425
|
+
|
|
2426
|
+
# Show PromptInput
|
|
2427
|
+
prompt_input = self.query_one(PromptInput)
|
|
2428
|
+
prompt_input.display = True
|
|
2429
|
+
|
|
2430
|
+
def _execute_cascade(self, scope: CascadeScope, dependent_files: list[str]) -> None:
|
|
2431
|
+
"""Execute cascade updates based on the selected scope.
|
|
2432
|
+
|
|
2433
|
+
Args:
|
|
2434
|
+
scope: The scope of files to update.
|
|
2435
|
+
dependent_files: List of dependent files that could be updated.
|
|
2436
|
+
|
|
2437
|
+
Note:
|
|
2438
|
+
Actual cascade execution (calling sub-agents) requires Stage 9's
|
|
2439
|
+
delegation tools. For now, this shows a hint about what would happen.
|
|
2440
|
+
"""
|
|
2441
|
+
if scope == CascadeScope.NONE:
|
|
2442
|
+
return
|
|
2443
|
+
|
|
2444
|
+
# Determine which files will be updated based on scope
|
|
2445
|
+
files_to_update: list[str] = []
|
|
2446
|
+
if scope == CascadeScope.ALL:
|
|
2447
|
+
files_to_update = dependent_files
|
|
2448
|
+
elif scope == CascadeScope.PLAN_ONLY:
|
|
2449
|
+
files_to_update = [f for f in dependent_files if "plan.md" in f]
|
|
2450
|
+
elif scope == CascadeScope.TASKS_ONLY:
|
|
2451
|
+
files_to_update = [f for f in dependent_files if "tasks.md" in f]
|
|
2452
|
+
|
|
2453
|
+
if files_to_update:
|
|
2454
|
+
file_names = ", ".join(f.split("/")[-1] for f in files_to_update)
|
|
2455
|
+
# TODO: Stage 9 will implement actual delegation to sub-agents
|
|
2456
|
+
self.mount_hint(f"📋 Cascade update queued for: {file_names}")
|
|
2457
|
+
|
|
2458
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2459
|
+
|
|
2460
|
+
def _check_pending_cascade(self) -> None:
|
|
2461
|
+
"""Check if there's a pending cascade and post CascadeConfirmationRequired if so.
|
|
2462
|
+
|
|
2463
|
+
This is called after each agent run to check if a file modification
|
|
2464
|
+
set a pending cascade in Planning mode.
|
|
2465
|
+
"""
|
|
2466
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2467
|
+
return
|
|
2468
|
+
|
|
2469
|
+
if self.deps.pending_cascade is None:
|
|
2470
|
+
return
|
|
2471
|
+
|
|
2472
|
+
# Extract cascade data and clear the pending state
|
|
2473
|
+
cascade = self.deps.pending_cascade
|
|
2474
|
+
self.deps.pending_cascade = None
|
|
2475
|
+
|
|
2476
|
+
# Post the CascadeConfirmationRequired message to trigger the cascade UI
|
|
2477
|
+
self.post_message(
|
|
2478
|
+
CascadeConfirmationRequired(
|
|
2479
|
+
updated_file=cascade.updated_file,
|
|
2480
|
+
dependent_files=cascade.dependent_files,
|
|
2481
|
+
)
|
|
2482
|
+
)
|
|
2483
|
+
|
|
2484
|
+
# =========================================================================
|
|
2485
|
+
# Plan Approval Handlers (Planning Mode - Stage 7)
|
|
2486
|
+
# =========================================================================
|
|
2487
|
+
|
|
2488
|
+
@on(PlanApprovalRequired)
|
|
2489
|
+
def handle_plan_approval_required(self, event: PlanApprovalRequired) -> None:
|
|
2490
|
+
"""Show approval widget when a multi-step plan is created.
|
|
2491
|
+
|
|
2492
|
+
In Planning mode, after creating a plan with multiple steps,
|
|
2493
|
+
this shows the PlanApprovalWidget to let the user decide
|
|
2494
|
+
whether to proceed or clarify.
|
|
2495
|
+
"""
|
|
2496
|
+
logger.debug(
|
|
2497
|
+
"[PLAN] handle_plan_approval_required - plan=%s",
|
|
2498
|
+
f"'{event.plan.goal}' with {len(event.plan.steps)} steps",
|
|
2499
|
+
)
|
|
2500
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2501
|
+
logger.debug("[PLAN] Not RouterDeps, skipping approval widget")
|
|
2502
|
+
return
|
|
2503
|
+
if self.deps.router_mode != RouterMode.PLANNING:
|
|
2504
|
+
logger.debug(
|
|
2505
|
+
"[PLAN] Not in PLANNING mode (%s), skipping approval widget",
|
|
2506
|
+
self.deps.router_mode,
|
|
2507
|
+
)
|
|
2508
|
+
return
|
|
2509
|
+
|
|
2510
|
+
# Show approval widget
|
|
2511
|
+
logger.debug("[PLAN] Showing approval widget")
|
|
2512
|
+
self._show_approval_widget(event.plan)
|
|
2513
|
+
|
|
2514
|
+
@on(PlanApproved)
|
|
2515
|
+
def handle_plan_approved(self) -> None:
|
|
2516
|
+
"""Begin plan execution when user approves."""
|
|
2517
|
+
self._hide_approval_widget()
|
|
2518
|
+
|
|
2519
|
+
if isinstance(self.deps, RouterDeps):
|
|
2520
|
+
# Track plan approved metric
|
|
2521
|
+
if self.deps.current_plan:
|
|
2522
|
+
track_event(
|
|
2523
|
+
"plan_approved",
|
|
2524
|
+
{
|
|
2525
|
+
"step_count": len(self.deps.current_plan.steps),
|
|
2526
|
+
},
|
|
2527
|
+
)
|
|
2528
|
+
|
|
2529
|
+
self.deps.approval_status = PlanApprovalStatus.APPROVED
|
|
2530
|
+
self.deps.is_executing = True
|
|
2531
|
+
|
|
2532
|
+
# Switch to Drafting mode when plan is approved
|
|
2533
|
+
self.deps.router_mode = RouterMode.DRAFTING
|
|
2534
|
+
self.widget_coordinator.update_for_mode_change(self.mode)
|
|
2535
|
+
|
|
2536
|
+
# Show plan panel now that plan is approved and executing
|
|
2537
|
+
plan = self.deps.current_plan
|
|
2538
|
+
if plan:
|
|
2539
|
+
self._show_plan_panel(plan)
|
|
2540
|
+
|
|
2541
|
+
# Begin execution of the first step
|
|
2542
|
+
if plan and plan.current_step():
|
|
2543
|
+
first_step = plan.current_step()
|
|
2544
|
+
if first_step:
|
|
2545
|
+
self.run_agent(f"Execute step: {first_step.title}")
|
|
2546
|
+
else:
|
|
2547
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2548
|
+
|
|
2549
|
+
@on(PlanRejected)
|
|
2550
|
+
def handle_plan_rejected(self) -> None:
|
|
2551
|
+
"""Return to prompt input for clarification when user rejects plan."""
|
|
2552
|
+
self._hide_approval_widget()
|
|
2553
|
+
|
|
2554
|
+
if isinstance(self.deps, RouterDeps):
|
|
2555
|
+
# Track plan rejected metric
|
|
2556
|
+
if self.deps.current_plan:
|
|
2557
|
+
track_event(
|
|
2558
|
+
"plan_rejected",
|
|
2559
|
+
{
|
|
2560
|
+
"step_count": len(self.deps.current_plan.steps),
|
|
2561
|
+
},
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
self.deps.approval_status = PlanApprovalStatus.REJECTED
|
|
2565
|
+
# Clear the plan since user wants to modify
|
|
2566
|
+
self.deps.current_plan = None
|
|
2567
|
+
|
|
2568
|
+
self.mount_hint("ℹ️ Plan cancelled. Please clarify what you'd like to do.")
|
|
2569
|
+
self.widget_coordinator.update_prompt_input(focus=True)
|
|
2570
|
+
|
|
2571
|
+
def _show_approval_widget(self, plan: ExecutionPlan) -> None:
|
|
2572
|
+
"""Replace PromptInput with PlanApprovalWidget.
|
|
2573
|
+
|
|
2574
|
+
Args:
|
|
2575
|
+
plan: The execution plan that needs user approval.
|
|
2576
|
+
"""
|
|
2577
|
+
# Hide plan panel to avoid showing duplicate plan info
|
|
2578
|
+
# (ApprovalWidget already shows the full plan details)
|
|
2579
|
+
self._hide_plan_panel()
|
|
2580
|
+
|
|
2581
|
+
# Create the approval widget
|
|
2582
|
+
self._approval_widget = PlanApprovalWidget(plan)
|
|
2583
|
+
|
|
2584
|
+
# Hide PromptInput
|
|
2585
|
+
prompt_input = self.query_one(PromptInput)
|
|
2586
|
+
prompt_input.display = False
|
|
2587
|
+
|
|
2588
|
+
# Mount approval widget in footer
|
|
2589
|
+
footer = self.query_one("#footer")
|
|
2590
|
+
footer.mount(self._approval_widget, after=prompt_input)
|
|
2591
|
+
|
|
2592
|
+
def _hide_approval_widget(self) -> None:
|
|
2593
|
+
"""Remove approval widget, restore PromptInput."""
|
|
2594
|
+
if self._approval_widget:
|
|
2595
|
+
self._approval_widget.remove()
|
|
2596
|
+
self._approval_widget = None
|
|
2597
|
+
|
|
2598
|
+
# Show PromptInput
|
|
2599
|
+
prompt_input = self.query_one(PromptInput)
|
|
2600
|
+
prompt_input.display = True
|
|
2601
|
+
|
|
2602
|
+
def _check_pending_approval(self) -> None:
|
|
2603
|
+
"""Check if there's a pending approval and post PlanApprovalRequired if so.
|
|
2604
|
+
|
|
2605
|
+
This is called after each agent run to check if create_plan
|
|
2606
|
+
set a pending approval in Planning mode.
|
|
2607
|
+
"""
|
|
2608
|
+
logger.debug("[PLAN] _check_pending_approval called")
|
|
2609
|
+
if not isinstance(self.deps, RouterDeps):
|
|
2610
|
+
logger.debug("[PLAN] Not RouterDeps, skipping pending approval check")
|
|
2611
|
+
return
|
|
2612
|
+
|
|
2613
|
+
# Don't show plan approval while user is answering questions.
|
|
2614
|
+
# The pending approval will remain set and be checked again
|
|
2615
|
+
# after Q&A completes (when run_agent is called with answers).
|
|
2616
|
+
if self.qa_mode:
|
|
2617
|
+
logger.debug("[PLAN] Q&A mode active, deferring plan approval")
|
|
2618
|
+
return
|
|
2619
|
+
|
|
2620
|
+
if self.deps.pending_approval is None:
|
|
2621
|
+
logger.debug("[PLAN] No pending approval")
|
|
2622
|
+
return
|
|
2623
|
+
|
|
2624
|
+
# Extract approval data and clear the pending state
|
|
2625
|
+
approval = self.deps.pending_approval
|
|
2626
|
+
self.deps.pending_approval = None
|
|
2627
|
+
|
|
2628
|
+
logger.debug(
|
|
2629
|
+
"[PLAN] Found pending approval for plan: '%s' with %d steps",
|
|
2630
|
+
approval.plan.goal,
|
|
2631
|
+
len(approval.plan.steps),
|
|
2632
|
+
)
|
|
2633
|
+
|
|
2634
|
+
# Post the PlanApprovalRequired message to trigger the approval UI
|
|
2635
|
+
self.post_message(PlanApprovalRequired(plan=approval.plan))
|
|
2636
|
+
|
|
2637
|
+
# =========================================================================
|
|
2638
|
+
# Plan Panel (Stage 11)
|
|
2639
|
+
# =========================================================================
|
|
2640
|
+
|
|
2641
|
+
@on(PlanUpdated)
|
|
2642
|
+
def handle_plan_updated(self, event: PlanUpdated) -> None:
|
|
2643
|
+
"""Auto-show/hide plan panel when plan changes.
|
|
2644
|
+
|
|
2645
|
+
The plan panel automatically shows when a plan is created or
|
|
2646
|
+
modified, and hides when the plan is cleared.
|
|
2647
|
+
"""
|
|
2648
|
+
if event.plan is not None:
|
|
2649
|
+
# Show panel (auto-reopens when plan changes)
|
|
2650
|
+
self._show_plan_panel(event.plan)
|
|
2651
|
+
else:
|
|
2652
|
+
# Plan cleared - hide panel
|
|
2653
|
+
self._hide_plan_panel()
|
|
2654
|
+
|
|
2655
|
+
@on(PlanPanelClosed)
|
|
2656
|
+
def handle_plan_panel_closed(self, event: PlanPanelClosed) -> None:
|
|
2657
|
+
"""Handle user closing the plan panel with × button."""
|
|
2658
|
+
self._hide_plan_panel()
|
|
2659
|
+
|
|
2660
|
+
def _show_plan_panel(self, plan: ExecutionPlan) -> None:
|
|
2661
|
+
"""Show the plan panel with the given plan.
|
|
2662
|
+
|
|
2663
|
+
Args:
|
|
2664
|
+
plan: The execution plan to display.
|
|
2665
|
+
"""
|
|
2666
|
+
if self._plan_panel is None:
|
|
2667
|
+
self._plan_panel = PlanPanelWidget(plan)
|
|
2668
|
+
# Mount in window container, before footer
|
|
2669
|
+
window = self.query_one("#window")
|
|
2670
|
+
footer = self.query_one("#footer")
|
|
2671
|
+
window.mount(self._plan_panel, before=footer)
|
|
2672
|
+
else:
|
|
2673
|
+
self._plan_panel.update_plan(plan)
|
|
2674
|
+
|
|
2675
|
+
def _hide_plan_panel(self) -> None:
|
|
2676
|
+
"""Hide the plan panel."""
|
|
2677
|
+
if self._plan_panel:
|
|
2678
|
+
self._plan_panel.remove()
|
|
2679
|
+
self._plan_panel = None
|
|
2680
|
+
|
|
2681
|
+
def _on_plan_changed(self, plan: ExecutionPlan | None) -> None:
|
|
2682
|
+
"""Handle plan changes from router tools.
|
|
2683
|
+
|
|
2684
|
+
This callback is set on RouterDeps to receive plan updates
|
|
2685
|
+
and post PlanUpdated messages to update the plan panel.
|
|
2686
|
+
|
|
2687
|
+
Args:
|
|
2688
|
+
plan: The updated plan or None if plan was cleared.
|
|
2689
|
+
"""
|
|
2690
|
+
logger.debug(
|
|
2691
|
+
"[PLAN] _on_plan_changed called - plan=%s",
|
|
2692
|
+
f"'{plan.goal}' with {len(plan.steps)} steps" if plan else "None",
|
|
2693
|
+
)
|
|
2694
|
+
self.post_message(PlanUpdated(plan))
|