shotgun-sh 0.1.14__py3-none-any.whl → 0.2.11__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.

Files changed (143) hide show
  1. shotgun/agents/agent_manager.py +715 -75
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +10 -5
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +129 -12
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/compact.py +186 -0
  49. shotgun/cli/config.py +41 -67
  50. shotgun/cli/context.py +111 -0
  51. shotgun/cli/export.py +1 -1
  52. shotgun/cli/feedback.py +50 -0
  53. shotgun/cli/models.py +3 -2
  54. shotgun/cli/plan.py +1 -1
  55. shotgun/cli/research.py +1 -1
  56. shotgun/cli/specify.py +1 -1
  57. shotgun/cli/tasks.py +1 -1
  58. shotgun/cli/update.py +16 -2
  59. shotgun/codebase/core/change_detector.py +5 -3
  60. shotgun/codebase/core/code_retrieval.py +4 -2
  61. shotgun/codebase/core/ingestor.py +57 -16
  62. shotgun/codebase/core/manager.py +20 -7
  63. shotgun/codebase/core/nl_query.py +1 -1
  64. shotgun/codebase/models.py +4 -4
  65. shotgun/exceptions.py +32 -0
  66. shotgun/llm_proxy/__init__.py +19 -0
  67. shotgun/llm_proxy/clients.py +44 -0
  68. shotgun/llm_proxy/constants.py +15 -0
  69. shotgun/logging_config.py +18 -27
  70. shotgun/main.py +91 -12
  71. shotgun/posthog_telemetry.py +81 -10
  72. shotgun/prompts/agents/export.j2 +18 -1
  73. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  74. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  75. shotgun/prompts/agents/plan.j2 +1 -1
  76. shotgun/prompts/agents/research.j2 +1 -1
  77. shotgun/prompts/agents/specify.j2 +270 -3
  78. shotgun/prompts/agents/state/system_state.j2 +4 -0
  79. shotgun/prompts/agents/tasks.j2 +1 -1
  80. shotgun/prompts/loader.py +2 -2
  81. shotgun/prompts/tools/web_search.j2 +14 -0
  82. shotgun/sentry_telemetry.py +27 -18
  83. shotgun/settings.py +238 -0
  84. shotgun/shotgun_web/__init__.py +19 -0
  85. shotgun/shotgun_web/client.py +138 -0
  86. shotgun/shotgun_web/constants.py +21 -0
  87. shotgun/shotgun_web/models.py +47 -0
  88. shotgun/telemetry.py +24 -36
  89. shotgun/tui/app.py +251 -23
  90. shotgun/tui/commands/__init__.py +1 -1
  91. shotgun/tui/components/context_indicator.py +179 -0
  92. shotgun/tui/components/mode_indicator.py +70 -0
  93. shotgun/tui/components/status_bar.py +48 -0
  94. shotgun/tui/containers.py +91 -0
  95. shotgun/tui/dependencies.py +39 -0
  96. shotgun/tui/protocols.py +45 -0
  97. shotgun/tui/screens/chat/__init__.py +5 -0
  98. shotgun/tui/screens/chat/chat.tcss +54 -0
  99. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  100. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  101. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  102. shotgun/tui/screens/chat/help_text.py +40 -0
  103. shotgun/tui/screens/chat/prompt_history.py +48 -0
  104. shotgun/tui/screens/chat.tcss +11 -0
  105. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  106. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  107. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  108. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  109. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  110. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  111. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  112. shotgun/tui/screens/confirmation_dialog.py +151 -0
  113. shotgun/tui/screens/feedback.py +193 -0
  114. shotgun/tui/screens/github_issue.py +102 -0
  115. shotgun/tui/screens/model_picker.py +352 -0
  116. shotgun/tui/screens/onboarding.py +431 -0
  117. shotgun/tui/screens/pipx_migration.py +153 -0
  118. shotgun/tui/screens/provider_config.py +156 -39
  119. shotgun/tui/screens/shotgun_auth.py +295 -0
  120. shotgun/tui/screens/welcome.py +198 -0
  121. shotgun/tui/services/__init__.py +5 -0
  122. shotgun/tui/services/conversation_service.py +184 -0
  123. shotgun/tui/state/__init__.py +7 -0
  124. shotgun/tui/state/processing_state.py +185 -0
  125. shotgun/tui/utils/mode_progress.py +14 -7
  126. shotgun/tui/widgets/__init__.py +5 -0
  127. shotgun/tui/widgets/widget_coordinator.py +262 -0
  128. shotgun/utils/datetime_utils.py +77 -0
  129. shotgun/utils/env_utils.py +13 -0
  130. shotgun/utils/file_system_utils.py +22 -2
  131. shotgun/utils/marketing.py +110 -0
  132. shotgun/utils/update_checker.py +69 -14
  133. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  134. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  135. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  136. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  137. shotgun/agents/history/token_counting.py +0 -429
  138. shotgun/agents/tools/user_interaction.py +0 -37
  139. shotgun/tui/screens/chat.py +0 -797
  140. shotgun/tui/screens/chat_screen/history.py +0 -350
  141. shotgun_sh-0.1.14.dist-info/METADATA +0 -466
  142. shotgun_sh-0.1.14.dist-info/RECORD +0 -133
  143. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -9,7 +9,7 @@ from textual.app import ComposeResult
