shotgun-sh 0.1.9__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 (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  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 +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  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/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -0,0 +1,352 @@
1
+ """Screen for selecting AI model."""
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 Horizontal, Vertical
10
+ from textual.reactive import reactive
11
+ from textual.screen import Screen
12
+ from textual.widgets import Button, Label, ListItem, ListView, Static
13
+
14
+ from shotgun.agents.agent_manager import ModelConfigUpdated
15
+ from shotgun.agents.config import ConfigManager
16
+ from shotgun.agents.config.models import MODEL_SPECS, ModelName, ShotgunConfig
17
+ from shotgun.agents.config.provider import (
18
+ get_default_model_for_provider,
19
+ get_provider_model,
20
+ )
21
+ from shotgun.logging_config import get_logger
22
+
23
+ if TYPE_CHECKING:
24
+ from ..app import ShotgunApp
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ # Available models for selection
30
+ AVAILABLE_MODELS = list(ModelName)
31
+
32
+
33
+ def _sanitize_model_name_for_id(model_name: ModelName) -> str:
34
+ """Convert model name to valid Textual ID by replacing dots with hyphens."""
35
+ return model_name.value.replace(".", "-")
36
+
37
+
38
+ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
39
+ """Select AI model to use.
40
+
41
+ Returns ModelConfigUpdated when a model is selected, None if cancelled.
42
+ """
43
+
44
+ CSS = """
45
+ ModelPicker {
46
+ layout: vertical;
47
+ }
48
+
49
+ #titlebox {
50
+ height: auto;
51
+ margin: 2 0;
52
+ padding: 1;
53
+ border: hkey $border;
54
+ content-align: center middle;
55
+
56
+ & > * {
57
+ text-align: center;
58
+ }
59
+ }
60
+
61
+ #model-picker-title {
62
+ padding: 1 0;
63
+ text-style: bold;
64
+ color: $text-accent;
65
+ }
66
+
67
+ #model-list {
68
+ margin: 2 0;
69
+ height: auto;
70
+ padding: 1;
71
+ & > * {
72
+ padding: 1 0;
73
+ }
74
+ }
75
+ #model-actions {
76
+ padding: 1;
77
+ }
78
+ #model-actions > * {
79
+ margin-right: 2;
80
+ }
81
+ """
82
+
83
+ BINDINGS = [
84
+ ("escape", "done", "Back"),
85
+ ]
86
+
87
+ selected_model: reactive[ModelName] = reactive(ModelName.GPT_5)
88
+
89
+ def compose(self) -> ComposeResult:
90
+ with Vertical(id="titlebox"):
91
+ yield Static("Model selection", id="model-picker-title")
92
+ yield Static(
93
+ "Select the AI model you want to use for your tasks.",
94
+ id="model-picker-summary",
95
+ )
96
+ yield ListView(id="model-list")
97
+ with Horizontal(id="model-actions"):
98
+ yield Button("Select \\[ENTER]", variant="primary", id="select")
99
+ yield Button("Done \\[ESC]", id="done")
100
+
101
+ async def _rebuild_model_list(self) -> None:
102
+ """Rebuild the model list from current config.
103
+
104
+ This method is called both on first show and when screen is resumed
105
+ to ensure the list always reflects the current configuration.
106
+ """
107
+ logger.debug("Rebuilding model list from current config")
108
+
109
+ # Load current config with force_reload to get latest API keys
110
+ config_manager = self.config_manager
111
+ config = await config_manager.load(force_reload=True)
112
+
113
+ # Log provider key status
114
+ logger.debug(
115
+ "Provider keys: openai=%s, anthropic=%s, google=%s, shotgun=%s",
116
+ config_manager._provider_has_api_key(config.openai),
117
+ config_manager._provider_has_api_key(config.anthropic),
118
+ config_manager._provider_has_api_key(config.google),
119
+ config_manager._provider_has_api_key(config.shotgun),
120
+ )
121
+
122
+ current_model = config.selected_model or get_default_model_for_provider(config)
123
+ self.selected_model = current_model
124
+ logger.debug("Current selected model: %s", current_model)
125
+
126
+ # Rebuild the model list with current available models
127
+ list_view = self.query_one(ListView)
128
+
129
+ # Remove all existing items
130
+ old_count = len(list(list_view.children))
131
+ for child in list(list_view.children):
132
+ child.remove()
133
+ logger.debug("Removed %d existing model items from list", old_count)
134
+
135
+ # Add new items (labels already have correct text including current indicator)
136
+ new_items = await self._build_model_items(config)
137
+ for item in new_items:
138
+ list_view.append(item)
139
+ logger.debug("Added %d available model items to list", len(new_items))
140
+
141
+ # Find and highlight current selection (if it's in the filtered list)
142
+ if list_view.children:
143
+ for i, child in enumerate(list_view.children):
144
+ if isinstance(child, ListItem) and child.id:
145
+ model_id = child.id.removeprefix("model-")
146
+ # Find the model name
147
+ for model_name in AVAILABLE_MODELS:
148
+ if _sanitize_model_name_for_id(model_name) == model_id:
149
+ if model_name == current_model:
150
+ list_view.index = i
151
+ break
152
+
153
+ def on_show(self) -> None:
154
+ """Rebuild model list when screen is first shown."""
155
+ logger.debug("ModelPickerScreen.on_show() called")
156
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
157
+
158
+ def on_screenresume(self) -> None:
159
+ """Rebuild model list when screen is resumed (subsequent visits).
160
+
161
+ This is called when returning to the screen after it was suspended,
162
+ ensuring the model list reflects any config changes made while away.
163
+ """
164
+ logger.debug("ModelPickerScreen.on_screenresume() called")
165
+ self.run_worker(self._rebuild_model_list(), exclusive=False)
166
+
167
+ def action_done(self) -> None:
168
+ self.dismiss()
169
+
170
+ @on(ListView.Highlighted)
171
+ def _on_model_highlighted(self, event: ListView.Highlighted) -> None:
172
+ model_name = self._model_from_item(event.item)
173
+ if model_name:
174
+ self.selected_model = model_name
175
+
176
+ @on(ListView.Selected)
177
+ def _on_model_selected(self, event: ListView.Selected) -> None:
178
+ model_name = self._model_from_item(event.item)
179
+ if model_name:
180
+ self.selected_model = model_name
181
+ self._select_model()
182
+
183
+ @on(Button.Pressed, "#select")
184
+ def _on_select_pressed(self) -> None:
185
+ self._select_model()
186
+
187
+ @on(Button.Pressed, "#done")
188
+ def _on_done_pressed(self) -> None:
189
+ self.action_done()
190
+
191
+ @property
192
+ def config_manager(self) -> ConfigManager:
193
+ app = cast("ShotgunApp", self.app)
194
+ return app.config_manager
195
+
196
+ async def refresh_model_labels(self) -> None:
197
+ """Update the list view entries to reflect current selection.
198
+
199
+ Note: This method only updates labels for currently displayed models.
200
+ To rebuild the entire list after provider changes, on_show() should be used.
201
+ """
202
+ # Load config once with force_reload
203
+ config = await self.config_manager.load(force_reload=True)
204
+ current_model = config.selected_model or get_default_model_for_provider(config)
205
+
206
+ # Update labels for available models only
207
+ for model_name in AVAILABLE_MODELS:
208
+ # Pass config to avoid multiple force reloads
209
+ if not self._is_model_available(model_name, config):
210
+ continue
211
+ label = self.query_one(
212
+ f"#label-{_sanitize_model_name_for_id(model_name)}", Label
213
+ )
214
+ label.update(
215
+ self._model_label(model_name, is_current=model_name == current_model)
216
+ )
217
+
218
+ async def _build_model_items(
219
+ self, config: ShotgunConfig | None = None
220
+ ) -> list[ListItem]:
221
+ if config is None:
222
+ config = await self.config_manager.load(force_reload=True)
223
+
224
+ items: list[ListItem] = []
225
+ current_model = self.selected_model
226
+ for model_name in AVAILABLE_MODELS:
227
+ # Only add models that are available
228
+ if not self._is_model_available(model_name, config):
229
+ continue
230
+
231
+ label = Label(
232
+ self._model_label(model_name, is_current=model_name == current_model),
233
+ id=f"label-{_sanitize_model_name_for_id(model_name)}",
234
+ )
235
+ items.append(
236
+ ListItem(label, id=f"model-{_sanitize_model_name_for_id(model_name)}")
237
+ )
238
+ return items
239
+
240
+ def _model_from_item(self, item: ListItem | None) -> ModelName | None:
241
+ """Get ModelName from a ListItem."""
242
+ if item is None or item.id is None:
243
+ return None
244
+ sanitized_id = item.id.removeprefix("model-")
245
+ # Find the original model name by comparing sanitized versions
246
+ for model_name in AVAILABLE_MODELS:
247
+ if _sanitize_model_name_for_id(model_name) == sanitized_id:
248
+ return model_name
249
+ return None
250
+
251
+ def _is_model_available(self, model_name: ModelName, config: ShotgunConfig) -> bool:
252
+ """Check if a model is available based on provider key configuration.
253
+
254
+ A model is available if:
255
+ 1. Shotgun Account key is configured (provides access to all models), OR
256
+ 2. The model's provider has an API key configured (BYOK mode)
257
+
258
+ Args:
259
+ model_name: The model to check availability for
260
+ config: Pre-loaded config (must be provided)
261
+
262
+ Returns:
263
+ True if the model can be used, False otherwise
264
+ """
265
+ # If Shotgun Account is configured, all models are available
266
+ if self.config_manager._provider_has_api_key(config.shotgun):
267
+ logger.debug("Model %s available (Shotgun Account configured)", model_name)
268
+ return True
269
+
270
+ # In BYOK mode, check if the model's provider has a key
271
+ if model_name not in MODEL_SPECS:
272
+ logger.debug("Model %s not available (not in MODEL_SPECS)", model_name)
273
+ return False
274
+
275
+ spec = MODEL_SPECS[model_name]
276
+ # Check provider key directly using the loaded config to avoid stale cache
277
+ provider_config = self.config_manager._get_provider_config(
278
+ config, spec.provider
279
+ )
280
+ has_key = self.config_manager._provider_has_api_key(provider_config)
281
+ logger.debug(
282
+ "Model %s available=%s (provider=%s, has_key=%s)",
283
+ model_name,
284
+ has_key,
285
+ spec.provider,
286
+ has_key,
287
+ )
288
+ return has_key
289
+
290
+ def _model_label(self, model_name: ModelName, is_current: bool) -> str:
291
+ """Generate label for model with specs and current indicator."""
292
+ if model_name not in MODEL_SPECS:
293
+ return model_name.value
294
+
295
+ spec = MODEL_SPECS[model_name]
296
+ display_name = self._model_display_name(model_name)
297
+
298
+ # Format context/output tokens in readable format
299
+ input_k = spec.max_input_tokens // 1000
300
+ output_k = spec.max_output_tokens // 1000
301
+
302
+ label = f"{display_name} · {input_k}K context · {output_k}K output"
303
+
304
+ # Add cost indicator for expensive models
305
+ if model_name == ModelName.CLAUDE_OPUS_4_1:
306
+ label += " · Expensive"
307
+
308
+ if is_current:
309
+ label += " · Current"
310
+
311
+ return label
312
+
313
+ def _model_display_name(self, model_name: ModelName) -> str:
314
+ """Get human-readable model name."""
315
+ names = {
316
+ ModelName.GPT_5: "GPT-5 (OpenAI)",
317
+ ModelName.CLAUDE_OPUS_4_1: "Claude Opus 4.1 (Anthropic)",
318
+ ModelName.CLAUDE_SONNET_4_5: "Claude Sonnet 4.5 (Anthropic)",
319
+ ModelName.GEMINI_2_5_PRO: "Gemini 2.5 Pro (Google)",
320
+ }
321
+ return names.get(model_name, model_name.value)
322
+
323
+ def _select_model(self) -> None:
324
+ """Save the selected model."""
325
+ self.run_worker(self._do_select_model(), exclusive=True)
326
+
327
+ async def _do_select_model(self) -> None:
328
+ """Async implementation of model selection."""
329
+ try:
330
+ # Get old model before updating
331
+ config = await self.config_manager.load()
332
+ old_model = config.selected_model
333
+
334
+ # Update the selected model in config
335
+ await self.config_manager.update_selected_model(self.selected_model)
336
+ await self.refresh_model_labels()
337
+
338
+ # Get the full model config with provider information
339
+ model_config = await get_provider_model(self.selected_model)
340
+
341
+ # Dismiss the screen and return the model config update to the caller
342
+ self.dismiss(
343
+ ModelConfigUpdated(
344
+ old_model=old_model,
345
+ new_model=self.selected_model,
346
+ provider=model_config.provider,
347
+ key_provider=model_config.key_provider,
348
+ model_config=model_config,
349
+ )
350
+ )
351
+ except Exception as exc: # pragma: no cover - defensive; textual path
352
+ self.notify(f"Failed to select model: {exc}", severity="error")