shotgun-sh 0.2.1.dev2__py3-none-any.whl → 0.2.1.dev4__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/manager.py +24 -8
- shotgun/agents/config/provider.py +4 -3
- shotgun/cli/config.py +16 -4
- shotgun/tui/app.py +5 -3
- shotgun/tui/screens/chat.py +2 -8
- shotgun/tui/screens/chat_screen/command_providers.py +99 -12
- shotgun/tui/screens/chat_screen/history.py +3 -1
- shotgun/tui/screens/model_picker.py +136 -24
- shotgun/tui/screens/provider_config.py +23 -5
- shotgun/utils/env_utils.py +12 -0
- {shotgun_sh-0.2.1.dev2.dist-info → shotgun_sh-0.2.1.dev4.dist-info}/METADATA +1 -1
- {shotgun_sh-0.2.1.dev2.dist-info → shotgun_sh-0.2.1.dev4.dist-info}/RECORD +15 -15
- {shotgun_sh-0.2.1.dev2.dist-info → shotgun_sh-0.2.1.dev4.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.1.dev2.dist-info → shotgun_sh-0.2.1.dev4.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.1.dev2.dist-info → shotgun_sh-0.2.1.dev4.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/manager.py
CHANGED
|
@@ -46,13 +46,16 @@ class ConfigManager:
|
|
|
46
46
|
|
|
47
47
|
self._config: ShotgunConfig | None = None
|
|
48
48
|
|
|
49
|
-
def load(self) -> ShotgunConfig:
|
|
49
|
+
def load(self, force_reload: bool = True) -> ShotgunConfig:
|
|
50
50
|
"""Load configuration from file.
|
|
51
51
|
|
|
52
|
+
Args:
|
|
53
|
+
force_reload: If True, reload from disk even if cached (default: True)
|
|
54
|
+
|
|
52
55
|
Returns:
|
|
53
56
|
ShotgunConfig: Loaded configuration or default config if file doesn't exist
|
|
54
57
|
"""
|
|
55
|
-
if self._config is not None:
|
|
58
|
+
if self._config is not None and not force_reload:
|
|
56
59
|
return self._config
|
|
57
60
|
|
|
58
61
|
if not self.config_path.exists():
|
|
@@ -109,7 +112,7 @@ class ConfigManager:
|
|
|
109
112
|
# Find default model for this provider
|
|
110
113
|
provider_models = {
|
|
111
114
|
ProviderType.OPENAI: ModelName.GPT_5,
|
|
112
|
-
ProviderType.ANTHROPIC: ModelName.
|
|
115
|
+
ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
|
|
113
116
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -210,7 +213,7 @@ class ConfigManager:
|
|
|
210
213
|
|
|
211
214
|
provider_models = {
|
|
212
215
|
ProviderType.OPENAI: ModelName.GPT_5,
|
|
213
|
-
ProviderType.ANTHROPIC: ModelName.
|
|
216
|
+
ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
|
|
214
217
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
215
218
|
}
|
|
216
219
|
if provider_enum in provider_models:
|
|
@@ -243,7 +246,8 @@ class ConfigManager:
|
|
|
243
246
|
|
|
244
247
|
This checks only the configuration file.
|
|
245
248
|
"""
|
|
246
|
-
|
|
249
|
+
# Use force_reload=False to avoid infinite loop when called from load()
|
|
250
|
+
config = self.load(force_reload=False)
|
|
247
251
|
provider_enum = self._ensure_provider_enum(provider)
|
|
248
252
|
provider_config = self._get_provider_config(config, provider_enum)
|
|
249
253
|
|
|
@@ -251,7 +255,8 @@ class ConfigManager:
|
|
|
251
255
|
|
|
252
256
|
def has_any_provider_key(self) -> bool:
|
|
253
257
|
"""Determine whether any provider has a configured API key."""
|
|
254
|
-
|
|
258
|
+
# Use force_reload=False to avoid infinite loop when called from load()
|
|
259
|
+
config = self.load(force_reload=False)
|
|
255
260
|
# Check LLM provider keys (BYOK)
|
|
256
261
|
has_llm_key = any(
|
|
257
262
|
self._provider_has_api_key(self._get_provider_config(config, provider))
|
|
@@ -381,6 +386,17 @@ class ConfigManager:
|
|
|
381
386
|
return config.user_id
|
|
382
387
|
|
|
383
388
|
|
|
389
|
+
# Global singleton instance
|
|
390
|
+
_config_manager_instance: ConfigManager | None = None
|
|
391
|
+
|
|
392
|
+
|
|
384
393
|
def get_config_manager() -> ConfigManager:
|
|
385
|
-
"""Get the global ConfigManager instance.
|
|
386
|
-
|
|
394
|
+
"""Get the global singleton ConfigManager instance.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
The singleton ConfigManager instance
|
|
398
|
+
"""
|
|
399
|
+
global _config_manager_instance
|
|
400
|
+
if _config_manager_instance is None:
|
|
401
|
+
_config_manager_instance = ConfigManager()
|
|
402
|
+
return _config_manager_instance
|
|
@@ -139,7 +139,8 @@ def get_provider_model(
|
|
|
139
139
|
ValueError: If provider is not configured properly or model not found
|
|
140
140
|
"""
|
|
141
141
|
config_manager = get_config_manager()
|
|
142
|
-
config
|
|
142
|
+
# Use cached config for read-only access (performance)
|
|
143
|
+
config = config_manager.load(force_reload=False)
|
|
143
144
|
|
|
144
145
|
# Priority 1: Check if Shotgun key exists - if so, use it for ANY model
|
|
145
146
|
shotgun_api_key = _get_api_key(config.shotgun.api_key)
|
|
@@ -219,8 +220,8 @@ def get_provider_model(
|
|
|
219
220
|
if not api_key:
|
|
220
221
|
raise ValueError("Anthropic API key not configured. Set via config.")
|
|
221
222
|
|
|
222
|
-
# Use requested model or default to claude-
|
|
223
|
-
model_name = requested_model if requested_model else ModelName.
|
|
223
|
+
# Use requested model or default to claude-sonnet-4-5
|
|
224
|
+
model_name = requested_model if requested_model else ModelName.CLAUDE_SONNET_4_5
|
|
224
225
|
if model_name not in MODEL_SPECS:
|
|
225
226
|
raise ValueError(f"Model '{model_name.value}' not found")
|
|
226
227
|
spec = MODEL_SPECS[model_name]
|
shotgun/cli/config.py
CHANGED
|
@@ -9,6 +9,7 @@ from rich.table import Table
|
|
|
9
9
|
|
|
10
10
|
from shotgun.agents.config import ProviderType, get_config_manager
|
|
11
11
|
from shotgun.logging_config import get_logger
|
|
12
|
+
from shotgun.utils.env_utils import is_shotgun_account_enabled
|
|
12
13
|
|
|
13
14
|
logger = get_logger(__name__)
|
|
14
15
|
console = Console()
|
|
@@ -162,12 +163,17 @@ def _show_full_config(config: Any) -> None:
|
|
|
162
163
|
table.add_row("", "") # Separator
|
|
163
164
|
|
|
164
165
|
# Provider configurations
|
|
165
|
-
|
|
166
|
+
providers_to_show = [
|
|
166
167
|
("OpenAI", config.openai),
|
|
167
168
|
("Anthropic", config.anthropic),
|
|
168
169
|
("Google", config.google),
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
# Only show Shotgun Account if feature flag is enabled
|
|
173
|
+
if is_shotgun_account_enabled():
|
|
174
|
+
providers_to_show.append(("Shotgun Account", config.shotgun))
|
|
175
|
+
|
|
176
|
+
for provider_name, provider_config in providers_to_show:
|
|
171
177
|
table.add_row(f"[bold]{provider_name}[/bold]", "")
|
|
172
178
|
|
|
173
179
|
# API Key
|
|
@@ -207,7 +213,13 @@ def _show_provider_config(provider: ProviderType, config: Any) -> None:
|
|
|
207
213
|
|
|
208
214
|
def _mask_secrets(data: dict[str, Any]) -> None:
|
|
209
215
|
"""Mask secrets in configuration data."""
|
|
210
|
-
|
|
216
|
+
providers = ["openai", "anthropic", "google"]
|
|
217
|
+
|
|
218
|
+
# Only mask shotgun if feature flag is enabled
|
|
219
|
+
if is_shotgun_account_enabled():
|
|
220
|
+
providers.append("shotgun")
|
|
221
|
+
|
|
222
|
+
for provider in providers:
|
|
211
223
|
if provider in data and isinstance(data[provider], dict):
|
|
212
224
|
if "api_key" in data[provider] and data[provider]["api_key"]:
|
|
213
225
|
data[provider]["api_key"] = _mask_value(data[provider]["api_key"])
|
shotgun/tui/app.py
CHANGED
|
@@ -64,7 +64,8 @@ class ShotgunApp(App[None]):
|
|
|
64
64
|
return
|
|
65
65
|
|
|
66
66
|
self.push_screen(
|
|
67
|
-
|
|
67
|
+
ProviderConfigScreen(),
|
|
68
|
+
callback=lambda _arg: self.refresh_startup_screen(),
|
|
68
69
|
)
|
|
69
70
|
return
|
|
70
71
|
|
|
@@ -73,7 +74,8 @@ class ShotgunApp(App[None]):
|
|
|
73
74
|
return
|
|
74
75
|
|
|
75
76
|
self.push_screen(
|
|
76
|
-
|
|
77
|
+
DirectorySetupScreen(),
|
|
78
|
+
callback=lambda _arg: self.refresh_startup_screen(),
|
|
77
79
|
)
|
|
78
80
|
return
|
|
79
81
|
|
|
@@ -110,7 +112,7 @@ class ShotgunApp(App[None]):
|
|
|
110
112
|
submit_feedback_survey(feedback)
|
|
111
113
|
self.notify("Feedback sent. Thank you!")
|
|
112
114
|
|
|
113
|
-
self.push_screen(
|
|
115
|
+
self.push_screen(FeedbackScreen(), callback=handle_feedback)
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
def run(no_update_check: bool = False, continue_session: bool = False) -> None:
|
shotgun/tui/screens/chat.py
CHANGED
|
@@ -54,11 +54,8 @@ from ..components.prompt_input import PromptInput
|
|
|
54
54
|
from ..components.spinner import Spinner
|
|
55
55
|
from ..utils.mode_progress import PlaceholderHints
|
|
56
56
|
from .chat_screen.command_providers import (
|
|
57
|
-
AgentModeProvider,
|
|
58
|
-
CodebaseCommandProvider,
|
|
59
57
|
DeleteCodebasePaletteProvider,
|
|
60
|
-
|
|
61
|
-
UsageProvider,
|
|
58
|
+
UnifiedCommandProvider,
|
|
62
59
|
)
|
|
63
60
|
|
|
64
61
|
logger = logging.getLogger(__name__)
|
|
@@ -233,10 +230,7 @@ class ChatScreen(Screen[None]):
|
|
|
233
230
|
]
|
|
234
231
|
|
|
235
232
|
COMMANDS = {
|
|
236
|
-
|
|
237
|
-
ProviderSetupProvider,
|
|
238
|
-
CodebaseCommandProvider,
|
|
239
|
-
UsageProvider,
|
|
233
|
+
UnifiedCommandProvider,
|
|
240
234
|
}
|
|
241
235
|
|
|
242
236
|
value = reactive("")
|
|
@@ -5,6 +5,8 @@ from textual.command import DiscoveryHit, Hit, Provider
|
|
|
5
5
|
|
|
6
6
|
from shotgun.agents.models import AgentType
|
|
7
7
|
from shotgun.codebase.models import CodebaseGraph
|
|
8
|
+
from shotgun.tui.screens.model_picker import ModelPickerScreen
|
|
9
|
+
from shotgun.tui.screens.provider_config import ProviderConfigScreen
|
|
8
10
|
|
|
9
11
|
if TYPE_CHECKING:
|
|
10
12
|
from shotgun.tui.screens.chat import ChatScreen
|
|
@@ -139,11 +141,11 @@ class ProviderSetupProvider(Provider):
|
|
|
139
141
|
|
|
140
142
|
def open_provider_config(self) -> None:
|
|
141
143
|
"""Show the provider configuration screen."""
|
|
142
|
-
self.chat_screen.app.push_screen(
|
|
144
|
+
self.chat_screen.app.push_screen(ProviderConfigScreen())
|
|
143
145
|
|
|
144
146
|
def open_model_picker(self) -> None:
|
|
145
147
|
"""Show the model picker screen."""
|
|
146
|
-
self.chat_screen.app.push_screen(
|
|
148
|
+
self.chat_screen.app.push_screen(ModelPickerScreen())
|
|
147
149
|
|
|
148
150
|
async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
|
|
149
151
|
yield DiscoveryHit(
|
|
@@ -191,30 +193,30 @@ class CodebaseCommandProvider(Provider):
|
|
|
191
193
|
return cast(ChatScreen, self.screen)
|
|
192
194
|
|
|
193
195
|
async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
|
|
194
|
-
yield DiscoveryHit(
|
|
195
|
-
"Codebase: Index Codebase",
|
|
196
|
-
self.chat_screen.index_codebase_command,
|
|
197
|
-
help="Index a repository into the codebase graph",
|
|
198
|
-
)
|
|
199
196
|
yield DiscoveryHit(
|
|
200
197
|
"Codebase: Delete Codebase Index",
|
|
201
198
|
self.chat_screen.delete_codebase_command,
|
|
202
199
|
help="Delete an existing codebase index",
|
|
203
200
|
)
|
|
201
|
+
yield DiscoveryHit(
|
|
202
|
+
"Codebase: Index Codebase",
|
|
203
|
+
self.chat_screen.index_codebase_command,
|
|
204
|
+
help="Index a repository into the codebase graph",
|
|
205
|
+
)
|
|
204
206
|
|
|
205
207
|
async def search(self, query: str) -> AsyncGenerator[Hit, None]:
|
|
206
208
|
matcher = self.matcher(query)
|
|
207
209
|
commands = [
|
|
208
|
-
(
|
|
209
|
-
"Codebase: Index Codebase",
|
|
210
|
-
self.chat_screen.index_codebase_command,
|
|
211
|
-
"Index a repository into the codebase graph",
|
|
212
|
-
),
|
|
213
210
|
(
|
|
214
211
|
"Codebase: Delete Codebase Index",
|
|
215
212
|
self.chat_screen.delete_codebase_command,
|
|
216
213
|
"Delete an existing codebase index",
|
|
217
214
|
),
|
|
215
|
+
(
|
|
216
|
+
"Codebase: Index Codebase",
|
|
217
|
+
self.chat_screen.index_codebase_command,
|
|
218
|
+
"Index a repository into the codebase graph",
|
|
219
|
+
),
|
|
218
220
|
]
|
|
219
221
|
for title, callback, help_text in commands:
|
|
220
222
|
score = matcher.match(title)
|
|
@@ -269,3 +271,88 @@ class DeleteCodebasePaletteProvider(Provider):
|
|
|
269
271
|
),
|
|
270
272
|
help=graph.repo_path,
|
|
271
273
|
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class UnifiedCommandProvider(Provider):
|
|
277
|
+
"""Unified command provider with all commands in alphabetical order."""
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def chat_screen(self) -> "ChatScreen":
|
|
281
|
+
from shotgun.tui.screens.chat import ChatScreen
|
|
282
|
+
|
|
283
|
+
return cast(ChatScreen, self.screen)
|
|
284
|
+
|
|
285
|
+
def open_provider_config(self) -> None:
|
|
286
|
+
"""Show the provider configuration screen."""
|
|
287
|
+
self.chat_screen.app.push_screen(ProviderConfigScreen())
|
|
288
|
+
|
|
289
|
+
def open_model_picker(self) -> None:
|
|
290
|
+
"""Show the model picker screen."""
|
|
291
|
+
self.chat_screen.app.push_screen(ModelPickerScreen())
|
|
292
|
+
|
|
293
|
+
async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
|
|
294
|
+
"""Provide commands in alphabetical order when palette opens."""
|
|
295
|
+
# Alphabetically ordered commands
|
|
296
|
+
yield DiscoveryHit(
|
|
297
|
+
"Codebase: Delete Codebase Index",
|
|
298
|
+
self.chat_screen.delete_codebase_command,
|
|
299
|
+
help="Delete an existing codebase index",
|
|
300
|
+
)
|
|
301
|
+
yield DiscoveryHit(
|
|
302
|
+
"Codebase: Index Codebase",
|
|
303
|
+
self.chat_screen.index_codebase_command,
|
|
304
|
+
help="Index a repository into the codebase graph",
|
|
305
|
+
)
|
|
306
|
+
yield DiscoveryHit(
|
|
307
|
+
"Open Provider Setup",
|
|
308
|
+
self.open_provider_config,
|
|
309
|
+
help="⚙️ Manage API keys for available providers",
|
|
310
|
+
)
|
|
311
|
+
yield DiscoveryHit(
|
|
312
|
+
"Select AI Model",
|
|
313
|
+
self.open_model_picker,
|
|
314
|
+
help="🤖 Choose which AI model to use",
|
|
315
|
+
)
|
|
316
|
+
yield DiscoveryHit(
|
|
317
|
+
"Show usage",
|
|
318
|
+
self.chat_screen.action_show_usage,
|
|
319
|
+
help="Display usage information for the current session",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def search(self, query: str) -> AsyncGenerator[Hit, None]:
|
|
323
|
+
"""Search for commands in alphabetical order."""
|
|
324
|
+
matcher = self.matcher(query)
|
|
325
|
+
|
|
326
|
+
# Define all commands in alphabetical order
|
|
327
|
+
commands = [
|
|
328
|
+
(
|
|
329
|
+
"Codebase: Delete Codebase Index",
|
|
330
|
+
self.chat_screen.delete_codebase_command,
|
|
331
|
+
"Delete an existing codebase index",
|
|
332
|
+
),
|
|
333
|
+
(
|
|
334
|
+
"Codebase: Index Codebase",
|
|
335
|
+
self.chat_screen.index_codebase_command,
|
|
336
|
+
"Index a repository into the codebase graph",
|
|
337
|
+
),
|
|
338
|
+
(
|
|
339
|
+
"Open Provider Setup",
|
|
340
|
+
self.open_provider_config,
|
|
341
|
+
"⚙️ Manage API keys for available providers",
|
|
342
|
+
),
|
|
343
|
+
(
|
|
344
|
+
"Select AI Model",
|
|
345
|
+
self.open_model_picker,
|
|
346
|
+
"🤖 Choose which AI model to use",
|
|
347
|
+
),
|
|
348
|
+
(
|
|
349
|
+
"Show usage",
|
|
350
|
+
self.chat_screen.action_show_usage,
|
|
351
|
+
"Display usage information for the current session",
|
|
352
|
+
),
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
for title, callback, help_text in commands:
|
|
356
|
+
score = matcher.match(title)
|
|
357
|
+
if score > 0:
|
|
358
|
+
yield Hit(score, matcher.highlight(title), callback, help=help_text)
|
|
@@ -217,7 +217,9 @@ class AgentResponseWidget(Widget):
|
|
|
217
217
|
return ""
|
|
218
218
|
for idx, part in enumerate(self.item.parts):
|
|
219
219
|
if isinstance(part, TextPart):
|
|
220
|
-
|
|
220
|
+
# Only show the circle prefix if there's actual content
|
|
221
|
+
if part.content and part.content.strip():
|
|
222
|
+
acc += f"**⏺** {part.content}\n\n"
|
|
221
223
|
elif isinstance(part, ToolCallPart):
|
|
222
224
|
parts_str = self._format_tool_call_part(part)
|
|
223
225
|
acc += parts_str + "\n\n"
|
|
@@ -12,11 +12,14 @@ from textual.screen import Screen
|
|
|
12
12
|
from textual.widgets import Button, Label, ListItem, ListView, Static
|
|
13
13
|
|
|
14
14
|
from shotgun.agents.config import ConfigManager
|
|
15
|
-
from shotgun.agents.config.models import MODEL_SPECS, ModelName
|
|
15
|
+
from shotgun.agents.config.models import MODEL_SPECS, ModelName, ShotgunConfig
|
|
16
|
+
from shotgun.logging_config import get_logger
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from ..app import ShotgunApp
|
|
19
20
|
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
# Available models for selection
|
|
22
25
|
AVAILABLE_MODELS = list(ModelName)
|
|
@@ -35,10 +38,6 @@ class ModelPickerScreen(Screen[None]):
|
|
|
35
38
|
layout: vertical;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
ModelPicker > * {
|
|
39
|
-
height: auto;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
41
|
#titlebox {
|
|
43
42
|
height: auto;
|
|
44
43
|
margin: 2 0;
|
|
@@ -60,6 +59,7 @@ class ModelPickerScreen(Screen[None]):
|
|
|
60
59
|
#model-list {
|
|
61
60
|
margin: 2 0;
|
|
62
61
|
height: auto;
|
|
62
|
+
padding: 1;
|
|
63
63
|
& > * {
|
|
64
64
|
padding: 1 0;
|
|
65
65
|
}
|
|
@@ -70,9 +70,6 @@ class ModelPickerScreen(Screen[None]):
|
|
|
70
70
|
#model-actions > * {
|
|
71
71
|
margin-right: 2;
|
|
72
72
|
}
|
|
73
|
-
#model-list {
|
|
74
|
-
padding: 1;
|
|
75
|
-
}
|
|
76
73
|
"""
|
|
77
74
|
|
|
78
75
|
BINDINGS = [
|
|
@@ -88,26 +85,76 @@ class ModelPickerScreen(Screen[None]):
|
|
|
88
85
|
"Select the AI model you want to use for your tasks.",
|
|
89
86
|
id="model-picker-summary",
|
|
90
87
|
)
|
|
91
|
-
yield ListView(
|
|
88
|
+
yield ListView(id="model-list")
|
|
92
89
|
with Horizontal(id="model-actions"):
|
|
93
90
|
yield Button("Select \\[ENTER]", variant="primary", id="select")
|
|
94
91
|
yield Button("Done \\[ESC]", id="done")
|
|
95
92
|
|
|
96
|
-
def
|
|
97
|
-
|
|
93
|
+
def _rebuild_model_list(self) -> None:
|
|
94
|
+
"""Rebuild the model list from current config.
|
|
95
|
+
|
|
96
|
+
This method is called both on first show and when screen is resumed
|
|
97
|
+
to ensure the list always reflects the current configuration.
|
|
98
|
+
"""
|
|
99
|
+
logger.debug("Rebuilding model list from current config")
|
|
100
|
+
|
|
101
|
+
# Load current config with force_reload to get latest API keys
|
|
98
102
|
config_manager = self.config_manager
|
|
99
|
-
config = config_manager.load()
|
|
100
|
-
|
|
103
|
+
config = config_manager.load(force_reload=True)
|
|
104
|
+
|
|
105
|
+
# Log provider key status
|
|
106
|
+
logger.debug(
|
|
107
|
+
"Provider keys: openai=%s, anthropic=%s, google=%s, shotgun=%s",
|
|
108
|
+
config_manager._provider_has_api_key(config.openai),
|
|
109
|
+
config_manager._provider_has_api_key(config.anthropic),
|
|
110
|
+
config_manager._provider_has_api_key(config.google),
|
|
111
|
+
config_manager._provider_has_api_key(config.shotgun),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
|
|
101
115
|
self.selected_model = current_model
|
|
116
|
+
logger.debug("Current selected model: %s", current_model)
|
|
102
117
|
|
|
103
|
-
#
|
|
118
|
+
# Rebuild the model list with current available models
|
|
104
119
|
list_view = self.query_one(ListView)
|
|
120
|
+
|
|
121
|
+
# Remove all existing items
|
|
122
|
+
old_count = len(list(list_view.children))
|
|
123
|
+
for child in list(list_view.children):
|
|
124
|
+
child.remove()
|
|
125
|
+
logger.debug("Removed %d existing model items from list", old_count)
|
|
126
|
+
|
|
127
|
+
# Add new items (labels already have correct text including current indicator)
|
|
128
|
+
new_items = self._build_model_items(config)
|
|
129
|
+
for item in new_items:
|
|
130
|
+
list_view.append(item)
|
|
131
|
+
logger.debug("Added %d available model items to list", len(new_items))
|
|
132
|
+
|
|
133
|
+
# Find and highlight current selection (if it's in the filtered list)
|
|
105
134
|
if list_view.children:
|
|
106
|
-
for i,
|
|
107
|
-
if
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
for i, child in enumerate(list_view.children):
|
|
136
|
+
if isinstance(child, ListItem) and child.id:
|
|
137
|
+
model_id = child.id.removeprefix("model-")
|
|
138
|
+
# Find the model name
|
|
139
|
+
for model_name in AVAILABLE_MODELS:
|
|
140
|
+
if _sanitize_model_name_for_id(model_name) == model_id:
|
|
141
|
+
if model_name == current_model:
|
|
142
|
+
list_view.index = i
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
def on_show(self) -> None:
|
|
146
|
+
"""Rebuild model list when screen is first shown."""
|
|
147
|
+
logger.debug("ModelPickerScreen.on_show() called")
|
|
148
|
+
self._rebuild_model_list()
|
|
149
|
+
|
|
150
|
+
def on_screenresume(self) -> None:
|
|
151
|
+
"""Rebuild model list when screen is resumed (subsequent visits).
|
|
152
|
+
|
|
153
|
+
This is called when returning to the screen after it was suspended,
|
|
154
|
+
ensuring the model list reflects any config changes made while away.
|
|
155
|
+
"""
|
|
156
|
+
logger.debug("ModelPickerScreen.on_screenresume() called")
|
|
157
|
+
self._rebuild_model_list()
|
|
111
158
|
|
|
112
159
|
def action_done(self) -> None:
|
|
113
160
|
self.dismiss()
|
|
@@ -139,11 +186,20 @@ class ModelPickerScreen(Screen[None]):
|
|
|
139
186
|
return app.config_manager
|
|
140
187
|
|
|
141
188
|
def refresh_model_labels(self) -> None:
|
|
142
|
-
"""Update the list view entries to reflect current selection.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
)
|
|
189
|
+
"""Update the list view entries to reflect current selection.
|
|
190
|
+
|
|
191
|
+
Note: This method only updates labels for currently displayed models.
|
|
192
|
+
To rebuild the entire list after provider changes, on_show() should be used.
|
|
193
|
+
"""
|
|
194
|
+
# Load config once with force_reload
|
|
195
|
+
config = self.config_manager.load(force_reload=True)
|
|
196
|
+
current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
|
|
197
|
+
|
|
198
|
+
# Update labels for available models only
|
|
146
199
|
for model_name in AVAILABLE_MODELS:
|
|
200
|
+
# Pass config to avoid multiple force reloads
|
|
201
|
+
if not self._is_model_available(model_name, config):
|
|
202
|
+
continue
|
|
147
203
|
label = self.query_one(
|
|
148
204
|
f"#label-{_sanitize_model_name_for_id(model_name)}", Label
|
|
149
205
|
)
|
|
@@ -151,10 +207,17 @@ class ModelPickerScreen(Screen[None]):
|
|
|
151
207
|
self._model_label(model_name, is_current=model_name == current_model)
|
|
152
208
|
)
|
|
153
209
|
|
|
154
|
-
def _build_model_items(self) -> list[ListItem]:
|
|
210
|
+
def _build_model_items(self, config: ShotgunConfig | None = None) -> list[ListItem]:
|
|
211
|
+
if config is None:
|
|
212
|
+
config = self.config_manager.load(force_reload=True)
|
|
213
|
+
|
|
155
214
|
items: list[ListItem] = []
|
|
156
215
|
current_model = self.selected_model
|
|
157
216
|
for model_name in AVAILABLE_MODELS:
|
|
217
|
+
# Only add models that are available
|
|
218
|
+
if not self._is_model_available(model_name, config):
|
|
219
|
+
continue
|
|
220
|
+
|
|
158
221
|
label = Label(
|
|
159
222
|
self._model_label(model_name, is_current=model_name == current_model),
|
|
160
223
|
id=f"label-{_sanitize_model_name_for_id(model_name)}",
|
|
@@ -165,6 +228,7 @@ class ModelPickerScreen(Screen[None]):
|
|
|
165
228
|
return items
|
|
166
229
|
|
|
167
230
|
def _model_from_item(self, item: ListItem | None) -> ModelName | None:
|
|
231
|
+
"""Get ModelName from a ListItem."""
|
|
168
232
|
if item is None or item.id is None:
|
|
169
233
|
return None
|
|
170
234
|
sanitized_id = item.id.removeprefix("model-")
|
|
@@ -174,6 +238,50 @@ class ModelPickerScreen(Screen[None]):
|
|
|
174
238
|
return model_name
|
|
175
239
|
return None
|
|
176
240
|
|
|
241
|
+
def _is_model_available(
|
|
242
|
+
self, model_name: ModelName, config: ShotgunConfig | None = None
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""Check if a model is available based on provider key configuration.
|
|
245
|
+
|
|
246
|
+
A model is available if:
|
|
247
|
+
1. Shotgun Account key is configured (provides access to all models), OR
|
|
248
|
+
2. The model's provider has an API key configured (BYOK mode)
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
model_name: The model to check availability for
|
|
252
|
+
config: Optional pre-loaded config to avoid multiple reloads
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True if the model can be used, False otherwise
|
|
256
|
+
"""
|
|
257
|
+
if config is None:
|
|
258
|
+
config = self.config_manager.load(force_reload=True)
|
|
259
|
+
|
|
260
|
+
# If Shotgun Account is configured, all models are available
|
|
261
|
+
if self.config_manager._provider_has_api_key(config.shotgun):
|
|
262
|
+
logger.debug("Model %s available (Shotgun Account configured)", model_name)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
# In BYOK mode, check if the model's provider has a key
|
|
266
|
+
if model_name not in MODEL_SPECS:
|
|
267
|
+
logger.debug("Model %s not available (not in MODEL_SPECS)", model_name)
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
spec = MODEL_SPECS[model_name]
|
|
271
|
+
# Check provider key directly using the loaded config to avoid stale cache
|
|
272
|
+
provider_config = self.config_manager._get_provider_config(
|
|
273
|
+
config, spec.provider
|
|
274
|
+
)
|
|
275
|
+
has_key = self.config_manager._provider_has_api_key(provider_config)
|
|
276
|
+
logger.debug(
|
|
277
|
+
"Model %s available=%s (provider=%s, has_key=%s)",
|
|
278
|
+
model_name,
|
|
279
|
+
has_key,
|
|
280
|
+
spec.provider,
|
|
281
|
+
has_key,
|
|
282
|
+
)
|
|
283
|
+
return has_key
|
|
284
|
+
|
|
177
285
|
def _model_label(self, model_name: ModelName, is_current: bool) -> str:
|
|
178
286
|
"""Generate label for model with specs and current indicator."""
|
|
179
287
|
if model_name not in MODEL_SPECS:
|
|
@@ -188,6 +296,10 @@ class ModelPickerScreen(Screen[None]):
|
|
|
188
296
|
|
|
189
297
|
label = f"{display_name} · {input_k}K context · {output_k}K output"
|
|
190
298
|
|
|
299
|
+
# Add cost indicator for expensive models
|
|
300
|
+
if model_name == ModelName.CLAUDE_OPUS_4_1:
|
|
301
|
+
label += " · Expensive"
|
|
302
|
+
|
|
191
303
|
if is_current:
|
|
192
304
|
label += " · Current"
|
|
193
305
|
|
|
@@ -12,12 +12,23 @@ from textual.screen import Screen
|
|
|
12
12
|
from textual.widgets import Button, Input, Label, ListItem, ListView, Markdown, Static
|
|
13
13
|
|
|
14
14
|
from shotgun.agents.config import ConfigManager, ProviderType
|
|
15
|
+
from shotgun.utils.env_utils import is_shotgun_account_enabled
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from ..app import ShotgunApp
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
def get_configurable_providers() -> list[str]:
|
|
22
|
+
"""Get list of configurable providers based on feature flags.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of provider identifiers that can be configured.
|
|
26
|
+
Includes shotgun only if SHOTGUN_ACCOUNT_ENABLED is set.
|
|
27
|
+
"""
|
|
28
|
+
providers = ["openai", "anthropic", "google"]
|
|
29
|
+
if is_shotgun_account_enabled():
|
|
30
|
+
providers.append("shotgun")
|
|
31
|
+
return providers
|
|
21
32
|
|
|
22
33
|
|
|
23
34
|
class ProviderConfigScreen(Screen[None]):
|
|
@@ -108,6 +119,13 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
108
119
|
self.selected_provider = "openai"
|
|
109
120
|
self.set_focus(self.query_one("#api-key", Input))
|
|
110
121
|
|
|
122
|
+
def on_screenresume(self) -> None:
|
|
123
|
+
"""Refresh provider status when screen is resumed.
|
|
124
|
+
|
|
125
|
+
This ensures the UI reflects any provider changes made elsewhere.
|
|
126
|
+
"""
|
|
127
|
+
self.refresh_provider_status()
|
|
128
|
+
|
|
111
129
|
def action_done(self) -> None:
|
|
112
130
|
self.dismiss()
|
|
113
131
|
|
|
@@ -155,13 +173,13 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
155
173
|
|
|
156
174
|
def refresh_provider_status(self) -> None:
|
|
157
175
|
"""Update the list view entries to reflect configured providers."""
|
|
158
|
-
for provider_id in
|
|
176
|
+
for provider_id in get_configurable_providers():
|
|
159
177
|
label = self.query_one(f"#label-{provider_id}", Label)
|
|
160
178
|
label.update(self._provider_label(provider_id))
|
|
161
179
|
|
|
162
180
|
def _build_provider_items(self) -> list[ListItem]:
|
|
163
181
|
items: list[ListItem] = []
|
|
164
|
-
for provider_id in
|
|
182
|
+
for provider_id in get_configurable_providers():
|
|
165
183
|
label = Label(self._provider_label(provider_id), id=f"label-{provider_id}")
|
|
166
184
|
items.append(ListItem(label, id=f"provider-{provider_id}"))
|
|
167
185
|
return items
|
|
@@ -170,7 +188,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
170
188
|
if item is None or item.id is None:
|
|
171
189
|
return None
|
|
172
190
|
provider_id = item.id.removeprefix("provider-")
|
|
173
|
-
return provider_id if provider_id in
|
|
191
|
+
return provider_id if provider_id in get_configurable_providers() else None
|
|
174
192
|
|
|
175
193
|
def _provider_label(self, provider_id: str) -> str:
|
|
176
194
|
display = self._provider_display_name(provider_id)
|
shotgun/utils/env_utils.py
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
"""Utilities for working with environment variables."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_shotgun_account_enabled() -> bool:
|
|
7
|
+
"""Check if Shotgun Account feature is enabled via environment variable.
|
|
8
|
+
|
|
9
|
+
Returns:
|
|
10
|
+
True if SHOTGUN_ACCOUNT_ENABLED is set to a truthy value,
|
|
11
|
+
False otherwise
|
|
12
|
+
"""
|
|
13
|
+
return is_truthy(os.environ.get("SHOTGUN_ACCOUNT_ENABLED"))
|
|
14
|
+
|
|
3
15
|
|
|
4
16
|
def is_truthy(value: str | None) -> bool:
|
|
5
17
|
"""Check if a string value represents true.
|
|
@@ -22,9 +22,9 @@ shotgun/agents/tasks.py,sha256=nk8zIl24o01hfzOGyWSbeVWeke6OGseO4Ppciurh13U,2999
|
|
|
22
22
|
shotgun/agents/usage_manager.py,sha256=5d9JC4_cthXwhTSytMfMExMDAUYp8_nkPepTJZXk13w,5017
|
|
23
23
|
shotgun/agents/config/__init__.py,sha256=Fl8K_81zBpm-OfOW27M_WWLSFdaHHek6lWz95iDREjQ,318
|
|
24
24
|
shotgun/agents/config/constants.py,sha256=I3f0ueoQaTg5HddXGCYimCYpj-U57z3IBQYIVJxVIhg,872
|
|
25
|
-
shotgun/agents/config/manager.py,sha256=
|
|
25
|
+
shotgun/agents/config/manager.py,sha256=2QBpaWBV3WAqZR-wFHE9Ufwen05KHQbEkPzjSb3r_V0,15140
|
|
26
26
|
shotgun/agents/config/models.py,sha256=ZojhfheNO337e1icy_cE2PpBXIl5oHkdajr4azzFF-U,5106
|
|
27
|
-
shotgun/agents/config/provider.py,sha256=
|
|
27
|
+
shotgun/agents/config/provider.py,sha256=TwwZC_BtYSOpN2jdX6WZdor29EnAqfMoQK5GmNEYaPI,11012
|
|
28
28
|
shotgun/agents/history/__init__.py,sha256=XFQj2a6fxDqVg0Q3juvN9RjV_RJbgvFZtQOCOjVJyp4,147
|
|
29
29
|
shotgun/agents/history/compaction.py,sha256=9RMpG0aY_7L4TecbgwHSOkGtbd9W5XZTg-MbzZmNl00,3515
|
|
30
30
|
shotgun/agents/history/constants.py,sha256=yWY8rrTZarLA3flCCMB_hS2NMvUDRDTwP4D4j7MIh1w,446
|
|
@@ -56,7 +56,7 @@ shotgun/agents/tools/web_search/gemini.py,sha256=-fI_deaBT4-_61A7KlKtz8tmKXW50fV
|
|
|
56
56
|
shotgun/agents/tools/web_search/openai.py,sha256=pnIcTV3vwXJQuxPs4I7gQNX18XzM7D7FqeNxnn1E7yw,3437
|
|
57
57
|
shotgun/agents/tools/web_search/utils.py,sha256=GLJ5QV9bT2ubFMuFN7caMN7tK9OTJ0R3GD57B-tCMF0,532
|
|
58
58
|
shotgun/cli/__init__.py,sha256=_F1uW2g87y4bGFxz8Gp8u7mq2voHp8vQIUtCmm8Tojo,40
|
|
59
|
-
shotgun/cli/config.py,sha256=
|
|
59
|
+
shotgun/cli/config.py,sha256=Lrcqxm7W7I6g6iP_K5-yK7QFOgcYt5KIc8A6Wit1Ksg,7835
|
|
60
60
|
shotgun/cli/export.py,sha256=3hIwK2_OM1MFYSTfzBxsGuuBGm5fo0XdxASfQ5Uqb3Y,2471
|
|
61
61
|
shotgun/cli/feedback.py,sha256=Me1dQQgkYwP4AIFwYgfHcPXxFdJ6CzFbCBttKcFd2Q0,1238
|
|
62
62
|
shotgun/cli/models.py,sha256=kwZEldQWUheNsqF_ezgDzRBc6h0Y0JxFw1VMQjZlvPE,182
|
|
@@ -114,7 +114,7 @@ shotgun/sdk/exceptions.py,sha256=qBcQv0v7ZTwP7CMcxZST4GqCsfOWtOUjSzGBo0-heqo,412
|
|
|
114
114
|
shotgun/sdk/models.py,sha256=X9nOTUHH0cdkQW1NfnMEDu-QgK9oUsEISh1Jtwr5Am4,5496
|
|
115
115
|
shotgun/sdk/services.py,sha256=J4PJFSxCQ6--u7rb3Ta-9eYtlYcxcbnzrMP6ThyCnw4,705
|
|
116
116
|
shotgun/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
117
|
-
shotgun/tui/app.py,sha256=
|
|
117
|
+
shotgun/tui/app.py,sha256=sBPviBs-3niD8rDoD0wC27lfBsKRbFYMUPttOwmDzOM,5139
|
|
118
118
|
shotgun/tui/filtered_codebase_service.py,sha256=lJ8gTMhIveTatmvmGLP299msWWTkVYKwvY_2FhuL2s4,1687
|
|
119
119
|
shotgun/tui/styles.tcss,sha256=ETyyw1bpMBOqTi5RLcAJUScdPWTvAWEqE9YcT0kVs_E,121
|
|
120
120
|
shotgun/tui/commands/__init__.py,sha256=8D5lvtpqMW5-fF7Bg3oJtUzU75cKOv6aUaHYYszydU8,2518
|
|
@@ -122,26 +122,26 @@ shotgun/tui/components/prompt_input.py,sha256=Ss-htqraHZAPaehGE4x86ij0veMjc4Ugad
|
|
|
122
122
|
shotgun/tui/components/spinner.py,sha256=ovTDeaJ6FD6chZx_Aepia6R3UkPOVJ77EKHfRmn39MY,2427
|
|
123
123
|
shotgun/tui/components/splash.py,sha256=vppy9vEIEvywuUKRXn2y11HwXSRkQZHLYoVjhDVdJeU,1267
|
|
124
124
|
shotgun/tui/components/vertical_tail.py,sha256=kROwTaRjUwVB7H35dtmNcUVPQqNYvvfq7K2tXBKEb6c,638
|
|
125
|
-
shotgun/tui/screens/chat.py,sha256=
|
|
125
|
+
shotgun/tui/screens/chat.py,sha256=CqAv_x6R4zl-MGbtg8KgZWt8OhpBJYpx5gGBQ3oxqgw,30313
|
|
126
126
|
shotgun/tui/screens/chat.tcss,sha256=2Yq3E23jxsySYsgZf4G1AYrYVcpX0UDW6kNNI0tDmtM,437
|
|
127
127
|
shotgun/tui/screens/directory_setup.py,sha256=lIZ1J4A6g5Q2ZBX8epW7BhR96Dmdcg22CyiM5S-I5WU,3237
|
|
128
128
|
shotgun/tui/screens/feedback.py,sha256=cYtmuM3qqKwevstu8gJ9mmk7lkIKZvfAyDEBUOLh-yI,5660
|
|
129
|
-
shotgun/tui/screens/model_picker.py,sha256=
|
|
130
|
-
shotgun/tui/screens/provider_config.py,sha256=
|
|
129
|
+
shotgun/tui/screens/model_picker.py,sha256=G-EvalpxgHKk0W3FgHMcxIr817VwZyEgh_ZadSQiRwo,11831
|
|
130
|
+
shotgun/tui/screens/provider_config.py,sha256=RWH7ksf9dp7eD7mz0g_o2Q-O6HTAyzIv7ZkOYrIOAI4,8686
|
|
131
131
|
shotgun/tui/screens/splash.py,sha256=E2MsJihi3c9NY1L28o_MstDxGwrCnnV7zdq00MrGAsw,706
|
|
132
132
|
shotgun/tui/screens/chat_screen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
133
|
-
shotgun/tui/screens/chat_screen/command_providers.py,sha256=
|
|
133
|
+
shotgun/tui/screens/chat_screen/command_providers.py,sha256=7Xnxd4k30bpLOMZSX32bcugU4IgpqU4Y8f6eHWKXd4o,12694
|
|
134
134
|
shotgun/tui/screens/chat_screen/hint_message.py,sha256=WOpbk8q7qt7eOHTyyHvh_IQIaublVDeJGaLpsxEk9FA,933
|
|
135
|
-
shotgun/tui/screens/chat_screen/history.py,sha256=
|
|
135
|
+
shotgun/tui/screens/chat_screen/history.py,sha256=Go859iEjw0s5aELKpF42MjLXy7UFQ52XnJMTIkV3aLo,12406
|
|
136
136
|
shotgun/tui/utils/__init__.py,sha256=cFjDfoXTRBq29wgP7TGRWUu1eFfiIG-LLOzjIGfadgI,150
|
|
137
137
|
shotgun/tui/utils/mode_progress.py,sha256=lseRRo7kMWLkBzI3cU5vqJmS2ZcCjyRYf9Zwtvc-v58,10931
|
|
138
138
|
shotgun/utils/__init__.py,sha256=WinIEp9oL2iMrWaDkXz2QX4nYVPAm8C9aBSKTeEwLtE,198
|
|
139
|
-
shotgun/utils/env_utils.py,sha256=
|
|
139
|
+
shotgun/utils/env_utils.py,sha256=5spVCdeqVKtlWoKocPhz_5j_iRN30neqcGUzUuiWmfc,1365
|
|
140
140
|
shotgun/utils/file_system_utils.py,sha256=l-0p1bEHF34OU19MahnRFdClHufThfGAjQ431teAIp0,1004
|
|
141
141
|
shotgun/utils/source_detection.py,sha256=Co6Q03R3fT771TF3RzB-70stfjNP2S4F_ArZKibwzm8,454
|
|
142
142
|
shotgun/utils/update_checker.py,sha256=IgzPHRhS1ETH7PnJR_dIx6lxgr1qHpCkMTgzUxvGjhI,7586
|
|
143
|
-
shotgun_sh-0.2.1.
|
|
144
|
-
shotgun_sh-0.2.1.
|
|
145
|
-
shotgun_sh-0.2.1.
|
|
146
|
-
shotgun_sh-0.2.1.
|
|
147
|
-
shotgun_sh-0.2.1.
|
|
143
|
+
shotgun_sh-0.2.1.dev4.dist-info/METADATA,sha256=V5zx_jzJA35YpklMNplhfdKnV63Emod0dZb0omi8x5U,11226
|
|
144
|
+
shotgun_sh-0.2.1.dev4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
145
|
+
shotgun_sh-0.2.1.dev4.dist-info/entry_points.txt,sha256=asZxLU4QILneq0MWW10saVCZc4VWhZfb0wFZvERnzfA,45
|
|
146
|
+
shotgun_sh-0.2.1.dev4.dist-info/licenses/LICENSE,sha256=YebsZl590zCHrF_acCU5pmNt0pnAfD2DmAnevJPB1tY,1065
|
|
147
|
+
shotgun_sh-0.2.1.dev4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|