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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- 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()
|