shotgun-sh 0.2.17__py3-none-any.whl → 0.3.3.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shotgun/agents/agent_manager.py +28 -14
- shotgun/agents/common.py +1 -1
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +323 -53
- shotgun/agents/config/models.py +85 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/ingestor.py +153 -7
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +73 -9
- shotgun/tui/containers.py +1 -1
- shotgun/tui/layout.py +5 -0
- shotgun/tui/screens/chat/chat_screen.py +372 -95
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -2
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +149 -0
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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__(
|
|
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(
|
|
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()
|