shotgun-sh 0.2.1.dev4__py3-none-any.whl → 0.2.1.dev5__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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/config/constants.py +2 -1
- shotgun/agents/config/manager.py +68 -13
- shotgun/agents/config/models.py +11 -2
- shotgun/cli/config.py +6 -6
- shotgun/cli/feedback.py +4 -2
- shotgun/codebase/core/ingestor.py +25 -5
- shotgun/posthog_telemetry.py +10 -8
- shotgun/sentry_telemetry.py +3 -3
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +17 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +7 -4
- shotgun/tui/app.py +21 -7
- shotgun/tui/screens/feedback.py +2 -2
- shotgun/tui/screens/provider_config.py +61 -2
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +176 -0
- {shotgun_sh-0.2.1.dev4.dist-info → shotgun_sh-0.2.1.dev5.dist-info}/METADATA +1 -1
- {shotgun_sh-0.2.1.dev4.dist-info → shotgun_sh-0.2.1.dev5.dist-info}/RECORD +23 -17
- {shotgun_sh-0.2.1.dev4.dist-info → shotgun_sh-0.2.1.dev5.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.1.dev4.dist-info → shotgun_sh-0.2.1.dev5.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.1.dev4.dist-info → shotgun_sh-0.2.1.dev5.dist-info}/licenses/LICENSE +0 -0
|
@@ -85,6 +85,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
85
85
|
|
|
86
86
|
BINDINGS = [
|
|
87
87
|
("escape", "done", "Back"),
|
|
88
|
+
("ctrl+c", "app.quit", "Quit"),
|
|
88
89
|
]
|
|
89
90
|
|
|
90
91
|
selected_provider: reactive[str] = reactive("openai")
|
|
@@ -108,15 +109,20 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
108
109
|
)
|
|
109
110
|
with Horizontal(id="provider-actions"):
|
|
110
111
|
yield Button("Save key \\[ENTER]", variant="primary", id="save")
|
|
112
|
+
yield Button("Authenticate", variant="success", id="authenticate")
|
|
111
113
|
yield Button("Clear key", id="clear", variant="warning")
|
|
112
114
|
yield Button("Done \\[ESC]", id="done")
|
|
113
115
|
|
|
114
116
|
def on_mount(self) -> None:
|
|
115
117
|
self.refresh_provider_status()
|
|
118
|
+
self._update_done_button_visibility()
|
|
116
119
|
list_view = self.query_one(ListView)
|
|
117
120
|
if list_view.children:
|
|
118
121
|
list_view.index = 0
|
|
119
122
|
self.selected_provider = "openai"
|
|
123
|
+
|
|
124
|
+
# Hide authenticate button by default (shown only for shotgun)
|
|
125
|
+
self.query_one("#authenticate", Button).display = False
|
|
120
126
|
self.set_focus(self.query_one("#api-key", Input))
|
|
121
127
|
|
|
122
128
|
def on_screenresume(self) -> None:
|
|
@@ -125,6 +131,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
125
131
|
This ensures the UI reflects any provider changes made elsewhere.
|
|
126
132
|
"""
|
|
127
133
|
self.refresh_provider_status()
|
|
134
|
+
self._update_done_button_visibility()
|
|
128
135
|
|
|
129
136
|
def action_done(self) -> None:
|
|
130
137
|
self.dismiss()
|
|
@@ -146,6 +153,10 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
146
153
|
def _on_save_pressed(self) -> None:
|
|
147
154
|
self._save_api_key()
|
|
148
155
|
|
|
156
|
+
@on(Button.Pressed, "#authenticate")
|
|
157
|
+
def _on_authenticate_pressed(self) -> None:
|
|
158
|
+
self.run_worker(self._start_shotgun_auth(), exclusive=True)
|
|
159
|
+
|
|
149
160
|
@on(Button.Pressed, "#clear")
|
|
150
161
|
def _on_clear_pressed(self) -> None:
|
|
151
162
|
self._clear_api_key()
|
|
@@ -162,9 +173,31 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
162
173
|
def watch_selected_provider(self, provider: ProviderType) -> None:
|
|
163
174
|
if not self.is_mounted:
|
|
164
175
|
return
|
|
176
|
+
|
|
177
|
+
# Show/hide UI elements based on provider type
|
|
178
|
+
is_shotgun = provider == "shotgun"
|
|
179
|
+
|
|
165
180
|
input_widget = self.query_one("#api-key", Input)
|
|
166
|
-
|
|
167
|
-
|
|
181
|
+
save_button = self.query_one("#save", Button)
|
|
182
|
+
auth_button = self.query_one("#authenticate", Button)
|
|
183
|
+
|
|
184
|
+
if is_shotgun:
|
|
185
|
+
# Hide API key input and save button
|
|
186
|
+
input_widget.display = False
|
|
187
|
+
save_button.display = False
|
|
188
|
+
|
|
189
|
+
# Only show Authenticate button if shotgun is NOT already configured
|
|
190
|
+
if self._has_provider_key("shotgun"):
|
|
191
|
+
auth_button.display = False
|
|
192
|
+
else:
|
|
193
|
+
auth_button.display = True
|
|
194
|
+
else:
|
|
195
|
+
# Show API key input and save button, hide authenticate button
|
|
196
|
+
input_widget.display = True
|
|
197
|
+
save_button.display = True
|
|
198
|
+
auth_button.display = False
|
|
199
|
+
input_widget.placeholder = self._input_placeholder(provider)
|
|
200
|
+
input_widget.value = ""
|
|
168
201
|
|
|
169
202
|
@property
|
|
170
203
|
def config_manager(self) -> ConfigManager:
|
|
@@ -177,6 +210,12 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
177
210
|
label = self.query_one(f"#label-{provider_id}", Label)
|
|
178
211
|
label.update(self._provider_label(provider_id))
|
|
179
212
|
|
|
213
|
+
def _update_done_button_visibility(self) -> None:
|
|
214
|
+
"""Show/hide Done button based on whether any provider keys are configured."""
|
|
215
|
+
done_button = self.query_one("#done", Button)
|
|
216
|
+
has_keys = self.config_manager.has_any_provider_key()
|
|
217
|
+
done_button.display = has_keys
|
|
218
|
+
|
|
180
219
|
def _build_provider_items(self) -> list[ListItem]:
|
|
181
220
|
items: list[ListItem] = []
|
|
182
221
|
for provider_id in get_configurable_providers():
|
|
@@ -242,6 +281,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
242
281
|
|
|
243
282
|
input_widget.value = ""
|
|
244
283
|
self.refresh_provider_status()
|
|
284
|
+
self._update_done_button_visibility()
|
|
245
285
|
self.notify(
|
|
246
286
|
f"Saved API key for {self._provider_display_name(self.selected_provider)}."
|
|
247
287
|
)
|
|
@@ -254,7 +294,26 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
254
294
|
return
|
|
255
295
|
|
|
256
296
|
self.refresh_provider_status()
|
|
297
|
+
self._update_done_button_visibility()
|
|
257
298
|
self.query_one("#api-key", Input).value = ""
|
|
299
|
+
|
|
300
|
+
# If we just cleared shotgun, show the Authenticate button
|
|
301
|
+
if self.selected_provider == "shotgun":
|
|
302
|
+
auth_button = self.query_one("#authenticate", Button)
|
|
303
|
+
auth_button.display = True
|
|
304
|
+
|
|
258
305
|
self.notify(
|
|
259
306
|
f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
|
|
260
307
|
)
|
|
308
|
+
|
|
309
|
+
async def _start_shotgun_auth(self) -> None:
|
|
310
|
+
"""Launch Shotgun Account authentication flow."""
|
|
311
|
+
from .shotgun_auth import ShotgunAuthScreen
|
|
312
|
+
|
|
313
|
+
# Push the auth screen and wait for result
|
|
314
|
+
result = await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
315
|
+
|
|
316
|
+
# Refresh provider status after auth completes
|
|
317
|
+
if result:
|
|
318
|
+
self.refresh_provider_status()
|
|
319
|
+
# Notify handled by auth screen
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Shotgun Account authentication screen."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Button, Label, Markdown, Static
|
|
13
|
+
from textual.worker import Worker, WorkerState
|
|
14
|
+
|
|
15
|
+
from shotgun.agents.config import ConfigManager
|
|
16
|
+
from shotgun.logging_config import get_logger
|
|
17
|
+
from shotgun.shotgun_web import (
|
|
18
|
+
ShotgunWebClient,
|
|
19
|
+
TokenStatus,
|
|
20
|
+
)
|
|
21
|
+
from shotgun.shotgun_web.constants import DEFAULT_POLL_INTERVAL_SECONDS
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ..app import ShotgunApp
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ShotgunAuthScreen(Screen[bool]):
|
|
30
|
+
"""Screen for Shotgun Account authentication flow.
|
|
31
|
+
|
|
32
|
+
Returns True if authentication was successful, False otherwise.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
CSS = """
|
|
36
|
+
ShotgunAuth {
|
|
37
|
+
layout: vertical;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#titlebox {
|
|
41
|
+
height: auto;
|
|
42
|
+
margin: 2 0;
|
|
43
|
+
padding: 1;
|
|
44
|
+
border: hkey $border;
|
|
45
|
+
content-align: center middle;
|
|
46
|
+
|
|
47
|
+
& > * {
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#auth-title {
|
|
53
|
+
padding: 1 0;
|
|
54
|
+
text-style: bold;
|
|
55
|
+
color: $text-accent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#content {
|
|
59
|
+
padding: 2;
|
|
60
|
+
height: auto;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#status {
|
|
64
|
+
padding: 1 0;
|
|
65
|
+
text-align: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#auth-url {
|
|
69
|
+
padding: 1;
|
|
70
|
+
border: solid $primary;
|
|
71
|
+
background: $surface;
|
|
72
|
+
text-align: center;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#actions {
|
|
76
|
+
padding: 1;
|
|
77
|
+
align: center middle;
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
BINDINGS = [
|
|
82
|
+
("escape", "cancel", "Cancel"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
self.token: str | None = None
|
|
88
|
+
self.auth_url: str | None = None
|
|
89
|
+
self.poll_worker: Worker[None] | None = None
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
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
|
+
)
|
|
98
|
+
|
|
99
|
+
with Vertical(id="content"):
|
|
100
|
+
yield Label("Initializing...", id="status")
|
|
101
|
+
yield Markdown("", id="auth-url")
|
|
102
|
+
yield Markdown(
|
|
103
|
+
"**Instructions:**\n"
|
|
104
|
+
"1. A browser window will open automatically\n"
|
|
105
|
+
"2. Sign in or create a Shotgun Account\n"
|
|
106
|
+
"3. Complete payment if required\n"
|
|
107
|
+
"4. This window will automatically detect completion",
|
|
108
|
+
id="instructions",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
with Vertical(id="actions"):
|
|
112
|
+
yield Button("Cancel", variant="default", id="cancel")
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
"""Start authentication flow when screen is mounted."""
|
|
116
|
+
self.run_worker(self._start_auth_flow(), exclusive=True)
|
|
117
|
+
|
|
118
|
+
def action_cancel(self) -> None:
|
|
119
|
+
"""Cancel authentication and close screen."""
|
|
120
|
+
if self.poll_worker and self.poll_worker.state == WorkerState.RUNNING:
|
|
121
|
+
self.poll_worker.cancel()
|
|
122
|
+
self.dismiss(False)
|
|
123
|
+
|
|
124
|
+
@on(Button.Pressed, "#cancel")
|
|
125
|
+
def _on_cancel_pressed(self) -> None:
|
|
126
|
+
"""Handle cancel button press."""
|
|
127
|
+
self.action_cancel()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def config_manager(self) -> ConfigManager:
|
|
131
|
+
app = cast("ShotgunApp", self.app)
|
|
132
|
+
return app.config_manager
|
|
133
|
+
|
|
134
|
+
async def _start_auth_flow(self) -> None:
|
|
135
|
+
"""Start the authentication flow."""
|
|
136
|
+
try:
|
|
137
|
+
# Get shotgun instance ID from config
|
|
138
|
+
shotgun_instance_id = self.config_manager.get_shotgun_instance_id()
|
|
139
|
+
logger.info("Starting auth flow with instance ID: %s", shotgun_instance_id)
|
|
140
|
+
|
|
141
|
+
# Update status
|
|
142
|
+
self.query_one("#status", Label).update(
|
|
143
|
+
"🔄 Creating authentication token..."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Create unification token
|
|
147
|
+
client = ShotgunWebClient()
|
|
148
|
+
response = client.create_unification_token(shotgun_instance_id)
|
|
149
|
+
|
|
150
|
+
self.token = response.token
|
|
151
|
+
self.auth_url = response.auth_url
|
|
152
|
+
|
|
153
|
+
logger.info("Auth URL: %s", self.auth_url)
|
|
154
|
+
|
|
155
|
+
# Update UI with auth URL
|
|
156
|
+
self.query_one("#status", Label).update("✅ Authentication URL ready")
|
|
157
|
+
self.query_one("#auth-url", Markdown).update(
|
|
158
|
+
f"**Authentication URL:**\n\n[{self.auth_url}]({self.auth_url})"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Try to open browser
|
|
162
|
+
try:
|
|
163
|
+
self.query_one("#status", Label).update("🌐 Opening browser...")
|
|
164
|
+
webbrowser.open(self.auth_url)
|
|
165
|
+
await asyncio.sleep(1)
|
|
166
|
+
self.query_one("#status", Label).update(
|
|
167
|
+
"⏳ Waiting for authentication... (opened in browser)"
|
|
168
|
+
)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.warning("Failed to open browser: %s", e)
|
|
171
|
+
self.query_one("#status", Label).update(
|
|
172
|
+
"⚠️ Please click the link above to authenticate"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Start polling for status
|
|
176
|
+
self.poll_worker = self.run_worker(
|
|
177
|
+
self._poll_token_status(), exclusive=False
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
except httpx.HTTPError as e:
|
|
181
|
+
logger.error("Failed to create auth token: %s", e)
|
|
182
|
+
self.query_one("#status", Label).update(
|
|
183
|
+
f"❌ Error: Failed to create authentication token\n{e}"
|
|
184
|
+
)
|
|
185
|
+
self.notify("Failed to start authentication", severity="error")
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error("Unexpected error during auth flow: %s", e)
|
|
189
|
+
self.query_one("#status", Label).update(f"❌ Unexpected error: {e}")
|
|
190
|
+
self.notify("Authentication failed", severity="error")
|
|
191
|
+
|
|
192
|
+
async def _poll_token_status(self) -> None:
|
|
193
|
+
"""Poll token status until completed or expired."""
|
|
194
|
+
if not self.token:
|
|
195
|
+
logger.error("No token available for polling")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
client = ShotgunWebClient()
|
|
199
|
+
poll_count = 0
|
|
200
|
+
max_polls = 600 # 30 minutes with 3 second intervals
|
|
201
|
+
|
|
202
|
+
while poll_count < max_polls:
|
|
203
|
+
try:
|
|
204
|
+
await asyncio.sleep(DEFAULT_POLL_INTERVAL_SECONDS)
|
|
205
|
+
poll_count += 1
|
|
206
|
+
|
|
207
|
+
logger.debug(
|
|
208
|
+
"Polling token status (attempt %d/%d)", poll_count, max_polls
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
status_response = client.check_token_status(self.token)
|
|
212
|
+
|
|
213
|
+
if status_response.status == TokenStatus.COMPLETED:
|
|
214
|
+
# Success! Save keys and dismiss
|
|
215
|
+
logger.info("Authentication completed successfully")
|
|
216
|
+
|
|
217
|
+
if status_response.litellm_key and status_response.supabase_key:
|
|
218
|
+
self.config_manager.update_shotgun_account(
|
|
219
|
+
api_key=status_response.litellm_key,
|
|
220
|
+
supabase_jwt=status_response.supabase_key,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self.query_one("#status", Label).update(
|
|
224
|
+
"✅ Authentication successful! Saving credentials..."
|
|
225
|
+
)
|
|
226
|
+
await asyncio.sleep(1)
|
|
227
|
+
self.notify(
|
|
228
|
+
"Shotgun Account configured successfully!",
|
|
229
|
+
severity="information",
|
|
230
|
+
)
|
|
231
|
+
self.dismiss(True)
|
|
232
|
+
else:
|
|
233
|
+
logger.error("Completed but missing keys")
|
|
234
|
+
self.query_one("#status", Label).update(
|
|
235
|
+
"❌ Error: Authentication completed but keys are missing"
|
|
236
|
+
)
|
|
237
|
+
self.notify("Authentication failed", severity="error")
|
|
238
|
+
await asyncio.sleep(3)
|
|
239
|
+
self.dismiss(False)
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
elif status_response.status == TokenStatus.AWAITING_PAYMENT:
|
|
243
|
+
self.query_one("#status", Label).update(
|
|
244
|
+
"💳 Waiting for payment completion..."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
elif status_response.status == TokenStatus.EXPIRED:
|
|
248
|
+
logger.error("Token expired")
|
|
249
|
+
self.query_one("#status", Label).update(
|
|
250
|
+
"❌ Authentication token expired (30 minutes)\n"
|
|
251
|
+
"Please try again."
|
|
252
|
+
)
|
|
253
|
+
self.notify("Authentication token expired", severity="error")
|
|
254
|
+
await asyncio.sleep(3)
|
|
255
|
+
self.dismiss(False)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
elif status_response.status == TokenStatus.PENDING:
|
|
259
|
+
# Still waiting, update status message
|
|
260
|
+
elapsed_minutes = (poll_count * DEFAULT_POLL_INTERVAL_SECONDS) // 60
|
|
261
|
+
self.query_one("#status", Label).update(
|
|
262
|
+
f"⏳ Waiting for authentication... ({elapsed_minutes}m elapsed)"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
except httpx.HTTPStatusError as e:
|
|
266
|
+
if e.response.status_code == 410:
|
|
267
|
+
# Token expired
|
|
268
|
+
logger.error("Token expired (410)")
|
|
269
|
+
self.query_one("#status", Label).update(
|
|
270
|
+
"❌ Authentication token expired"
|
|
271
|
+
)
|
|
272
|
+
self.notify("Authentication token expired", severity="error")
|
|
273
|
+
await asyncio.sleep(3)
|
|
274
|
+
self.dismiss(False)
|
|
275
|
+
return
|
|
276
|
+
else:
|
|
277
|
+
logger.error("HTTP error polling status: %s", e)
|
|
278
|
+
self.query_one("#status", Label).update(
|
|
279
|
+
f"❌ Error checking status: {e}"
|
|
280
|
+
)
|
|
281
|
+
await asyncio.sleep(5) # Wait a bit longer on error
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error("Error polling token status: %s", e)
|
|
285
|
+
self.query_one("#status", Label).update(f"⚠️ Error checking status: {e}")
|
|
286
|
+
await asyncio.sleep(5) # Wait a bit longer on error
|
|
287
|
+
|
|
288
|
+
# Timeout reached
|
|
289
|
+
logger.error("Polling timeout reached")
|
|
290
|
+
self.query_one("#status", Label).update(
|
|
291
|
+
"❌ Authentication timeout (30 minutes)\nPlease try again."
|
|
292
|
+
)
|
|
293
|
+
self.notify("Authentication timeout", severity="error")
|
|
294
|
+
await asyncio.sleep(3)
|
|
295
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Welcome screen for choosing between Shotgun Account and BYOK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
10
|
+
from textual.screen import Screen
|
|
11
|
+
from textual.widgets import Button, Markdown, Static
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..app import ShotgunApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WelcomeScreen(Screen[None]):
|
|
18
|
+
"""Welcome screen for first-time setup."""
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
WelcomeScreen {
|
|
22
|
+
layout: vertical;
|
|
23
|
+
align: center middle;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#titlebox {
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: auto;
|
|
29
|
+
margin: 2 0;
|
|
30
|
+
padding: 1;
|
|
31
|
+
border: hkey $border;
|
|
32
|
+
content-align: center middle;
|
|
33
|
+
|
|
34
|
+
& > * {
|
|
35
|
+
text-align: center;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#welcome-title {
|
|
40
|
+
padding: 1 0;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
color: $text-accent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#welcome-subtitle {
|
|
46
|
+
padding: 0 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#options-container {
|
|
50
|
+
width: 100%;
|
|
51
|
+
height: auto;
|
|
52
|
+
padding: 2;
|
|
53
|
+
align: center middle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#options {
|
|
57
|
+
width: auto;
|
|
58
|
+
height: auto;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.option-box {
|
|
62
|
+
width: 45;
|
|
63
|
+
height: auto;
|
|
64
|
+
border: solid $primary;
|
|
65
|
+
padding: 2;
|
|
66
|
+
margin: 0 1;
|
|
67
|
+
background: $surface;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.option-box:focus-within {
|
|
71
|
+
border: solid $accent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.option-title {
|
|
75
|
+
text-style: bold;
|
|
76
|
+
color: $text-accent;
|
|
77
|
+
padding: 0 0 1 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.option-benefits {
|
|
81
|
+
padding: 1 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.option-button {
|
|
85
|
+
margin: 1 0 0 0;
|
|
86
|
+
width: 100%;
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
BINDINGS = [
|
|
91
|
+
("ctrl+c", "app.quit", "Quit"),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def compose(self) -> ComposeResult:
|
|
95
|
+
with Vertical(id="titlebox"):
|
|
96
|
+
yield Static("Welcome to Shotgun", id="welcome-title")
|
|
97
|
+
yield Static(
|
|
98
|
+
"Choose how you'd like to get started",
|
|
99
|
+
id="welcome-subtitle",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
with Container(id="options-container"):
|
|
103
|
+
with Horizontal(id="options"):
|
|
104
|
+
# Left box - Shotgun Account
|
|
105
|
+
with Vertical(classes="option-box", id="shotgun-box"):
|
|
106
|
+
yield Static("Use a Shotgun Account", classes="option-title")
|
|
107
|
+
yield Markdown(
|
|
108
|
+
"**Benefits:**\n"
|
|
109
|
+
"• Use of all models in the Model Garden\n"
|
|
110
|
+
"• We'll pick the optimal models to give you the best "
|
|
111
|
+
"experience for things like web search, codebase indexing",
|
|
112
|
+
classes="option-benefits",
|
|
113
|
+
)
|
|
114
|
+
yield Button(
|
|
115
|
+
"Sign Up for/Use your Shotgun Account",
|
|
116
|
+
variant="primary",
|
|
117
|
+
id="shotgun-button",
|
|
118
|
+
classes="option-button",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Right box - BYOK
|
|
122
|
+
with Vertical(classes="option-box", id="byok-box"):
|
|
123
|
+
yield Static("Bring Your Own Key (BYOK)", classes="option-title")
|
|
124
|
+
yield Markdown(
|
|
125
|
+
"**Benefits:**\n"
|
|
126
|
+
"• 100% Supported by the application\n"
|
|
127
|
+
"• Use your existing API keys from OpenAI, Anthropic, or Google",
|
|
128
|
+
classes="option-benefits",
|
|
129
|
+
)
|
|
130
|
+
yield Button(
|
|
131
|
+
"Configure API Keys",
|
|
132
|
+
variant="success",
|
|
133
|
+
id="byok-button",
|
|
134
|
+
classes="option-button",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def on_mount(self) -> None:
|
|
138
|
+
"""Focus the first button on mount."""
|
|
139
|
+
self.query_one("#shotgun-button", Button).focus()
|
|
140
|
+
|
|
141
|
+
@on(Button.Pressed, "#shotgun-button")
|
|
142
|
+
def _on_shotgun_pressed(self) -> None:
|
|
143
|
+
"""Handle Shotgun Account button press."""
|
|
144
|
+
self.run_worker(self._start_shotgun_auth(), exclusive=True)
|
|
145
|
+
|
|
146
|
+
@on(Button.Pressed, "#byok-button")
|
|
147
|
+
def _on_byok_pressed(self) -> None:
|
|
148
|
+
"""Handle BYOK button press."""
|
|
149
|
+
self._mark_welcome_shown()
|
|
150
|
+
# Push provider config screen before dismissing
|
|
151
|
+
from .provider_config import ProviderConfigScreen
|
|
152
|
+
|
|
153
|
+
self.app.push_screen(
|
|
154
|
+
ProviderConfigScreen(),
|
|
155
|
+
callback=lambda _arg: self.dismiss(),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _start_shotgun_auth(self) -> None:
|
|
159
|
+
"""Launch Shotgun Account authentication flow."""
|
|
160
|
+
from .shotgun_auth import ShotgunAuthScreen
|
|
161
|
+
|
|
162
|
+
# Mark welcome screen as shown before auth
|
|
163
|
+
self._mark_welcome_shown()
|
|
164
|
+
|
|
165
|
+
# Push the auth screen and wait for result
|
|
166
|
+
await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
167
|
+
|
|
168
|
+
# Dismiss welcome screen after auth
|
|
169
|
+
self.dismiss()
|
|
170
|
+
|
|
171
|
+
def _mark_welcome_shown(self) -> None:
|
|
172
|
+
"""Mark the welcome screen as shown in config."""
|
|
173
|
+
app = cast("ShotgunApp", self.app)
|
|
174
|
+
config = app.config_manager.load()
|
|
175
|
+
config.shown_welcome_screen = True
|
|
176
|
+
app.config_manager.save(config)
|