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,6 +1,7 @@
1
1
  """Shotgun Account authentication screen."""
2
2
 
3
3
  import asyncio
4
+ import time
4
5
  import webbrowser
5
6
  from typing import TYPE_CHECKING, cast
6
7
 
@@ -8,17 +9,20 @@ import httpx
8
9
  from textual import on
9
10
  from textual.app import ComposeResult
10
11
  from textual.containers import Vertical
12
+ from textual.events import Resize
11
13
  from textual.screen import Screen
12
14
  from textual.widgets import Button, Label, Markdown, Static
13
15
  from textual.worker import Worker, WorkerState
14
16
 
15
17
  from shotgun.agents.config import ConfigManager
16
18
  from shotgun.logging_config import get_logger
19
+ from shotgun.posthog_telemetry import track_event
17
20
  from shotgun.shotgun_web import (
18
21
  ShotgunWebClient,
19
22
  TokenStatus,
20
23
  )
21
24
  from shotgun.shotgun_web.constants import DEFAULT_POLL_INTERVAL_SECONDS
25
+ from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
22
26
 
23
27
  if TYPE_CHECKING:
24
28
  from ..app import ShotgunApp
@@ -76,25 +80,52 @@ class ShotgunAuthScreen(Screen[bool]):
76
80
  padding: 1;
77
81
  align: center middle;
78
82
  }
