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.
Files changed (112) hide show
  1. shotgun/agents/agent_manager.py +28 -14
  2. shotgun/agents/common.py +1 -1
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +323 -53
  6. shotgun/agents/config/models.py +85 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/runner.py +230 -0
  23. shotgun/agents/tools/web_search/openai.py +1 -1
  24. shotgun/build_constants.py +2 -2
  25. shotgun/cli/clear.py +1 -1
  26. shotgun/cli/compact.py +5 -3
  27. shotgun/cli/context.py +44 -1
  28. shotgun/cli/error_handler.py +24 -0
  29. shotgun/cli/export.py +34 -34
  30. shotgun/cli/plan.py +34 -34
  31. shotgun/cli/research.py +17 -9
  32. shotgun/cli/spec/__init__.py +5 -0
  33. shotgun/cli/spec/backup.py +81 -0
  34. shotgun/cli/spec/commands.py +132 -0
  35. shotgun/cli/spec/models.py +48 -0
  36. shotgun/cli/spec/pull_service.py +219 -0
  37. shotgun/cli/specify.py +20 -19
  38. shotgun/cli/tasks.py +34 -34
  39. shotgun/codebase/core/ingestor.py +153 -7
  40. shotgun/codebase/models.py +2 -0
  41. shotgun/exceptions.py +325 -0
  42. shotgun/llm_proxy/__init__.py +17 -0
  43. shotgun/llm_proxy/client.py +215 -0
  44. shotgun/llm_proxy/models.py +137 -0
  45. shotgun/logging_config.py +42 -0
  46. shotgun/main.py +4 -0
  47. shotgun/posthog_telemetry.py +1 -1
  48. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
  49. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  50. shotgun/prompts/agents/plan.j2 +16 -0
  51. shotgun/prompts/agents/research.j2 +16 -3
  52. shotgun/prompts/agents/specify.j2 +54 -1
  53. shotgun/prompts/agents/state/system_state.j2 +0 -2
  54. shotgun/prompts/agents/tasks.j2 +16 -0
  55. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  56. shotgun/prompts/history/combine_summaries.j2 +53 -0
  57. shotgun/sdk/codebase.py +14 -3
  58. shotgun/settings.py +5 -0
  59. shotgun/shotgun_web/__init__.py +67 -1
  60. shotgun/shotgun_web/client.py +42 -1
  61. shotgun/shotgun_web/constants.py +46 -0
  62. shotgun/shotgun_web/exceptions.py +29 -0
  63. shotgun/shotgun_web/models.py +390 -0
  64. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  65. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  66. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  67. shotgun/shotgun_web/shared_specs/models.py +71 -0
  68. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  69. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  70. shotgun/shotgun_web/specs_client.py +703 -0
  71. shotgun/shotgun_web/supabase_client.py +31 -0
  72. shotgun/tui/app.py +73 -9
  73. shotgun/tui/containers.py +1 -1
  74. shotgun/tui/layout.py +5 -0
  75. shotgun/tui/screens/chat/chat_screen.py +372 -95
  76. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  77. shotgun/tui/screens/chat_screen/command_providers.py +13 -2
  78. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  79. shotgun/tui/screens/confirmation_dialog.py +40 -0
  80. shotgun/tui/screens/directory_setup.py +45 -41
  81. shotgun/tui/screens/feedback.py +10 -3
  82. shotgun/tui/screens/github_issue.py +11 -2
  83. shotgun/tui/screens/model_picker.py +28 -8
  84. shotgun/tui/screens/onboarding.py +149 -0
  85. shotgun/tui/screens/pipx_migration.py +58 -6
  86. shotgun/tui/screens/provider_config.py +66 -8
  87. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  88. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  89. shotgun/tui/screens/shared_specs/models.py +56 -0
  90. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  91. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  92. shotgun/tui/screens/shotgun_auth.py +110 -16
  93. shotgun/tui/screens/spec_pull.py +288 -0
  94. shotgun/tui/screens/welcome.py +123 -0
  95. shotgun/tui/services/conversation_service.py +5 -2
  96. shotgun/tui/widgets/widget_coordinator.py +1 -1
  97. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
  98. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
  99. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  100. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  101. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  102. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  103. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  104. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  105. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  106. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  107. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  108. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  109. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  110. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  111. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
  112. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,35 @@