9
9
  from textual.containers import Horizontal, Vertical
10
10
  from textual.reactive import reactive
11
11
  from textual.screen import Screen
12
- from textual.widgets import Button, Input, Label, ListItem, ListView, Static
12
+ from textual.widgets import Button, Input, Label, ListItem, ListView, Markdown, Static
13
13
 
14
14
  from shotgun.agents.config import ConfigManager, ProviderType
15
15
 
@@ -17,6 +17,16 @@ if TYPE_CHECKING:
17
17
  from ..app import ShotgunApp
18
18
 
19
19
 
20
+ def get_configurable_providers() -> list[str]:
21
+ """Get list of configurable providers.
22
+
23
+ Returns:
24
+ List of provider identifiers that can be configured.
25
+ Includes all providers: openai, anthropic, google, and shotgun.
26
+ """
27
+ return ["openai", "anthropic", "google", "shotgun"]
28
+
29
+
20
30
  class ProviderConfigScreen(Screen[None]):
21
31
  """Collect API keys for available providers."""
22
32
 
@@ -47,6 +57,10 @@ class ProviderConfigScreen(Screen[None]):
47
57
  color: $text-accent;
48
58
  }
49
59
 
60
+ #provider-links {
61
+ padding: 1 0;
62
+ }
63
+
50
64
  #provider-list {
51
65
  margin: 2 0;
52
66
  height: auto;
@@ -67,9 +81,10 @@ class ProviderConfigScreen(Screen[None]):
67
81
 
68
82
  BINDINGS = [
69
83
  ("escape", "done", "Back"),
84
+ ("ctrl+c", "app.quit", "Quit"),
70
85
  ]
71
86
 
72
- selected_provider: reactive[ProviderType] = reactive(ProviderType.OPENAI)
87
+ selected_provider: reactive[str] = reactive("openai")
73
88
 
74
89
  def compose(self) -> ComposeResult:
75
90
  with Vertical(id="titlebox"):
@@ -78,7 +93,11 @@ class ProviderConfigScreen(Screen[None]):
78
93
  "Select a provider and enter the API key needed to activate it.",
79
94
  id="provider-config-summary",
80
95
  )
81
- yield ListView(*self._build_provider_items(), id="provider-list")
96
+ yield Markdown(
97
+ "Don't have an API Key? Use these links to get one: [OpenAI](https://platform.openai.com/api-keys) | [Anthropic](https://console.anthropic.com) | [Google Gemini](https://aistudio.google.com)",
98
+ id="provider-links",
99
+ )
100
+ yield ListView(*self._build_provider_items_sync(), id="provider-list")
82
101
  yield Input(
83
102
  placeholder=self._input_placeholder(self.selected_provider),
84
103
  password=True,
@@ -86,17 +105,35 @@ class ProviderConfigScreen(Screen[None]):
86
105
  )
87
106
  with Horizontal(id="provider-actions"):
88
107
  yield Button("Save key \\[ENTER]", variant="primary", id="save")
108
+ yield Button("Authenticate", variant="success", id="authenticate")
89
109
  yield Button("Clear key", id="clear", variant="warning")
90
110
  yield Button("Done \\[ESC]", id="done")
91
111
 
92
112
  def on_mount(self) -> None:
93
- self.refresh_provider_status()
94
113
  list_view = self.query_one(ListView)
95
114
  if list_view.children:
96
115
  list_view.index = 0
97
- self.selected_provider = ProviderType.OPENAI
116
+ self.selected_provider = "openai"
117
+
118
+ # Hide authenticate button by default (shown only for shotgun)
119
+ self.query_one("#authenticate", Button).display = False
98
120
  self.set_focus(self.query_one("#api-key", Input))
99
121
 
122
+ # Refresh UI asynchronously
123
+ self.run_worker(self._refresh_ui(), exclusive=False)
124
+
125
+ def on_screenresume(self) -> None:
126
+ """Refresh provider status when screen is resumed.
127
+
128
+ This ensures the UI reflects any provider changes made elsewhere.
129
+ """
130
+ self.run_worker(self._refresh_ui(), exclusive=False)
131
+
132
+ async def _refresh_ui(self) -> None:
133
+ """Refresh provider status and button visibility."""
134
+ await self.refresh_provider_status()
135
+ await self._update_done_button_visibility()
136
+
100
137
  def action_done(self) -> None:
