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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. shotgun/agents/agent_manager.py +307 -8
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +12 -0
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +10 -7
  6. shotgun/agents/config/models.py +5 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  9. shotgun/agents/file_read.py +176 -0
  10. shotgun/agents/messages.py +15 -3
  11. shotgun/agents/models.py +24 -1
  12. shotgun/agents/router/models.py +8 -0
  13. shotgun/agents/router/tools/delegation_tools.py +55 -1
  14. shotgun/agents/router/tools/plan_tools.py +88 -7
  15. shotgun/agents/runner.py +17 -2
  16. shotgun/agents/tools/__init__.py +8 -0
  17. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  18. shotgun/agents/tools/codebase/file_read.py +26 -35
  19. shotgun/agents/tools/codebase/query_graph.py +9 -0
  20. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  21. shotgun/agents/tools/file_management.py +32 -2
  22. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  23. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  24. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  25. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  26. shotgun/agents/tools/markdown_tools/models.py +86 -0
  27. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  28. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  29. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  30. shotgun/agents/tools/registry.py +44 -6
  31. shotgun/agents/tools/web_search/openai.py +42 -23
  32. shotgun/attachments/__init__.py +41 -0
  33. shotgun/attachments/errors.py +60 -0
  34. shotgun/attachments/models.py +107 -0
  35. shotgun/attachments/parser.py +257 -0
  36. shotgun/attachments/processor.py +193 -0
  37. shotgun/build_constants.py +4 -7
  38. shotgun/cli/clear.py +2 -2
  39. shotgun/cli/codebase/commands.py +181 -65
  40. shotgun/cli/compact.py +2 -2
  41. shotgun/cli/context.py +2 -2
  42. shotgun/cli/error_handler.py +2 -2
  43. shotgun/cli/run.py +90 -0
  44. shotgun/cli/spec/backup.py +2 -1
  45. shotgun/codebase/__init__.py +2 -0
  46. shotgun/codebase/benchmarks/__init__.py +35 -0
  47. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  48. shotgun/codebase/benchmarks/exporters.py +119 -0
  49. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  50. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  51. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  52. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  53. shotgun/codebase/benchmarks/models.py +129 -0
  54. shotgun/codebase/core/__init__.py +4 -0
  55. shotgun/codebase/core/call_resolution.py +91 -0
  56. shotgun/codebase/core/change_detector.py +11 -6
  57. shotgun/codebase/core/errors.py +159 -0
  58. shotgun/codebase/core/extractors/__init__.py +23 -0
  59. shotgun/codebase/core/extractors/base.py +138 -0
  60. shotgun/codebase/core/extractors/factory.py +63 -0
  61. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  62. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  63. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  64. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  65. shotgun/codebase/core/extractors/protocol.py +109 -0
  66. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  67. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  68. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  69. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  70. shotgun/codebase/core/extractors/types.py +15 -0
  71. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  72. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  73. shotgun/codebase/core/gitignore.py +252 -0
  74. shotgun/codebase/core/ingestor.py +644 -354
  75. shotgun/codebase/core/kuzu_compat.py +119 -0
  76. shotgun/codebase/core/language_config.py +239 -0
  77. shotgun/codebase/core/manager.py +256 -46
  78. shotgun/codebase/core/metrics_collector.py +310 -0
  79. shotgun/codebase/core/metrics_types.py +347 -0
  80. shotgun/codebase/core/parallel_executor.py +424 -0
  81. shotgun/codebase/core/work_distributor.py +254 -0
  82. shotgun/codebase/core/worker.py +768 -0
  83. shotgun/codebase/indexing_state.py +86 -0
  84. shotgun/codebase/models.py +94 -0
  85. shotgun/codebase/service.py +13 -0
  86. shotgun/exceptions.py +9 -9
  87. shotgun/main.py +3 -16
  88. shotgun/posthog_telemetry.py +165 -24
  89. shotgun/prompts/agents/file_read.j2 +48 -0
  90. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
  91. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  92. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  93. shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
  94. shotgun/prompts/agents/plan.j2 +14 -0
  95. shotgun/prompts/agents/router.j2 +531 -258
  96. shotgun/prompts/agents/specify.j2 +14 -0
  97. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  98. shotgun/prompts/agents/state/system_state.j2 +13 -11
  99. shotgun/prompts/agents/tasks.j2 +14 -0
  100. shotgun/settings.py +49 -10
  101. shotgun/tui/app.py +149 -18
  102. shotgun/tui/commands/__init__.py +9 -1
  103. shotgun/tui/components/attachment_bar.py +87 -0
  104. shotgun/tui/components/prompt_input.py +25 -28
  105. shotgun/tui/components/status_bar.py +14 -7
  106. shotgun/tui/dependencies.py +3 -8
  107. shotgun/tui/protocols.py +18 -0
  108. shotgun/tui/screens/chat/chat.tcss +15 -0
  109. shotgun/tui/screens/chat/chat_screen.py +766 -235
  110. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  111. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  112. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  113. shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
  114. shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
  115. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  116. shotgun/tui/screens/database_locked_dialog.py +219 -0
  117. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  118. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  119. shotgun/tui/screens/model_picker.py +1 -3
  120. shotgun/tui/screens/models.py +11 -0
  121. shotgun/tui/state/processing_state.py +19 -0
  122. shotgun/tui/widgets/widget_coordinator.py +18 -0
  123. shotgun/utils/file_system_utils.py +4 -1
  124. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
  125. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
  126. shotgun/cli/export.py +0 -81
  127. shotgun/cli/plan.py +0 -73
  128. shotgun/cli/research.py +0 -93
  129. shotgun/cli/specify.py +0 -70
  130. shotgun/cli/tasks.py +0 -78
  131. shotgun/sentry_telemetry.py +0 -232
  132. shotgun/tui/screens/onboarding.py +0 -584
  133. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  134. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  135. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -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: wide $primary;
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(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
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 < COMPACT_HEIGHT_THRESHOLD)
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 that only contain ToolReturnPart
78
- # (these are internal tool results, not user prompts)
81
+ # Skip ModelRequest messages without visible user content
79
82
  if isinstance(item, ModelRequest):
80
- has_user_content = any(
81
- isinstance(part, UserPromptPart) for part in item.parts
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 has_user_content:
84
- # This is just a tool return, skip displaying it
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
- # Only handle clicks that weren't already handled by a child widget
122
- if event.button == 1: # Left click
123
- results = self.screen.query(PromptInput)
124
- if results:
125
- prompt_input = results.first()
126
- if prompt_input.display:
127
- prompt_input.focus()
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
- acc += (
37
- f"**>** {part.content if isinstance(part.content, str) else ''}\n\n"
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")