1
1
  """Modal dialog for codebase indexing prompts."""
2
2
 
3
+ import os
4
+ import webbrowser
3
5
  from pathlib import Path
4
6
 
5
7
  from textual import on
6
8
  from textual.app import ComposeResult
7
- from textual.containers import Container
9
+ from textual.containers import Container, VerticalScroll
10
+ from textual.events import Resize
8
11
  from textual.screen import ModalScreen
9
- from textual.widgets import Button, Label, Static
12
+ from textual.widgets import Button, Label, Markdown, Static
13
+
14
+ from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
15
+ from shotgun.utils.file_system_utils import get_shotgun_home
16
+
17
+
18
+ def _is_home_directory() -> bool:
19
+ """Check if cwd is user's home directory.
20
+
21
+ Can be simulated with HOME_DIRECTORY_SIMULATE=true env var for testing.
22
+ """
23
+ if os.environ.get("HOME_DIRECTORY_SIMULATE", "").lower() == "true":
24
+ return True
25
+ return Path.cwd() == Path.home()
26
+
27
+
28
+ def _track_event(event_name: str) -> None:
29
+ """Track an event to PostHog."""
30
+ from shotgun.posthog_telemetry import track_event
31
+
32
+ track_event(event_name)
10
33
 
11
34
 
12
35
  class CodebaseIndexPromptScreen(ModalScreen[bool]):
@@ -19,39 +42,182 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
19
42
  }
20
43
 
21
44
  CodebaseIndexPromptScreen > #index-prompt-dialog {
22
- width: 60%;
23
- max-width: 60;
45
+ width: 80%;
46
+ max-width: 90;
24
47
  height: auto;
48
+ max-height: 85%;
25
49
  border: wide $primary;
26
50
  padding: 1 2;
27
51
  layout: vertical;
28
52
  background: $surface;
53
+ }
54
+
55
+ #index-prompt-title {
56
+ text-style: bold;
57
+ color: $text-accent;
58
+ text-align: center;
59
+ padding-bottom: 1;
60
+ }
61
+
62
+ #index-prompt-content {
29
63
  height: auto;
64
+ max-height: 1fr;
65
+ }
66
+
67
+ #index-prompt-info {
68
+ padding: 0 1;
30
69
  }
31
70
 
32
71
  #index-prompt-buttons {
33
72
  layout: horizontal;
34
73
  align-horizontal: right;
35
74
  height: auto;
75
+ padding-top: 1;
76
+ }
77
+
78
+ #index-prompt-buttons Button {
79
+ margin: 0 1;
80
+ min-width: 12;
81
+ }
82
+
83
+ #index-prompt-warning {
84
+ background: $surface-lighten-1;
85
+ color: $text;
86
+ padding: 1 2;
87
+ margin-bottom: 1;
88
+ text-align: center;
89
+ }
90
+
91
+ #compact-link {
92
+ text-align: center;
93
+ padding: 1 0;
94
+ display: none;
95
+ }
96
+
97
+ /* Compact styles for short terminals */
98
+ #index-prompt-dialog.compact {
99
+ padding: 0 1;
100
+ border: none;
101
+ max-height: 100%;
102
+ }
103
+
104
+ #index-prompt-dialog.compact #index-prompt-content {
105
+ display: none;
106
+ }
107
+
108
+ #index-prompt-dialog.compact #compact-link {
109
+ display: block;
110
+ }
111
+
112
+ #index-prompt-dialog.compact #index-prompt-warning {
113
+ padding: 0;
114
+ margin-bottom: 0;
115
+ background: transparent;
116
+ }
117
+
118
+ #index-prompt-dialog.compact #index-prompt-title {
119
+ padding-bottom: 0;
120
+ }
121
+
122
+ #index-prompt-dialog.compact #index-prompt-buttons {
123
+ padding-top: 0;
36
124
  }
