shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.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,13 +78,15 @@ 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,
57
- ErrorNotPickedUpBySentry,
81
+ AgentCancelledException,
58
82
  ShotgunAccountException,
83
+ UserActionableError,
59
84
  )
60
85
  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.onboarding import OnboardingModal
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
- # Prevent mode switching during Q&A
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="⚠️ Cannot switch modes while answering questions")
578
+ HintMessage(message="Exited Q&A mode via Shift+Tab")
339
579
  )
340
580
  return
341
581
 
342
- modes = [
343
- AgentType.RESEARCH,
344
- AgentType.SPECIFY,
345
- AgentType.PLAN,
346
- AgentType.TASKS,
347
- AgentType.EXPORT,
348
- ]
349
- self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
350
- self.agent_manager.set_agent(self.mode)
351
- # Re-focus input after mode change
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.run_agent(text) # Use stripped text
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
- error_str = str(exception).lower()
1209
- error_indicators = [
1210
- "not a directory",
1211
- "errno 20",
1212
- "corrupted",
1213
- ".kuzu",
1214
- "ioexception",
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
- def create_progress_bar(percentage: float, width: int = 20) -> str:
1232
- """Create a visual progress bar using Unicode block characters."""
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
- def progress_callback(progress_info: IndexProgress) -> None:
1263
- """Update progress state (timer renders it independently)."""
1264
- # Calculate overall percentage with weights based on actual timing:
1265
- # Structure: 0-2%, Definitions: 2-18%, Relationships: 18-20%
1266
- # Flush nodes: 20-28%, Flush relationships: 28-100%
1267
- if progress_info.phase == ProgressPhase.STRUCTURE:
1268
- # Phase 1: 0-2% (actual: ~0%)
1269
- overall_pct = 2.0 if progress_info.phase_complete else 1.0
1270
- elif progress_info.phase == ProgressPhase.DEFINITIONS:
1271
- # Phase 2: 2-18% based on files processed (actual: ~16%)
1272
- if progress_info.total and progress_info.total > 0:
1273
- phase_pct = (progress_info.current / progress_info.total) * 16.0
1274
- overall_pct = 2.0 + phase_pct
1275
- else:
1276
- overall_pct = 2.0
1277
- elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
1278
- # Phase 3: 18-20% based on relationships processed (actual: ~0.3%)
1279
- if progress_info.total and progress_info.total > 0:
1280
- phase_pct = (progress_info.current / progress_info.total) * 2.0
1281
- overall_pct = 18.0 + phase_pct
1282
- else:
1283
- overall_pct = 18.0
1284
- elif progress_info.phase == ProgressPhase.FLUSH_NODES:
1285
- # Phase 4: 20-28% based on nodes flushed (actual: ~7.5%)
1286
- if progress_info.total and progress_info.total > 0:
1287
- phase_pct = (progress_info.current / progress_info.total) * 8.0
1288
- overall_pct = 20.0 + phase_pct
1289
- else:
1290
- overall_pct = 20.0
1291
- elif progress_info.phase == ProgressPhase.FLUSH_RELATIONSHIPS:
1292
- # Phase 5: 28-100% based on relationships flushed (actual: ~76%)
1293
- if progress_info.total and progress_info.total > 0:
1294
- phase_pct = (progress_info.current / progress_info.total) * 72.0
1295
- overall_pct = 28.0 + phase_pct
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 = 28.0
1298
- else:
1299
- overall_pct = 0.0
1817
+ overall_pct = 0.0
1300
1818
 
1301
- # Update shared state (timer will render it)
1302
- progress_state["percentage"] = overall_pct
1819
+ # Update shared state (timer will render it)
1820
+ progress_state["percentage"] = overall_pct
1303
1821
 
1304
- # Start progress animation timer (10 fps = 100ms interval)
1305
- progress_timer = self.set_interval(0.1, update_progress_display)
1822
+ # Start progress animation timer (10 fps = 100ms interval)
1823
+ progress_timer = self.set_interval(0.1, update_progress_display)
1306
1824
 
1307
- # Retry logic for handling kuzu corruption
1308
- max_retries = 3
1825
+ # Retry logic for handling kuzu corruption
1826
+ max_retries = 3
1309
1827
 
1310
- for attempt in range(max_retries):
1311
- try:
1312
- # Clean up corrupted DBs before retry (skip on first attempt)
1313
- if attempt > 0:
1314
- logger.info(
1315
- f"Retry attempt {attempt + 1}/{max_retries} - cleaning up corrupted databases"
1316
- )
1317
- manager = CodebaseGraphManager(
1318
- self.codebase_sdk.service.storage_dir
1319
- )
1320
- cleaned = await manager.cleanup_corrupted_databases()
1321
- logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
1322
- self.agent_manager.add_hint_message(
1323
- HintMessage(
1324
- message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
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
- # Pass the current working directory as the indexed_from_cwd
1329
- logger.debug(
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
- # Success! Stop progress animation
1341
- progress_timer.stop()
1342
-
1343
- # Show 100% completion after indexing finishes
1344
- final_bar = create_progress_bar(100.0)
1345
- label.update(
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
- # Calculate duration and format message
1351
- duration = time.time() - index_start_time
1352
- duration_str = _format_duration(duration)
1353
- entity_count = result.node_count + result.relationship_count
1354
- entity_str = _format_count(entity_count)
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
- logger.info(
1357
- f"Successfully indexed codebase '{result.name}' in {duration_str} "
1358
- f"({entity_count} entities)"
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
- break # Success - exit retry loop
1366
-
1367
- except CodebaseAlreadyIndexedError as exc:
1368
- progress_timer.stop()
1369
- logger.warning(f"Codebase already indexed: {exc}")
1370
- self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
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
- except Exception as exc: # pragma: no cover - defensive UI path
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
- f"Kuzu corruption detected on attempt {attempt + 1}/{max_retries}: {exc}. "
1383
- f"Will retry after cleanup..."
1889
+ "index_codebase worker was cancelled - this should not happen"
1384
1890
  )
1385
- # Exponential backoff: 1s, 2s
1386
- await asyncio.sleep(2**attempt)
1387
- continue
1388
-
1389
- # Either final retry failed OR not a corruption error - show error
1390
- logger.exception(
1391
- f"Failed to index codebase after {attempt + 1} attempts - "
1392
- f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
1393
- )
1394
- self.agent_manager.add_hint_message(
1395
- HintMessage(
1396
- message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
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
- # Always stop the progress timer and clean up label
1402
- progress_timer.stop()
1403
- label.update("")
1404
- label.refresh()
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(self, message: str) -> None:
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(message)
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,7 +1991,13 @@ class ChatScreen(Screen[None]):
1432
1991
  else:
1433
1992
  # Fallback if message format is unexpected
1434
1993
  self.mount_hint(e.to_markdown())
1435
- except ErrorNotPickedUpBySentry as e:
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())
2000
+ except UserActionableError as e:
1436
2001
  # All other user-actionable errors - display with markdown
1437
2002
  self.mount_hint(e.to_markdown())
1438
2003
  except Exception as e:
@@ -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
- @work
1519
- async def _check_and_show_onboarding(self) -> None:
1520
- """Check if onboarding should be shown and display modal if needed."""
1521
- config_manager = get_config_manager()
1522
- config = await config_manager.load()
1523
-
1524
- # Only show onboarding if it hasn't been shown before
1525
- if config.shown_onboarding_popup is None:
1526
- # Show the onboarding modal
1527
- await self.app.push_screen_wait(OnboardingModal())
1528
-
1529
- # Mark as shown in config with current timestamp
1530
- config.shown_onboarding_popup = datetime.now(timezone.utc)
1531
- await config_manager.save(config)
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))