shotgun-sh 0.2.17__py3-none-any.whl → 0.3.3.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shotgun/agents/agent_manager.py +28 -14
- shotgun/agents/common.py +1 -1
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +323 -53
- shotgun/agents/config/models.py +85 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/ingestor.py +153 -7
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +73 -9
- shotgun/tui/containers.py +1 -1
- shotgun/tui/layout.py +5 -0
- shotgun/tui/screens/chat/chat_screen.py +372 -95
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -2
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +149 -0
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
|
@@ -36,20 +36,27 @@ from shotgun.agents.agent_manager import (
|
|
|
36
36
|
)
|
|
37
37
|
from shotgun.agents.config import get_config_manager
|
|
38
38
|
from shotgun.agents.config.models import MODEL_SPECS
|
|
39
|
-
from shotgun.agents.
|
|
40
|
-
from shotgun.agents.history.compaction import apply_persistent_compaction
|
|
41
|
-
from shotgun.agents.history.token_estimation import
|
|
39
|
+
from shotgun.agents.conversation import ConversationManager
|
|
40
|
+
from shotgun.agents.conversation.history.compaction import apply_persistent_compaction
|
|
41
|
+
from shotgun.agents.conversation.history.token_estimation import (
|
|
42
|
+
estimate_tokens_from_messages,
|
|
43
|
+
)
|
|
42
44
|
from shotgun.agents.models import (
|
|
43
45
|
AgentDeps,
|
|
44
46
|
AgentType,
|
|
45
47
|
FileOperationTracker,
|
|
46
48
|
)
|
|
49
|
+
from shotgun.agents.runner import AgentRunner
|
|
47
50
|
from shotgun.codebase.core.manager import (
|
|
48
51
|
CodebaseAlreadyIndexedError,
|
|
49
52
|
CodebaseGraphManager,
|
|
50
53
|
)
|
|
51
54
|
from shotgun.codebase.models import IndexProgress, ProgressPhase
|
|
52
|
-
from shotgun.exceptions import
|
|
55
|
+
from shotgun.exceptions import (
|
|
56
|
+
SHOTGUN_CONTACT_EMAIL,
|
|
57
|
+
ErrorNotPickedUpBySentry,
|
|
58
|
+
ShotgunAccountException,
|
|
59
|
+
)
|
|
53
60
|
from shotgun.posthog_telemetry import track_event
|
|
54
61
|
from shotgun.sdk.codebase import CodebaseSDK
|
|
55
62
|
from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
|
|
@@ -59,6 +66,8 @@ from shotgun.tui.components.mode_indicator import ModeIndicator
|
|
|
59
66
|
from shotgun.tui.components.prompt_input import PromptInput
|
|
60
67
|
from shotgun.tui.components.spinner import Spinner
|
|
61
68
|
from shotgun.tui.components.status_bar import StatusBar
|
|
69
|
+
|
|
70
|
+
# TUIErrorHandler removed - exceptions now caught directly
|
|
62
71
|
from shotgun.tui.screens.chat.codebase_index_prompt_screen import (
|
|
63
72
|
CodebaseIndexPromptScreen,
|
|
64
73
|
)
|
|
@@ -76,16 +85,50 @@ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
|
76
85
|
from shotgun.tui.screens.chat_screen.history import ChatHistory
|
|
77
86
|
from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
|
|
78
87
|
from shotgun.tui.screens.onboarding import OnboardingModal
|
|
88
|
+
from shotgun.tui.screens.shared_specs import (
|
|
89
|
+
CreateSpecDialog,
|
|
90
|
+
ShareSpecsAction,
|
|
91
|
+
ShareSpecsDialog,
|
|
92
|
+
UploadProgressScreen,
|
|
93
|
+
)
|
|
79
94
|
from shotgun.tui.services.conversation_service import ConversationService
|
|
80
95
|
from shotgun.tui.state.processing_state import ProcessingStateManager
|
|
81
96
|
from shotgun.tui.utils.mode_progress import PlaceholderHints
|
|
82
97
|
from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
|
|
83
98
|
from shotgun.utils import get_shotgun_home
|
|
99
|
+
from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
84
100
|
from shotgun.utils.marketing import MarketingManager
|
|
85
101
|
|
|
86
102
|
logger = logging.getLogger(__name__)
|
|
87
103
|
|
|
88
104
|
|
|
105
|
+
def _format_duration(seconds: float) -> str:
|
|
106
|
+
"""Format duration in natural language."""
|
|
107
|
+
if seconds < 60:
|
|
108
|
+
return f"{int(seconds)} seconds"
|
|
109
|
+
minutes = int(seconds // 60)
|
|
110
|
+
secs = int(seconds % 60)
|
|
111
|
+
if secs == 0:
|
|
112
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
|
113
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''} {secs} seconds"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _format_count(count: int) -> str:
|
|
117
|
+
"""Format count in natural language (e.g., '5 thousand')."""
|
|
118
|
+
if count < 1000:
|
|
119
|
+
return str(count)
|
|
120
|
+
elif count < 1_000_000:
|
|
121
|
+
thousands = count / 1000
|
|
122
|
+
if thousands == int(thousands):
|
|
123
|
+
return f"{int(thousands)} thousand"
|
|
124
|
+
return f"{thousands:.1f} thousand"
|
|
125
|
+
else:
|
|
126
|
+
millions = count / 1_000_000
|
|
127
|
+
if millions == int(millions):
|
|
128
|
+
return f"{int(millions)} million"
|
|
129
|
+
return f"{millions:.1f} million"
|
|
130
|
+
|
|
131
|
+
|
|
89
132
|
class ChatScreen(Screen[None]):
|
|
90
133
|
CSS_PATH = "chat.tcss"
|
|
91
134
|
|
|
@@ -131,6 +174,7 @@ class ChatScreen(Screen[None]):
|
|
|
131
174
|
deps: AgentDeps,
|
|
132
175
|
continue_session: bool = False,
|
|
133
176
|
force_reindex: bool = False,
|
|
177
|
+
show_pull_hint: bool = False,
|
|
134
178
|
) -> None:
|
|
135
179
|
"""Initialize the ChatScreen.
|
|
136
180
|
|
|
@@ -149,6 +193,7 @@ class ChatScreen(Screen[None]):
|
|
|
149
193
|
deps: AgentDeps configuration for agent dependencies
|
|
150
194
|
continue_session: Whether to continue a previous session
|
|
151
195
|
force_reindex: Whether to force reindexing of codebases
|
|
196
|
+
show_pull_hint: Whether to show hint about recently pulled spec
|
|
152
197
|
"""
|
|
153
198
|
super().__init__()
|
|
154
199
|
|
|
@@ -164,6 +209,7 @@ class ChatScreen(Screen[None]):
|
|
|
164
209
|
self.processing_state = processing_state
|
|
165
210
|
self.continue_session = continue_session
|
|
166
211
|
self.force_reindex = force_reindex
|
|
212
|
+
self.show_pull_hint = show_pull_hint
|
|
167
213
|
|
|
168
214
|
def on_mount(self) -> None:
|
|
169
215
|
# Use widget coordinator to focus input
|
|
@@ -179,6 +225,10 @@ class ChatScreen(Screen[None]):
|
|
|
179
225
|
if self.continue_session:
|
|
180
226
|
self.call_later(self._check_and_load_conversation)
|
|
181
227
|
|
|
228
|
+
# Show pull hint if launching after spec pull
|
|
229
|
+
if self.show_pull_hint:
|
|
230
|
+
self.call_later(self._show_pull_hint)
|
|
231
|
+
|
|
182
232
|
self.call_later(self.check_if_codebase_is_indexed)
|
|
183
233
|
# Initial update of context indicator
|
|
184
234
|
self.update_context_indicator()
|
|
@@ -284,10 +334,8 @@ class ChatScreen(Screen[None]):
|
|
|
284
334
|
def action_toggle_mode(self) -> None:
|
|
285
335
|
# Prevent mode switching during Q&A
|
|
286
336
|
if self.qa_mode:
|
|
287
|
-
self.
|
|
288
|
-
"Cannot switch modes while answering questions"
|
|
289
|
-
severity="warning",
|
|
290
|
-
timeout=3,
|
|
337
|
+
self.agent_manager.add_hint_message(
|
|
338
|
+
HintMessage(message="⚠️ Cannot switch modes while answering questions")
|
|
291
339
|
)
|
|
292
340
|
return
|
|
293
341
|
|
|
@@ -303,20 +351,90 @@ class ChatScreen(Screen[None]):
|
|
|
303
351
|
# Re-focus input after mode change
|
|
304
352
|
self.call_later(lambda: self.widget_coordinator.update_prompt_input(focus=True))
|
|
305
353
|
|
|
306
|
-
def action_show_usage(self) -> None:
|
|
354
|
+
async def action_show_usage(self) -> None:
|
|
307
355
|
usage_hint = self.agent_manager.get_usage_hint()
|
|
308
356
|
logger.info(f"Usage hint: {usage_hint}")
|
|
357
|
+
|
|
358
|
+
# Add budget info for Shotgun Account users
|
|
359
|
+
if self.deps.llm_model.is_shotgun_account:
|
|
360
|
+
try:
|
|
361
|
+
from shotgun.llm_proxy import LiteLLMProxyClient
|
|
362
|
+
|
|
363
|
+
logger.debug("Fetching budget info for Shotgun Account")
|
|
364
|
+
client = LiteLLMProxyClient(self.deps.llm_model.api_key)
|
|
365
|
+
budget_info = await client.get_budget_info()
|
|
366
|
+
|
|
367
|
+
# Format budget section
|
|
368
|
+
source_label = "Key" if budget_info.source == "key" else "Team"
|
|
369
|
+
budget_section = f"""## Shotgun Account Budget
|
|
370
|
+
|
|
371
|
+
* Max Budget: ${budget_info.max_budget:.2f}
|
|
372
|
+
* Current Spend: ${budget_info.spend:.2f}
|
|
373
|
+
* Remaining: ${budget_info.remaining:.2f} ({100 - budget_info.percentage_used:.1f}%)
|
|
374
|
+
* Budget Source: {source_label}-level
|
|
375
|
+
|
|
376
|
+
**Questions or need help?**"""
|
|
377
|
+
|
|
378
|
+
# Build markdown_before (usage + budget info before email)
|
|
379
|
+
if usage_hint:
|
|
380
|
+
markdown_before = f"{usage_hint}\n\n{budget_section}"
|
|
381
|
+
else:
|
|
382
|
+
markdown_before = budget_section
|
|
383
|
+
|
|
384
|
+
markdown_after = (
|
|
385
|
+
"\n\n_Reach out anytime for billing questions "
|
|
386
|
+
"or to increase your budget._"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Mount with email copy button
|
|
390
|
+
self.mount_hint_with_email(
|
|
391
|
+
markdown_before=markdown_before,
|
|
392
|
+
email="contact@shotgun.sh",
|
|
393
|
+
markdown_after=markdown_after,
|
|
394
|
+
)
|
|
395
|
+
logger.debug("Successfully added budget info to usage hint")
|
|
396
|
+
return # Exit early since we've already mounted
|
|
397
|
+
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.warning(f"Failed to fetch budget info: {e}")
|
|
400
|
+
# For Shotgun Account, show budget fetch error
|
|
401
|
+
# If we have usage data, still show it
|
|
402
|
+
if usage_hint:
|
|
403
|
+
# Show usage even though budget fetch failed
|
|
404
|
+
self.mount_hint(usage_hint)
|
|
405
|
+
else:
|
|
406
|
+
# No usage and budget fetch failed - show specific error with email
|
|
407
|
+
markdown_before = (
|
|
408
|
+
"⚠️ **Unable to fetch budget information**\n\n"
|
|
409
|
+
"There was an error retrieving your budget data."
|
|
410
|
+
)
|
|
411
|
+
markdown_after = (
|
|
412
|
+
"\n\n_Try the command again in a moment. "
|
|
413
|
+
"If the issue persists, reach out for help._"
|
|
414
|
+
)
|
|
415
|
+
self.mount_hint_with_email(
|
|
416
|
+
markdown_before=markdown_before,
|
|
417
|
+
email="contact@shotgun.sh",
|
|
418
|
+
markdown_after=markdown_after,
|
|
419
|
+
)
|
|
420
|
+
return # Exit early
|
|
421
|
+
|
|
422
|
+
# Fallback for non-Shotgun Account users
|
|
309
423
|
if usage_hint:
|
|
310
424
|
self.mount_hint(usage_hint)
|
|
311
425
|
else:
|
|
312
|
-
self.
|
|
426
|
+
self.agent_manager.add_hint_message(
|
|
427
|
+
HintMessage(message="⚠️ No usage hint available")
|
|
428
|
+
)
|
|
313
429
|
|
|
314
430
|
async def action_show_context(self) -> None:
|
|
315
431
|
context_hint = await self.agent_manager.get_context_hint()
|
|
316
432
|
if context_hint:
|
|
317
433
|
self.mount_hint(context_hint)
|
|
318
434
|
else:
|
|
319
|
-
self.
|
|
435
|
+
self.agent_manager.add_hint_message(
|
|
436
|
+
HintMessage(message="⚠️ No context analysis available")
|
|
437
|
+
)
|
|
320
438
|
|
|
321
439
|
def action_view_onboarding(self) -> None:
|
|
322
440
|
"""Show the onboarding modal."""
|
|
@@ -441,7 +559,9 @@ class ChatScreen(Screen[None]):
|
|
|
441
559
|
|
|
442
560
|
except Exception as e:
|
|
443
561
|
logger.error(f"Failed to compact conversation: {e}", exc_info=True)
|
|
444
|
-
self.
|
|
562
|
+
self.agent_manager.add_hint_message(
|
|
563
|
+
HintMessage(message=f"❌ Failed to compact: {e}")
|
|
564
|
+
)
|
|
445
565
|
finally:
|
|
446
566
|
# Hide spinner
|
|
447
567
|
self.processing_state.stop_processing()
|
|
@@ -489,7 +609,9 @@ class ChatScreen(Screen[None]):
|
|
|
489
609
|
|
|
490
610
|
except Exception as e:
|
|
491
611
|
logger.error(f"Failed to clear conversation: {e}", exc_info=True)
|
|
492
|
-
self.
|
|
612
|
+
self.agent_manager.add_hint_message(
|
|
613
|
+
HintMessage(message=f"❌ Failed to clear: {e}")
|
|
614
|
+
)
|
|
493
615
|
|
|
494
616
|
@work(exclusive=False)
|
|
495
617
|
async def update_context_indicator(self) -> None:
|
|
@@ -576,6 +698,53 @@ class ChatScreen(Screen[None]):
|
|
|
576
698
|
hint = HintMessage(message=markdown)
|
|
577
699
|
self.agent_manager.add_hint_message(hint)
|
|
578
700
|
|
|
701
|
+
def _show_pull_hint(self) -> None:
|
|
702
|
+
"""Show hint about recently pulled spec from meta.json."""
|
|
703
|
+
# Import at runtime to avoid circular import (CLI -> TUI dependency)
|
|
704
|
+
from shotgun.cli.spec.models import SpecMeta
|
|
705
|
+
|
|
706
|
+
shotgun_dir = get_shotgun_base_path()
|
|
707
|
+
meta_path = shotgun_dir / "meta.json"
|
|
708
|
+
if not meta_path.exists():
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
meta: SpecMeta = SpecMeta.model_validate_json(meta_path.read_text())
|
|
713
|
+
# Only show if pulled within last 60 seconds
|
|
714
|
+
age_seconds = (datetime.now(timezone.utc) - meta.pulled_at).total_seconds()
|
|
715
|
+
if age_seconds > 60:
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
hint_parts = [f"You just pulled **{meta.spec_name}** from the cloud."]
|
|
719
|
+
if meta.web_url:
|
|
720
|
+
hint_parts.append(f"[View in browser]({meta.web_url})")
|
|
721
|
+
hint_parts.append(
|
|
722
|
+
f"The specs are now located at `{shotgun_dir}` so Shotgun has access to them."
|
|
723
|
+
)
|
|
724
|
+
if meta.backup_path:
|
|
725
|
+
hint_parts.append(
|
|
726
|
+
f"Previous files were backed up to: `{meta.backup_path}`"
|
|
727
|
+
)
|
|
728
|
+
self.mount_hint("\n\n".join(hint_parts))
|
|
729
|
+
except Exception:
|
|
730
|
+
# Ignore errors reading meta.json - this is optional UI feedback
|
|
731
|
+
logger.debug("Failed to read meta.json for pull hint", exc_info=True)
|
|
732
|
+
|
|
733
|
+
def mount_hint_with_email(
|
|
734
|
+
self, markdown_before: str, email: str, markdown_after: str = ""
|
|
735
|
+
) -> None:
|
|
736
|
+
"""Mount a hint with inline email copy button.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
markdown_before: Markdown content to display before the email line
|
|
740
|
+
email: Email address to display with copy button
|
|
741
|
+
markdown_after: Optional markdown content to display after the email line
|
|
742
|
+
"""
|
|
743
|
+
hint = HintMessage(
|
|
744
|
+
message=markdown_before, email=email, markdown_after=markdown_after
|
|
745
|
+
)
|
|
746
|
+
self.agent_manager.add_hint_message(hint)
|
|
747
|
+
|
|
579
748
|
@on(PartialResponseMessage)
|
|
580
749
|
def handle_partial_response(self, event: PartialResponseMessage) -> None:
|
|
581
750
|
# Filter event.messages to exclude ModelRequest with only ToolReturnPart
|
|
@@ -762,6 +931,19 @@ class ChatScreen(Screen[None]):
|
|
|
762
931
|
# Update the agent manager's model configuration
|
|
763
932
|
self.agent_manager.deps.llm_model = result.model_config
|
|
764
933
|
|
|
934
|
+
# Reset agents so they get recreated with new model
|
|
935
|
+
self.agent_manager._agents_initialized = False
|
|
936
|
+
self.agent_manager._research_agent = None
|
|
937
|
+
self.agent_manager._plan_agent = None
|
|
938
|
+
self.agent_manager._tasks_agent = None
|
|
939
|
+
self.agent_manager._specify_agent = None
|
|
940
|
+
self.agent_manager._export_agent = None
|
|
941
|
+
self.agent_manager._research_deps = None
|
|
942
|
+
self.agent_manager._plan_deps = None
|
|
943
|
+
self.agent_manager._tasks_deps = None
|
|
944
|
+
self.agent_manager._specify_deps = None
|
|
945
|
+
self.agent_manager._export_deps = None
|
|
946
|
+
|
|
765
947
|
# Get current analysis and update context indicator via coordinator
|
|
766
948
|
analysis = await self.agent_manager.get_context_analysis()
|
|
767
949
|
self.widget_coordinator.update_context_indicator(analysis, result.new_model)
|
|
@@ -928,6 +1110,71 @@ class ChatScreen(Screen[None]):
|
|
|
928
1110
|
)
|
|
929
1111
|
)
|
|
930
1112
|
|
|
1113
|
+
def share_specs_command(self) -> None:
|
|
1114
|
+
"""Launch the share specs workflow."""
|
|
1115
|
+
self.call_later(lambda: self._start_share_specs_flow())
|
|
1116
|
+
|
|
1117
|
+
@work
|
|
1118
|
+
async def _start_share_specs_flow(self) -> None:
|
|
1119
|
+
"""Main workflow for sharing specs to workspace."""
|
|
1120
|
+
# 1. Check preconditions (instant check, no API call)
|
|
1121
|
+
shotgun_dir = Path.cwd() / ".shotgun"
|
|
1122
|
+
if not shotgun_dir.exists():
|
|
1123
|
+
self.mount_hint("No .shotgun/ directory found in current directory")
|
|
1124
|
+
return
|
|
1125
|
+
|
|
1126
|
+
# 2. Show spec selection dialog (handles workspace fetch, permissions, and spec loading)
|
|
1127
|
+
result = await self.app.push_screen_wait(ShareSpecsDialog())
|
|
1128
|
+
if result is None or result.action is None:
|
|
1129
|
+
return # User cancelled or error
|
|
1130
|
+
|
|
1131
|
+
workspace_id = result.workspace_id
|
|
1132
|
+
if not workspace_id:
|
|
1133
|
+
self.mount_hint("Failed to get workspace")
|
|
1134
|
+
return
|
|
1135
|
+
|
|
1136
|
+
# 3. Handle create vs add version
|
|
1137
|
+
if result.action == ShareSpecsAction.CREATE:
|
|
1138
|
+
# Show create spec dialog
|
|
1139
|
+
create_result = await self.app.push_screen_wait(CreateSpecDialog())
|
|
1140
|
+
if create_result is None:
|
|
1141
|
+
return # User cancelled
|
|
1142
|
+
|
|
1143
|
+
# Pass spec creation info to UploadProgressScreen
|
|
1144
|
+
# It will create the spec/version and then upload
|
|
1145
|
+
upload_result = await self.app.push_screen_wait(
|
|
1146
|
+
UploadProgressScreen(
|
|
1147
|
+
workspace_id,
|
|
1148
|
+
spec_name=create_result.name,
|
|
1149
|
+
spec_description=create_result.description,
|
|
1150
|
+
spec_is_public=create_result.is_public,
|
|
1151
|
+
)
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
else: # add_version
|
|
1155
|
+
spec_id = result.spec_id
|
|
1156
|
+
if not spec_id:
|
|
1157
|
+
self.mount_hint("No spec selected")
|
|
1158
|
+
return
|
|
1159
|
+
|
|
1160
|
+
# Pass spec_id to UploadProgressScreen
|
|
1161
|
+
# It will create the version and then upload
|
|
1162
|
+
upload_result = await self.app.push_screen_wait(
|
|
1163
|
+
UploadProgressScreen(workspace_id, spec_id=spec_id)
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
# 7. Show result
|
|
1167
|
+
if upload_result and upload_result.success:
|
|
1168
|
+
if upload_result.web_url:
|
|
1169
|
+
self.mount_hint(
|
|
1170
|
+
f"Specs shared successfully!\n\nView at: {upload_result.web_url}"
|
|
1171
|
+
)
|
|
1172
|
+
else:
|
|
1173
|
+
self.mount_hint("Specs shared successfully!")
|
|
1174
|
+
elif upload_result and upload_result.cancelled:
|
|
1175
|
+
self.mount_hint("Upload cancelled")
|
|
1176
|
+
# Error case is handled by the upload screen
|
|
1177
|
+
|
|
931
1178
|
def delete_codebase_from_palette(self, graph_id: str) -> None:
|
|
932
1179
|
stack = getattr(self.app, "screen_stack", None)
|
|
933
1180
|
if stack and isinstance(stack[-1], CommandPalette):
|
|
@@ -939,11 +1186,15 @@ class ChatScreen(Screen[None]):
|
|
|
939
1186
|
async def delete_codebase(self, graph_id: str) -> None:
|
|
940
1187
|
try:
|
|
941
1188
|
await self.codebase_sdk.delete_codebase(graph_id)
|
|
942
|
-
self.
|
|
1189
|
+
self.agent_manager.add_hint_message(
|
|
1190
|
+
HintMessage(message=f"✓ Deleted codebase: {graph_id}")
|
|
1191
|
+
)
|
|
943
1192
|
except CodebaseNotFoundError as exc:
|
|
944
|
-
self.
|
|
1193
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
945
1194
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
946
|
-
self.
|
|
1195
|
+
self.agent_manager.add_hint_message(
|
|
1196
|
+
HintMessage(message=f"❌ Failed to delete codebase: {exc}")
|
|
1197
|
+
)
|
|
947
1198
|
|
|
948
1199
|
def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
|
|
949
1200
|
"""Check if error is related to kuzu database corruption.
|
|
@@ -969,6 +1220,8 @@ class ChatScreen(Screen[None]):
|
|
|
969
1220
|
|
|
970
1221
|
@work
|
|
971
1222
|
async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
|
|
1223
|
+
index_start_time = time.time()
|
|
1224
|
+
|
|
972
1225
|
label = self.query_one("#indexing-job-display", Static)
|
|
973
1226
|
label.update(
|
|
974
1227
|
f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
|
|
@@ -1008,24 +1261,40 @@ class ChatScreen(Screen[None]):
|
|
|
1008
1261
|
|
|
1009
1262
|
def progress_callback(progress_info: IndexProgress) -> None:
|
|
1010
1263
|
"""Update progress state (timer renders it independently)."""
|
|
1011
|
-
# Calculate overall percentage
|
|
1264
|
+
# Calculate overall percentage with weights based on actual timing:
|
|
1265
|
+
# Structure: 0-2%, Definitions: 2-18%, Relationships: 18-20%
|
|
1266
|
+
# Flush nodes: 20-28%, Flush relationships: 28-100%
|
|
1012
1267
|
if progress_info.phase == ProgressPhase.STRUCTURE:
|
|
1013
|
-
# Phase 1: 0-
|
|
1014
|
-
overall_pct =
|
|
1268
|
+
# Phase 1: 0-2% (actual: ~0%)
|
|
1269
|
+
overall_pct = 2.0 if progress_info.phase_complete else 1.0
|
|
1015
1270
|
elif progress_info.phase == ProgressPhase.DEFINITIONS:
|
|
1016
|
-
# Phase 2:
|
|
1271
|
+
# Phase 2: 2-18% based on files processed (actual: ~16%)
|
|
1017
1272
|
if progress_info.total and progress_info.total > 0:
|
|
1018
|
-
phase_pct = (progress_info.current / progress_info.total) *
|
|
1019
|
-
overall_pct =
|
|
1273
|
+
phase_pct = (progress_info.current / progress_info.total) * 16.0
|
|
1274
|
+
overall_pct = 2.0 + phase_pct
|
|
1020
1275
|
else:
|
|
1021
|
-
overall_pct =
|
|
1276
|
+
overall_pct = 2.0
|
|
1022
1277
|
elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
|
|
1023
|
-
# Phase 3:
|
|
1278
|
+
# Phase 3: 18-20% based on relationships processed (actual: ~0.3%)
|
|
1279
|
+
if progress_info.total and progress_info.total > 0:
|
|
1280
|
+
phase_pct = (progress_info.current / progress_info.total) * 2.0
|
|
1281
|
+
overall_pct = 18.0 + phase_pct
|
|
1282
|
+
else:
|
|
1283
|
+
overall_pct = 18.0
|
|
1284
|
+
elif progress_info.phase == ProgressPhase.FLUSH_NODES:
|
|
1285
|
+
# Phase 4: 20-28% based on nodes flushed (actual: ~7.5%)
|
|
1286
|
+
if progress_info.total and progress_info.total > 0:
|
|
1287
|
+
phase_pct = (progress_info.current / progress_info.total) * 8.0
|
|
1288
|
+
overall_pct = 20.0 + phase_pct
|
|
1289
|
+
else:
|
|
1290
|
+
overall_pct = 20.0
|
|
1291
|
+
elif progress_info.phase == ProgressPhase.FLUSH_RELATIONSHIPS:
|
|
1292
|
+
# Phase 5: 28-100% based on relationships flushed (actual: ~76%)
|
|
1024
1293
|
if progress_info.total and progress_info.total > 0:
|
|
1025
|
-
phase_pct = (progress_info.current / progress_info.total) *
|
|
1026
|
-
overall_pct =
|
|
1294
|
+
phase_pct = (progress_info.current / progress_info.total) * 72.0
|
|
1295
|
+
overall_pct = 28.0 + phase_pct
|
|
1027
1296
|
else:
|
|
1028
|
-
overall_pct =
|
|
1297
|
+
overall_pct = 28.0
|
|
1029
1298
|
else:
|
|
1030
1299
|
overall_pct = 0.0
|
|
1031
1300
|
|
|
@@ -1050,9 +1319,10 @@ class ChatScreen(Screen[None]):
|
|
|
1050
1319
|
)
|
|
1051
1320
|
cleaned = await manager.cleanup_corrupted_databases()
|
|
1052
1321
|
logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
|
|
1053
|
-
self.
|
|
1054
|
-
|
|
1055
|
-
|
|
1322
|
+
self.agent_manager.add_hint_message(
|
|
1323
|
+
HintMessage(
|
|
1324
|
+
message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
|
|
1325
|
+
)
|
|
1056
1326
|
)
|
|
1057
1327
|
|
|
1058
1328
|
# Pass the current working directory as the indexed_from_cwd
|
|
@@ -1077,25 +1347,32 @@ class ChatScreen(Screen[None]):
|
|
|
1077
1347
|
)
|
|
1078
1348
|
label.refresh()
|
|
1079
1349
|
|
|
1350
|
+
# Calculate duration and format message
|
|
1351
|
+
duration = time.time() - index_start_time
|
|
1352
|
+
duration_str = _format_duration(duration)
|
|
1353
|
+
entity_count = result.node_count + result.relationship_count
|
|
1354
|
+
entity_str = _format_count(entity_count)
|
|
1355
|
+
|
|
1080
1356
|
logger.info(
|
|
1081
|
-
f"Successfully indexed codebase '{result.name}'
|
|
1357
|
+
f"Successfully indexed codebase '{result.name}' in {duration_str} "
|
|
1358
|
+
f"({entity_count} entities)"
|
|
1082
1359
|
)
|
|
1083
|
-
self.
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1360
|
+
self.agent_manager.add_hint_message(
|
|
1361
|
+
HintMessage(
|
|
1362
|
+
message=f"✓ Indexed '{result.name}' in {duration_str} ({entity_str} entities)"
|
|
1363
|
+
)
|
|
1087
1364
|
)
|
|
1088
1365
|
break # Success - exit retry loop
|
|
1089
1366
|
|
|
1090
1367
|
except CodebaseAlreadyIndexedError as exc:
|
|
1091
1368
|
progress_timer.stop()
|
|
1092
1369
|
logger.warning(f"Codebase already indexed: {exc}")
|
|
1093
|
-
self.
|
|
1370
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
|
|
1094
1371
|
return
|
|
1095
1372
|
except InvalidPathError as exc:
|
|
1096
1373
|
progress_timer.stop()
|
|
1097
1374
|
logger.error(f"Invalid path error: {exc}")
|
|
1098
|
-
self.
|
|
1375
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
1099
1376
|
return
|
|
1100
1377
|
|
|
1101
1378
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
@@ -1114,10 +1391,10 @@ class ChatScreen(Screen[None]):
|
|
|
1114
1391
|
f"Failed to index codebase after {attempt + 1} attempts - "
|
|
1115
1392
|
f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
|
|
1116
1393
|
)
|
|
1117
|
-
self.
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1394
|
+
self.agent_manager.add_hint_message(
|
|
1395
|
+
HintMessage(
|
|
1396
|
+
message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
|
|
1397
|
+
)
|
|
1121
1398
|
)
|
|
1122
1399
|
break
|
|
1123
1400
|
|
|
@@ -1128,8 +1405,6 @@ class ChatScreen(Screen[None]):
|
|
|
1128
1405
|
|
|
1129
1406
|
@work
|
|
1130
1407
|
async def run_agent(self, message: str) -> None:
|
|
1131
|
-
prompt = None
|
|
1132
|
-
|
|
1133
1408
|
# Start processing with spinner
|
|
1134
1409
|
from textual.worker import get_current_worker
|
|
1135
1410
|
|
|
@@ -1139,65 +1414,41 @@ class ChatScreen(Screen[None]):
|
|
|
1139
1414
|
# Start context indicator animation immediately
|
|
1140
1415
|
self.widget_coordinator.set_context_streaming(True)
|
|
1141
1416
|
|
|
1142
|
-
prompt = message
|
|
1143
|
-
|
|
1144
1417
|
try:
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
)
|
|
1148
|
-
except
|
|
1149
|
-
#
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
f"3. Clear conversation (`/clear`)\n"
|
|
1160
|
-
)
|
|
1161
|
-
|
|
1162
|
-
self.mount_hint(hint)
|
|
1163
|
-
|
|
1164
|
-
# Log for debugging (won't send to Sentry due to ErrorNotPickedUpBySentry)
|
|
1165
|
-
logger.info(
|
|
1166
|
-
"Context size limit exceeded",
|
|
1167
|
-
extra={
|
|
1168
|
-
"max_tokens": e.max_tokens,
|
|
1169
|
-
"model_name": e.model_name,
|
|
1170
|
-
},
|
|
1171
|
-
)
|
|
1172
|
-
except Exception as e:
|
|
1173
|
-
# Log with full stack trace to shotgun.log
|
|
1174
|
-
logger.exception(
|
|
1175
|
-
"Agent run failed",
|
|
1176
|
-
extra={
|
|
1177
|
-
"agent_mode": self.mode.value,
|
|
1178
|
-
"error_type": type(e).__name__,
|
|
1179
|
-
},
|
|
1180
|
-
)
|
|
1181
|
-
|
|
1182
|
-
# Determine user-friendly message based on error type
|
|
1183
|
-
error_name = type(e).__name__
|
|
1184
|
-
error_message = str(e)
|
|
1185
|
-
|
|
1186
|
-
if "APIStatusError" in error_name and "overload" in error_message.lower():
|
|
1187
|
-
hint = "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
|
|
1188
|
-
elif "APIStatusError" in error_name and "rate" in error_message.lower():
|
|
1189
|
-
hint = "⚠️ Rate limit reached. Please wait before trying again."
|
|
1190
|
-
elif "APIStatusError" in error_name:
|
|
1191
|
-
hint = f"⚠️ AI service error: {error_message}"
|
|
1418
|
+
# Use unified agent runner - exceptions propagate for handling
|
|
1419
|
+
runner = AgentRunner(self.agent_manager)
|
|
1420
|
+
await runner.run(message)
|
|
1421
|
+
except ShotgunAccountException as e:
|
|
1422
|
+
# Shotgun Account errors show contact email UI
|
|
1423
|
+
message_parts = e.to_markdown().split("**Need help?**")
|
|
1424
|
+
if len(message_parts) == 2:
|
|
1425
|
+
markdown_before = message_parts[0] + "**Need help?**"
|
|
1426
|
+
markdown_after = message_parts[1].strip()
|
|
1427
|
+
self.mount_hint_with_email(
|
|
1428
|
+
markdown_before=markdown_before,
|
|
1429
|
+
email=SHOTGUN_CONTACT_EMAIL,
|
|
1430
|
+
markdown_after=markdown_after,
|
|
1431
|
+
)
|
|
1192
1432
|
else:
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1433
|
+
# Fallback if message format is unexpected
|
|
1434
|
+
self.mount_hint(e.to_markdown())
|
|
1435
|
+
except ErrorNotPickedUpBySentry as e:
|
|
1436
|
+
# All other user-actionable errors - display with markdown
|
|
1437
|
+
self.mount_hint(e.to_markdown())
|
|
1438
|
+
except Exception as e:
|
|
1439
|
+
# Unexpected errors that weren't wrapped (shouldn't happen)
|
|
1440
|
+
logger.exception("Unexpected error in run_agent")
|
|
1441
|
+
self.mount_hint(f"⚠️ An unexpected error occurred: {str(e)}")
|
|
1196
1442
|
finally:
|
|
1197
1443
|
self.processing_state.stop_processing()
|
|
1198
1444
|
# Stop context indicator animation
|
|
1199
1445
|
self.widget_coordinator.set_context_streaming(False)
|
|
1200
1446
|
|
|
1447
|
+
# Check for low balance after agent loop completes (only for Shotgun Account)
|
|
1448
|
+
# This runs after processing but doesn't interfere with Q&A mode
|
|
1449
|
+
if self.deps.llm_model.is_shotgun_account:
|
|
1450
|
+
await self._check_low_balance_warning()
|
|
1451
|
+
|
|
1201
1452
|
# Save conversation after each interaction
|
|
1202
1453
|
self._save_conversation()
|
|
1203
1454
|
|
|
@@ -1212,6 +1463,32 @@ class ChatScreen(Screen[None]):
|
|
|
1212
1463
|
exclusive=True,
|
|
1213
1464
|
)
|
|
1214
1465
|
|
|
1466
|
+
async def _check_low_balance_warning(self) -> None:
|
|
1467
|
+
"""Check account balance and show warning if $2.50 or less remaining.
|
|
1468
|
+
|
|
1469
|
+
This runs after every agent loop completion for Shotgun Account users.
|
|
1470
|
+
Errors are silently caught to avoid disrupting user workflow.
|
|
1471
|
+
"""
|
|
1472
|
+
try:
|
|
1473
|
+
from shotgun.llm_proxy import LiteLLMProxyClient
|
|
1474
|
+
|
|
1475
|
+
client = LiteLLMProxyClient(self.deps.llm_model.api_key)
|
|
1476
|
+
budget_info = await client.get_budget_info()
|
|
1477
|
+
|
|
1478
|
+
# Show warning if remaining balance is $2.50 or less
|
|
1479
|
+
if budget_info.remaining <= 2.50:
|
|
1480
|
+
warning_message = (
|
|
1481
|
+
f"⚠️ **Low Balance Warning**\n\n"
|
|
1482
|
+
f"Your Shotgun Account has **${budget_info.remaining:.2f}** remaining.\n\n"
|
|
1483
|
+
f"👉 **[Top Up Now at https://app.shotgun.sh/dashboard](https://app.shotgun.sh/dashboard)**"
|
|
1484
|
+
)
|
|
1485
|
+
self.agent_manager.add_hint_message(
|
|
1486
|
+
HintMessage(message=warning_message)
|
|
1487
|
+
)
|
|
1488
|
+
except Exception as e:
|
|
1489
|
+
# Silently log and continue - don't block user workflow
|
|
1490
|
+
logger.debug(f"Failed to check low balance warning: {e}")
|
|
1491
|
+
|
|
1215
1492
|
async def _check_and_load_conversation(self) -> None:
|
|
1216
1493
|
"""Check if conversation exists and load it if it does."""
|
|
1217
1494
|
if await self.conversation_manager.exists():
|