37
125
  """
38
126
 
39
127
  def compose(self) -> ComposeResult:
128
+ storage_path = get_shotgun_home() / "codebases"
129
+ cwd = Path.cwd()
130
+ is_home = _is_home_directory()
131
+
40
132
  with Container(id="index-prompt-dialog"):
41
- yield Label("Index this codebase?", id="index-prompt-title")
42
- yield Static(
43
- f"Would you like to index the codebase at:\n{Path.cwd()}\n\n"
44
- "This is required for the agent to understand your code and answer "
45
- "questions about it. Without indexing, the agent cannot analyze "
46
- "your codebase."
47
- )
48
- with Container(id="index-prompt-buttons"):
49
- yield Button(
50
- "Index now",
51
- id="index-prompt-confirm",
52
- variant="primary",
133
+ if is_home:
134
+ # Show warning for home directory
135
+ yield Label(
136
+ "Home directory detected",
137
+ id="index-prompt-title",
138
+ )
139
+ yield Static(
140
+ "Running from home directory isn't recommended.",
141
+ id="index-prompt-warning",
142
+ )
143
+ with Container(id="index-prompt-buttons"):
144
+ yield Button(
145
+ "Quit",
146
+ id="index-prompt-quit",
147
+ )
148
+ yield Button(
149
+ "Continue without indexing",
150
+ id="index-prompt-continue",
151
+ )
152
+ else:
153
+ # Normal indexing prompt
154
+ content = f"""
155
+ ## 🔒 Your code never leaves your computer
156
+
157
+ Shotgun will index the codebase at:
158
+ **`{cwd}`**
159
+ _(This is the current working directory where you started Shotgun)_
160
+
161
+ ### What happens during indexing:
162
+
163
+ - **Stays on your computer**: Index is stored locally at `{storage_path}` - it will not be stored on a server
164
+ - **Zero cost**: Indexing runs entirely on your machine
165
+ - **Runs in the background**: Usually takes 1-3 minutes, and you can continue using Shotgun while it indexes
166
+ - **Enable code understanding**: Allows Shotgun to answer questions about your codebase
167
+
168
+ ---
169
+
170
+ If you're curious, you can review how Shotgun indexes/queries code by taking a look at the [source code](https://github.com/shotgun-sh/shotgun).
171
+
172
+ We take your privacy seriously. You can read our full [privacy policy](https://app.shotgun.sh/privacy) for more details.
173
+ """
174
+ yield Label(
175
+ "Want to index your codebase?",
176
+ id="index-prompt-title",
177
+ )
178
+ # Compact mode: show only a link
179
+ yield Static(
180
+ "[@click=screen.open_faq]Learn more about indexing[/]",
181
+ id="compact-link",
182
+ markup=True,
53
183
  )
54
- yield Button("Not now", id="index-prompt-cancel")
184
+ # Full mode: show detailed content
185
+ with VerticalScroll(id="index-prompt-content"):
186
+ yield Markdown(content, id="index-prompt-info")
187
+ with Container(id="index-prompt-buttons"):
188
+ yield Button(
189
+ "Not now",
190
+ id="index-prompt-cancel",
191
+ )
192
+ yield Button(
193
+ "Index now",
194
+ id="index-prompt-confirm",
195
+ variant="primary",
196
+ )
197
+
198
+ def on_mount(self) -> None:
199
+ """Track when the home directory warning screen is shown and apply compact layout."""
200
+ if _is_home_directory():
201
+ _track_event("home_directory_warning_shown")
202
+ # Apply compact layout if starting in a short terminal
203
+ self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
204
+
205
+ @on(Resize)
206
+ def handle_resize(self, event: Resize) -> None:
207
+ """Adjust layout based on terminal height."""
208
+ self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
209
+
210
+ def _apply_compact_layout(self, compact: bool) -> None:
211
+ """Apply or remove compact layout classes for short terminals."""
212
+ dialog = self.query_one("#index-prompt-dialog")
213
+ if compact:
214
+ dialog.add_class("compact")
215
+ else:
216
+ dialog.remove_class("compact")
217
+
218
+ def action_open_faq(self) -> None:
219
+ """Open the FAQ page in a browser."""
220
+ webbrowser.open("https://github.com/shotgun-sh/shotgun?tab=readme-ov-file#faq")
55
221
 
56
222
  @on(Button.Pressed, "#index-prompt-cancel")
57
223
  def handle_cancel(self, event: Button.Pressed) -> None:
@@ -62,3 +228,16 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
62
228
  def handle_confirm(self, event: Button.Pressed) -> None:
63
229
  event.stop()
64
230
  self.dismiss(True)
231
+
232
+ @on(Button.Pressed, "#index-prompt-continue")
233
+ def handle_continue(self, event: Button.Pressed) -> None:
234
+ """Continue without indexing when in home directory."""
235
+ event.stop()
236
+ _track_event("home_directory_warning_continue")
237
+ self.dismiss(False)
238
+
239
+ @on(Button.Pressed, "#index-prompt-quit")
240
+ def handle_quit(self, event: Button.Pressed) -> None:
241
+ event.stop()
242
+ _track_event("home_directory_warning_quit")
243
+ self.app.exit()
@@ -5,6 +5,7 @@ from textual.command import DiscoveryHit, Hit, Provider
5
5
 
6
6
  from shotgun.agents.models import AgentType
7
7
  from shotgun.codebase.models import CodebaseGraph
8
+ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
8
9
  from shotgun.tui.screens.model_picker import ModelPickerScreen
9
10
  from shotgun.tui.screens.provider_config import ProviderConfigScreen
10
11
 
@@ -271,8 +272,8 @@ class DeleteCodebasePaletteProvider(Provider):
271
272
  try:
272
273
  result = await self.chat_screen.codebase_sdk.list_codebases()
273
274
  except Exception as exc: # pragma: no cover - defensive UI path
274
- self.chat_screen.notify(
275
- f"Unable to load codebases: {exc}", severity="error"
275
+ self.chat_screen.agent_manager.add_hint_message(
276
+ HintMessage(message=f"Unable to load codebases: {exc}")
276
277
  )
277
278
  return []
278
279
  return result.graphs
@@ -359,6 +360,11 @@ class UnifiedCommandProvider(Provider):
359
360
  self.open_model_picker,
360
361
  help="🤖 Choose which AI model to use",
361
362
  )
363
+ yield DiscoveryHit(
364
+ "Share specs to workspace",
365
+ self.chat_screen.share_specs_command,
366
+ help="📤 Upload .shotgun/ files to share with your team",
367
+ )
362
368
  yield DiscoveryHit(
363
369
  "Show context",
364
370
  self.chat_screen.action_show_context,
@@ -411,6 +417,11 @@ class UnifiedCommandProvider(Provider):
411
417
  self.open_model_picker,
412
418
  "🤖 Choose which AI model to use",
413
419
  ),
420
+ (
421
+ "Share specs to workspace",
422
+ self.chat_screen.share_specs_command,
423
+ "📤 Upload .shotgun/ files to share with your team",
424
+ ),
414
425
  (
415
426
  "Show context",
416
427
  self.chat_screen.action_show_context,
@@ -1,14 +1,23 @@
1
1
  from typing import Literal
2
2
 
3
3
  from pydantic import BaseModel
4
+ from textual import on
4
5
  from textual.app import ComposeResult
6
+ from textual.containers import Horizontal
5
7
  from textual.widget import Widget
6
- from textual.widgets import Markdown
8
+ from textual.widgets import Button, Label, Markdown, Static
9
+
10
+ from shotgun.logging_config import get_logger
11
+
12
+ logger = get_logger(__name__)
7
13
 
8
14
 
9
15
  class HintMessage(BaseModel):
10
16
  message: str
11
17
  kind: Literal["hint"] = "hint"
18
+ # Optional email copy functionality
19
+ email: str | None = None
20
+ markdown_after: str | None = None
12
21
 
13
22
 
14
23
  class HintMessageWidget(Widget):
@@ -30,6 +39,30 @@ class HintMessageWidget(Widget):
30
39
  }
31
40
  }
32
41
 
42
+ HintMessageWidget .email-copy-row {
43
+ width: auto;
44
+ height: auto;
45
+ margin: 1 0;
46
+ }
47
+
48
+ HintMessageWidget .email-text {
49
+ width: auto;
50
+ margin-right: 1;
51
+ content-align: left middle;
52
+ }
53
+
54
+ HintMessageWidget .copy-btn {
55
+ width: auto;
56
+ min-width: 12;
57
+ }
58
+
59
+ HintMessageWidget #copy-status {
60
+ height: 1;
61
+ width: 100%;
62
+ margin-top: 1;
63
+ content-align: left middle;
64
+ }
65
+
33
66
  """
34
67
 
35
68
  def __init__(self, message: HintMessage) -> None:
@@ -37,4 +70,46 @@ class HintMessageWidget(Widget):
37
70
  self.message = message
38
71
 
39
72
  def compose(self) -> ComposeResult:
73
+ # Main message markdown
40
74
  yield Markdown(markdown=f"{self.message.message}")
75
+
76
+ # Optional email copy section
77
+ if self.message.email:
78
+ # Email + copy button on same line
79
+ with Horizontal(classes="email-copy-row"):
80
+ yield Static(f"Contact: {self.message.email}", classes="email-text")
81
+ yield Button("Copy email", id="copy-email-btn", classes="copy-btn")
82
+
83
+ # Status feedback label
84
+ yield Label("", id="copy-status")
85
+
86
+ # Optional markdown after email
87
+ if self.message.markdown_after:
88
+ yield Markdown(self.message.markdown_after)
89
+
90
+ @on(Button.Pressed, "#copy-email-btn")
91
+ def _copy_email(self) -> None:
92
+ """Copy email address to clipboard when button is pressed."""
93
+ if not self.message.email:
94
+ return
95
+
96
+ status_label = self.query_one("#copy-status", Label)
97
+
98
+ try:
99
+ import pyperclip # type: ignore[import-untyped] # noqa: PGH003
100
+
101
+ pyperclip.copy(self.message.email)
102
+ status_label.update("✓ Copied to clipboard!")
103
+ logger.debug(
104
+ f"Successfully copied email to clipboard: {self.message.email}"
105
+ )
106
+
107
+ except ImportError:
108
+ status_label.update(
109
+ f"⚠️ Clipboard unavailable. Please manually copy: {self.message.email}"
110
+ )
111
+ logger.warning("pyperclip not available for clipboard operations")
112
+
113
+ except Exception as e:
114
+ status_label.update(f"⚠️ Copy failed: {e}")
115
+ logger.error(f"Failed to copy email to clipboard: {e}", exc_info=True)
@@ -5,9 +5,12 @@ from typing import Literal
5
5
  from textual import on
6
6
  from textual.app import ComposeResult
7
7
  from textual.containers import Container
8
+ from textual.events import Resize
8
9
  from textual.screen import ModalScreen
9
10
  from textual.widgets import Button, Label, Static
10
11
 
12
+ from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
13
+
11
14
  ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
12
15
 
13
16
 
@@ -87,6 +90,20 @@ class ConfirmationDialog(ModalScreen[bool]):
87
90
  #dialog-buttons Button {
88
91
  margin-left: 1;
89
92
  }
