shotgun-sh 0.4.0.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 (135) hide show
  1. shotgun/agents/agent_manager.py +307 -8
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +12 -0
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +10 -7
  6. shotgun/agents/config/models.py +5 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  9. shotgun/agents/file_read.py +176 -0
  10. shotgun/agents/messages.py +15 -3
  11. shotgun/agents/models.py +24 -1
  12. shotgun/agents/router/models.py +8 -0
  13. shotgun/agents/router/tools/delegation_tools.py +55 -1
  14. shotgun/agents/router/tools/plan_tools.py +88 -7
  15. shotgun/agents/runner.py +17 -2
  16. shotgun/agents/tools/__init__.py +8 -0
  17. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  18. shotgun/agents/tools/codebase/file_read.py +26 -35
  19. shotgun/agents/tools/codebase/query_graph.py +9 -0
  20. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  21. shotgun/agents/tools/file_management.py +32 -2
  22. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  23. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  24. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  25. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  26. shotgun/agents/tools/markdown_tools/models.py +86 -0
  27. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  28. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  29. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  30. shotgun/agents/tools/registry.py +44 -6
  31. shotgun/agents/tools/web_search/openai.py +42 -23
  32. shotgun/attachments/__init__.py +41 -0
  33. shotgun/attachments/errors.py +60 -0
  34. shotgun/attachments/models.py +107 -0
  35. shotgun/attachments/parser.py +257 -0
  36. shotgun/attachments/processor.py +193 -0
  37. shotgun/build_constants.py +4 -7
  38. shotgun/cli/clear.py +2 -2
  39. shotgun/cli/codebase/commands.py +181 -65
  40. shotgun/cli/compact.py +2 -2
  41. shotgun/cli/context.py +2 -2
  42. shotgun/cli/error_handler.py +2 -2
  43. shotgun/cli/run.py +90 -0
  44. shotgun/cli/spec/backup.py +2 -1
  45. shotgun/codebase/__init__.py +2 -0
  46. shotgun/codebase/benchmarks/__init__.py +35 -0
  47. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  48. shotgun/codebase/benchmarks/exporters.py +119 -0
  49. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  50. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  51. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  52. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  53. shotgun/codebase/benchmarks/models.py +129 -0
  54. shotgun/codebase/core/__init__.py +4 -0
  55. shotgun/codebase/core/call_resolution.py +91 -0
  56. shotgun/codebase/core/change_detector.py +11 -6
  57. shotgun/codebase/core/errors.py +159 -0
  58. shotgun/codebase/core/extractors/__init__.py +23 -0
  59. shotgun/codebase/core/extractors/base.py +138 -0
  60. shotgun/codebase/core/extractors/factory.py +63 -0
  61. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  62. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  63. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  64. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  65. shotgun/codebase/core/extractors/protocol.py +109 -0
  66. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  67. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  68. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  69. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  70. shotgun/codebase/core/extractors/types.py +15 -0
  71. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  72. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  73. shotgun/codebase/core/gitignore.py +252 -0
  74. shotgun/codebase/core/ingestor.py +644 -354
  75. shotgun/codebase/core/kuzu_compat.py +119 -0
  76. shotgun/codebase/core/language_config.py +239 -0
  77. shotgun/codebase/core/manager.py +256 -46
  78. shotgun/codebase/core/metrics_collector.py +310 -0
  79. shotgun/codebase/core/metrics_types.py +347 -0
  80. shotgun/codebase/core/parallel_executor.py +424 -0
  81. shotgun/codebase/core/work_distributor.py +254 -0
  82. shotgun/codebase/core/worker.py +768 -0
  83. shotgun/codebase/indexing_state.py +86 -0
  84. shotgun/codebase/models.py +94 -0
  85. shotgun/codebase/service.py +13 -0
  86. shotgun/exceptions.py +9 -9
  87. shotgun/main.py +3 -16
  88. shotgun/posthog_telemetry.py +165 -24
  89. shotgun/prompts/agents/file_read.j2 +48 -0
  90. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
  91. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  92. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  93. shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
  94. shotgun/prompts/agents/plan.j2 +14 -0
  95. shotgun/prompts/agents/router.j2 +531 -258
  96. shotgun/prompts/agents/specify.j2 +14 -0
  97. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  98. shotgun/prompts/agents/state/system_state.j2 +13 -11
  99. shotgun/prompts/agents/tasks.j2 +14 -0
  100. shotgun/settings.py +49 -10
  101. shotgun/tui/app.py +149 -18
  102. shotgun/tui/commands/__init__.py +9 -1
  103. shotgun/tui/components/attachment_bar.py +87 -0
  104. shotgun/tui/components/prompt_input.py +25 -28
  105. shotgun/tui/components/status_bar.py +14 -7
  106. shotgun/tui/dependencies.py +3 -8
  107. shotgun/tui/protocols.py +18 -0
  108. shotgun/tui/screens/chat/chat.tcss +15 -0
  109. shotgun/tui/screens/chat/chat_screen.py +766 -235
  110. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  111. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  112. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  113. shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
  114. shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
  115. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  116. shotgun/tui/screens/database_locked_dialog.py +219 -0
  117. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  118. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  119. shotgun/tui/screens/model_picker.py +1 -3
  120. shotgun/tui/screens/models.py +11 -0
  121. shotgun/tui/state/processing_state.py +19 -0
  122. shotgun/tui/widgets/widget_coordinator.py +18 -0
  123. shotgun/utils/file_system_utils.py +4 -1
  124. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
  125. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
  126. shotgun/cli/export.py +0 -81
  127. shotgun/cli/plan.py +0 -73
  128. shotgun/cli/research.py +0 -93
  129. shotgun/cli/specify.py +0 -70
  130. shotgun/cli/tasks.py +0 -78
  131. shotgun/sentry_telemetry.py +0 -232
  132. shotgun/tui/screens/onboarding.py +0 -584
  133. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  134. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  135. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, cast