101
138
  self.dismiss()
102
139
 
@@ -117,6 +154,10 @@ class ProviderConfigScreen(Screen[None]):
117
154
  def _on_save_pressed(self) -> None:
118
155
  self._save_api_key()
119
156
 
157
+ @on(Button.Pressed, "#authenticate")
158
+ def _on_authenticate_pressed(self) -> None:
159
+ self.run_worker(self._start_shotgun_auth(), exclusive=True)
160
+
120
161
  @on(Button.Pressed, "#clear")
121
162
  def _on_clear_pressed(self) -> None:
122
163
  self._clear_api_key()
@@ -133,58 +174,110 @@ class ProviderConfigScreen(Screen[None]):
133
174
  def watch_selected_provider(self, provider: ProviderType) -> None:
134
175
  if not self.is_mounted:
135
176
  return
177
+
178
+ # Show/hide UI elements based on provider type asynchronously
179
+ self.run_worker(self._update_provider_ui(provider), exclusive=False)
180
+
181
+ async def _update_provider_ui(self, provider: ProviderType) -> None:
182
+ """Update UI elements based on selected provider."""
183
+ is_shotgun = provider == "shotgun"
184
+
136
185
  input_widget = self.query_one("#api-key", Input)
137
- input_widget.placeholder = self._input_placeholder(provider)
138
- input_widget.value = ""
186
+ save_button = self.query_one("#save", Button)
187
+ auth_button = self.query_one("#authenticate", Button)
188
+
189
+ if is_shotgun:
190
+ # Hide API key input and save button
191
+ input_widget.display = False
192
+ save_button.display = False
193
+
194
+ # Only show Authenticate button if shotgun is NOT already configured
195
+ if await self._has_provider_key("shotgun"):
196
+ auth_button.display = False
197
+ else:
198
+ auth_button.display = True
199
+ else:
200
+ # Show API key input and save button, hide authenticate button
201
+ input_widget.display = True
202
+ save_button.display = True
203
+ auth_button.display = False
204
+ input_widget.placeholder = self._input_placeholder(provider)
205
+ input_widget.value = ""
139
206
 
140
207
  @property
141
208
  def config_manager(self) -> ConfigManager:
142
209
  app = cast("ShotgunApp", self.app)
143
210
  return app.config_manager
144
211
 
145
- def refresh_provider_status(self) -> None:
212
+ async def refresh_provider_status(self) -> None:
146
213
  """Update the list view entries to reflect configured providers."""
147
- for provider in ProviderType:
148
- label = self.query_one(f"#label-{provider.value}", Label)
149
- label.update(self._provider_label(provider))
214
+ for provider_id in get_configurable_providers():
215
+ label = self.query_one(f"#label-{provider_id}", Label)
216
+ label.update(await self._provider_label(provider_id))
217
+
218
+ async def _update_done_button_visibility(self) -> None:
219
+ """Show/hide Done button based on whether any provider keys are configured."""
220
+ done_button = self.query_one("#done", Button)
221
+ has_keys = await self.config_manager.has_any_provider_key()
222
+ done_button.display = has_keys
223
+
224
+ def _build_provider_items_sync(self) -> list[ListItem]:
225
+ """Build provider items synchronously for compose().
150
226
 
151
- def _build_provider_items(self) -> list[ListItem]:
227
+ Labels will be populated with status asynchronously in on_mount().
228
+ """
152
229
  items: list[ListItem] = []
153
- for provider in ProviderType:
154
- label = Label(self._provider_label(provider), id=f"label-{provider.value}")
155
- items.append(ListItem(label, id=f"provider-{provider.value}"))
230
+ for provider_id in get_configurable_providers():
231
+ # Create labels with placeholder text - will be updated in on_mount()
232
+ label = Label(
233
+ self._provider_display_name(provider_id), id=f"label-{provider_id}"
234
+ )
235
+ items.append(ListItem(label, id=f"provider-{provider_id}"))
156
236
  return items
157
237
 
158
- def _provider_from_item(self, item: ListItem | None) -> ProviderType | None:
238
+ def _provider_from_item(self, item: ListItem | None) -> str | None:
159
239
  if item is None or item.id is None:
160
240
  return None
161
241
  provider_id = item.id.removeprefix("provider-")
162
- try:
163
- return ProviderType(provider_id)
164
- except ValueError:
165
- return None
242
+ return provider_id if provider_id in get_configurable_providers() else None
166
243
 