93
+
94
+ /* Compact styles for short terminals */
95
+ #dialog-container.compact {
96
+ padding: 0 2;
97
+ max-height: 98%;
98
+ }
99
+
100
+ #dialog-title.compact {
101
+ padding-bottom: 0;
102
+ }
103
+
104
+ #dialog-message.compact {
105
+ padding-bottom: 0;
106
+ }
90
107
  """
91
108
 
92
109
  def __init__(
@@ -138,6 +155,29 @@ class ConfirmationDialog(ModalScreen[bool]):
138
155
  # Focus cancel button by default for safety
139
156
  self.query_one("#cancel", Button).focus()
140
157
 
158
+ # Apply compact layout if starting in a short terminal
159
+ self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
160
+
161
+ @on(Resize)
162
+ def handle_resize(self, event: Resize) -> None:
163
+ """Adjust layout based on terminal height."""
164
+ self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
165
+
166
+ def _apply_compact_layout(self, compact: bool) -> None:
167
+ """Apply or remove compact layout classes for short terminals."""
168
+ container = self.query_one("#dialog-container")
169
+ title = self.query_one("#dialog-title")
170
+ message = self.query_one("#dialog-message")
171
+
172
+ if compact:
173
+ container.add_class("compact")
174
+ title.add_class("compact")
175
+ message.add_class("compact")
176
+ else:
177
+ container.remove_class("compact")
178
+ title.remove_class("compact")
179
+ message.remove_class("compact")
180
+
141
181
  @on(Button.Pressed, "#cancel")
142
182
  def handle_cancel(self, event: Button.Pressed) -> None:
143
183
  """Handle cancel button press."""
@@ -1,4 +1,4 @@
1
- """Screen for setting up the local .shotgun directory."""
1
+ """Screen for displaying .shotgun directory creation errors."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,13 +8,20 @@ from textual import on
8
8
  from textual.app import ComposeResult
