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.
- shotgun/agents/agent_manager.py +307 -8
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +12 -0
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +10 -7
- shotgun/agents/config/models.py +5 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +24 -1
- shotgun/agents/router/models.py +8 -0
- shotgun/agents/router/tools/delegation_tools.py +55 -1
- shotgun/agents/router/tools/plan_tools.py +88 -7
- shotgun/agents/runner.py +17 -2
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +32 -2
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +44 -6
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/build_constants.py +4 -7
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/error_handler.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +9 -9
- shotgun/main.py +3 -16
- shotgun/posthog_telemetry.py +165 -24
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
- shotgun/prompts/agents/plan.j2 +14 -0
- shotgun/prompts/agents/router.j2 +531 -258
- shotgun/prompts/agents/specify.j2 +14 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +13 -11
- shotgun/prompts/agents/tasks.j2 +14 -0
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +149 -18
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +3 -8
- shotgun/tui/protocols.py +18 -0
- shotgun/tui/screens/chat/chat.tcss +15 -0
- shotgun/tui/screens/chat/chat_screen.py +766 -235
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -10
- shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
- shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +1 -3
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/sentry_telemetry.py +0 -232
- shotgun/tui/screens/onboarding.py +0 -584
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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.
|
|
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
|
-
#
|
|
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="
|
|
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.
|
|
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
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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 =
|
|
1466
|
-
else:
|
|
1467
|
-
overall_pct = 0.0
|
|
1817
|
+
overall_pct = 0.0
|
|
1468
1818
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1819
|
+
# Update shared state (timer will render it)
|
|
1820
|
+
progress_state["percentage"] = overall_pct
|
|
1471
1821
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1822
|
+
# Start progress animation timer (10 fps = 100ms interval)
|
|
1823
|
+
progress_timer = self.set_interval(0.1, update_progress_display)
|
|
1474
1824
|
|
|
1475
|
-
|
|
1476
|
-
|
|
1825
|
+
# Retry logic for handling kuzu corruption
|
|
1826
|
+
max_retries = 3
|
|
1477
1827
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
1859
|
+
# Success! Stop progress animation
|
|
1860
|
+
progress_timer.stop()
|
|
1510
1861
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1551
|
-
f"Will retry after cleanup..."
|
|
1889
|
+
"index_codebase worker was cancelled - this should not happen"
|
|
1552
1890
|
)
|
|
1553
|
-
#
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
message=f"❌
|
|
1891
|
+
raise # Re-raise to let finally block clean up
|
|
1892
|
+
|
|
1893
|
+
except CodebaseAlreadyIndexedError as exc:
|
|
1894
|
+
progress_timer.stop()
|
|
1895
|
+
logger.warning(f"Codebase already indexed: {exc}")
|
|
1896
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
|
|
1897
|
+
return
|
|
1898
|
+
except InvalidPathError as exc:
|
|
1899
|
+
progress_timer.stop()
|
|
1900
|
+
logger.error(f"Invalid path error: {exc}")
|
|
1901
|
+
self.agent_manager.add_hint_message(
|
|
1902
|
+
HintMessage(message=f"❌ {exc}")
|
|
1903
|
+
)
|
|
1904
|
+
return
|
|
1905
|
+
except KuzuImportError as exc:
|
|
1906
|
+
progress_timer.stop()
|
|
1907
|
+
logger.error(f"Kuzu import error (Windows DLL issue): {exc}")
|
|
1908
|
+
# Show dialog with copy button for Windows users
|
|
1909
|
+
await self.app.push_screen_wait(KuzuErrorDialog())
|
|
1910
|
+
return
|
|
1911
|
+
|
|
1912
|
+
except Exception as exc: # pragma: no cover - defensive UI path
|
|
1913
|
+
# Check if this is a kuzu corruption error and we have retries left
|
|
1914
|
+
if attempt < max_retries - 1 and self._is_kuzu_corruption_error(
|
|
1915
|
+
exc
|
|
1916
|
+
):
|
|
1917
|
+
logger.warning(
|
|
1918
|
+
f"Kuzu corruption detected on attempt {attempt + 1}/{max_retries}: {exc}. "
|
|
1919
|
+
f"Will retry after cleanup..."
|
|
1920
|
+
)
|
|
1921
|
+
# Exponential backoff: 1s, 2s
|
|
1922
|
+
await asyncio.sleep(2**attempt)
|
|
1923
|
+
continue
|
|
1924
|
+
|
|
1925
|
+
# Either final retry failed OR not a corruption error - show error
|
|
1926
|
+
logger.exception(
|
|
1927
|
+
f"Failed to index codebase after {attempt + 1} attempts - "
|
|
1928
|
+
f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
|
|
1565
1929
|
)
|
|
1566
|
-
)
|
|
1567
|
-
break
|
|
1568
1930
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
#
|
|
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
|