titan-cli 0.1.0__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.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
@@ -0,0 +1,498 @@
1
+ """
2
+ AI Configuration Screen
3
+
4
+ Screen for managing AI providers (list, add, set default, test, delete).
5
+ """
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.widgets import Static, LoadingIndicator
9
+ from textual.containers import Container, Horizontal, VerticalScroll, Grid
10
+ from textual.binding import Binding
11
+ from textual.screen import ModalScreen
12
+
13
+ from titan_cli.ui.tui.icons import Icons
14
+ from titan_cli.ui.tui.widgets import DimText, Button, SuccessText, ErrorText
15
+ from .base import BaseScreen
16
+ import tomli
17
+ import tomli_w
18
+ from titan_cli.core.config import TitanConfig
19
+
20
+
21
+ class TestConnectionModal(ModalScreen):
22
+ """Modal screen for testing AI provider connection."""
23
+
24
+ DEFAULT_CSS = """
25
+ TestConnectionModal {
26
+ align: center middle;
27
+ }
28
+
29
+ #test-modal-container {
30
+ width: 70;
31
+ height: auto;
32
+ background: $surface-lighten-1;
33
+ border: solid $primary;
34
+ padding: 2;
35
+ }
36
+
37
+ #test-modal-header {
38
+ text-style: bold;
39
+ text-align: center;
40
+ margin-bottom: 2;
41
+ }
42
+
43
+ #test-modal-content {
44
+ height: auto;
45
+ min-height: 15;
46
+ align: center middle;
47
+ }
48
+
49
+ #test-modal-buttons {
50
+ height: auto;
51
+ align: center middle;
52
+ margin-top: 2;
53
+ }
54
+
55
+ #loading-container {
56
+ width: 100%;
57
+ height: auto;
58
+ align: center middle;
59
+ }
60
+
61
+ LoadingIndicator {
62
+ height: 5;
63
+ margin: 2 0;
64
+ }
65
+
66
+ .center-text {
67
+ text-align: center;
68
+ width: 100%;
69
+ }
70
+ """
71
+
72
+ BINDINGS = [
73
+ Binding("escape", "close_modal", "Close"),
74
+ ]
75
+
76
+ def __init__(self, provider_id: str, provider_cfg, config, **kwargs):
77
+ super().__init__(**kwargs)
78
+ self.provider_id = provider_id
79
+ self.provider_cfg = provider_cfg
80
+ self.config = config
81
+
82
+ def compose(self) -> ComposeResult:
83
+ """Compose the test modal."""
84
+ with Container(id="test-modal-container"):
85
+ yield Static(f"{Icons.SETTINGS} Testing connection: {self.provider_cfg.name}", id="test-modal-header")
86
+ yield Container(id="test-modal-content")
87
+ with Container(id="test-modal-buttons"):
88
+ yield Button("Close", variant="default", id="close-modal-button")
89
+
90
+ def on_mount(self) -> None:
91
+ """Start the test when modal mounts."""
92
+ # Show loading indicator
93
+ content = self.query_one("#test-modal-content", Container)
94
+
95
+ # Mount loading indicator and text directly
96
+ content.mount(LoadingIndicator())
97
+ content.mount(DimText(f"\nTesting connection to {self.provider_cfg.name}...", classes="center-text"))
98
+
99
+ # Run test in background AFTER the UI has refreshed
100
+ self.call_after_refresh(self._start_test)
101
+
102
+ def _start_test(self) -> None:
103
+ """Start the test after UI refresh."""
104
+ self.run_worker(self._run_test(), exclusive=True)
105
+
106
+ async def _run_test(self) -> None:
107
+ """Run the test asynchronously."""
108
+ import asyncio
109
+ from titan_cli.core.secrets import SecretManager
110
+ from titan_cli.ai.client import AIClient
111
+ from titan_cli.ai.models import AIMessage
112
+
113
+ content = self.query_one("#test-modal-content", Container)
114
+ secrets = SecretManager()
115
+
116
+ try:
117
+ # Initialize AIClient with the specific provider_id
118
+ ai_client = AIClient(self.config.config.ai, secrets, provider_id=self.provider_id)
119
+
120
+ # Run the blocking generate call in a thread to keep UI responsive
121
+ response = await asyncio.to_thread(
122
+ ai_client.generate,
123
+ messages=[AIMessage(role="user", content="Say 'Hello!' if you can hear me")],
124
+ max_tokens=200
125
+ )
126
+
127
+ # Show success - remove loading and show result
128
+ content.remove_children()
129
+ content.mount(SuccessText(f"{Icons.CHECK} Connection successful!\n\n"))
130
+
131
+ model_info = f" with model '{self.provider_cfg.model}'" if self.provider_cfg.model else ""
132
+ endpoint_info = " (custom endpoint)" if self.provider_cfg.base_url else ""
133
+
134
+ content.mount(DimText(f"Provider: {self.provider_cfg.provider}{model_info}{endpoint_info}\n"))
135
+ content.mount(DimText(f"Model: {response.model}\n"))
136
+ content.mount(DimText(f"\nResponse: {response.content}"))
137
+
138
+ except Exception as e:
139
+ # Show error - remove loading and show error
140
+ content.remove_children()
141
+ content.mount(ErrorText(f"{Icons.ERROR} Connection failed!\n\n"))
142
+ content.mount(DimText(f"Error: {str(e)}"))
143
+
144
+ def on_button_pressed(self, event: Button.Pressed) -> None:
145
+ """Handle button press."""
146
+ if event.button.id == "close-modal-button":
147
+ self.dismiss()
148
+
149
+ def action_close_modal(self) -> None:
150
+ """Close the modal."""
151
+ self.dismiss()
152
+
153
+
154
+ class ProviderCard(Container):
155
+ """Widget showing a single AI provider with action buttons."""
156
+
157
+ DEFAULT_CSS = """
158
+ ProviderCard {
159
+ width: 100%;
160
+ max-width: 60;
161
+ height: auto;
162
+ background: $surface-lighten-1;
163
+ border: solid $accent;
164
+ padding: 1 2;
165
+ }
166
+
167
+ ProviderCard.default {
168
+ border: solid $primary;
169
+ }
170
+
171
+ ProviderCard .provider-name {
172
+ text-style: bold;
173
+ }
174
+
175
+ ProviderCard .provider-info {
176
+ color: $text-muted;
177
+ }
178
+
179
+ ProviderCard .button-row {
180
+ height: auto;
181
+ margin-top: 1;
182
+ }
183
+ """
184
+
185
+ def __init__(self, provider_id: str, provider_cfg: dict, is_default: bool = False, **kwargs):
186
+ super().__init__(**kwargs)
187
+ self.provider_id = provider_id
188
+ self.provider_cfg = provider_cfg
189
+ self.is_default = is_default
190
+
191
+ def compose(self) -> ComposeResult:
192
+ """Compose the provider card."""
193
+ import re
194
+
195
+ # Clean provider_id for use in button IDs (only allow valid characters)
196
+ clean_id = re.sub(r'[^a-z0-9_-]', '', self.provider_id.lower())
197
+
198
+ # Provider name with default indicator
199
+ name = self.provider_cfg.get("name", self.provider_id)
200
+ default_marker = f"{Icons.STAR} " if self.is_default else ""
201
+ yield Static(f"{default_marker}{name}", classes="provider-name")
202
+
203
+ # Provider details
204
+ provider = self.provider_cfg.get("provider", "")
205
+ provider_label = "Anthropic" if provider == "anthropic" else "Google" if provider == "gemini" else provider
206
+ model = self.provider_cfg.get("model", "")
207
+
208
+ yield DimText(f"Provider: {provider_label} (Claude)" if provider == "anthropic" else f"Provider: {provider_label} (Gemini)", classes="provider-info")
209
+ yield DimText(f"Model: {model}", classes="provider-info")
210
+
211
+ # Show base URL (always show this line for consistent height)
212
+ base_url = self.provider_cfg.get("base_url")
213
+ if base_url:
214
+ yield DimText(f"Base URL: {base_url}", classes="provider-info")
215
+ else:
216
+ # Add empty line to maintain consistent height
217
+ yield DimText(" ", classes="provider-info")
218
+
219
+ # Show type
220
+ config_type = self.provider_cfg.get("type", "")
221
+ type_label = "Corporate" if config_type == "corporate" else "Individual"
222
+ yield DimText(f"Type: {type_label}", classes="provider-info")
223
+
224
+ # Action buttons (use cleaned ID for button IDs)
225
+ with Horizontal(classes="button-row"):
226
+ if not self.is_default:
227
+ yield Button("Set Default", variant="primary", id=f"set-default-{clean_id}")
228
+ yield Button("Test Connection", variant="default", id=f"test-{clean_id}")
229
+ yield Button("Delete", variant="error", id=f"delete-{clean_id}")
230
+
231
+
232
+ class AIConfigScreen(BaseScreen):
233
+ """
234
+ Screen for AI provider configuration and management.
235
+ """
236
+
237
+ BINDINGS = [
238
+ Binding("escape", "back", "Back"),
239
+ ]
240
+
241
+ CSS = """
242
+ AIConfigScreen {
243
+ align: center middle;
244
+ }
245
+
246
+ #config-container {
247
+ width: 85%;
248
+ height: 1fr;
249
+ background: $surface-lighten-1;
250
+ padding: 0 2 1 2;
251
+ margin: 1 0 1 0;
252
+ }
253
+
254
+ #providers-scroll {
255
+ height: 1fr;
256
+ padding: 1 0;
257
+ align: center top;
258
+ overflow-y: auto;
259
+ }
260
+
261
+ #providers-grid {
262
+ grid-size: 2;
263
+ grid-gutter: 2;
264
+ width: 70%;
265
+ height: auto;
266
+ }
267
+
268
+ #no-providers {
269
+ text-align: center;
270
+ color: $text-muted;
271
+ margin: 8 0;
272
+ column-span: 2;
273
+ }
274
+
275
+ #add-provider-container {
276
+ height: auto;
277
+ padding: 0 0;
278
+ align: center middle;
279
+ }
280
+ """
281
+
282
+ def __init__(self, config):
283
+ super().__init__(
284
+ config,
285
+ title=f"{Icons.SETTINGS} AI Configuration",
286
+ show_back=True
287
+ )
288
+
289
+ def compose_content(self) -> ComposeResult:
290
+ """Compose the AI configuration screen."""
291
+ with Container(id="config-container"):
292
+ # Scrollable area with grid for providers
293
+ with VerticalScroll(id="providers-scroll"):
294
+ yield Grid(id="providers-grid")
295
+
296
+ # Add provider button at the bottom
297
+ with Container(id="add-provider-container"):
298
+ yield Button(f"{Icons.SETTINGS} New Provider", variant="primary", id="add-provider-button")
299
+
300
+ def on_mount(self) -> None:
301
+ """Load providers when mounted."""
302
+ self.load_providers()
303
+
304
+ def on_screen_resume(self) -> None:
305
+ """Reload providers when returning from wizard."""
306
+ self.load_providers()
307
+ # Update status bar in case a new provider was added
308
+ self._refresh_status_bar()
309
+
310
+ def _refresh_status_bar(self) -> None:
311
+ """Refresh the status bar with current AI info."""
312
+ try:
313
+ from titan_cli.ui.tui.widgets import StatusBarWidget
314
+ status_bar = self.query_one(StatusBarWidget)
315
+ self._update_status_bar(status_bar)
316
+ except Exception:
317
+ pass # Status bar might not be available
318
+
319
+ def load_providers(self) -> None:
320
+ """Load and display all configured providers."""
321
+ # Reload config to get latest
322
+ self.config.load()
323
+
324
+ # Get the grid
325
+ try:
326
+ grid = self.query_one("#providers-grid", Grid)
327
+ except Exception:
328
+ # Grid was removed, recreate it
329
+ scroll = self.query_one("#providers-scroll", VerticalScroll)
330
+ # Remove no-providers message if it exists
331
+ try:
332
+ no_prov = self.query_one("#no-providers", Static)
333
+ no_prov.remove()
334
+ except Exception:
335
+ pass
336
+ grid = Grid(id="providers-grid")
337
+ scroll.mount(grid)
338
+
339
+ grid.remove_children()
340
+
341
+ if not self.config.config.ai or not self.config.config.ai.providers:
342
+ # Remove any existing no-providers message first
343
+ try:
344
+ existing = self.query_one("#no-providers", Static)
345
+ existing.remove()
346
+ except Exception:
347
+ pass
348
+
349
+ # Show no providers message
350
+ grid.mount(Static(
351
+ "No AI providers configured yet.\n\n"
352
+ "Click 'Add New Provider' to configure your first provider.",
353
+ id="no-providers"
354
+ ))
355
+ return
356
+
357
+ # Get default provider
358
+ default_id = self.config.config.ai.default
359
+
360
+ # Display each provider
361
+ for provider_id, provider_cfg in self.config.config.ai.providers.items():
362
+ is_default = (provider_id == default_id)
363
+ card = ProviderCard(
364
+ provider_id=provider_id,
365
+ provider_cfg=provider_cfg.dict(),
366
+ is_default=is_default
367
+ )
368
+ if is_default:
369
+ card.add_class("default")
370
+ grid.mount(card)
371
+
372
+ def on_button_pressed(self, event: Button.Pressed) -> None:
373
+ """Handle button presses."""
374
+ button_id = event.button.id
375
+
376
+ if button_id == "add-provider-button":
377
+ self.handle_add_provider()
378
+ elif button_id.startswith("set-default-") or button_id.startswith("test-") or button_id.startswith("delete-"):
379
+ # Find the ProviderCard that contains this button
380
+ card = event.button.parent.parent # Button -> Horizontal -> ProviderCard
381
+ if isinstance(card, ProviderCard):
382
+ provider_id = card.provider_id
383
+
384
+ if button_id.startswith("set-default-"):
385
+ self.handle_set_default(provider_id)
386
+ elif button_id.startswith("test-"):
387
+ self.handle_test_connection(provider_id)
388
+ elif button_id.startswith("delete-"):
389
+ self.handle_delete(provider_id)
390
+
391
+ def handle_add_provider(self) -> None:
392
+ """Open the configuration wizard to add a new provider."""
393
+ from .ai_config_wizard import AIConfigWizardScreen
394
+
395
+ self.app.push_screen(AIConfigWizardScreen(self.config))
396
+
397
+ def handle_set_default(self, provider_id: str) -> None:
398
+ """Set a provider as default."""
399
+
400
+ try:
401
+ # Load global config
402
+ global_config_path = TitanConfig.GLOBAL_CONFIG
403
+ with open(global_config_path, "rb") as f:
404
+ global_config_data = tomli.load(f)
405
+
406
+ # Update default
407
+ global_config_data["ai"]["default"] = provider_id
408
+
409
+ # Save to disk
410
+ with open(global_config_path, "wb") as f:
411
+ tomli_w.dump(global_config_data, f)
412
+
413
+ # Reload and refresh display
414
+ self.config.load()
415
+ self.load_providers()
416
+
417
+ # Update status bar
418
+ self._refresh_status_bar()
419
+
420
+ provider_name = self.config.config.ai.providers[provider_id].name
421
+ self.app.notify(f"'{provider_name}' is now the default provider", severity="information")
422
+
423
+ except Exception as e:
424
+ self.app.notify(f"Failed to set default: {e}", severity="error")
425
+
426
+ def handle_test_connection(self, provider_id: str) -> None:
427
+ """Test connection to a provider."""
428
+ # Reload config to get latest
429
+ self.config.load()
430
+
431
+ if provider_id not in self.config.config.ai.providers:
432
+ self.app.notify("Provider not found", severity="error")
433
+ return
434
+
435
+ provider_cfg = self.config.config.ai.providers[provider_id]
436
+
437
+ # Open modal
438
+ self.app.push_screen(TestConnectionModal(provider_id, provider_cfg, self.config))
439
+
440
+ def handle_delete(self, provider_id: str) -> None:
441
+ """Delete a provider."""
442
+ import tomli
443
+ import tomli_w
444
+ from titan_cli.core.config import TitanConfig
445
+ from titan_cli.core.secrets import SecretManager
446
+
447
+ try:
448
+ # Reload config
449
+ self.config.load()
450
+
451
+ if provider_id not in self.config.config.ai.providers:
452
+ self.app.notify("Provider not found", severity="error")
453
+ return
454
+
455
+ provider_name = self.config.config.ai.providers[provider_id].name
456
+
457
+ # Load global config
458
+ global_config_path = TitanConfig.GLOBAL_CONFIG
459
+ with open(global_config_path, "rb") as f:
460
+ global_config_data = tomli.load(f)
461
+
462
+ # Remove provider
463
+ del global_config_data["ai"]["providers"][provider_id]
464
+
465
+ # If this was the default, set a new default (first available)
466
+ if global_config_data["ai"].get("default") == provider_id:
467
+ remaining_providers = list(global_config_data["ai"]["providers"].keys())
468
+ if remaining_providers:
469
+ global_config_data["ai"]["default"] = remaining_providers[0]
470
+ else:
471
+ global_config_data["ai"]["default"] = None
472
+
473
+ # Save to disk
474
+ with open(global_config_path, "wb") as f:
475
+ tomli_w.dump(global_config_data, f)
476
+
477
+ # Delete API key from secrets
478
+ secrets = SecretManager()
479
+ try:
480
+ secrets.delete(f"{provider_id}_api_key", scope="user")
481
+ except Exception:
482
+ pass # Key might not exist
483
+
484
+ # Reload and refresh display
485
+ self.config.load()
486
+ self.load_providers()
487
+
488
+ # Update status bar
489
+ self._refresh_status_bar()
490
+
491
+ self.app.notify(f"Provider '{provider_name}' deleted", severity="information")
492
+
493
+ except Exception as e:
494
+ self.app.notify(f"Failed to delete provider: {e}", severity="error")
495
+
496
+ def action_back(self) -> None:
497
+ """Go back to main menu."""
498
+ self.app.pop_screen()