9
9
  from textual.containers import Horizontal, Vertical
10
10
  from textual.screen import Screen
11
- from textual.widgets import Button, Static
12
-
13
- from shotgun.utils.file_system_utils import ensure_shotgun_directory_exists
11
+ from textual.widgets import Button, Label, Static
14
12
 
15
13
 
16
14
  class DirectorySetupScreen(Screen[None]):
17
- """Prompt the user to initialize the .shotgun directory."""
15
+ """Display an error when .shotgun directory creation fails."""
16
+
17
+ def __init__(self, error_message: str) -> None:
18
+ """Initialize the error screen.
19
+
20
+ Args:
21
+ error_message: The error message to display to the user.
22
+ """
23
+ super().__init__()
24
+ self.error_message = error_message
18
25
 
19
26
  CSS = """
20
27
  DirectorySetupScreen {
@@ -56,58 +63,55 @@ class DirectorySetupScreen(Screen[None]):
56
63
  #directory-actions > * {
57
64
  margin-right: 2;
58
65
  }
66
+
67
+ #directory-status {
68
+ height: auto;
69
+ padding: 0 1;
70
+ min-height: 1;
71
+ color: $error;
72
+ text-align: center;
73
+ }
59
74
  """
60
75
 
61
76
  BINDINGS = [
62
- ("enter", "confirm", "Initialize"),
77
+ ("enter", "retry", "Retry"),
63
78
  ("escape", "cancel", "Exit"),
64
79
  ]
