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
|
@@ -11,9 +11,11 @@ from textual.events import Resize
|
|
|
11
11
|
from textual.screen import ModalScreen
|
|
12
12
|
from textual.widgets import Button, Label, Markdown, Static
|
|
13
13
|
|
|
14
|
-
from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
|
|
15
14
|
from shotgun.utils.file_system_utils import get_shotgun_home
|
|
16
15
|
|
|
16
|
+
# Use a higher threshold than the global default since this dialog has more content
|
|
17
|
+
INDEX_PROMPT_COMPACT_THRESHOLD = 45
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
def _is_home_directory() -> bool:
|
|
19
21
|
"""Check if cwd is user's home directory.
|
|
@@ -46,7 +48,7 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
|
|
|
46
48
|
max-width: 90;
|
|
47
49
|
height: auto;
|
|
48
50
|
max-height: 85%;
|
|
49
|
-
border:
|
|
51
|
+
border: none;
|
|
50
52
|
padding: 1 2;
|
|
51
53
|
layout: vertical;
|
|
52
54
|
background: $surface;
|
|
@@ -200,12 +202,14 @@ We take your privacy seriously. You can read our full [privacy policy](https://a
|
|
|
200
202
|
if _is_home_directory():
|
|
201
203
|
_track_event("home_directory_warning_shown")
|
|
202
204
|
# Apply compact layout if starting in a short terminal
|
|
203
|
-
self._apply_compact_layout(
|
|
205
|
+
self._apply_compact_layout(
|
|
206
|
+
self.app.size.height < INDEX_PROMPT_COMPACT_THRESHOLD
|
|
207
|
+
)
|
|
204
208
|
|
|
205
209
|
@on(Resize)
|
|
206
210
|
def handle_resize(self, event: Resize) -> None:
|
|
207
211
|
"""Adjust layout based on terminal height."""
|
|
208
|
-
self._apply_compact_layout(event.size.height <
|
|
212
|
+
self._apply_compact_layout(event.size.height < INDEX_PROMPT_COMPACT_THRESHOLD)
|
|
209
213
|
|
|
210
214
|
def _apply_compact_layout(self, compact: bool) -> None:
|
|
211
215
|
"""Apply or remove compact layout classes for short terminals."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Attachment hint widget for displaying attachments in chat history."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from shotgun.attachments import AttachmentHint, get_attachment_icon
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AttachmentHintWidget(Widget):
|
|
11
|
+
"""Widget that displays attachment indicator in chat history.
|
|
12
|
+
|
|
13
|
+
Display format: "icon Attached: filename (size)"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
AttachmentHintWidget {
|
|
18
|
+
height: auto;
|
|
19
|
+
padding: 0 1;
|
|
20
|
+
margin: 0 1;
|
|
21
|
+
color: $text-muted;
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, hint: AttachmentHint) -> None:
|
|
26
|
+
"""Initialize with attachment hint data.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
hint: AttachmentHint model containing display info.
|
|
30
|
+
"""
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.hint = hint
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
"""Compose the attachment hint widget."""
|
|
36
|
+
icon = get_attachment_icon(self.hint.file_type)
|
|
37
|
+
display_text = (
|
|
38
|
+
f"{icon} Attached: {self.hint.filename} ({self.hint.file_size_display})"
|
|
39
|
+
)
|
|
40
|
+
yield Static(display_text)
|
|
@@ -288,11 +288,6 @@ class UnifiedCommandProvider(Provider):
|
|
|
288
288
|
self.chat_screen.action_show_usage,
|
|
289
289
|
help="Display usage information for the current session",
|
|
290
290
|
)
|
|
291
|
-
yield DiscoveryHit(
|
|
292
|
-
"View Onboarding",
|
|
293
|
-
self.chat_screen.action_view_onboarding,
|
|
294
|
-
help="View the onboarding tutorial and helpful resources",
|
|
295
|
-
)
|
|
296
291
|
|
|
297
292
|
async def search(self, query: str) -> AsyncGenerator[Hit, None]:
|
|
298
293
|
"""Search for commands in alphabetical order."""
|
|
@@ -345,11 +340,6 @@ class UnifiedCommandProvider(Provider):
|
|
|
345
340
|
self.chat_screen.action_show_usage,
|
|
346
341
|
"Display usage information for the current session",
|
|
347
342
|
),
|
|
348
|
-
(
|
|
349
|
-
"View Onboarding",
|
|
350
|
-
self.chat_screen.action_view_onboarding,
|
|
351
|
-
"View the onboarding tutorial and helpful resources",
|
|
352
|
-
),
|
|
353
343
|
]
|
|
354
344
|
|
|
355
345
|
for title, callback, help_text in commands:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Chat history widget - main container for message display."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from collections.abc import Generator, Sequence
|
|
4
5
|
|
|
5
6
|
from pydantic_ai.messages import (
|
|
@@ -13,6 +14,7 @@ from textual.app import ComposeResult
|
|
|
13
14
|
from textual.reactive import reactive
|
|
14
15
|
from textual.widget import Widget
|
|
15
16
|
|
|
17
|
+
from shotgun.agents.messages import InternalPromptPart
|
|
16
18
|
from shotgun.tui.components.prompt_input import PromptInput
|
|
17
19
|
from shotgun.tui.components.vertical_tail import VerticalTail
|
|
18
20
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage, HintMessageWidget
|
|
@@ -21,6 +23,8 @@ from .agent_response import AgentResponseWidget
|
|
|
21
23
|
from .partial_response import PartialResponseWidget
|
|
22
24
|
from .user_question import UserQuestionWidget
|
|
23
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
class ChatHistory(Widget):
|
|
26
30
|
"""Main widget for displaying chat message history."""
|
|
@@ -74,14 +78,16 @@ class ChatHistory(Widget):
|
|
|
74
78
|
def filtered_items(self) -> Generator[ModelMessage | HintMessage, None, None]:
|
|
75
79
|
"""Filter and yield items for display."""
|
|
76
80
|
for item in self.items:
|
|
77
|
-
# Skip ModelRequest messages
|
|
78
|
-
# (these are internal tool results, not user prompts)
|
|
81
|
+
# Skip ModelRequest messages without visible user content
|
|
79
82
|
if isinstance(item, ModelRequest):
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
# Check for visible user content (UserPromptPart but NOT InternalPromptPart)
|
|
84
|
+
has_visible_user_content = any(
|
|
85
|
+
isinstance(part, UserPromptPart)
|
|
86
|
+
and not isinstance(part, InternalPromptPart)
|
|
87
|
+
for part in item.parts
|
|
82
88
|
)
|
|
83
|
-
if not
|
|
84
|
-
#
|
|
89
|
+
if not has_visible_user_content:
|
|
90
|
+
# Skip: either just tool returns or internal system prompts
|
|
85
91
|
continue
|
|
86
92
|
|
|
87
93
|
yield item
|
|
@@ -89,14 +95,35 @@ class ChatHistory(Widget):
|
|
|
89
95
|
def update_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
|
|
90
96
|
"""Update the displayed messages using incremental mounting."""
|
|
91
97
|
if not self.vertical_tail:
|
|
98
|
+
logger.debug(
|
|
99
|
+
"[CHAT_HISTORY] update_messages called but vertical_tail is None"
|
|
100
|
+
)
|
|
92
101
|
return
|
|
93
102
|
|
|
94
103
|
self.items = messages
|
|
95
104
|
filtered = list(self.filtered_items())
|
|
96
105
|
|
|
106
|
+
# If rendered count is higher than filtered count, the message list was
|
|
107
|
+
# modified (not just appended). Reset rendered count to allow new messages
|
|
108
|
+
# to be mounted. This can happen when messages are compacted or filtered.
|
|
109
|
+
if self._rendered_count > len(filtered):
|
|
110
|
+
logger.debug(
|
|
111
|
+
"[CHAT_HISTORY] Rendered count (%d) > filtered count (%d), "
|
|
112
|
+
"resetting to allow new messages to be mounted",
|
|
113
|
+
self._rendered_count,
|
|
114
|
+
len(filtered),
|
|
115
|
+
)
|
|
116
|
+
self._rendered_count = len(filtered)
|
|
117
|
+
|
|
97
118
|
# Only mount new messages that haven't been rendered yet
|
|
98
119
|
if len(filtered) > self._rendered_count:
|
|
99
120
|
new_messages = filtered[self._rendered_count :]
|
|
121
|
+
logger.debug(
|
|
122
|
+
"[CHAT_HISTORY] Mounting %d new messages (total=%d, filtered=%d)",
|
|
123
|
+
len(new_messages),
|
|
124
|
+
len(messages),
|
|
125
|
+
len(filtered),
|
|
126
|
+
)
|
|
100
127
|
for item in new_messages:
|
|
101
128
|
widget: Widget
|
|
102
129
|
if isinstance(item, ModelRequest):
|
|
@@ -106,6 +133,10 @@ class ChatHistory(Widget):
|
|
|
106
133
|
elif isinstance(item, ModelResponse):
|
|
107
134
|
widget = AgentResponseWidget(item)
|
|
108
135
|
else:
|
|
136
|
+
logger.debug(
|
|
137
|
+
"[CHAT_HISTORY] Skipping unknown message type: %s",
|
|
138
|
+
type(item).__name__,
|
|
139
|
+
)
|
|
109
140
|
continue
|
|
110
141
|
|
|
111
142
|
# Mount before the PartialResponseWidget
|
|
@@ -117,11 +148,20 @@ class ChatHistory(Widget):
|
|
|
117
148
|
self.vertical_tail.scroll_end(animate=False)
|
|
118
149
|
|
|
119
150
|
def on_click(self, event: events.Click) -> None:
|
|
120
|
-
"""Focus the prompt input when clicking on the history area.
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
"""Focus the prompt input when clicking on the history area.
|
|
152
|
+
|
|
153
|
+
Skip focusing if text is selected (to allow copy operations).
|
|
154
|
+
"""
|
|
155
|
+
# Only handle left clicks
|
|
156
|
+
if event.button != 1:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Don't focus input if user has selected text (they might want to copy it)
|
|
160
|
+
if self.screen.get_selected_text():
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
results = self.screen.query(PromptInput)
|
|
164
|
+
if results:
|
|
165
|
+
prompt_input = results.first()
|
|
166
|
+
if prompt_input.display:
|
|
167
|
+
prompt_input.focus()
|
|
@@ -95,6 +95,17 @@ class ToolFormatter:
|
|
|
95
95
|
args, display_config.key_arg, part.tool_name
|
|
96
96
|
)
|
|
97
97
|
if key_value:
|
|
98
|
+
# Check for secondary key arg
|
|
99
|
+
if display_config.secondary_key_arg:
|
|
100
|
+
secondary_value = cls._extract_key_arg(
|
|
101
|
+
args, display_config.secondary_key_arg, part.tool_name
|
|
102
|
+
)
|
|
103
|
+
if secondary_value:
|
|
104
|
+
# Format: "display_text: key_value → secondary_value"
|
|
105
|
+
return (
|
|
106
|
+
f"{display_config.display_text}: "
|
|
107
|
+
f"{cls.truncate(key_value)} → {cls.truncate(secondary_value)}"
|
|
108
|
+
)
|
|
98
109
|
# Format: "display_text: key_value"
|
|
99
110
|
return f"{display_config.display_text}: {cls.truncate(key_value)}"
|
|
100
111
|
else:
|
|
@@ -135,6 +146,17 @@ class ToolFormatter:
|
|
|
135
146
|
# Get the key argument value
|
|
136
147
|
key_value = cls._extract_key_arg(args, display_config.key_arg)
|
|
137
148
|
if key_value:
|
|
149
|
+
# Check for secondary key arg
|
|
150
|
+
if display_config.secondary_key_arg:
|
|
151
|
+
secondary_value = cls._extract_key_arg(
|
|
152
|
+
args, display_config.secondary_key_arg
|
|
153
|
+
)
|
|
154
|
+
if secondary_value:
|
|
155
|
+
# Format: "display_text: key_value → secondary_value"
|
|
156
|
+
return (
|
|
157
|
+
f"{display_config.display_text}: "
|
|
158
|
+
f"{cls.truncate(key_value)} → {cls.truncate(secondary_value)}"
|
|
159
|
+
)
|
|
138
160
|
# Format: "display_text: key_value"
|
|
139
161
|
return f"{display_config.display_text}: {cls.truncate(key_value)}"
|
|
140
162
|
else:
|
|
@@ -12,6 +12,8 @@ from textual.app import ComposeResult
|
|
|
12
12
|
from textual.widget import Widget
|
|
13
13
|
from textual.widgets import Markdown
|
|
14
14
|
|
|
15
|
+
from shotgun.agents.messages import InternalPromptPart
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class UserQuestionWidget(Widget):
|
|
17
19
|
"""Widget that displays user prompts in the chat history."""
|
|
@@ -33,10 +35,30 @@ class UserQuestionWidget(Widget):
|
|
|
33
35
|
acc = ""
|
|
34
36
|
for part in parts:
|
|
35
37
|
if isinstance(part, UserPromptPart):
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
# Skip internal prompts (system-generated, not user input)
|
|
39
|
+
if isinstance(part, InternalPromptPart):
|
|
40
|
+
continue
|
|
41
|
+
content = self._extract_text_content(part.content)
|
|
42
|
+
if content:
|
|
43
|
+
acc += f"**>** {content}\n\n"
|
|
44
|
+
# Skip if no displayable text (e.g., only binary files)
|
|
39
45
|
elif isinstance(part, ToolReturnPart):
|
|
40
46
|
# Don't show tool return parts in the UI
|
|
41
47
|
pass
|
|
42
48
|
return acc
|
|
49
|
+
|
|
50
|
+
def _extract_text_content(self, content: object) -> str:
|
|
51
|
+
"""Extract displayable text from UserPromptPart content.
|
|
52
|
+
|
|
53
|
+
Content can be:
|
|
54
|
+
- str: Return directly
|
|
55
|
+
- list: Extract text strings, skip binary content (BinaryContent, ImageUrl, etc.)
|
|
56
|
+
- other: Return empty string
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(content, str):
|
|
59
|
+
return content
|
|
60
|
+
if isinstance(content, list):
|
|
61
|
+
# Multimodal content - extract only text strings
|
|
62
|
+
text_parts = [item for item in content if isinstance(item, str)]
|
|
63
|
+
return " ".join(text_parts) if text_parts else ""
|
|
64
|
+
return ""
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Dialog shown when the database is locked by another process."""
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
|
|
5
|
+
import pyperclip # type: ignore[import-untyped]
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container, Horizontal
|
|
9
|
+
from textual.events import Resize
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Label, Static
|
|
12
|
+
|
|
13
|
+
from shotgun.exceptions import SHOTGUN_CONTACT_EMAIL
|
|
14
|
+
from shotgun.posthog_telemetry import track_event
|
|
15
|
+
from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
|
|
16
|
+
from shotgun.tui.screens.confirmation_dialog import ConfirmationDialog
|
|
17
|
+
from shotgun.tui.screens.models import LockedDialogAction
|
|
18
|
+
|
|
19
|
+
# Discord invite link for support
|
|
20
|
+
DISCORD_LINK = "https://discord.gg/5RmY6J2N7s"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DatabaseLockedDialog(ModalScreen[LockedDialogAction]):
|
|
24
|
+
"""Dialog shown when the database is locked by another process.
|
|
25
|
+
|
|
26
|
+
This modal informs the user that the database is locked, which could mean
|
|
27
|
+
another instance is running OR a previous instance shut down unsafely
|
|
28
|
+
without releasing the lock.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
LockedDialogAction.RETRY if user wants to retry after closing other instances
|
|
32
|
+
LockedDialogAction.DELETE if user wants to delete the locked database
|
|
33
|
+
LockedDialogAction.QUIT if user wants to quit the application
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
DEFAULT_CSS = """
|
|
37
|
+
DatabaseLockedDialog {
|
|
38
|
+
align: center middle;
|
|
39
|
+
background: rgba(0, 0, 0, 0.0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
DatabaseLockedDialog > #dialog-container {
|
|
43
|
+
width: 70%;
|
|
44
|
+
max-width: 80;
|
|
45
|
+
height: auto;
|
|
46
|
+
border: wide $warning;
|
|
47
|
+
padding: 1 2;
|
|
48
|
+
layout: vertical;
|
|
49
|
+
background: $surface;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#dialog-title {
|
|
53
|
+
text-style: bold;
|
|
54
|
+
color: $warning;
|
|
55
|
+
padding-bottom: 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#dialog-message {
|
|
59
|
+
padding-bottom: 1;
|
|
60
|
+
color: $text-muted;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#support-buttons {
|
|
64
|
+
layout: horizontal;
|
|
65
|
+
height: auto;
|
|
66
|
+
padding-bottom: 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#support-buttons Button {
|
|
70
|
+
margin-right: 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#dialog-buttons {
|
|
74
|
+
layout: horizontal;
|
|
75
|
+
align-horizontal: right;
|
|
76
|
+
height: auto;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#dialog-buttons Button {
|
|
80
|
+
margin-left: 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#delete-section {
|
|
84
|
+
layout: horizontal;
|
|
85
|
+
height: auto;
|
|
86
|
+
padding-top: 1;
|
|
87
|
+
border-top: solid $warning-darken-2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#delete-section Static {
|
|
91
|
+
width: 1fr;
|
|
92
|
+
color: $text-muted;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#delete-section Button {
|
|
96
|
+
margin-left: 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Compact styles for short terminals */
|
|
100
|
+
#dialog-container.compact {
|
|
101
|
+
padding: 0 2;
|
|
102
|
+
max-height: 98%;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#dialog-title.compact {
|
|
106
|
+
padding-bottom: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#dialog-message.compact {
|
|
110
|
+
padding-bottom: 0;
|
|
111
|
+
}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def compose(self) -> ComposeResult:
|
|
115
|
+
"""Compose the dialog widgets."""
|
|
116
|
+
with Container(id="dialog-container"):
|
|
117
|
+
yield Label("Codebase Index Unavailable", id="dialog-title")
|
|
118
|
+
message = (
|
|
119
|
+
"Unable to access the codebase index because it is locked.\n\n"
|
|
120
|
+
"We can't determine if another shotgun instance is currently running "
|
|
121
|
+
"or if a previous instance shut down unsafely without releasing the lock.\n\n"
|
|
122
|
+
"To resolve this:\n"
|
|
123
|
+
"1. Close any other shotgun instances and click Retry\n"
|
|
124
|
+
"2. If no other instance is running, you can delete the index\n\n"
|
|
125
|
+
"Need help? Contact support:"
|
|
126
|
+
)
|
|
127
|
+
yield Static(message, id="dialog-message")
|
|
128
|
+
with Horizontal(id="support-buttons"):
|
|
129
|
+
yield Button(
|
|
130
|
+
f"Copy Support Email [{SHOTGUN_CONTACT_EMAIL}]", id="copy-email"
|
|
131
|
+
)
|
|
132
|
+
yield Button("Open Support Discord", id="open-discord")
|
|
133
|
+
with Container(id="dialog-buttons"):
|
|
134
|
+
yield Button("Retry", id="retry", variant="primary")
|
|
135
|
+
yield Button("Quit", id="cancel")
|
|
136
|
+
with Horizontal(id="delete-section"):
|
|
137
|
+
yield Static("Caution: Only delete if no other instance is running.")
|
|
138
|
+
yield Button("Delete Index", id="delete", variant="error")
|
|
139
|
+
|
|
140
|
+
def on_mount(self) -> None:
|
|
141
|
+
"""Set up the dialog after mounting."""
|
|
142
|
+
# Track this event in PostHog
|
|
143
|
+
track_event("database_locked_dialog_shown", {})
|
|
144
|
+
|
|
145
|
+
# Focus retry button - user likely wants to retry after closing other instance
|
|
146
|
+
self.query_one("#retry", Button).focus()
|
|
147
|
+
|
|
148
|
+
# Apply compact layout if starting in a short terminal
|
|
149
|
+
self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
|
|
150
|
+
|
|
151
|
+
@on(Resize)
|
|
152
|
+
def handle_resize(self, event: Resize) -> None:
|
|
153
|
+
"""Adjust layout based on terminal height."""
|
|
154
|
+
self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
|
|
155
|
+
|
|
156
|
+
def _apply_compact_layout(self, compact: bool) -> None:
|
|
157
|
+
"""Apply or remove compact layout classes for short terminals."""
|
|
158
|
+
container = self.query_one("#dialog-container")
|
|
159
|
+
title = self.query_one("#dialog-title")
|
|
160
|
+
message = self.query_one("#dialog-message")
|
|
161
|
+
|
|
162
|
+
if compact:
|
|
163
|
+
container.add_class("compact")
|
|
164
|
+
title.add_class("compact")
|
|
165
|
+
message.add_class("compact")
|
|
166
|
+
else:
|
|
167
|
+
container.remove_class("compact")
|
|
168
|
+
title.remove_class("compact")
|
|
169
|
+
message.remove_class("compact")
|
|
170
|
+
|
|
171
|
+
@on(Button.Pressed, "#cancel")
|
|
172
|
+
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
173
|
+
"""Handle cancel button press."""
|
|
174
|
+
event.stop()
|
|
175
|
+
self.dismiss(LockedDialogAction.QUIT)
|
|
176
|
+
|
|
177
|
+
@on(Button.Pressed, "#retry")
|
|
178
|
+
def handle_retry(self, event: Button.Pressed) -> None:
|
|
179
|
+
"""Handle retry button press."""
|
|
180
|
+
event.stop()
|
|
181
|
+
self.dismiss(LockedDialogAction.RETRY)
|
|
182
|
+
|
|
183
|
+
@on(Button.Pressed, "#delete")
|
|
184
|
+
async def handle_delete(self, event: Button.Pressed) -> None:
|
|
185
|
+
"""Handle delete button press with confirmation."""
|
|
186
|
+
event.stop()
|
|
187
|
+
# Show confirmation dialog before proceeding
|
|
188
|
+
confirmed = await self.app.push_screen_wait(
|
|
189
|
+
ConfirmationDialog(
|
|
190
|
+
title="Delete Codebase Index?",
|
|
191
|
+
message=(
|
|
192
|
+
"Have you checked that no other shotgun instance is running?\n\n"
|
|
193
|
+
"Deleting while another instance is open could cause data loss. "
|
|
194
|
+
"You will need to re-index the codebase after deletion."
|
|
195
|
+
),
|
|
196
|
+
confirm_label="Delete Index",
|
|
197
|
+
cancel_label="Cancel",
|
|
198
|
+
confirm_variant="error",
|
|
199
|
+
danger=True,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
if confirmed:
|
|
203
|
+
track_event("database_locked_dialog_delete", {})
|
|
204
|
+
self.dismiss(LockedDialogAction.DELETE)
|
|
205
|
+
|
|
206
|
+
@on(Button.Pressed, "#copy-email")
|
|
207
|
+
def handle_copy_email(self, event: Button.Pressed) -> None:
|
|
208
|
+
"""Copy support email to clipboard."""
|
|
209
|
+
event.stop()
|
|
210
|
+
pyperclip.copy(SHOTGUN_CONTACT_EMAIL)
|
|
211
|
+
track_event("database_locked_dialog_copy_email", {})
|
|
212
|
+
self.notify("Email copied to clipboard", severity="information")
|
|
213
|
+
|
|
214
|
+
@on(Button.Pressed, "#open-discord")
|
|
215
|
+
def handle_open_discord(self, event: Button.Pressed) -> None:
|
|
216
|
+
"""Open Discord link in browser."""
|
|
217
|
+
event.stop()
|
|
218
|
+
webbrowser.open(DISCORD_LINK)
|
|
219
|
+
track_event("database_locked_dialog_open_discord", {})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Dialog shown when database operation times out."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Container
|
|
8
|
+
from textual.events import Resize
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, Label, Static
|
|
11
|
+
|
|
12
|
+
from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
|
|
13
|
+
|
|
14
|
+
TimeoutAction = Literal["retry", "skip", "cancel"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatabaseTimeoutDialog(ModalScreen[TimeoutAction]):
|
|
18
|
+
"""Dialog shown when database operation takes longer than expected.
|
|
19
|
+
|
|
20
|
+
This modal informs the user that the database operation is taking longer
|
|
21
|
+
than expected (can happen with large codebases) and offers options to
|
|
22
|
+
wait longer, skip, or cancel.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
codebase_name: Name of the codebase that timed out
|
|
26
|
+
timeout_seconds: The timeout that was exceeded
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
"retry" - Wait longer (90s timeout)
|
|
30
|
+
"skip" - Skip this database and continue
|
|
31
|
+
"cancel" - Cancel the operation
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
DEFAULT_CSS = """
|
|
35
|
+
DatabaseTimeoutDialog {
|
|
36
|
+
align: center middle;
|
|
37
|
+
background: rgba(0, 0, 0, 0.0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
DatabaseTimeoutDialog > #dialog-container {
|
|
41
|
+
width: 60%;
|
|
42
|
+
max-width: 70;
|
|
43
|
+
height: auto;
|
|
44
|
+
border: wide $warning;
|
|
45
|
+
padding: 1 2;
|
|
46
|
+
layout: vertical;
|
|
47
|
+
background: $surface;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#dialog-title {
|
|
51
|
+
text-style: bold;
|
|
52
|
+
color: $warning;
|
|
53
|
+
padding-bottom: 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#dialog-message {
|
|
57
|
+
padding-bottom: 1;
|
|
58
|
+
color: $text-muted;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#dialog-buttons {
|
|
62
|
+
layout: horizontal;
|
|
63
|
+
align-horizontal: right;
|
|
64
|
+
height: auto;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#dialog-buttons Button {
|
|
68
|
+
margin-left: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Compact styles for short terminals */
|
|
72
|
+
#dialog-container.compact {
|
|
73
|
+
padding: 0 2;
|
|
74
|
+
max-height: 98%;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#dialog-title.compact {
|
|
78
|
+
padding-bottom: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#dialog-message.compact {
|
|
82
|
+
padding-bottom: 0;
|
|
83
|
+
}
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, codebase_name: str = "", timeout_seconds: float = 10.0) -> None:
|
|
87
|
+
"""Initialize the dialog.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
codebase_name: Name of the codebase that timed out
|
|
91
|
+
timeout_seconds: The timeout that was exceeded
|
|
92
|
+
"""
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.codebase_name = codebase_name
|
|
95
|
+
self.timeout_seconds = timeout_seconds
|
|
96
|
+
|
|
97
|
+
def compose(self) -> ComposeResult:
|
|
98
|
+
"""Compose the dialog widgets."""
|
|
99
|
+
with Container(id="dialog-container"):
|
|
100
|
+
yield Label("Database Taking Longer Than Expected", id="dialog-title")
|
|
101
|
+
message = (
|
|
102
|
+
f"The database operation exceeded {self.timeout_seconds:.0f} seconds.\n\n"
|
|
103
|
+
"This can happen with large codebases. "
|
|
104
|
+
"Would you like to wait longer (90 seconds)?"
|
|
105
|
+
)
|
|
106
|
+
if self.codebase_name:
|
|
107
|
+
message = f"Codebase: {self.codebase_name}\n\n" + message
|
|
108
|
+
yield Static(message, id="dialog-message")
|
|
109
|
+
with Container(id="dialog-buttons"):
|
|
110
|
+
yield Button("Wait Longer", id="retry", variant="primary")
|
|
111
|
+
yield Button("Skip", id="skip")
|
|
112
|
+
yield Button("Cancel", id="cancel")
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
"""Set up the dialog after mounting."""
|
|
116
|
+
# Focus "Wait Longer" button - most likely what user wants
|
|
117
|
+
self.query_one("#retry", Button).focus()
|
|
118
|
+
|
|
119
|
+
# Apply compact layout if starting in a short terminal
|
|
120
|
+
self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
|
|
121
|
+
|
|
122
|
+
@on(Resize)
|
|
123
|
+
def handle_resize(self, event: Resize) -> None:
|
|
124
|
+
"""Adjust layout based on terminal height."""
|
|
125
|
+
self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
|
|
126
|
+
|
|
127
|
+
def _apply_compact_layout(self, compact: bool) -> None:
|
|
128
|
+
"""Apply or remove compact layout classes for short terminals."""
|
|
129
|
+
container = self.query_one("#dialog-container")
|
|
130
|
+
title = self.query_one("#dialog-title")
|
|
131
|
+
message = self.query_one("#dialog-message")
|
|
132
|
+
|
|
133
|
+
if compact:
|
|
134
|
+
container.add_class("compact")
|
|
135
|
+
title.add_class("compact")
|
|
136
|
+
message.add_class("compact")
|
|
137
|
+
else:
|
|
138
|
+
container.remove_class("compact")
|
|
139
|
+
title.remove_class("compact")
|
|
140
|
+
message.remove_class("compact")
|
|
141
|
+
|
|
142
|
+
@on(Button.Pressed, "#cancel")
|
|
143
|
+
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
144
|
+
"""Handle cancel button press."""
|
|
145
|
+
event.stop()
|
|
146
|
+
self.dismiss("cancel")
|
|
147
|
+
|
|
148
|
+
@on(Button.Pressed, "#skip")
|
|
149
|
+
def handle_skip(self, event: Button.Pressed) -> None:
|
|
150
|
+
"""Handle skip button press."""
|
|
151
|
+
event.stop()
|
|
152
|
+
self.dismiss("skip")
|
|
153
|
+
|
|
154
|
+
@on(Button.Pressed, "#retry")
|
|
155
|
+
def handle_retry(self, event: Button.Pressed) -> None:
|
|
156
|
+
"""Handle retry button press."""
|
|
157
|
+
event.stop()
|
|
158
|
+
self.dismiss("retry")
|