83
+
84
+ /* Compact styles for short terminals */
85
+ ShotgunAuthScreen.compact #titlebox {
86
+ margin: 0;
87
+ padding: 0;
88
+ border: none;
89
+ }
90
+
91
+ ShotgunAuthScreen.compact #auth-subtitle {
92
+ display: none;
93
+ }
94
+
95
+ ShotgunAuthScreen.compact #content {
96
+ padding: 0;
97
+ }
98
+
99
+ ShotgunAuthScreen.compact #instructions {
100
+ display: none;
101
+ }
102
+
103
+ ShotgunAuthScreen.compact #actions {
104
+ padding: 0;
105
+ }
79
106
  """
80
107
 
81
108
  BINDINGS = [
82
109
  ("escape", "cancel", "Cancel"),
83
110
  ]
84
111
 
85
- def __init__(self) -> None:
112
+ def __init__(
113
+ self,
114
+ title: str = "Shotgun Account Setup",
115
+ subtitle: str = "Authenticate with your Shotgun Account to get started",
116
+ ) -> None:
86
117
  super().__init__()
118
+ self._title = title
119
+ self._subtitle = subtitle
87
120
  self.token: str | None = None
88
121
  self.auth_url: str | None = None
89
122
  self.poll_worker: Worker[None] | None = None
123
+ self._auth_start_time: float | None = None
90
124
 
91
125
  def compose(self) -> ComposeResult:
92
126
  with Vertical(id="titlebox"):
93
- yield Static("Shotgun Account Setup", id="auth-title")
94
- yield Static(
95
- "Authenticate with your Shotgun Account to get started",
96
- id="auth-subtitle",
97
- )
127
+ yield Static(self._title, id="auth-title")
128
+ yield Static(self._subtitle, id="auth-subtitle")
98
129
 
99
130
  with Vertical(id="content"):
100
131
  yield Label("Initializing...", id="status")
@@ -113,10 +144,24 @@ class ShotgunAuthScreen(Screen[bool]):
113
144
 
114
145
  def on_mount(self) -> None:
115
146
  """Start authentication flow when screen is mounted."""
147
+ self._apply_layout_for_height(self.app.size.height)
116
148
  self.run_worker(self._start_auth_flow(), exclusive=True)
117
149
 
150
+ @on(Resize)
151
+ def handle_resize(self, event: Resize) -> None:
152
+ """Adjust layout based on terminal height."""
153
+ self._apply_layout_for_height(event.size.height)
154
+
155
+ def _apply_layout_for_height(self, height: int) -> None:
156
+ """Apply appropriate layout based on terminal height."""
157
+ if height < COMPACT_HEIGHT_THRESHOLD:
158
+ self.add_class("compact")
159
+ else:
160
+ self.remove_class("compact")
161
+
118
162
  def action_cancel(self) -> None:
119
163
  """Cancel authentication and close screen."""
164
+ track_event("auth_cancelled")
120
165
  if self.poll_worker and self.poll_worker.state == WorkerState.RUNNING:
121
166
  self.poll_worker.cancel()
122
167
  self.dismiss(False)
@@ -133,6 +178,9 @@ class ShotgunAuthScreen(Screen[bool]):
133
178
 
134
179
  async def _start_auth_flow(self) -> None:
135
180
  """Start the authentication flow."""
181
+ self._auth_start_time = time.time()
182
+ track_event("auth_started")
183
+
136
184
  try:
137
185
  # Get shotgun instance ID from config
138
186
  shotgun_instance_id = await self.config_manager.get_shotgun_instance_id()
@@ -179,15 +227,21 @@ class ShotgunAuthScreen(Screen[bool]):
179
227
 
180
228
  except httpx.HTTPError as e:
181
229
  logger.error("Failed to create auth token: %s", e)
230
+ track_event(
231
+ "auth_failed",
232
+ {"phase": "token_creation", "error_type": type(e).__name__},
233
+ )
182
234
  self.query_one("#status", Label).update(
183
235
  f"❌ Error: Failed to create authentication token\n{e}"
184
236
  )
185
- self.notify("Failed to start authentication", severity="error")
186
237
 
187
238
  except Exception as e:
188
239
  logger.error("Unexpected error during auth flow: %s", e)
240
+ track_event(
241
+ "auth_failed",
242
+ {"phase": "token_creation", "error_type": type(e).__name__},
243
+ )
189
244
  self.query_one("#status", Label).update(f"❌ Unexpected error: {e}")
190
- self.notify("Authentication failed", severity="error")
191
245
 
192
246
  async def _poll_token_status(self) -> None:
193
247
  """Poll token status until completed or expired."""
@@ -215,26 +269,49 @@ class ShotgunAuthScreen(Screen[bool]):
215
269
  logger.info("Authentication completed successfully")
216
270
 
217
271
  if status_response.litellm_key and status_response.supabase_key:
272
+ # Fetch user info to get workspace_id
273
+ workspace_id: str | None = None
274
+ try:
275
+ me_response = client.get_me(status_response.supabase_key)
276
+ workspace_id = me_response.workspace.id
277
+ logger.info("Fetched workspace_id: %s", workspace_id)
278
+ except Exception as e:
279
+ # Log warning but continue - workspace_id can be fetched later
280
+ logger.warning(
281
+ "Failed to fetch workspace_id from /api/me: %s", e
282
+ )
283
+
218
284
  await self.config_manager.update_shotgun_account(
219
285
  api_key=status_response.litellm_key,
220
286
  supabase_jwt=status_response.supabase_key,
287
+ workspace_id=workspace_id,
288
+ )
289
+
290
+ # Track successful auth
291
+ duration = (
292
+ time.time() - self._auth_start_time
293
+ if self._auth_start_time
294
+ else 0
295
+ )
296
+ track_event(
297
+ "auth_completed",
298
+ {"duration_seconds": round(duration, 2)},
221
299
  )
222
300
 
223
301
  self.query_one("#status", Label).update(
224
302
  "✅ Authentication successful! Saving credentials..."
225
303
  )
226
304
  await asyncio.sleep(1)
227
- self.notify(
228
- "Shotgun Account configured successfully!",
229
- severity="information",
230
- )
231
305
  self.dismiss(True)
232
306
  else:
233
307
  logger.error("Completed but missing keys")
308
+ track_event(
309
+ "auth_failed",
310
+ {"phase": "polling", "error_type": "MissingKeys"},
311
+ )
234
312
  self.query_one("#status", Label).update(
235
313
  "❌ Error: Authentication completed but keys are missing"
236
314
  )
237
- self.notify("Authentication failed", severity="error")
238
315
  await asyncio.sleep(3)
239
316
  self.dismiss(False)
240
317
  return
@@ -246,11 +323,14 @@ class ShotgunAuthScreen(Screen[bool]):
246
323
 
247
324
  elif status_response.status == TokenStatus.EXPIRED:
248
325
  logger.error("Token expired")
326
+ track_event(
327
+ "auth_failed",
328
+ {"phase": "token_expired", "error_type": "TokenExpired"},
329
+ )
249
330
  self.query_one("#status", Label).update(
250
331
  "❌ Authentication token expired (30 minutes)\n"
251
332
  "Please try again."
252
333
  )
253
- self.notify("Authentication token expired", severity="error")
254
334
  await asyncio.sleep(3)
255
335
  self.dismiss(False)
256
336
  return
@@ -266,15 +346,22 @@ class ShotgunAuthScreen(Screen[bool]):
266
346
  if e.response.status_code == 410:
267
347
  # Token expired
268
348
  logger.error("Token expired (410)")
349
+ track_event(
350
+ "auth_failed",
351
+ {"phase": "token_expired", "error_type": "TokenExpired"},
352
+ )
269
353
  self.query_one("#status", Label).update(
270
354
  "❌ Authentication token expired"
271
355
  )
272
- self.notify("Authentication token expired", severity="error")
273
356
  await asyncio.sleep(3)
274
357
  self.dismiss(False)
275
358
  return
276
359
  else:
277
360
  logger.error("HTTP error polling status: %s", e)
361
+ track_event(
362
+ "auth_failed",
363
+ {"phase": "polling_error", "error_type": type(e).__name__},
364
+ )
278
365
  self.query_one("#status", Label).update(
279
366
  f"❌ Error checking status: {e}"
280
367
  )
@@ -282,14 +369,21 @@ class ShotgunAuthScreen(Screen[bool]):
282
369
 
283
370
  except Exception as e:
284
371
  logger.error("Error polling token status: %s", e)
372
+ track_event(
373
+ "auth_failed",
374
+ {"phase": "polling_error", "error_type": type(e).__name__},
375
+ )
285
376
  self.query_one("#status", Label).update(f"⚠️ Error checking status: {e}")
286
377
  await asyncio.sleep(5) # Wait a bit longer on error
287
378
 
288
379
  # Timeout reached
289
380
  logger.error("Polling timeout reached")
381
+ track_event(
382
+ "auth_failed",
383
+ {"phase": "timeout", "error_type": "Timeout"},
384
+ )
290
385
  self.query_one("#status", Label).update(
291
386
  "❌ Authentication timeout (30 minutes)\nPlease try again."
292
387
  )
293
- self.notify("Authentication timeout", severity="error")
294
388
  await asyncio.sleep(3)
295
389
  self.dismiss(False)
@@ -0,0 +1,288 @@
1
+ """Screen showing download progress for pulling specs."""
2
+
3
+ from textual import on, work
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Container, Horizontal
6
+ from textual.events import Resize
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Label, ProgressBar, Static
9
+ from textual.worker import Worker, get_current_worker
10
+
11
+ from shotgun.cli.spec.models import PullSource
12
+ from shotgun.cli.spec.pull_service import (
13
+ CancelledError,
14
+ PullProgress,
15
+ SpecPullService,
16
+ )
17
+ from shotgun.logging_config import get_logger
18
+ from shotgun.shotgun_web.exceptions import (
19
+ ForbiddenError,
20
+ NotFoundError,
21
+ UnauthorizedError,
22
+ )
23
+ from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
24
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ class SpecPullScreen(ModalScreen[bool]):
30
+ """Screen to pull a spec version with progress display.
31
+
32
+ Returns True if pull was successful, False otherwise.
33
+ """
34
+
35
+ DEFAULT_CSS = """
36
+ SpecPullScreen {
37
+ align: center middle;
38
+ background: rgba(0, 0, 0, 0.0);
39
+ }
40
+
41
+ SpecPullScreen > #dialog-container {
42
+ width: 80%;
43
+ max-width: 90;
44
+ height: auto;
45
+ border: wide $primary;
46
+ padding: 1 2;
47
+ layout: vertical;
48
+ background: $surface;
49
+ }
50
+
51
+ #dialog-title {
52
+ text-style: bold;
53
+ color: $text-accent;
54
+ padding-bottom: 1;
55
+ text-align: center;
56
+ }
57
+
58
+ #phase-label {
59
+ padding: 0;
60
+ }
61
+
62
+ #progress-bar {
63
+ width: 100%;
64
+ padding: 0;
65
+ }
66
+
67
+ #file-label {
68
+ color: $text-muted;
69
+ }
70
+
71
+ #error-label {
72
+ color: $error;
73
+ }
74
+
75
+ #success-label {
76
+ color: $success;
77
+ text-style: bold;
78
+ }
79
+
80
+ #dialog-buttons {
81
+ layout: horizontal;
82
+ align-horizontal: center;
83
+ height: auto;
84
+ }
85
+
86
+ #dialog-buttons Button {
87
+ margin: 0 1;
88
+ }
89
+
90
+ /* Hide elements initially */
91
+ #success-label {
92
+ display: none;
93
+ }
94
+
95
+ #error-label {
96
+ display: none;
97
+ }
98
+
99
+ /* Compact styles for short terminals */
100
+ SpecPullScreen.compact #dialog-container {
101
+ padding: 0 2;
102
+ max-height: 98%;
103
+ }
104
+
105
+ SpecPullScreen.compact #dialog-title {
106
+ padding-bottom: 0;
107
+ }
108
+ """
109
+
110
+ BINDINGS = [
111
+ ("escape", "cancel", "Cancel"),
112
+ ]
113
+
114
+ def __init__(self, version_id: str) -> None:
115
+ """Initialize the screen.
116
+
117
+ Args:
118
+ version_id: Version UUID to pull.
119
+ """
120
+ super().__init__()
121
+ self.version_id = version_id
122
+ self._success = False
123
+ self._download_worker: Worker[None] | None = None
124
+ self._cancelled = False
125
+
126
+ def compose(self) -> ComposeResult:
127
+ """Compose the screen widgets."""
128
+ with Container(id="dialog-container"):
129
+ yield Label("Pulling spec from cloud", id="dialog-title")
130
+
131
+ # Progress section
132
+ yield Static("Fetching version info...", id="phase-label")
133
+ yield ProgressBar(total=100, id="progress-bar")
134
+ yield Static("", id="file-label")
135
+
136
+ # Error section (hidden by default)
137
+ yield Static("", id="error-label")
138
+
139
+ # Success section (hidden by default)
140
+ yield Static("Spec pulled successfully!", id="success-label")
141
+
142
+ # Buttons
143
+ with Horizontal(id="dialog-buttons"):
144
+ yield Button("Cancel", id="cancel-btn")
145
+ yield Button("Continue", variant="primary", id="done-btn")
146
+
147
+ def on_mount(self) -> None:
148
+ """Start the download when screen is mounted."""
149
+ # Hide done button initially
150
+ self.query_one("#done-btn", Button).display = False
151
+
152
+ # Apply compact layout if starting in a short terminal
153
+ self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
154
+
155
+ # Start the download
156
+ self._start_download()
157
+
158
+ @on(Resize)
159
+ def handle_resize(self, event: Resize) -> None:
160
+ """Adjust layout based on terminal height."""
161
+ self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
162
+
163
+ def _apply_compact_layout(self, compact: bool) -> None:
164
+ """Apply or remove compact layout class for short terminals."""
165
+ if compact:
166
+ self.add_class("compact")
167
+ else:
168
+ self.remove_class("compact")
169
+
170
+ @work(exclusive=True)
171
+ async def _start_download(self) -> None:
172
+ """Run the download pipeline."""
173
+ worker = get_current_worker()
174
+ self._download_worker = worker
175
+
176
+ shotgun_dir = get_shotgun_base_path()
177
+ service = SpecPullService()
178
+
179
+ def on_progress(p: PullProgress) -> None:
180
+ pct = 0.0
181
+ if p.total_files and p.file_index is not None:
182
+ pct = ((p.file_index + 1) / p.total_files) * 100
183
+ self._update_phase(p.phase, progress=pct, current_file=p.current_file)
184
+
185
+ try:
186
+ result = await service.pull_version(
187
+ version_id=self.version_id,
188
+ shotgun_dir=shotgun_dir,
189
+ on_progress=on_progress,
190
+ is_cancelled=lambda: worker.is_cancelled,
191
+ source=PullSource.TUI,
192
+ )
193
+
194
+ if result.success:
195
+ self._update_title(f"Pulled: {result.spec_name}")
196
+ self._success = True
197
+ self._show_success()
198
+ else:
199
+ self._show_error(result.error or "Unknown error")
200
+
201
+ except CancelledError:
202
+ self._cancelled = True
203
+ self._show_cancelled()
204
+ except UnauthorizedError:
205
+ self._show_error("Not authenticated. Please try again.")
206
+ except NotFoundError:
207
+ self._show_error(f"Version not found: {self.version_id}")
208
+ except ForbiddenError:
209
+ self._show_error("You don't have access to this spec.")
210
+ except Exception as e:
211
+ logger.exception(f"Download failed: {type(e).__name__}: {e}")
212
+ error_msg = str(e) if str(e) else type(e).__name__
213
+ self._show_error(error_msg)
214
+
215
+ def _update_title(self, title: str) -> None:
216
+ """Update the dialog title."""
217
+ self.query_one("#dialog-title", Label).update(title)
218
+
219
+ def _update_phase(
220
+ self,
221
+ phase_text: str,
222
+ progress: float = 0,
223
+ current_file: str | None = None,
224
+ ) -> None:
225
+ """Update the progress UI."""
226
+ self.query_one("#phase-label", Static).update(phase_text)
227
+ self.query_one("#progress-bar", ProgressBar).update(progress=progress)
228
+
229
+ file_label = self.query_one("#file-label", Static)
230
+ if current_file:
231
+ file_label.update(f"Current: {current_file}")
232
+ else:
233
+ file_label.update("")
234
+
235
+ def _show_success(self) -> None:
236
+ """Show success state."""
237
+ self.query_one("#phase-label", Static).update("Download complete!")
238
+ self.query_one("#progress-bar", ProgressBar).update(progress=100)
239
+ self.query_one("#success-label", Static).display = True
240
+ self.query_one("#cancel-btn", Button).display = False
241
+ self.query_one("#done-btn", Button).display = True
242
+
243
+ def _show_error(self, error: str) -> None:
244
+ """Show error state."""
245
+ error_label = self.query_one("#error-label", Static)
246
+ error_label.update(f"Error: {error}")
247
+ error_label.display = True
248
+ self.query_one("#cancel-btn", Button).display = False
249
+ self.query_one("#done-btn", Button).display = True
250
+ self.query_one("#done-btn", Button).label = "Close"
251
+
252
+ def _show_cancelled(self) -> None:
253
+ """Show cancelled state."""
254
+ self.query_one("#phase-label", Static).update("Download cancelled")
255
+ self.query_one("#cancel-btn", Button).display = False
256
+ self.query_one("#done-btn", Button).display = True
257
+ self.query_one("#done-btn", Button).label = "Close"
258
+
259
+ @on(Button.Pressed, "#cancel-btn")
260
+ def _on_cancel(self, event: Button.Pressed) -> None:
261
+ """Handle cancel button."""
262
+ event.stop()
263
+ self._cancel_download()
264
+
265
+ @on(Button.Pressed, "#done-btn")
266
+ def _on_done(self, event: Button.Pressed) -> None:
267
+ """Handle done button."""
268
+ event.stop()
269
+ self.dismiss(self._success)
270
+
271
+ def action_cancel(self) -> None:
272
+ """Handle escape key."""
273
+ if (
274
+ self._success
275
+ or self._cancelled
276
+ or self.query_one("#error-label", Static).display
277
+ ):
278
+ # Already finished, just dismiss
279
+ self.dismiss(self._success)
280
+ else:
281
+ # Download in progress, cancel it
282
+ self._cancel_download()
283
+
284
+ def _cancel_download(self) -> None:
285
+ """Cancel the download."""
286
+ if self._download_worker and not self._download_worker.is_cancelled:
287
+ self._cancelled = True
288
+ self._download_worker.cancel()