167
- def _provider_label(self, provider: ProviderType) -> str:
168
- display = self._provider_display_name(provider)
169
- status = (
170
- "Configured"
171
- if self.config_manager.has_provider_key(provider)
172
- else "Not configured"
173
- )
244
+ async def _provider_label(self, provider_id: str) -> str:
245
+ display = self._provider_display_name(provider_id)
246
+ has_key = await self._has_provider_key(provider_id)
247
+ status = "Configured" if has_key else "Not configured"
174
248
  return f"{display} · {status}"
175
249
 
176
- def _provider_display_name(self, provider: ProviderType) -> str:
250
+ def _provider_display_name(self, provider_id: str) -> str:
177
251
  names = {
178
- ProviderType.OPENAI: "OpenAI",
179
- ProviderType.ANTHROPIC: "Anthropic",
180
- ProviderType.GOOGLE: "Google Gemini",
252
+ "openai": "OpenAI",
253
+ "anthropic": "Anthropic",
254
+ "google": "Google Gemini",
255
+ "shotgun": "Shotgun Account",
181
256
  }
182
- return names.get(provider, provider.value.title())
183
-
184
- def _input_placeholder(self, provider: ProviderType) -> str:
185
- return f"{self._provider_display_name(provider)} API key"
257
+ return names.get(provider_id, provider_id.title())
258
+
259
+ def _input_placeholder(self, provider_id: str) -> str:
260
+ return f"{self._provider_display_name(provider_id)} API key"
261
+
262
+ async def _has_provider_key(self, provider_id: str) -> bool:
263
+ """Check if provider has a configured API key."""
264
+ if provider_id == "shotgun":
265
+ # Check shotgun key directly
266
+ config = await self.config_manager.load()
267
+ return self.config_manager._provider_has_api_key(config.shotgun)
268
+ else:
269
+ # Check LLM provider key
270
+ try:
271
+ provider = ProviderType(provider_id)
272
+ return await self.config_manager.has_provider_key(provider)
273
+ except ValueError:
274
+ return False
186
275
 
187
276
  def _save_api_key(self) -> None:
277
+ self.run_worker(self._do_save_api_key(), exclusive=True)
278
+
279
+ async def _do_save_api_key(self) -> None:
280
+ """Async implementation of API key saving."""
188
281
  input_widget = self.query_one("#api-key", Input)
189
282
  api_key = input_widget.value.strip()
190
283
 
@@ -193,7 +286,7 @@ class ProviderConfigScreen(Screen[None]):
193
286
  return
194
287
 
195
288
  try:
196
- self.config_manager.update_provider(
289
+ await self.config_manager.update_provider(
197
290
  self.selected_provider,
198
291
  api_key=api_key,
199
292
  )
@@ -202,20 +295,44 @@ class ProviderConfigScreen(Screen[None]):
202
295
  return
203
296
 
204
297
  input_widget.value = ""
205
- self.refresh_provider_status()
298
+ await self.refresh_provider_status()
299
+ await self._update_done_button_visibility()
206
300
  self.notify(
207
301
  f"Saved API key for {self._provider_display_name(self.selected_provider)}."
208
302
  )
209
303
 
210
304
  def _clear_api_key(self) -> None:
305
+ self.run_worker(self._do_clear_api_key(), exclusive=True)
306
+
307
+ async def _do_clear_api_key(self) -> None:
308
+ """Async implementation of API key clearing."""
211
309
  try:
212
- self.config_manager.clear_provider_key(self.selected_provider)
310
+ await self.config_manager.clear_provider_key(self.selected_provider)
213
311
  except Exception as exc: # pragma: no cover - defensive; textual path
214
312
  self.notify(f"Failed to clear key: {exc}", severity="error")
215
313
  return
216
314
 
217
- self.refresh_provider_status()
315
+ await self.refresh_provider_status()
316
+ await self._update_done_button_visibility()
218
317
  self.query_one("#api-key", Input).value = ""
318
+
319
+ # If we just cleared shotgun, show the Authenticate button
320
+ if self.selected_provider == "shotgun":
321
+ auth_button = self.query_one("#authenticate", Button)
322
+ auth_button.display = True
323
+
219
324
  self.notify(
220
325
  f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
221
326
  )
327
+
328
+ async def _start_shotgun_auth(self) -> None:
329
+ """Launch Shotgun Account authentication flow."""
330
+ from .shotgun_auth import ShotgunAuthScreen
331
+
332
+ # Push the auth screen and wait for result
333
+ result = await self.app.push_screen_wait(ShotgunAuthScreen())
334
+
335
+ # Refresh provider status after auth completes
336
+ if result:
337
+ await self.refresh_provider_status()
338
+ # 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 = await 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
+ await 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)