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,162 @@
1
+ """
2
+ Main Menu Screen
3
+
4
+ The primary navigation screen for Titan TUI.
5
+ """
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.widgets import OptionList
9
+ from textual.widgets.option_list import Option
10
+ from textual.containers import Container
11
+
12
+ from titan_cli.ui.tui.icons import Icons
13
+ from .base import BaseScreen
14
+
15
+ from .cli_launcher import CLILauncherScreen
16
+ from .ai_config import AIConfigScreen
17
+ from .plugin_management import PluginManagementScreen
18
+
19
+ class MainMenuScreen(BaseScreen):
20
+ """
21
+ Main menu screen with navigation options.
22
+
23
+ Displays the primary actions available in Titan:
24
+ - Launch External CLI
25
+ - Project Management
26
+ - Workflows
27
+ - Plugin Management
28
+ - AI Configuration
29
+ - Switch Project
30
+ - Exit
31
+ """
32
+
33
+ BINDINGS = [
34
+ ("q", "quit", "Quit"),
35
+ ("escape", "quit", "Quit"),
36
+ ]
37
+
38
+ CSS = """
39
+ MainMenuScreen {
40
+ align: center middle;
41
+ }
42
+
43
+ #menu-container {
44
+ width: 70%;
45
+ height: 1fr;
46
+ background: $surface-lighten-1;
47
+ border: solid $primary;
48
+ margin: 1;
49
+ padding: 1 0;
50
+ }
51
+
52
+ #menu-title {
53
+ text-align: center;
54
+ color: $primary;
55
+ text-style: bold;
56
+ margin-bottom: 1;
57
+ }
58
+
59
+ OptionList {
60
+ height: auto;
61
+ border: none;
62
+ background: $surface-lighten-1;
63
+ }
64
+
65
+ OptionList:focus {
66
+ border: none;
67
+ background: $surface-lighten-1;
68
+ }
69
+
70
+ OptionList > .option-list--option {
71
+ padding: 1 2;
72
+ background: $surface-lighten-1;
73
+ border-left: none;
74
+ }
75
+
76
+ OptionList > .option-list--option-highlighted {
77
+ background: $primary;
78
+ border-left: none;
79
+ }
80
+
81
+ OptionList:focus > .option-list--option {
82
+ border-left: none;
83
+ }
84
+
85
+ OptionList:focus > .option-list--option-highlighted {
86
+ border-left: none;
87
+ }
88
+
89
+ """
90
+
91
+ def compose_content(self) -> ComposeResult:
92
+ """Compose the main menu content."""
93
+ with Container(id="menu-container"):
94
+
95
+ # Build menu options
96
+ options = [
97
+ Option("🚀 Launch External CLI", id="cli"),
98
+ ]
99
+
100
+ # Only show Workflows if there are enabled plugins
101
+ installed_plugins = self.config.registry.list_installed()
102
+ enabled_plugins = [
103
+ p for p in installed_plugins if self.config.is_plugin_enabled(p)
104
+ ]
105
+ if enabled_plugins:
106
+ options.append(Option(f"{Icons.WORKFLOW} Workflows", id="run_workflow"))
107
+
108
+ options.extend(
109
+ [
110
+ Option(f"{Icons.PLUGIN} Plugin Management", id="plugin_management"),
111
+ Option(f"{Icons.SETTINGS} AI Configuration", id="ai_config"),
112
+ ]
113
+ )
114
+
115
+ yield OptionList(*options)
116
+
117
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
118
+ """Handle menu option selection."""
119
+ action = event.option.id
120
+
121
+ if action == "exit":
122
+ self.app.exit()
123
+ elif action == "cli":
124
+ self.handle_cli_action()
125
+ elif action == "projects":
126
+ self.handle_projects_action()
127
+ elif action == "run_workflow":
128
+ self.handle_workflow_action()
129
+ elif action == "plugin_management":
130
+ self.handle_plugin_management_action()
131
+ elif action == "ai_config":
132
+ self.handle_ai_config_action()
133
+
134
+ def handle_cli_action(self) -> None:
135
+ """Handle Launch External CLI action."""
136
+ self.app.push_screen(CLILauncherScreen(self.config))
137
+
138
+ def handle_projects_action(self) -> None:
139
+ """Handle Project Management action."""
140
+ self.app.notify("Project management - Coming soon!")
141
+
142
+ def handle_workflow_action(self) -> None:
143
+ """Handle Workflows action."""
144
+ from .workflows import WorkflowsScreen
145
+
146
+ self.app.push_screen(WorkflowsScreen(self.config))
147
+
148
+ def handle_plugin_management_action(self) -> None:
149
+ """Handle Plugin Management action."""
150
+ self.app.push_screen(PluginManagementScreen(self.config))
151
+
152
+ def handle_ai_config_action(self) -> None:
153
+ """Handle AI Configuration action."""
154
+ self.app.push_screen(AIConfigScreen(self.config))
155
+
156
+ def handle_switch_project_action(self) -> None:
157
+ """Handle Switch Project action."""
158
+ self.app.notify("Switch project - Coming soon!")
159
+
160
+ def action_quit(self) -> None:
161
+ """Quit the application."""
162
+ self.app.exit()
@@ -0,0 +1,550 @@
1
+ """
2
+ Plugin Configuration Wizard Screen
3
+
4
+ Generic wizard that adapts to any plugin's configuration schema.
5
+ Each plugin can have different configuration steps based on its schema.
6
+ """
7
+
8
+ import logging
9
+ from textual.app import ComposeResult
10
+ from textual.widgets import Static, Input
11
+ from textual.containers import Container, Horizontal, VerticalScroll
12
+ from textual.binding import Binding
13
+
14
+ from titan_cli.ui.tui.icons import Icons
15
+ from titan_cli.ui.tui.widgets import Text, DimText, Button, BoldText
16
+ from .base import BaseScreen
17
+
18
+ # Use the same logger as project_setup_wizard
19
+ logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
20
+
21
+
22
+ class StepIndicator(Static):
23
+ """Widget showing a single step with status indicator."""
24
+
25
+ def __init__(self, step_number: int, title: str, status: str = "pending"):
26
+ self.step_number = step_number
27
+ self.title = title
28
+ self.status = status
29
+ super().__init__()
30
+
31
+ def render(self) -> str:
32
+ """Render the step with appropriate icon."""
33
+ if self.status == "completed":
34
+ icon = Icons.SUCCESS
35
+ style = "dim"
36
+ elif self.status == "in_progress":
37
+ icon = Icons.RUNNING
38
+ style = "bold cyan"
39
+ else: # pending
40
+ icon = Icons.PENDING
41
+ style = "dim"
42
+
43
+ return f"[{style}]{icon} {self.step_number}. {self.title}[/{style}]"
44
+
45
+
46
+ class PluginConfigWizardScreen(BaseScreen):
47
+ """
48
+ Generic wizard for configuring any plugin.
49
+
50
+ Adapts its steps based on the plugin's configuration schema.
51
+ """
52
+
53
+ BINDINGS = [
54
+ Binding("escape", "cancel", "Cancel"),
55
+ ]
56
+
57
+ CSS = """
58
+ PluginConfigWizardScreen {
59
+ align: center middle;
60
+ }
61
+
62
+ #wizard-container {
63
+ width: 100%;
64
+ height: 1fr;
65
+ background: $surface-lighten-1;
66
+ padding: 0 2 1 2;
67
+ }
68
+
69
+ #steps-panel {
70
+ width: 20%;
71
+ height: 100%;
72
+ border: round $primary;
73
+ border-title-align: center;
74
+ background: $surface-lighten-1;
75
+ padding: 0;
76
+ }
77
+
78
+ #steps-content {
79
+ padding: 1;
80
+ }
81
+
82
+ StepIndicator {
83
+ height: auto;
84
+ margin-bottom: 1;
85
+ }
86
+
87
+ #content-panel {
88
+ width: 80%;
89
+ height: 100%;
90
+ border: round $primary;
91
+ border-title-align: center;
92
+ background: $surface-lighten-1;
93
+ padding: 0;
94
+ layout: vertical;
95
+ }
96
+
97
+ #content-scroll {
98
+ height: 1fr;
99
+ }
100
+
101
+ #content-area {
102
+ padding: 1;
103
+ height: auto;
104
+ }
105
+
106
+ #content-title {
107
+ color: $accent;
108
+ text-style: bold;
109
+ margin-bottom: 2;
110
+ height: auto;
111
+ }
112
+
113
+ #content-body {
114
+ height: auto;
115
+ margin-bottom: 2;
116
+ }
117
+
118
+ #button-container {
119
+ height: auto;
120
+ padding: 1 2;
121
+ background: $surface-lighten-1;
122
+ border-top: solid $primary;
123
+ align: right middle;
124
+ }
125
+
126
+ .field-label {
127
+ margin-top: 1;
128
+ margin-bottom: 0;
129
+ }
130
+
131
+ Input {
132
+ width: 100%;
133
+ margin-bottom: 2;
134
+ border: solid $accent;
135
+ }
136
+
137
+ Input:focus {
138
+ border: solid $primary;
139
+ }
140
+ """
141
+
142
+ def __init__(self, config, plugin_name: str):
143
+ super().__init__(
144
+ config,
145
+ title=f"{Icons.SETTINGS} Configure {plugin_name}",
146
+ show_back=False,
147
+ show_status_bar=False
148
+ )
149
+ self.plugin_name = plugin_name
150
+ self.current_step = 0
151
+ self.config_data = {}
152
+ self.steps = []
153
+ self.schema = None
154
+ self.properties = {}
155
+ self.required_fields = []
156
+
157
+ def compose_content(self) -> ComposeResult:
158
+ """Compose the wizard screen with two panels."""
159
+ with Container(id="wizard-container"):
160
+ with Horizontal():
161
+ # Left panel: Steps
162
+ left_panel = VerticalScroll(id="steps-panel")
163
+ left_panel.border_title = "Configuration Steps"
164
+ with left_panel:
165
+ with Container(id="steps-content"):
166
+ # Steps will be added dynamically
167
+ pass
168
+
169
+ # Right panel: Content
170
+ right_panel = Container(id="content-panel")
171
+ right_panel.border_title = "Configuration"
172
+ with right_panel:
173
+ with VerticalScroll(id="content-scroll"):
174
+ with Container(id="content-area"):
175
+ yield Static("", id="content-title")
176
+ yield Container(id="content-body")
177
+
178
+ # Bottom buttons
179
+ with Horizontal(id="button-container"):
180
+ yield Button("Back", variant="default", id="back-button", disabled=True)
181
+ yield Button("Next", variant="primary", id="next-button")
182
+ yield Button("Cancel", variant="default", id="cancel-button")
183
+
184
+ def on_mount(self) -> None:
185
+ """Load plugin schema and build steps."""
186
+ # Get plugin instance directly from registry's internal dict
187
+ logger.debug(f"PluginConfigWizard mounted for: '{self.plugin_name}'")
188
+ logger.debug(f"Registry plugins: {list(self.config.registry._plugins.keys())}")
189
+
190
+ plugin = self.config.registry._plugins.get(self.plugin_name)
191
+
192
+ if not plugin:
193
+ available = list(self.config.registry._plugins.keys())
194
+ logger.error(f"Plugin '{self.plugin_name}' not found. Available: {available}")
195
+ self.app.notify(f"Plugin '{self.plugin_name}' not found", severity="error")
196
+ self.dismiss(result=False)
197
+ return
198
+
199
+ # Check if plugin has configuration schema
200
+ if not hasattr(plugin, "get_config_schema"):
201
+ logger.debug(f"Plugin '{self.plugin_name}' has no config schema")
202
+ self.dismiss(result=True)
203
+ return
204
+
205
+ try:
206
+ self.schema = plugin.get_config_schema()
207
+ logger.debug(f"Got schema for '{self.plugin_name}': {self.schema}")
208
+ except Exception as e:
209
+ logger.error(f"Failed to get config schema: {e}")
210
+ self.app.notify(f"Failed to get config schema: {e}", severity="error")
211
+ self.dismiss(result=False)
212
+ return
213
+
214
+ self.properties = self.schema.get("properties", {})
215
+ self.required_fields = self.schema.get("required", [])
216
+
217
+ logger.debug(f"Properties: {list(self.properties.keys())}")
218
+ logger.debug(f"Required fields: {self.required_fields}")
219
+
220
+ if not self.properties:
221
+ logger.debug(f"Plugin '{self.plugin_name}' has no config fields")
222
+ self.dismiss(result=True)
223
+ return
224
+
225
+ # Build steps based on field types
226
+ self._build_steps()
227
+ self._render_step_indicators()
228
+ self.load_step(0)
229
+
230
+ def _build_steps(self):
231
+ """Build wizard steps based on plugin configuration schema."""
232
+ # Group fields by type/category
233
+ # For now, we'll create one step per field for simplicity
234
+ # In the future, plugins could define custom step grouping
235
+
236
+ for field_name in self.properties.keys():
237
+ self.steps.append({
238
+ "id": field_name,
239
+ "title": field_name.replace("_", " ").title(),
240
+ "field": field_name
241
+ })
242
+
243
+ # Add review step
244
+ self.steps.append({
245
+ "id": "review",
246
+ "title": "Review",
247
+ "field": None
248
+ })
249
+
250
+ def _render_step_indicators(self):
251
+ """Render step indicators in the left panel."""
252
+ steps_content = self.query_one("#steps-content", Container)
253
+
254
+ for i, step in enumerate(self.steps, 1):
255
+ status = "in_progress" if i == 1 else "pending"
256
+ steps_content.mount(StepIndicator(i, step["title"], status=status))
257
+
258
+ def load_step(self, step_index: int):
259
+ """Load content for the given step."""
260
+ self.current_step = step_index
261
+ step = self.steps[step_index]
262
+
263
+ # Update step indicators
264
+ for i, indicator in enumerate(self.query(StepIndicator)):
265
+ if i < step_index:
266
+ indicator.status = "completed"
267
+ elif i == step_index:
268
+ indicator.status = "in_progress"
269
+ else:
270
+ indicator.status = "pending"
271
+ indicator.refresh()
272
+
273
+ # Update buttons
274
+ back_button = self.query_one("#back-button", Button)
275
+ back_button.disabled = (step_index == 0)
276
+
277
+ next_button = self.query_one("#next-button", Button)
278
+ if step_index == len(self.steps) - 1:
279
+ next_button.label = "Save"
280
+ else:
281
+ next_button.label = "Next"
282
+
283
+ # Load step content
284
+ if step["id"] == "review":
285
+ self.load_review_step()
286
+ else:
287
+ self.load_field_step(step["field"])
288
+
289
+ def load_field_step(self, field_name: str):
290
+ """Load a configuration field step."""
291
+ content_title = self.query_one("#content-title", Static)
292
+ content_body = self.query_one("#content-body", Container)
293
+
294
+ content_title.update(field_name.replace("_", " ").title())
295
+ content_body.remove_children()
296
+
297
+ field_schema = self.properties[field_name]
298
+ description = field_schema.get("description", "")
299
+ field_format = field_schema.get("format", "")
300
+ default_value = field_schema.get("default")
301
+ is_required = field_name in self.required_fields
302
+
303
+ # Detect if secret
304
+ is_secret = (
305
+ "token" in field_name.lower() or
306
+ "password" in field_name.lower() or
307
+ "secret" in field_name.lower() or
308
+ "api_key" in field_name.lower() or
309
+ field_format == "password"
310
+ )
311
+
312
+ # Show description
313
+ if description:
314
+ desc_text = Text(f"{description}\n")
315
+ content_body.mount(desc_text)
316
+
317
+ # Show if required
318
+ if is_required:
319
+ req_text = BoldText("This field is required.")
320
+ content_body.mount(req_text)
321
+ else:
322
+ opt_text = DimText("This field is optional.")
323
+ content_body.mount(opt_text)
324
+
325
+ # Check for existing value
326
+ current_value = self.config_data.get(field_name)
327
+ if current_value is None:
328
+ current_value = default_value
329
+
330
+ # For secrets, check keychain
331
+ if is_secret:
332
+ project_name = self.config.get_project_name()
333
+ secret_key = f"{self.plugin_name}_{field_name}"
334
+ keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
335
+ existing_secret = self.config.secrets.get(keychain_key) or self.config.secrets.get(secret_key)
336
+
337
+ if existing_secret:
338
+ info = DimText("\n\nAlready configured. Leave blank to keep existing value.")
339
+ content_body.mount(info)
340
+
341
+ # Create input
342
+ input_value = ""
343
+ if not is_secret and current_value is not None:
344
+ input_value = str(current_value)
345
+
346
+ input_widget = Input(
347
+ value=input_value,
348
+ placeholder=f"Enter {field_name}...",
349
+ password=is_secret,
350
+ id=f"input-{field_name}"
351
+ )
352
+ input_widget.styles.margin = (2, 0, 0, 0)
353
+ content_body.mount(input_widget)
354
+
355
+ # Focus input
356
+ self.call_after_refresh(lambda: input_widget.focus())
357
+
358
+ def load_review_step(self):
359
+ """Load review step showing all configuration."""
360
+ content_title = self.query_one("#content-title", Static)
361
+ content_body = self.query_one("#content-body", Container)
362
+
363
+ content_title.update("Review Configuration")
364
+ content_body.remove_children()
365
+
366
+ desc = Text("Please review your configuration before saving.\n")
367
+ content_body.mount(desc)
368
+
369
+ # Show all configured values
370
+ for field_name, value in self.config_data.items():
371
+ is_secret = (
372
+ "token" in field_name.lower() or
373
+ "password" in field_name.lower() or
374
+ "secret" in field_name.lower() or
375
+ "api_key" in field_name.lower()
376
+ )
377
+
378
+ label = BoldText(f"\n{field_name.replace('_', ' ').title()}:")
379
+ content_body.mount(label)
380
+
381
+ if is_secret:
382
+ value_text = DimText(" ••••••••••••")
383
+ else:
384
+ value_text = DimText(f" {value}")
385
+ content_body.mount(value_text)
386
+
387
+ def on_input_submitted(self, event: Input.Submitted) -> None:
388
+ """Handle Enter key in input fields."""
389
+ self.handle_next()
390
+
391
+ def on_button_pressed(self, event: Button.Pressed) -> None:
392
+ """Handle button presses."""
393
+ if event.button.id == "next-button":
394
+ self.handle_next()
395
+ elif event.button.id == "back-button":
396
+ self.handle_back()
397
+ elif event.button.id == "cancel-button":
398
+ self.action_cancel()
399
+
400
+ def handle_next(self) -> None:
401
+ """Move to next step or save."""
402
+ # Validate and save current step
403
+ if not self.validate_and_save_step():
404
+ return
405
+
406
+ # If on last step, save configuration
407
+ if self.current_step == len(self.steps) - 1:
408
+ self.save_configuration()
409
+ return
410
+
411
+ # Move to next step
412
+ if self.current_step < len(self.steps) - 1:
413
+ self.load_step(self.current_step + 1)
414
+
415
+ def validate_and_save_step(self) -> bool:
416
+ """Validate and save current step data."""
417
+ step = self.steps[self.current_step]
418
+
419
+ if step["id"] == "review":
420
+ # No validation needed
421
+ return True
422
+
423
+ # Get field input
424
+ field_name = step["field"]
425
+ field_schema = self.properties[field_name]
426
+ is_required = field_name in self.required_fields
427
+
428
+ try:
429
+ input_widget = self.query_one(f"#input-{field_name}", Input)
430
+ value = input_widget.value.strip()
431
+
432
+ # Check for existing secret
433
+ is_secret = (
434
+ "token" in field_name.lower() or
435
+ "password" in field_name.lower() or
436
+ "secret" in field_name.lower() or
437
+ "api_key" in field_name.lower()
438
+ )
439
+
440
+ if is_secret:
441
+ project_name = self.config.get_project_name()
442
+ secret_key = f"{self.plugin_name}_{field_name}"
443
+ keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
444
+ existing_secret = self.config.secrets.get(keychain_key) or self.config.secrets.get(secret_key)
445
+
446
+ if not value and existing_secret:
447
+ # Keep existing
448
+ self.config_data[field_name] = {"_is_secret": True, "_existing": True}
449
+ return True
450
+ elif not value and is_required:
451
+ self.app.notify(f"{field_name} is required", severity="warning")
452
+ return False
453
+ elif value:
454
+ self.config_data[field_name] = {"_is_secret": True, "_value": value}
455
+ return True
456
+ return True
457
+
458
+ # Validate required
459
+ if is_required and not value:
460
+ self.app.notify(f"{field_name} is required", severity="warning")
461
+ return False
462
+
463
+ # Skip empty optional fields
464
+ if not value:
465
+ return True
466
+
467
+ # Type conversion
468
+ field_type = field_schema.get("type")
469
+ if field_type == "integer":
470
+ try:
471
+ value = int(value)
472
+ except ValueError:
473
+ self.app.notify(f"{field_name} must be a number", severity="warning")
474
+ return False
475
+ elif field_type == "boolean":
476
+ value = value.lower() in ("true", "yes", "1")
477
+
478
+ self.config_data[field_name] = value
479
+ return True
480
+
481
+ except Exception:
482
+ self.app.notify("Please enter a value", severity="error")
483
+ return False
484
+
485
+ def handle_back(self) -> None:
486
+ """Move to previous step."""
487
+ if self.current_step > 0:
488
+ self.load_step(self.current_step - 1)
489
+
490
+ def save_configuration(self) -> None:
491
+ """Save plugin configuration."""
492
+ import tomli
493
+ import tomli_w
494
+
495
+ try:
496
+ project_cfg_path = self.config.project_config_path
497
+ if not project_cfg_path:
498
+ self.app.notify("No project config found", severity="error")
499
+ return
500
+
501
+ project_cfg_dict = {}
502
+ if project_cfg_path.exists():
503
+ with open(project_cfg_path, "rb") as f:
504
+ project_cfg_dict = tomli.load(f)
505
+
506
+ plugins_table = project_cfg_dict.setdefault("plugins", {})
507
+ plugin_specific_table = plugins_table.setdefault(self.plugin_name, {})
508
+ plugin_config_table = plugin_specific_table.setdefault("config", {})
509
+
510
+ # Separate secrets from regular config
511
+ secrets_to_save = {}
512
+ config_values = {}
513
+
514
+ for field_name, value in self.config_data.items():
515
+ if isinstance(value, dict) and value.get("_is_secret"):
516
+ secret_key = f"{self.plugin_name}_{field_name}"
517
+ if value.get("_existing"):
518
+ # Keep existing secret
519
+ project_name = self.config.get_project_name()
520
+ keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
521
+ existing = self.config.secrets.get(keychain_key) or self.config.secrets.get(secret_key)
522
+ if existing:
523
+ secrets_to_save[secret_key] = existing
524
+ else:
525
+ secrets_to_save[secret_key] = value["_value"]
526
+ else:
527
+ config_values[field_name] = value
528
+
529
+ # Update config
530
+ plugin_config_table.update(config_values)
531
+
532
+ # Write config file
533
+ with open(project_cfg_path, "wb") as f:
534
+ tomli_w.dump(project_cfg_dict, f)
535
+
536
+ # Save secrets
537
+ project_name = self.config.get_project_name()
538
+ for secret_key, secret_value in secrets_to_save.items():
539
+ keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
540
+ self.config.secrets.set(keychain_key, secret_value, scope="user")
541
+
542
+ self.app.notify(f"Plugin '{self.plugin_name}' configured successfully!", severity="information")
543
+ self.dismiss(result=True)
544
+
545
+ except Exception as e:
546
+ self.app.notify(f"Failed to save configuration: {e}", severity="error")
547
+
548
+ def action_cancel(self) -> None:
549
+ """Cancel configuration."""
550
+ self.dismiss(result=False)