65
80
 
66
81
  def compose(self) -> ComposeResult:
67
82
  with Vertical(id="titlebox"):
68
- yield Static("Directory setup", id="directory-setup-title")
69
- yield Static("Shotgun keeps workspace data in a .shotgun directory.\n")
70
- yield Static("Initialize it in the current directory?\n")
71
- yield Static(f"[$foreground-muted]({Path.cwd().resolve()})[/]")
72
- with Horizontal(id="directory-actions"):
73
- yield Button(
74
- "Initialize and proceed \\[ENTER]", variant="primary", id="initialize"
83
+ yield Static(
84
+ "Failed to create .shotgun directory", id="directory-setup-title"
85
+ )
86
+ yield Static("Shotgun was unable to create the .shotgun directory in:\n")
87
+ yield Static(f"[$foreground-muted]({Path.cwd().resolve()})[/]\n")
88
+ yield Static(f"[bold red]Error:[/] {self.error_message}\n")
89
+ yield Static(
90
+ "This directory is required for storing workspace data. "
91
+ "Please check permissions and try again."
75
92
  )
76
- yield Button("Exit without setup \\[ESC]", variant="default", id="exit")
93
+ yield Label("", id="directory-status")
94
+ with Horizontal(id="directory-actions"):
95
+ yield Button("Retry \\[ENTER]", variant="primary", id="retry")
96
+ yield Button("Exit \\[ESC]", variant="default", id="exit")
77
97
 
78
98
  def on_mount(self) -> None:
79
- self.set_focus(self.query_one("#initialize", Button))
99
+ self.set_focus(self.query_one("#retry", Button))
80
100
 
81
- def action_confirm(self) -> None:
82
- self._initialize_directory()
101
+ def action_retry(self) -> None:
102
+ """Retry by dismissing the screen, which will trigger refresh_startup_screen."""
103
+ self.dismiss()
83
104
 
84
105
  def action_cancel(self) -> None:
85
- self._exit_application()
106
+ """Exit the application."""
107
+ self.app.exit()
86
108
 
87
- @on(Button.Pressed, "#initialize")
88
- def _on_initialize_pressed(self) -> None:
89
- self._initialize_directory()
109
+ @on(Button.Pressed, "#retry")
110
+ def _on_retry_pressed(self) -> None:
111
+ """Retry by dismissing the screen."""
112
+ self.dismiss()
90
113
 
91
114
  @on(Button.Pressed, "#exit")
92
115
  def _on_exit_pressed(self) -> None:
93
- self._exit_application()
94
-
95
- def _initialize_directory(self) -> None:
96
- try:
97
- path = ensure_shotgun_directory_exists()
98
- except Exception as exc: # pragma: no cover - defensive; textual path
99
- self.notify(f"Failed to initialize directory: {exc}", severity="error")
100
- return
101
-
102
- # Double-check a directory now exists; guard against unexpected filesystem state.
103
- if not path.is_dir():
104
- self.notify(
105
- "Unable to initialize .shotgun directory due to filesystem conflict.",
106
- severity="error",
107
- )
108
- return
109
-
110
- self.dismiss()
111
-
112
- def _exit_application(self) -> None:
116
+ """Exit the application."""
113
117
  self.app.exit()
@@ -76,6 +76,13 @@ class FeedbackScreen(Screen[Feedback | None]):
76
76
  #feedback-type-list {
77
77
  padding: 1;
78
78
  }