10
10
  if TYPE_CHECKING:
11
11
  from shotgun.agents.router.models import ExecutionStep
12
12
 
13
+ from pydantic_ai import BinaryContent
13
14
  from pydantic_ai.messages import (
14
15
  ModelMessage,
15
16
  ModelRequest,
@@ -33,13 +34,13 @@ from shotgun.agents.agent_manager import (
33
34
  ClarifyingQuestionsMessage,
34
35
  CompactionCompletedMessage,
35
36
  CompactionStartedMessage,
37
+ FileRequestPendingMessage,
36
38
  MessageHistoryUpdated,
37
39
  ModelConfigUpdated,
38
40
  PartialResponseMessage,
39
41
  ToolExecutionStartedMessage,
40
42
  ToolStreamingProgressMessage,
41
43
  )
42
- from shotgun.agents.config import get_config_manager
43
44
  from shotgun.agents.config.models import MODEL_SPECS
44
45
  from shotgun.agents.conversation import ConversationManager
45
46
  from shotgun.agents.conversation.history.compaction import apply_persistent_compaction
@@ -59,6 +60,17 @@ from shotgun.agents.router.models import (
59
60
  RouterMode,
60
61
  )
61
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
62
74
  from shotgun.codebase.core.manager import (
63
75
  CodebaseAlreadyIndexedError,
64
76
  CodebaseGraphManager,
@@ -66,13 +78,15 @@ from shotgun.codebase.core.manager import (
66
78
  from shotgun.codebase.models import IndexProgress, ProgressPhase
67
79
  from shotgun.exceptions import (
68
80
  SHOTGUN_CONTACT_EMAIL,
69
- ErrorNotPickedUpBySentry,
81
+ AgentCancelledException,
70
82
  ShotgunAccountException,
83
+ UserActionableError,
71
84
  )
72
85
  from shotgun.posthog_telemetry import track_event
73
86
  from shotgun.sdk.codebase import CodebaseSDK
74
87
  from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
75
88
  from shotgun.tui.commands import CommandHandler
89
+ from shotgun.tui.components.attachment_bar import AttachmentBar
76
90
  from shotgun.tui.components.context_indicator import ContextIndicator
77
91
  from shotgun.tui.components.mode_indicator import ModeIndicator
78
92
  from shotgun.tui.components.prompt_input import PromptInput
@@ -112,7 +126,10 @@ from shotgun.tui.screens.chat_screen.messages import (
112
126
  SubAgentStarted,
113
127
  )
114
128
  from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
115
- 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
116
133
  from shotgun.tui.screens.shared_specs import (
117
134
  CreateSpecDialog,
118
135
  ShareSpecsAction,
@@ -165,7 +182,6 @@ class ChatScreen(Screen[None]):
165
182
  CSS_PATH = "chat.tcss"
166
183
 
167
184
  BINDINGS = [
168
- ("ctrl+p", "command_palette", "Command Palette"),
169
185
  ("shift+tab", "toggle_mode", "Toggle mode"),
170
186
  ("ctrl+u", "show_usage", "Show usage"),
171
187
  ]
@@ -273,9 +289,6 @@ class ChatScreen(Screen[None]):
273
289
  # Bind spinner to processing state manager
274
290
  self.processing_state.bind_spinner(self.query_one("#spinner", Spinner))
275
291
 
276
- # Load saved router mode if using Router agent
277
- self.call_later(self._load_saved_router_mode)
278
-
279
292
  # Load conversation history if --continue flag was provided
280
293
  # Use call_later to handle async exists() check
281
294
  if self.continue_session:
@@ -289,9 +302,6 @@ class ChatScreen(Screen[None]):
289
302
  # Initial update of context indicator
290
303
  self.update_context_indicator()
291
304
 
292
- # Show onboarding popup if not shown before
293
- self.call_later(self._check_and_show_onboarding)
294
-
295
305
  async def on_key(self, event: events.Key) -> None:
296
306
  """Handle key presses for cancellation."""
297
307
  # If escape is pressed during Q&A mode, exit Q&A
@@ -313,8 +323,152 @@ class ChatScreen(Screen[None]):
313
323
  # Prevent the event from propagating (don't quit the app)
314
324
  event.stop()
315
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
+
316
465
  @work
317
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
+
318
472
  cur_dir = Path.cwd().resolve()
319
473
  is_empty = all(
320
474
  dir.is_dir() and dir.name in ["__pycache__", ".git", ".shotgun"]
@@ -417,10 +571,11 @@ class ChatScreen(Screen[None]):
417
571
  """Toggle between Planning and Drafting modes for Router."""
418
572
  from shotgun.agents.router.models import RouterDeps, RouterMode
419
573
 
420
- # Prevent mode switching during Q&A
574
+ # If in Q&A mode, exit it first (SHIFT+TAB escapes Q&A mode)
421
575
  if self.qa_mode:
576
+ self._exit_qa_mode()
422
577
  self.agent_manager.add_hint_message(
423
- HintMessage(message="⚠️ Cannot switch modes while answering questions")
578
+ HintMessage(message="Exited Q&A mode via Shift+Tab")
424
579
  )
425
580
  return
426
581
 
@@ -454,9 +609,6 @@ class ChatScreen(Screen[None]):
454
609
  self.deps.approval_status = PlanApprovalStatus.SKIPPED
455
610
  self.deps.is_executing = False
456
611
 
457
- # Persist mode (fire-and-forget)
458
- self._save_router_mode(self.deps.router_mode.value)
459
-
460
612
  # Show mode change feedback
461
613
  self.agent_manager.add_hint_message(
462
614
  HintMessage(message=f"Switched to {mode_name} mode")
@@ -466,30 +618,6 @@ class ChatScreen(Screen[None]):
466
618
  self.widget_coordinator.update_for_mode_change(self.mode)
467
619
  self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
468
620
 
469
- def _save_router_mode(self, mode: str) -> None:
470
- """Save router mode to config (fire-and-forget)."""
471
-
472
- async def _save() -> None:
473
- config_manager = get_config_manager()
474
- await config_manager.set_router_mode(mode)
475
-
476
- asyncio.create_task(_save())
477
-
478
- async def _load_saved_router_mode(self) -> None:
479
- """Load saved router mode from config."""
480
- from shotgun.agents.router.models import RouterDeps, RouterMode
481
-
482
- if isinstance(self.deps, RouterDeps):
483
- config_manager = get_config_manager()
484
- saved_mode = await config_manager.get_router_mode()
485
-
486
- if saved_mode == "drafting":
487
- self.deps.router_mode = RouterMode.DRAFTING
488
- else:
489
- self.deps.router_mode = RouterMode.PLANNING
490
-
491
- logger.debug("Loaded router mode from config: %s", saved_mode)
492
-
493
621
  async def action_show_usage(self) -> None:
494
622
  usage_hint = self.agent_manager.get_usage_hint()
495
623
  logger.info(f"Usage hint: {usage_hint}")
@@ -575,10 +703,6 @@ class ChatScreen(Screen[None]):
575
703
  HintMessage(message="⚠️ No context analysis available")
576
704
  )
577
705
 
578
- def action_view_onboarding(self) -> None:
579
- """Show the onboarding modal."""
580
- self.app.push_screen(OnboardingModal())
581
-
582
706
  @work
583
707
  async def action_compact_conversation(self) -> None:
584
708
  """Compact the conversation history to reduce size."""
@@ -821,6 +945,7 @@ class ChatScreen(Screen[None]):
821
945
  classes="" if self.working else "hidden",
822
946
  )
823
947
  yield StatusBar(working=self.working)
948
+ yield AttachmentBar(id="attachment-bar")
824
949
  yield PromptInput(
825
950
  text=self.value,
826
951
  highlight_cursor_line=False,
@@ -884,6 +1009,72 @@ class ChatScreen(Screen[None]):
884
1009
  )
885
1010
  self.agent_manager.add_hint_message(hint)
886
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
+
887
1078
  @on(PartialResponseMessage)
888
1079
  def handle_partial_response(self, event: PartialResponseMessage) -> None:
889
1080
  # Filter event.messages to exclude ModelRequest with only ToolReturnPart
@@ -977,17 +1168,88 @@ class ChatScreen(Screen[None]):
977
1168
  # Clear any streaming partial response (removes final_result JSON)
978
1169
  self._clear_partial_response()
979
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
+
980
1176
  # Enter Q&A mode
981
1177
  self.qa_mode = True
982
1178
  self.qa_questions = event.questions
983
1179
  self.qa_current_index = 0
984
1180
  self.qa_answers = []
985
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
+
986
1244
  @on(MessageHistoryUpdated)
987
1245
  async def handle_message_history_updated(
988
1246
  self, event: MessageHistoryUpdated
989
1247
  ) -> None:
990
1248
  """Handle message history updates from the agent manager."""
1249
+ logger.debug(
1250
+ "[MSG_HISTORY] MessageHistoryUpdated received - %d messages",
1251
+ len(event.messages),
1252
+ )
991
1253
  self._clear_partial_response()
992
1254
  self.messages = event.messages
993
1255
 
@@ -1152,8 +1414,27 @@ class ChatScreen(Screen[None]):
1152
1414
  HintMessage(message=f"⚠ Failed to update model configuration: {e}")
1153
1415
  )
1154
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
+
1155
1422
  @on(PromptInput.Submitted)
1156
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
+ """
1157
1438
  text = message.text.strip()
1158
1439
 
1159
1440
  # If empty text, just clear input and return
@@ -1162,6 +1443,23 @@ class ChatScreen(Screen[None]):
1162
1443
  self.value = ""
1163
1444
  return
1164
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
+
1165
1463
  # Handle Q&A mode (from structured output clarifying questions)
1166
1464
  if self.qa_mode and self.qa_questions:
1167
1465
  # Collect answer
@@ -1237,6 +1535,33 @@ class ChatScreen(Screen[None]):
1237
1535
  return
1238
1536
 
1239
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
+
1240
1565
  self.history.append(message.text)
1241
1566
 
1242
1567
  # Add user message to agent_manager's history BEFORE running the agent
@@ -1245,9 +1570,10 @@ class ChatScreen(Screen[None]):
1245
1570
  self.agent_manager.ui_message_history.append(user_message)
1246
1571
  self.messages = self.agent_manager.ui_message_history.copy()
1247
1572
 
1248
- # Clear the input
1573
+ # Clear the input and attachment bar
1249
1574
  self.value = ""
1250
- 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
1251
1577
 
1252
1578
  self.widget_coordinator.update_prompt_input(clear=True)
1253
1579
 
@@ -1364,6 +1690,17 @@ class ChatScreen(Screen[None]):
1364
1690
  HintMessage(message=f"❌ Failed to delete codebase: {exc}")
1365
1691
  )
1366
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
+
1367
1704
  def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
1368
1705
  """Check if error is related to kuzu database corruption.
1369
1706
 
@@ -1371,221 +1708,275 @@ class ChatScreen(Screen[None]):
1371
1708
  exception: The exception to check
1372
1709
 
1373
1710
  Returns:
1374
- True if the error indicates kuzu database corruption
1711
+ True if the error indicates kuzu database corruption or lock issues
1375
1712
  """
1376
- error_str = str(exception).lower()
1377
- error_indicators = [
1378
- "not a directory",
1379
- "errno 20",
1380
- "corrupted",
1381
- ".kuzu",
1382
- "ioexception",
1383
- "unordered_map", # C++ STL map errors from kuzu
1384
- "key not found", # unordered_map::at errors
1385
- "std::exception", # Generic C++ exceptions from kuzu
1386
- ]
1387
- 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
+ )
1388
1720
 
1389
- @work
1721
+ @work(group="indexing", exit_on_error=False)
1390
1722
  async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
1723
+ logger.debug(f"index_codebase worker starting for {selection.repo_path}")
1391
1724
  index_start_time = time.time()
1392
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
+
1393
1738
  label = self.query_one("#indexing-job-display", Static)
1394
1739
  label.update(
1395
1740
  f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
1396
1741
  )
1397
1742
  label.refresh()
1398
1743
 
1399
- def create_progress_bar(percentage: float, width: int = 20) -> str:
1400
- """Create a visual progress bar using Unicode block characters."""
1401
- filled = int((percentage / 100) * width)
1402
- empty = width - filled
1403
- return "▓" * filled + "░" * empty
1404
-
1405
- # Spinner animation frames
1406
- spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1407
-
1408
- # Progress state (shared between timer and progress callback)
1409
- progress_state: dict[str, int | float] = {
1410
- "frame_index": 0,
1411
- "percentage": 0.0,
1412
- }
1413
-
1414
- def update_progress_display() -> None:
1415
- """Update progress bar on timer - runs every 100ms."""
1416
- # Advance spinner frame
1417
- frame_idx = int(progress_state["frame_index"])
1418
- progress_state["frame_index"] = (frame_idx + 1) % len(spinner_frames)
1419
- spinner = spinner_frames[frame_idx]
1420
-
1421
- # Get current state
1422
- pct = float(progress_state["percentage"])
1423
- bar = create_progress_bar(pct)
1424
-
1425
- # Update label
1426
- label.update(
1427
- f"[$foreground-muted]Indexing codebase: {spinner} {bar} {pct:.0f}%[/]"
1428
- )
1744
+ # Track progress timer for cleanup
1745
+ progress_timer = None
1429
1746
 
1430
- def progress_callback(progress_info: IndexProgress) -> None:
1431
- """Update progress state (timer renders it independently)."""
1432
- # Calculate overall percentage with weights based on actual timing:
1433
- # Structure: 0-2%, Definitions: 2-18%, Relationships: 18-20%
1434
- # Flush nodes: 20-28%, Flush relationships: 28-100%
1435
- if progress_info.phase == ProgressPhase.STRUCTURE:
1436
- # Phase 1: 0-2% (actual: ~0%)
1437
- overall_pct = 2.0 if progress_info.phase_complete else 1.0
1438
- elif progress_info.phase == ProgressPhase.DEFINITIONS:
1439
- # Phase 2: 2-18% based on files processed (actual: ~16%)
1440
- if progress_info.total and progress_info.total > 0:
1441
- phase_pct = (progress_info.current / progress_info.total) * 16.0
1442
- overall_pct = 2.0 + phase_pct
1443
- else:
1444
- overall_pct = 2.0
1445
- elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
1446
- # Phase 3: 18-20% based on relationships processed (actual: ~0.3%)
1447
- if progress_info.total and progress_info.total > 0:
1448
- phase_pct = (progress_info.current / progress_info.total) * 2.0
1449
- overall_pct = 18.0 + phase_pct
1450
- else:
1451
- overall_pct = 18.0
1452
- elif progress_info.phase == ProgressPhase.FLUSH_NODES:
1453
- # Phase 4: 20-28% based on nodes flushed (actual: ~7.5%)
1454
- if progress_info.total and progress_info.total > 0:
1455
- phase_pct = (progress_info.current / progress_info.total) * 8.0
1456
- overall_pct = 20.0 + phase_pct
1457
- else:
1458
- overall_pct = 20.0
1459
- elif progress_info.phase == ProgressPhase.FLUSH_RELATIONSHIPS:
1460
- # Phase 5: 28-100% based on relationships flushed (actual: ~76%)
1461
- if progress_info.total and progress_info.total > 0:
1462
- phase_pct = (progress_info.current / progress_info.total) * 72.0
1463
- overall_pct = 28.0 + phase_pct
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
1464
1816
  else:
1465
- overall_pct = 28.0
1466
- else:
1467
- overall_pct = 0.0
1817
+ overall_pct = 0.0
1468
1818
 
1469
- # Update shared state (timer will render it)
1470
- progress_state["percentage"] = overall_pct
1819
+ # Update shared state (timer will render it)
1820
+ progress_state["percentage"] = overall_pct
1471
1821
 
1472
- # Start progress animation timer (10 fps = 100ms interval)
1473
- 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)
1474
1824
 
1475
- # Retry logic for handling kuzu corruption
1476
- max_retries = 3
1825
+ # Retry logic for handling kuzu corruption
1826
+ max_retries = 3
1477
1827
 
1478
- for attempt in range(max_retries):
1479
- try:
1480
- # Clean up corrupted DBs before retry (skip on first attempt)
1481
- if attempt > 0:
1482
- logger.info(
1483
- f"Retry attempt {attempt + 1}/{max_retries} - cleaning up corrupted databases"
1484
- )
1485
- manager = CodebaseGraphManager(
1486
- self.codebase_sdk.service.storage_dir
1487
- )
1488
- cleaned = await manager.cleanup_corrupted_databases()
1489
- logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
1490
- self.agent_manager.add_hint_message(
1491
- HintMessage(
1492
- 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
+ )
1493
1844
  )
1494
- )
1495
1845
 
1496
- # Pass the current working directory as the indexed_from_cwd
1497
- logger.debug(
1498
- f"Starting indexing - repo_path: {selection.repo_path}, "
1499
- f"name: {selection.name}, cwd: {Path.cwd().resolve()}"
1500
- )
1501
- result = await self.codebase_sdk.index_codebase(
1502
- selection.repo_path,
1503
- selection.name,
1504
- indexed_from_cwd=str(Path.cwd().resolve()),
1505
- progress_callback=progress_callback,
1506
- )
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,
1856
+ )
1857
+ logger.debug("index_codebase SDK call completed successfully")
1507
1858
 
1508
- # Success! Stop progress animation
1509
- progress_timer.stop()
1859
+ # Success! Stop progress animation
1860
+ progress_timer.stop()
1510
1861
 
1511
- # Show 100% completion after indexing finishes
1512
- final_bar = create_progress_bar(100.0)
1513
- label.update(
1514
- f"[$foreground-muted]Indexing codebase: {final_bar} 100%[/]"
1515
- )
1516
- 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()
1517
1868
 
1518
- # Calculate duration and format message
1519
- duration = time.time() - index_start_time
1520
- duration_str = _format_duration(duration)
1521
- entity_count = result.node_count + result.relationship_count
1522
- entity_str = _format_count(entity_count)
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)
1523
1874
 
1524
- logger.info(
1525
- f"Successfully indexed codebase '{result.name}' in {duration_str} "
1526
- f"({entity_count} entities)"
1527
- )
1528
- self.agent_manager.add_hint_message(
1529
- HintMessage(
1530
- 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)"
1531
1878
  )
1532
- )
1533
- break # Success - exit retry loop
1534
-
1535
- except CodebaseAlreadyIndexedError as exc:
1536
- progress_timer.stop()
1537
- logger.warning(f"Codebase already indexed: {exc}")
1538
- self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
1539
- return
1540
- except InvalidPathError as exc:
1541
- progress_timer.stop()
1542
- logger.error(f"Invalid path error: {exc}")
1543
- self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
1544
- 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
1545
1886
 
1546
- except Exception as exc: # pragma: no cover - defensive UI path
1547
- # Check if this is a kuzu corruption error and we have retries left
1548
- if attempt < max_retries - 1 and self._is_kuzu_corruption_error(exc):
1887
+ except asyncio.CancelledError:
1549
1888
  logger.warning(
1550
- f"Kuzu corruption detected on attempt {attempt + 1}/{max_retries}: {exc}. "
1551
- f"Will retry after cleanup..."
1889
+ "index_codebase worker was cancelled - this should not happen"
1552
1890
  )
1553
- # Exponential backoff: 1s, 2s
1554
- await asyncio.sleep(2**attempt)
1555
- continue
1556
-
1557
- # Either final retry failed OR not a corruption error - show error
1558
- logger.exception(
1559
- f"Failed to index codebase after {attempt + 1} attempts - "
1560
- f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
1561
- )
1562
- self.agent_manager.add_hint_message(
1563
- HintMessage(
1564
- 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}"
1565
1929
  )
1566
- )
1567
- break
1568
1930
 
1569
- # Always stop the progress timer and clean up label
1570
- progress_timer.stop()
1571
- label.update("")
1572
- 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)
1573
1954
 
1574
1955
  @work
1575
- 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:
1576
1962
  # Start processing with spinner
1577
1963
  from textual.worker import get_current_worker
1578
1964
 
1579
1965
  self.processing_state.start_processing("Processing...")
1580
1966
  self.processing_state.bind_worker(get_current_worker())
1581
1967
 
1968
+ # Pass cancellation event to deps for responsive ESC handling
1969
+ self.deps.cancellation_event = self.processing_state.cancellation_event
1970
+
1582
1971
  # Start context indicator animation immediately
1583
1972
  self.widget_coordinator.set_context_streaming(True)
1584
1973
 
1585
1974
  try:
1586
1975
  # Use unified agent runner - exceptions propagate for handling
1587
1976
  runner = AgentRunner(self.agent_manager)
1588
- await runner.run(message)
1977
+ await runner.run(
1978
+ message, attachment=attachment, file_contents=file_contents
1979
+ )
1589
1980
  except ShotgunAccountException as e:
1590
1981
  # Shotgun Account errors show contact email UI
1591
1982
  message_parts = e.to_markdown().split("**Need help?**")
@@ -1600,7 +1991,13 @@ class ChatScreen(Screen[None]):
1600
1991
  else:
1601
1992
  # Fallback if message format is unexpected
1602
1993
  self.mount_hint(e.to_markdown())
1603
- 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:
1604
2001
  # All other user-actionable errors - display with markdown
1605
2002
  self.mount_hint(e.to_markdown())
1606
2003
  except Exception as e:
@@ -1623,6 +2020,12 @@ class ChatScreen(Screen[None]):
1623
2020
  # Check for pending checkpoint (Planning mode step completion)
1624
2021
  self._check_pending_checkpoint()
1625
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
+
1626
2029
  # Save conversation after each interaction
1627
2030
  self._save_conversation()
1628
2031
 
@@ -1689,21 +2092,6 @@ class ChatScreen(Screen[None]):
1689
2092
 
1690
2093
  self.run_worker(_do_load(), exclusive=False)
1691
2094
 
1692
- @work
1693
- async def _check_and_show_onboarding(self) -> None:
1694
- """Check if onboarding should be shown and display modal if needed."""
1695
- config_manager = get_config_manager()
1696
- config = await config_manager.load()
1697
-
1698
- # Only show onboarding if it hasn't been shown before
1699
- if config.shown_onboarding_popup is None:
1700
- # Show the onboarding modal
1701
- await self.app.push_screen_wait(OnboardingModal())
1702
-
1703
- # Mark as shown in config with current timestamp
1704
- config.shown_onboarding_popup = datetime.now(timezone.utc)
1705
- await config_manager.save(config)
1706
-
1707
2095
  # =========================================================================
1708
2096
  # Step Checkpoint Handlers (Planning Mode)
1709
2097
  # =========================================================================
@@ -1724,9 +2112,27 @@ class ChatScreen(Screen[None]):
1724
2112
  # Show checkpoint widget
1725
2113
  self._show_checkpoint_widget(event.step, event.next_step)
1726
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
+
1727
2132
  @on(CheckpointContinue)
1728
2133
  def handle_checkpoint_continue(self) -> None:
1729
2134
  """Continue to next step when user approves at checkpoint."""
2135
+ self._track_checkpoint_event("checkpoint_continued")
1730
2136
  self._hide_checkpoint_widget()
1731
2137
  self._execute_next_step()
1732
2138
 
@@ -1743,6 +2149,7 @@ class ChatScreen(Screen[None]):
1743
2149
  @on(CheckpointStop)
1744
2150
  def handle_checkpoint_stop(self) -> None:
1745
2151
  """Stop execution, keep remaining steps as pending."""
2152
+ self._track_checkpoint_event("checkpoint_stopped")
1746
2153
  self._hide_checkpoint_widget()
1747
2154
 
1748
2155
  if isinstance(self.deps, RouterDeps):
@@ -1826,6 +2233,98 @@ class ChatScreen(Screen[None]):
1826
2233
  )
1827
2234
  )
1828
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
+
1829
2328
  # =========================================================================
1830
2329
  # Sub-Agent Lifecycle Handlers (Stage 8)
1831
2330
  # =========================================================================
@@ -2018,16 +2517,28 @@ class ChatScreen(Screen[None]):
2018
2517
  self._hide_approval_widget()
2019
2518
 
2020
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
+
2021
2529
  self.deps.approval_status = PlanApprovalStatus.APPROVED
2022
2530
  self.deps.is_executing = True
2023
2531
 
2024
2532
  # Switch to Drafting mode when plan is approved
2025
2533
  self.deps.router_mode = RouterMode.DRAFTING
2026
- self._save_router_mode(RouterMode.DRAFTING.value)
2027
2534
  self.widget_coordinator.update_for_mode_change(self.mode)
2028
2535
 
2029
- # Begin execution of the first step
2536
+ # Show plan panel now that plan is approved and executing
2030
2537
  plan = self.deps.current_plan
2538
+ if plan:
2539
+ self._show_plan_panel(plan)
2540
+
2541
+ # Begin execution of the first step
2031
2542
  if plan and plan.current_step():
2032
2543
  first_step = plan.current_step()
2033
2544
  if first_step:
@@ -2041,6 +2552,15 @@ class ChatScreen(Screen[None]):
2041
2552
  self._hide_approval_widget()
2042
2553
 
2043
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
+
2044
2564
  self.deps.approval_status = PlanApprovalStatus.REJECTED
2045
2565
  # Clear the plan since user wants to modify
2046
2566
  self.deps.current_plan = None
@@ -2054,6 +2574,10 @@ class ChatScreen(Screen[None]):
2054
2574
  Args:
2055
2575
  plan: The execution plan that needs user approval.
2056
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
+
2057
2581
  # Create the approval widget
2058
2582
  self._approval_widget = PlanApprovalWidget(plan)
2059
2583
 
@@ -2086,6 +2610,13 @@ class ChatScreen(Screen[None]):
2086
2610
  logger.debug("[PLAN] Not RouterDeps, skipping pending approval check")
2087
2611
  return
2088
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
+
2089
2620
  if self.deps.pending_approval is None:
2090
2621
  logger.debug("[PLAN] No pending approval")
2091
2622
  return