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,882 @@
1
+ """
2
+ AI Configuration Wizard Screen
3
+
4
+ Step-by-step wizard for configuring AI providers with visual progress tracking.
5
+ """
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.widgets import Static, OptionList, Input
9
+ from textual.widgets.option_list import Option
10
+ from textual.containers import Container, Horizontal, VerticalScroll
11
+ from textual.binding import Binding
12
+
13
+ from titan_cli.ui.tui.icons import Icons
14
+ from titan_cli.ui.tui.widgets import Text, DimText, Button
15
+ from .base import BaseScreen
16
+
17
+
18
+ class StepIndicator(Static):
19
+ """Widget showing a single step with status indicator."""
20
+
21
+ def __init__(self, step_number: int, title: str, status: str = "pending"):
22
+ self.step_number = step_number
23
+ self.title = title
24
+ self.status = status
25
+ super().__init__()
26
+
27
+ def render(self) -> str:
28
+ """Render the step with appropriate icon."""
29
+ if self.status == "completed":
30
+ icon = Icons.SUCCESS
31
+ style = "dim"
32
+ elif self.status == "in_progress":
33
+ icon = Icons.RUNNING
34
+ style = "bold cyan"
35
+ else: # pending
36
+ icon = Icons.PENDING
37
+ style = "dim"
38
+
39
+ return f"[{style}]{icon} {self.step_number}. {self.title}[/{style}]"
40
+
41
+
42
+ class AIConfigWizardScreen(BaseScreen):
43
+ """
44
+ Wizard screen for AI provider configuration.
45
+ """
46
+
47
+ BINDINGS = [
48
+ Binding("escape", "back", "Back"),
49
+ ]
50
+
51
+ CSS = """
52
+ AIConfigWizardScreen {
53
+ align: center middle;
54
+ }
55
+
56
+ #wizard-container {
57
+ width: 100%;
58
+ height: 1fr;
59
+ background: $surface-lighten-1;
60
+ padding: 0 2 1 2;
61
+ }
62
+
63
+ #steps-panel {
64
+ width: 20%;
65
+ height: 100%;
66
+ border: round $primary;
67
+ border-title-align: center;
68
+ background: $surface-lighten-1;
69
+ padding: 0;
70
+ }
71
+
72
+ #steps-content {
73
+ padding: 1;
74
+ }
75
+
76
+ StepIndicator {
77
+ height: auto;
78
+ margin-bottom: 1;
79
+ }
80
+
81
+ #content-panel {
82
+ width: 80%;
83
+ height: 100%;
84
+ border: round $primary;
85
+ border-title-align: center;
86
+ background: $surface-lighten-1;
87
+ padding: 0;
88
+ layout: vertical;
89
+ }
90
+
91
+ #content-scroll {
92
+ height: 1fr;
93
+ }
94
+
95
+ #content-area {
96
+ padding: 1;
97
+ height: auto;
98
+ }
99
+
100
+ #content-title {
101
+ color: $accent;
102
+ text-style: bold;
103
+ margin-bottom: 2;
104
+ height: auto;
105
+ }
106
+
107
+ #content-body {
108
+ height: auto;
109
+ margin-bottom: 2;
110
+ }
111
+
112
+ #steps-content {
113
+ height: auto;
114
+ }
115
+
116
+ #button-container {
117
+ height: auto;
118
+ padding: 1 2;
119
+ background: $surface-lighten-1;
120
+ border-top: solid $primary;
121
+ align: right middle;
122
+ }
123
+
124
+ #type-options-list, #provider-options-list {
125
+ height: auto;
126
+ margin-top: 1;
127
+ margin-bottom: 2;
128
+ border: solid $accent;
129
+ }
130
+
131
+ #type-options-list > .option-list--option,
132
+ #provider-options-list > .option-list--option {
133
+ padding: 1;
134
+ }
135
+
136
+ #type-options-list > .option-list--option-highlighted,
137
+ #provider-options-list > .option-list--option-highlighted {
138
+ padding: 1;
139
+ }
140
+
141
+ Input {
142
+ width: 100%;
143
+ margin-top: 1;
144
+ border: solid $accent;
145
+ }
146
+
147
+ Input:focus {
148
+ border: solid $primary;
149
+ }
150
+ """
151
+
152
+ def __init__(self, config):
153
+ super().__init__(
154
+ config,
155
+ title=f"{Icons.SETTINGS} Configure AI Provider",
156
+ show_back=True,
157
+ show_status_bar=False
158
+ )
159
+ self.current_step = 0
160
+ self.wizard_data = {}
161
+
162
+ # Define all wizard steps
163
+ self.steps = [
164
+ {"id": "type", "title": "Configuration Type"},
165
+ {"id": "base_url", "title": "Base URL"},
166
+ {"id": "provider", "title": "Select Provider"},
167
+ {"id": "api_key", "title": "API Key"},
168
+ {"id": "model", "title": "Select Model"},
169
+ {"id": "name", "title": "Provider Name"},
170
+ {"id": "advanced", "title": "Advanced Options"},
171
+ {"id": "review", "title": "Review & Save"},
172
+ ]
173
+
174
+ def compose_content(self) -> ComposeResult:
175
+ """Compose the wizard screen with two panels."""
176
+ with Container(id="wizard-container"):
177
+ with Horizontal():
178
+ # Left panel: Steps
179
+ left_panel = VerticalScroll(id="steps-panel")
180
+ left_panel.border_title = "Configuration Steps"
181
+ with left_panel:
182
+ with Container(id="steps-content"):
183
+ for i, step in enumerate(self.steps, 1):
184
+ status = "in_progress" if i == 1 else "pending"
185
+ yield StepIndicator(i, step["title"], status=status)
186
+
187
+ # Right panel: Content
188
+ right_panel = Container(id="content-panel")
189
+ right_panel.border_title = "Step Configuration"
190
+ with right_panel:
191
+ with VerticalScroll(id="content-scroll"):
192
+ with Container(id="content-area"):
193
+ yield Static("", id="content-title")
194
+ yield Container(id="content-body")
195
+
196
+ # Bottom buttons
197
+ with Horizontal(id="button-container"):
198
+ yield Button("Back", variant="default", id="back-button", disabled=True)
199
+ yield Button("Next", variant="primary", id="next-button")
200
+ yield Button("Cancel", variant="default", id="cancel-button")
201
+
202
+ def on_mount(self) -> None:
203
+ """Load the first step when mounted."""
204
+ self.load_step(0)
205
+
206
+ def load_step(self, step_index: int) -> None:
207
+ """Load content for the given step."""
208
+ self.current_step = step_index
209
+ step = self.steps[step_index]
210
+
211
+ # Update step indicators
212
+ for i, indicator in enumerate(self.query(StepIndicator)):
213
+ if i < step_index:
214
+ indicator.status = "completed"
215
+ elif i == step_index:
216
+ indicator.status = "in_progress"
217
+ else:
218
+ indicator.status = "pending"
219
+ indicator.refresh()
220
+
221
+ # Update buttons
222
+ back_button = self.query_one("#back-button", Button)
223
+ back_button.disabled = (step_index == 0)
224
+
225
+ # Change Next button to Save on last step
226
+ next_button = self.query_one("#next-button", Button)
227
+ if step_index == len(self.steps) - 1:
228
+ next_button.label = "Save"
229
+ else:
230
+ next_button.label = "Next"
231
+
232
+ # Load step content
233
+ content_title = self.query_one("#content-title", Static)
234
+ content_body = self.query_one("#content-body", Container)
235
+
236
+ if step["id"] == "type":
237
+ self.load_type_step(content_title, content_body)
238
+ elif step["id"] == "base_url":
239
+ self.load_base_url_step(content_title, content_body)
240
+ elif step["id"] == "provider":
241
+ self.load_provider_step(content_title, content_body)
242
+ elif step["id"] == "api_key":
243
+ self.load_api_key_step(content_title, content_body)
244
+ elif step["id"] == "model":
245
+ self.load_model_step(content_title, content_body)
246
+ elif step["id"] == "name":
247
+ self.load_name_step(content_title, content_body)
248
+ elif step["id"] == "advanced":
249
+ self.load_advanced_step(content_title, content_body)
250
+ elif step["id"] == "review":
251
+ self.load_review_step(content_title, content_body)
252
+
253
+ def load_type_step(self, title_widget: Static, body_widget: Container) -> None:
254
+ """Load Configuration Type step."""
255
+ title_widget.update("Select Configuration Type")
256
+
257
+ # Clear previous content
258
+ body_widget.remove_children()
259
+
260
+ # Add description
261
+ description = Static(
262
+ "Choose the type of AI configuration:\n\n"
263
+ "• Corporate: Use your company's AI endpoint\n"
264
+ "• Individual: Use your personal API key"
265
+ )
266
+ body_widget.mount(description)
267
+
268
+ # Add options
269
+ options = OptionList(
270
+ Option("Corporate Configuration", id="corporate"),
271
+ Option("Individual Configuration", id="individual"),
272
+ id="type-options-list"
273
+ )
274
+ body_widget.mount(options)
275
+
276
+ # Focus the options list
277
+ self.call_after_refresh(lambda: options.focus())
278
+
279
+ def load_base_url_step(self, title_widget: Static, body_widget: Container) -> None:
280
+ """Load Base URL step (only for corporate)."""
281
+ title_widget.update("Base URL Configuration")
282
+ body_widget.remove_children()
283
+
284
+ # Add description
285
+ description = Text(
286
+ "Configure your corporate AI endpoint.\n\n"
287
+ "Enter the base URL for your organization's AI service."
288
+ )
289
+ body_widget.mount(description)
290
+
291
+ # Add examples
292
+ examples = DimText(
293
+ "\nExamples:\n"
294
+ " • https://ai.yourcompany.com\n"
295
+ " • https://api.internal.corp/ai\n"
296
+ " • https://llm-gateway.enterprise.local"
297
+ )
298
+ body_widget.mount(examples)
299
+
300
+ # Add input field with default value
301
+ default_url = self.wizard_data.get("base_url", "https://")
302
+ input_widget = Input(
303
+ value=default_url,
304
+ placeholder="Enter base URL...",
305
+ id="base-url-input"
306
+ )
307
+ input_widget.styles.margin = (2, 0, 0, 0)
308
+ body_widget.mount(input_widget)
309
+
310
+ # Focus the input
311
+ self.call_after_refresh(lambda: input_widget.focus())
312
+
313
+ def load_provider_step(self, title_widget: Static, body_widget: Container) -> None:
314
+ """Load Provider Selection step."""
315
+ title_widget.update("Select AI Provider")
316
+ body_widget.remove_children()
317
+
318
+ # Add description
319
+ description = Text(
320
+ "Choose your AI provider.\n\n"
321
+ "Select the AI service you want to use for this configuration."
322
+ )
323
+ body_widget.mount(description)
324
+
325
+ # Add provider options (only Anthropic and Gemini are supported)
326
+ options = OptionList(
327
+ Option("Anthropic (Claude)", id="anthropic"),
328
+ Option("Google (Gemini)", id="gemini"),
329
+ id="provider-options-list"
330
+ )
331
+ body_widget.mount(options)
332
+
333
+ # Focus the options list
334
+ self.call_after_refresh(lambda: options.focus())
335
+
336
+ def load_api_key_step(self, title_widget: Static, body_widget: Container) -> None:
337
+ """Load API Key step."""
338
+ title_widget.update("Enter API Key")
339
+ body_widget.remove_children()
340
+
341
+ # Get provider name for context
342
+ provider = self.wizard_data.get("provider", "")
343
+ provider_name = "Anthropic" if provider == "anthropic" else "Google" if provider == "gemini" else "AI"
344
+
345
+ # Add description
346
+ description = Text(
347
+ f"Enter your {provider_name} API key.\n\n"
348
+ f"This key will be securely stored in your system's keyring."
349
+ )
350
+ body_widget.mount(description)
351
+
352
+ # Add info about getting the key
353
+ info = DimText(
354
+ "\nWhere to get your API key:\n"
355
+ " • Anthropic: https://console.anthropic.com/settings/keys\n"
356
+ " • Google: https://aistudio.google.com/app/apikey"
357
+ )
358
+ body_widget.mount(info)
359
+
360
+ # Add input field (password type to hide the key)
361
+ default_key = self.wizard_data.get("api_key", "")
362
+ input_widget = Input(
363
+ value=default_key,
364
+ placeholder="Enter API key...",
365
+ password=True,
366
+ id="api-key-input"
367
+ )
368
+ input_widget.styles.margin = (2, 0, 0, 0)
369
+ body_widget.mount(input_widget)
370
+
371
+ # Focus the input
372
+ self.call_after_refresh(lambda: input_widget.focus())
373
+
374
+ def load_model_step(self, title_widget: Static, body_widget: Container) -> None:
375
+ """Load Model Selection step."""
376
+ title_widget.update("Select Model")
377
+ body_widget.remove_children()
378
+
379
+ # Get provider to show relevant models
380
+ provider = self.wizard_data.get("provider", "")
381
+
382
+ # Add description
383
+ description = Text(
384
+ "Select or enter the model to use.\n\n"
385
+ "You can choose from popular models or enter a custom model name."
386
+ )
387
+ body_widget.mount(description)
388
+
389
+ # Show popular models based on provider
390
+ if provider == "anthropic":
391
+ models_info = DimText(
392
+ "\nPopular Claude models:\n"
393
+ " • claude-3-5-sonnet-20241022\n"
394
+ " • claude-3-opus-20240229\n"
395
+ " • claude-3-sonnet-20240229\n"
396
+ " • claude-3-haiku-20240307\n"
397
+ " • claude-3-5-haiku-20241022"
398
+ )
399
+ elif provider == "gemini":
400
+ models_info = DimText(
401
+ "\nPopular Gemini models:\n"
402
+ " • gemini-1.5-pro\n"
403
+ " • gemini-1.5-flash\n"
404
+ " • gemini-pro"
405
+ )
406
+ else:
407
+ models_info = DimText("\nEnter the model name for your provider.")
408
+
409
+ body_widget.mount(models_info)
410
+
411
+ # Add input field with default model
412
+ from titan_cli.ai.constants import get_default_model
413
+ default_model = self.wizard_data.get("model", get_default_model(provider) if provider else "")
414
+
415
+ input_widget = Input(
416
+ value=default_model,
417
+ placeholder="Enter model name...",
418
+ id="model-input"
419
+ )
420
+ input_widget.styles.margin = (2, 0, 0, 0)
421
+ body_widget.mount(input_widget)
422
+
423
+ # Focus the input
424
+ self.call_after_refresh(lambda: input_widget.focus())
425
+
426
+ def load_name_step(self, title_widget: Static, body_widget: Container) -> None:
427
+ """Load Provider Name step."""
428
+ title_widget.update("Provider Name")
429
+ body_widget.remove_children()
430
+
431
+ # Add description
432
+ description = Text(
433
+ "Name this provider configuration.\n\n"
434
+ "This helps you identify this configuration when you have multiple providers."
435
+ )
436
+ body_widget.mount(description)
437
+
438
+ # Generate default name based on type and provider
439
+ config_type = self.wizard_data.get("config_type", "")
440
+ provider = self.wizard_data.get("provider", "")
441
+
442
+ config_type_label = "Corporate" if config_type == "corporate" else "Individual"
443
+ provider_name = "Anthropic" if provider == "anthropic" else "Google" if provider == "gemini" else "AI"
444
+
445
+ default_name = self.wizard_data.get("provider_name", f"{config_type_label} {provider_name}")
446
+
447
+ # Add example
448
+ example = DimText(
449
+ f"\nExamples:\n"
450
+ f" • {config_type_label} {provider_name}\n"
451
+ f" • My {provider_name} Account\n"
452
+ f" • Work Claude\n"
453
+ f" • Personal Gemini"
454
+ )
455
+ body_widget.mount(example)
456
+
457
+ # Add input field
458
+ input_widget = Input(
459
+ value=default_name,
460
+ placeholder="Enter provider name...",
461
+ id="name-input"
462
+ )
463
+ input_widget.styles.margin = (2, 0, 0, 0)
464
+ body_widget.mount(input_widget)
465
+
466
+ # Focus the input
467
+ self.call_after_refresh(lambda: input_widget.focus())
468
+
469
+ def load_advanced_step(self, title_widget: Static, body_widget: Container) -> None:
470
+ """Load Advanced Options step."""
471
+ title_widget.update("Advanced Options")
472
+ body_widget.remove_children()
473
+
474
+ # Add description
475
+ description = Text(
476
+ "Configure advanced AI parameters (optional).\n\n"
477
+ "These settings control the AI's behavior. You can use the defaults or customize them."
478
+ )
479
+ body_widget.mount(description)
480
+
481
+ # Get provider to show correct temperature range
482
+ provider = self.wizard_data.get("provider", "")
483
+ max_temp = "1.0" if provider == "anthropic" else "2.0"
484
+
485
+ # Temperature input
486
+ temp_label = Text(f"\nTemperature (0.0 - {max_temp}):")
487
+ temp_label.styles.margin = (2, 0, 0, 0)
488
+ body_widget.mount(temp_label)
489
+
490
+ temp_info = DimText(
491
+ "Controls randomness. Lower = more focused, higher = more creative.\n"
492
+ "Default: 0.7"
493
+ )
494
+ body_widget.mount(temp_info)
495
+
496
+ default_temp = str(self.wizard_data.get("temperature", "0.7"))
497
+ temp_input = Input(
498
+ value=default_temp,
499
+ placeholder="0.7",
500
+ id="temperature-input"
501
+ )
502
+ temp_input.styles.margin = (1, 0, 0, 0)
503
+ body_widget.mount(temp_input)
504
+
505
+ # Max tokens input
506
+ tokens_label = Text("\nMax Tokens:")
507
+ tokens_label.styles.margin = (2, 0, 0, 0)
508
+ body_widget.mount(tokens_label)
509
+
510
+ tokens_info = DimText(
511
+ "Maximum length of AI responses.\n"
512
+ "Default: 4096"
513
+ )
514
+ body_widget.mount(tokens_info)
515
+
516
+ default_tokens = str(self.wizard_data.get("max_tokens", "4096"))
517
+ tokens_input = Input(
518
+ value=default_tokens,
519
+ placeholder="4096",
520
+ id="max-tokens-input"
521
+ )
522
+ tokens_input.styles.margin = (1, 0, 0, 0)
523
+ body_widget.mount(tokens_input)
524
+
525
+ # Focus the temperature input
526
+ self.call_after_refresh(lambda: temp_input.focus())
527
+
528
+ def load_review_step(self, title_widget: Static, body_widget: Container) -> None:
529
+ """Load Review & Save step."""
530
+ title_widget.update("Review Configuration")
531
+ body_widget.remove_children()
532
+
533
+ # Add description
534
+ description = Text(
535
+ "Review your configuration before saving.\n\n"
536
+ "Please verify all settings are correct."
537
+ )
538
+ body_widget.mount(description)
539
+
540
+ # Build configuration summary
541
+ config_type = self.wizard_data.get("config_type", "")
542
+ base_url = self.wizard_data.get("base_url", "")
543
+ provider = self.wizard_data.get("provider", "")
544
+ model = self.wizard_data.get("model", "")
545
+ provider_name = self.wizard_data.get("provider_name", "")
546
+ temperature = self.wizard_data.get("temperature", 0.7)
547
+ max_tokens = self.wizard_data.get("max_tokens", 4096)
548
+
549
+ # Format provider name
550
+ provider_label = "Anthropic" if provider == "anthropic" else "Google" if provider == "gemini" else provider
551
+ config_type_label = "Corporate" if config_type == "corporate" else "Individual"
552
+
553
+ # Create summary text
554
+ summary = Text("\n")
555
+ summary.styles.margin = (2, 0, 0, 0)
556
+ body_widget.mount(summary)
557
+
558
+ # Configuration details
559
+ from titan_cli.ui.tui.widgets import BoldText
560
+
561
+ body_widget.mount(BoldText("Configuration Type:"))
562
+ body_widget.mount(DimText(f" {config_type_label}"))
563
+ body_widget.mount(Text(""))
564
+
565
+ if base_url:
566
+ body_widget.mount(BoldText("Base URL:"))
567
+ body_widget.mount(DimText(f" {base_url}"))
568
+ body_widget.mount(Text(""))
569
+
570
+ body_widget.mount(BoldText("Provider:"))
571
+ body_widget.mount(DimText(f" {provider_label}"))
572
+ body_widget.mount(Text(""))
573
+
574
+ body_widget.mount(BoldText("Model:"))
575
+ body_widget.mount(DimText(f" {model}"))
576
+ body_widget.mount(Text(""))
577
+
578
+ body_widget.mount(BoldText("Provider Name:"))
579
+ body_widget.mount(DimText(f" {provider_name}"))
580
+ body_widget.mount(Text(""))
581
+
582
+ body_widget.mount(BoldText("Temperature:"))
583
+ body_widget.mount(DimText(f" {temperature}"))
584
+ body_widget.mount(Text(""))
585
+
586
+ body_widget.mount(BoldText("Max Tokens:"))
587
+ body_widget.mount(DimText(f" {max_tokens}"))
588
+ body_widget.mount(Text(""))
589
+
590
+ body_widget.mount(BoldText("API Key:"))
591
+ body_widget.mount(DimText(" ••••••••••••••••••••"))
592
+ body_widget.mount(Text(""))
593
+
594
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
595
+ """Handle option selection in lists - auto-advance to next step."""
596
+ # Save the selection and move to next step
597
+ self.handle_next()
598
+
599
+ def on_input_submitted(self, event: Input.Submitted) -> None:
600
+ """Handle Enter key in input fields - auto-advance to next step."""
601
+ self.handle_next()
602
+
603
+ def on_button_pressed(self, event: Button.Pressed) -> None:
604
+ """Handle button presses."""
605
+ if event.button.id == "next-button":
606
+ self.handle_next()
607
+ elif event.button.id == "back-button":
608
+ self.handle_back()
609
+ elif event.button.id == "cancel-button":
610
+ self.action_back()
611
+
612
+ def handle_next(self) -> None:
613
+ """Move to next step or save configuration."""
614
+ # Validate and save current step data
615
+ if not self.validate_and_save_step():
616
+ return
617
+
618
+ # If on last step, save configuration
619
+ if self.current_step == len(self.steps) - 1:
620
+ self.save_configuration()
621
+ return
622
+
623
+ # Move to next step
624
+ if self.current_step < len(self.steps) - 1:
625
+ next_step = self.current_step + 1
626
+
627
+ # Skip Base URL step if Individual was selected
628
+ if self.steps[next_step]["id"] == "base_url" and self.wizard_data.get("config_type") == "individual":
629
+ next_step += 1
630
+
631
+ self.load_step(next_step)
632
+
633
+ def validate_and_save_step(self) -> bool:
634
+ """Validate and save data from current step."""
635
+ step = self.steps[self.current_step]
636
+
637
+ if step["id"] == "type":
638
+ # Get selected configuration type
639
+ try:
640
+ options_list = self.query_one("#type-options-list", OptionList)
641
+ if options_list.highlighted is None:
642
+ self.app.notify("Please select a configuration type", severity="warning")
643
+ return False
644
+
645
+ selected_option = options_list.get_option_at_index(options_list.highlighted)
646
+ self.wizard_data["config_type"] = selected_option.id
647
+ return True
648
+ except Exception:
649
+ self.app.notify("Please select a configuration type", severity="error")
650
+ return False
651
+
652
+ elif step["id"] == "base_url":
653
+ # Get base URL from input
654
+ try:
655
+ input_widget = self.query_one("#base-url-input", Input)
656
+ base_url = input_widget.value.strip()
657
+
658
+ # Validate URL
659
+ if not base_url:
660
+ self.app.notify("Please enter a base URL", severity="warning")
661
+ return False
662
+
663
+ if not base_url.startswith(("http://", "https://")):
664
+ self.app.notify("Base URL must start with http:// or https://", severity="warning")
665
+ return False
666
+
667
+ self.wizard_data["base_url"] = base_url
668
+ return True
669
+ except Exception:
670
+ self.app.notify("Please enter a valid base URL", severity="error")
671
+ return False
672
+
673
+ elif step["id"] == "provider":
674
+ # Get selected provider
675
+ try:
676
+ options_list = self.query_one("#provider-options-list", OptionList)
677
+ if options_list.highlighted is None:
678
+ self.app.notify("Please select a provider", severity="warning")
679
+ return False
680
+
681
+ selected_option = options_list.get_option_at_index(options_list.highlighted)
682
+ self.wizard_data["provider"] = selected_option.id
683
+ return True
684
+ except Exception:
685
+ self.app.notify("Please select a provider", severity="error")
686
+ return False
687
+
688
+ elif step["id"] == "api_key":
689
+ # Get API key from input
690
+ try:
691
+ input_widget = self.query_one("#api-key-input", Input)
692
+ api_key = input_widget.value.strip()
693
+
694
+ # Validate API key
695
+ if not api_key:
696
+ self.app.notify("Please enter an API key", severity="warning")
697
+ return False
698
+
699
+ # Basic validation: should be alphanumeric with possible hyphens/underscores
700
+ if len(api_key) < 10:
701
+ self.app.notify("API key seems too short", severity="warning")
702
+ return False
703
+
704
+ self.wizard_data["api_key"] = api_key
705
+ return True
706
+ except Exception:
707
+ self.app.notify("Please enter a valid API key", severity="error")
708
+ return False
709
+
710
+ elif step["id"] == "model":
711
+ # Get model from input
712
+ try:
713
+ input_widget = self.query_one("#model-input", Input)
714
+ model = input_widget.value.strip()
715
+
716
+ # Validate model
717
+ if not model:
718
+ self.app.notify("Please enter a model name", severity="warning")
719
+ return False
720
+
721
+ self.wizard_data["model"] = model
722
+ return True
723
+ except Exception:
724
+ self.app.notify("Please enter a valid model name", severity="error")
725
+ return False
726
+
727
+ elif step["id"] == "name":
728
+ # Get provider name from input
729
+ try:
730
+ input_widget = self.query_one("#name-input", Input)
731
+ provider_name = input_widget.value.strip()
732
+
733
+ # Validate name
734
+ if not provider_name:
735
+ self.app.notify("Please enter a provider name", severity="warning")
736
+ return False
737
+
738
+ self.wizard_data["provider_name"] = provider_name
739
+ return True
740
+ except Exception:
741
+ self.app.notify("Please enter a valid provider name", severity="error")
742
+ return False
743
+
744
+ elif step["id"] == "advanced":
745
+ # Get temperature and max_tokens
746
+ try:
747
+ temp_input = self.query_one("#temperature-input", Input)
748
+ tokens_input = self.query_one("#max-tokens-input", Input)
749
+
750
+ # Get provider to determine max temperature
751
+ provider = self.wizard_data.get("provider", "")
752
+ max_temp = 1.0 if provider == "anthropic" else 2.0
753
+
754
+ # Validate temperature
755
+ try:
756
+ temperature = float(temp_input.value.strip())
757
+ if temperature < 0.0 or temperature > max_temp:
758
+ self.app.notify(f"Temperature must be between 0.0 and {max_temp}", severity="warning")
759
+ return False
760
+ except ValueError:
761
+ self.app.notify("Temperature must be a number", severity="warning")
762
+ return False
763
+
764
+ # Validate max_tokens
765
+ try:
766
+ max_tokens = int(tokens_input.value.strip())
767
+ if max_tokens < 1:
768
+ self.app.notify("Max tokens must be at least 1", severity="warning")
769
+ return False
770
+ except ValueError:
771
+ self.app.notify("Max tokens must be a number", severity="warning")
772
+ return False
773
+
774
+ self.wizard_data["temperature"] = temperature
775
+ self.wizard_data["max_tokens"] = max_tokens
776
+ return True
777
+ except Exception:
778
+ self.app.notify("Please enter valid advanced options", severity="error")
779
+ return False
780
+
781
+ # TODO: Add validation for review step
782
+ return True
783
+
784
+ def handle_back(self) -> None:
785
+ """Move to previous step."""
786
+ if self.current_step > 0:
787
+ prev_step = self.current_step - 1
788
+
789
+ # Skip Base URL step if going back and config type is Individual
790
+ if self.steps[prev_step]["id"] == "base_url" and self.wizard_data.get("config_type") == "individual":
791
+ prev_step -= 1
792
+
793
+ self.load_step(prev_step)
794
+
795
+ def save_configuration(self) -> None:
796
+ """Save the AI provider configuration."""
797
+ import tomli
798
+ import tomli_w
799
+ import re
800
+ import logging
801
+ from titan_cli.core.config import TitanConfig
802
+ from titan_cli.core.secrets import SecretManager
803
+
804
+ logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
805
+ logger.debug("AI wizard - save_configuration() called")
806
+
807
+ try:
808
+ # Generate provider ID from name (clean to only allow valid characters)
809
+ provider_name = self.wizard_data.get("provider_name", "")
810
+ # Replace spaces with hyphens, remove invalid characters
811
+ provider_id = provider_name.lower().replace(" ", "-")
812
+ provider_id = re.sub(r'[^a-z0-9_-]', '', provider_id)
813
+
814
+ # Load global config
815
+ global_config_path = TitanConfig.GLOBAL_CONFIG
816
+ global_config_data = {}
817
+ if global_config_path.exists():
818
+ with open(global_config_path, "rb") as f:
819
+ global_config_data = tomli.load(f)
820
+
821
+ # Initialize AI config structure if needed
822
+ if "ai" not in global_config_data:
823
+ global_config_data["ai"] = {}
824
+ if "providers" not in global_config_data["ai"]:
825
+ global_config_data["ai"]["providers"] = {}
826
+
827
+ # Check if provider ID already exists
828
+ if provider_id in global_config_data["ai"]["providers"]:
829
+ self.app.notify(f"Provider ID '{provider_id}' already exists", severity="error")
830
+ return
831
+
832
+ # Build provider configuration
833
+ provider_cfg = {
834
+ "name": self.wizard_data.get("provider_name"),
835
+ "type": self.wizard_data.get("config_type"),
836
+ "provider": self.wizard_data.get("provider"),
837
+ "model": self.wizard_data.get("model"),
838
+ "temperature": self.wizard_data.get("temperature", 0.7),
839
+ "max_tokens": self.wizard_data.get("max_tokens", 4096),
840
+ }
841
+
842
+ # Add base_url if it's a corporate configuration
843
+ if self.wizard_data.get("base_url"):
844
+ provider_cfg["base_url"] = self.wizard_data.get("base_url")
845
+
846
+ # Save provider configuration
847
+ global_config_data["ai"]["providers"][provider_id] = provider_cfg
848
+
849
+ # Set as default if it's the first provider
850
+ if len(global_config_data["ai"]["providers"]) == 1:
851
+ global_config_data["ai"]["default"] = provider_id
852
+ elif "default" not in global_config_data["ai"]:
853
+ global_config_data["ai"]["default"] = provider_id
854
+
855
+ # Save to disk
856
+ logger.debug(f"AI wizard - Saving to {global_config_path}")
857
+ logger.debug(f"AI wizard - Config data: {global_config_data}")
858
+ global_config_path.parent.mkdir(parents=True, exist_ok=True)
859
+ with open(global_config_path, "wb") as f:
860
+ tomli_w.dump(global_config_data, f)
861
+ logger.debug("AI wizard - Config saved successfully")
862
+
863
+ # Save API key to secrets
864
+ secrets = SecretManager()
865
+ api_key = self.wizard_data.get("api_key")
866
+ secrets.set(f"{provider_id}_api_key", api_key, scope="user")
867
+ logger.debug("AI wizard - API key saved to secrets")
868
+
869
+ # Show success message
870
+ self.app.notify(f"AI provider '{provider_name}' configured successfully!", severity="information")
871
+
872
+ # Close wizard and trigger callback
873
+ logger.debug("AI wizard - Dismissing with result=True")
874
+ self.dismiss(result=True)
875
+
876
+ except Exception as e:
877
+ logger.error(f"AI wizard - Error saving: {e}", exc_info=True)
878
+ self.app.notify(f"Failed to save configuration: {e}", severity="error")
879
+
880
+ def action_back(self) -> None:
881
+ """Cancel and go back."""
882
+ self.dismiss(result=False)