79
+
80
+ #feedback-status {
81
+ height: auto;
82
+ padding: 0 1;
83
+ min-height: 1;
84
+ color: $error;
85
+ }
79
86
  """
80
87
 
81
88
  BINDINGS = [
@@ -96,6 +103,7 @@ class FeedbackScreen(Screen[Feedback | None]):
96
103
  "",
97
104
  id="feedback-description",
98
105
  )
106
+ yield Label("", id="feedback-status")
99
107
  with Horizontal(id="feedback-actions"):
100
108
  yield Button("Submit", variant="primary", id="submit")
101
109
  yield Button("Cancel \\[ESC]", id="cancel")
@@ -176,9 +184,8 @@ class FeedbackScreen(Screen[Feedback | None]):
176
184
  description = text_area.text.strip()
177
185
 
178
186
  if not description:
179
- self.notify(
180
- "Please enter a description before submitting.", severity="error"
181
- )
187
+ status_label = self.query_one("#feedback-status", Label)
188
+ status_label.update("Please enter a description before submitting.")
182
189
  return
183
190
 
184
191
  app = cast("ShotgunApp", self.app)
@@ -6,7 +6,7 @@ from textual import on
6
6
  from textual.app import ComposeResult
7
7
  from textual.containers import Container, Vertical
8
8
  from textual.screen import ModalScreen
9
- from textual.widgets import Button, Markdown, Static
9
+ from textual.widgets import Button, Label, Markdown, Static
10
10
 
11
11
 
12
12
  class GitHubIssueScreen(ModalScreen[None]):
@@ -47,6 +47,13 @@ class GitHubIssueScreen(ModalScreen[None]):
47
47
  margin: 1 1;
48
48
  min-width: 20;
49
49
  }
50
+
51
+ #issue-status {
52
+ height: auto;
53
+ padding: 1;
54
+ min-height: 1;
55
+ text-align: center;
56
+ }
50
57
  """
51
58
 
52
59
  BINDINGS = [
@@ -85,6 +92,7 @@ We review all issues and will respond as soon as possible!
85
92
  id="issue-markdown",
86
93
  )
87
94
  with Vertical(id="issue-buttons"):
95
+ yield Label("", id="issue-status")
88
96
  yield Button(
89
97
  "🐙 Open GitHub Issues", id="github-button", variant="primary"
90
98
  )
@@ -94,7 +102,8 @@ We review all issues and will respond as soon as possible!
94
102
  def handle_github(self) -> None:
95
103
  """Open GitHub issues page in browser."""
96
104
  webbrowser.open("https://github.com/shotgun-sh/shotgun/issues")
97
- self.notify("Opening GitHub Issues in your browser...")
105
+ status_label = self.query_one("#issue-status", Label)
106
+ status_label.update("✓ Opening GitHub Issues in your browser...")
98
107
 
99
108
  @on(Button.Pressed, "#close-button")
100
109
  def handle_close(self) -> None: