titan-cli 0.1.4__py3-none-any.whl → 0.1.6__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/core/config.py +3 -1
- titan_cli/core/workflows/__init__.py +2 -1
- titan_cli/core/workflows/project_step_source.py +95 -32
- titan_cli/core/workflows/workflow_filter_service.py +16 -8
- titan_cli/core/workflows/workflow_registry.py +12 -1
- titan_cli/core/workflows/workflow_sources.py +1 -1
- titan_cli/engine/__init__.py +5 -1
- titan_cli/engine/results.py +31 -1
- titan_cli/engine/steps/ai_assistant_step.py +47 -12
- titan_cli/engine/workflow_executor.py +13 -3
- titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
- titan_cli/ui/tui/screens/workflow_execution.py +28 -50
- titan_cli/ui/tui/screens/workflows.py +8 -4
- titan_cli/ui/tui/textual_components.py +342 -185
- titan_cli/ui/tui/textual_workflow_executor.py +39 -3
- titan_cli/ui/tui/theme.py +34 -5
- titan_cli/ui/tui/widgets/__init__.py +17 -0
- titan_cli/ui/tui/widgets/multiline_input.py +32 -0
- titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
- titan_cli/ui/tui/widgets/prompt_input.py +74 -0
- titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
- titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
- titan_cli/ui/tui/widgets/step_container.py +70 -0
- titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
- titan_cli/ui/tui/widgets/text.py +51 -130
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/METADATA +3 -5
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/RECORD +61 -46
- titan_plugin_git/clients/git_client.py +140 -5
- titan_plugin_git/plugin.py +13 -0
- titan_plugin_git/steps/ai_commit_message_step.py +39 -34
- titan_plugin_git/steps/branch_steps.py +18 -37
- titan_plugin_git/steps/checkout_step.py +66 -0
- titan_plugin_git/steps/commit_step.py +18 -22
- titan_plugin_git/steps/create_branch_step.py +131 -0
- titan_plugin_git/steps/diff_summary_step.py +180 -0
- titan_plugin_git/steps/pull_step.py +70 -0
- titan_plugin_git/steps/push_step.py +27 -11
- titan_plugin_git/steps/restore_original_branch_step.py +97 -0
- titan_plugin_git/steps/save_current_branch_step.py +82 -0
- titan_plugin_git/steps/status_step.py +32 -25
- titan_plugin_git/workflows/commit-ai.yaml +9 -3
- titan_plugin_github/agents/pr_agent.py +15 -2
- titan_plugin_github/steps/ai_pr_step.py +99 -40
- titan_plugin_github/steps/create_pr_step.py +18 -8
- titan_plugin_github/steps/github_prompt_steps.py +53 -1
- titan_plugin_github/steps/issue_steps.py +31 -18
- titan_plugin_github/steps/preview_step.py +15 -4
- titan_plugin_github/utils.py +5 -4
- titan_plugin_github/workflows/create-pr-ai.yaml +6 -11
- titan_plugin_jira/messages.py +12 -0
- titan_plugin_jira/plugin.py +4 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +12 -7
- titan_plugin_jira/steps/get_issue_step.py +17 -13
- titan_plugin_jira/steps/list_versions_step.py +133 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +20 -8
- titan_plugin_jira/steps/search_jql_step.py +191 -0
- titan_plugin_jira/steps/search_saved_query_step.py +26 -24
- titan_plugin_jira/utils/__init__.py +1 -1
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/LICENSE +0 -0
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/WHEEL +0 -0
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/entry_points.txt +0 -0
|
@@ -7,193 +7,39 @@ Steps can import widgets directly from titan_cli.ui.tui.widgets and mount them u
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import threading
|
|
10
|
-
from typing import Optional,
|
|
10
|
+
from typing import Optional, List, Any
|
|
11
11
|
from contextlib import contextmanager
|
|
12
12
|
from textual.widget import Widget
|
|
13
|
-
from textual.widgets import
|
|
13
|
+
from textual.widgets import LoadingIndicator, Static, Markdown
|
|
14
14
|
from textual.containers import Container
|
|
15
|
-
from
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class PromptInput(Widget):
|
|
19
|
-
"""Widget wrapper for Input that handles submission events."""
|
|
20
|
-
|
|
21
|
-
# Allow this widget and its children to receive focus
|
|
22
|
-
can_focus = True
|
|
23
|
-
can_focus_children = True
|
|
24
|
-
|
|
25
|
-
DEFAULT_CSS = """
|
|
26
|
-
PromptInput {
|
|
27
|
-
width: 100%;
|
|
28
|
-
height: auto;
|
|
29
|
-
padding: 1;
|
|
30
|
-
margin: 1 0;
|
|
31
|
-
background: $surface-lighten-1;
|
|
32
|
-
border: round $accent;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
PromptInput > Static {
|
|
36
|
-
width: 100%;
|
|
37
|
-
height: auto;
|
|
38
|
-
margin-bottom: 1;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
PromptInput > Input {
|
|
42
|
-
width: 100%;
|
|
43
|
-
}
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
|
-
def __init__(self, question: str, default: str, placeholder: str, on_submit: Callable[[str], None], **kwargs):
|
|
47
|
-
super().__init__(**kwargs)
|
|
48
|
-
self.question = question
|
|
49
|
-
self.default = default
|
|
50
|
-
self.placeholder = placeholder
|
|
51
|
-
self.on_submit_callback = on_submit
|
|
52
|
-
|
|
53
|
-
def compose(self):
|
|
54
|
-
from textual.widgets import Static
|
|
55
|
-
yield Static(f"[bold cyan]{self.question}[/bold cyan]")
|
|
56
|
-
yield Input(
|
|
57
|
-
value=self.default,
|
|
58
|
-
placeholder=self.placeholder,
|
|
59
|
-
id="prompt-input"
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
def on_mount(self):
|
|
63
|
-
"""Focus input when mounted and scroll into view."""
|
|
64
|
-
# Use call_after_refresh to ensure widget tree is ready
|
|
65
|
-
self.call_after_refresh(self._focus_input)
|
|
66
|
-
|
|
67
|
-
def _focus_input(self):
|
|
68
|
-
"""Focus the input widget and scroll into view."""
|
|
69
|
-
try:
|
|
70
|
-
input_widget = self.query_one(Input)
|
|
71
|
-
# Use app.set_focus() to force focus change from steps-panel
|
|
72
|
-
self.app.set_focus(input_widget)
|
|
73
|
-
# Scroll to make this widget visible
|
|
74
|
-
self.scroll_visible(animate=False)
|
|
75
|
-
except Exception:
|
|
76
|
-
pass
|
|
77
|
-
|
|
78
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
79
|
-
"""Handle input submission."""
|
|
80
|
-
value = event.value
|
|
81
|
-
self.on_submit_callback(value)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
class MultilineInput(TextArea):
|
|
85
|
-
"""Custom TextArea that handles Enter for submission and Shift+Enter for new lines."""
|
|
86
|
-
|
|
87
|
-
class Submitted(Message):
|
|
88
|
-
"""Message sent when the input is submitted."""
|
|
89
|
-
def __init__(self, sender: Widget, value: str):
|
|
90
|
-
super().__init__()
|
|
91
|
-
self.sender = sender
|
|
92
|
-
self.value = value
|
|
93
|
-
|
|
94
|
-
def _on_key(self, event) -> None:
|
|
95
|
-
"""Intercept key events before TextArea processes them."""
|
|
96
|
-
from textual.events import Key
|
|
97
|
-
|
|
98
|
-
# Check if it's Enter without shift
|
|
99
|
-
if isinstance(event, Key) and event.key == "enter":
|
|
100
|
-
# Submit the input
|
|
101
|
-
self.post_message(self.Submitted(self, self.text))
|
|
102
|
-
event.prevent_default()
|
|
103
|
-
event.stop()
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
# For all other keys, let TextArea handle it
|
|
107
|
-
super()._on_key(event)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
class PromptTextArea(Widget):
|
|
111
|
-
"""Widget wrapper for MultilineInput that handles multiline input submission."""
|
|
112
|
-
|
|
113
|
-
can_focus = True
|
|
114
|
-
can_focus_children = True
|
|
115
|
-
|
|
116
|
-
DEFAULT_CSS = """
|
|
117
|
-
PromptTextArea {
|
|
118
|
-
width: 100%;
|
|
119
|
-
height: auto;
|
|
120
|
-
padding: 1;
|
|
121
|
-
margin: 1 0;
|
|
122
|
-
background: $surface-lighten-1;
|
|
123
|
-
border: round $accent;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
PromptTextArea > Static {
|
|
127
|
-
width: 100%;
|
|
128
|
-
height: auto;
|
|
129
|
-
margin-bottom: 1;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
PromptTextArea > MultilineInput {
|
|
133
|
-
width: 100%;
|
|
134
|
-
height: auto;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
PromptTextArea .hint-text {
|
|
138
|
-
width: 100%;
|
|
139
|
-
height: auto;
|
|
140
|
-
margin-top: 1;
|
|
141
|
-
color: $text-muted;
|
|
142
|
-
}
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
def __init__(self, question: str, default: str, on_submit: Callable[[str], None], **kwargs):
|
|
146
|
-
super().__init__(**kwargs)
|
|
147
|
-
self.question = question
|
|
148
|
-
self.default = default
|
|
149
|
-
self.on_submit_callback = on_submit
|
|
150
|
-
|
|
151
|
-
def compose(self):
|
|
152
|
-
from textual.widgets import Static
|
|
153
|
-
yield Static(f"[bold cyan]{self.question}[/bold cyan]")
|
|
154
|
-
yield MultilineInput(
|
|
155
|
-
text=self.default,
|
|
156
|
-
id="prompt-textarea",
|
|
157
|
-
soft_wrap=True
|
|
158
|
-
)
|
|
159
|
-
yield Static("[dim]Press Enter to submit, Shift+Enter for new line[/dim]", classes="hint-text")
|
|
160
|
-
|
|
161
|
-
def on_mount(self):
|
|
162
|
-
"""Focus textarea when mounted and scroll into view."""
|
|
163
|
-
self.call_after_refresh(self._focus_textarea)
|
|
164
|
-
|
|
165
|
-
def _focus_textarea(self):
|
|
166
|
-
"""Focus the textarea widget and scroll into view."""
|
|
167
|
-
try:
|
|
168
|
-
textarea = self.query_one(MultilineInput)
|
|
169
|
-
self.app.set_focus(textarea)
|
|
170
|
-
self.scroll_visible(animate=False)
|
|
171
|
-
except Exception:
|
|
172
|
-
pass
|
|
173
|
-
|
|
174
|
-
def on_multiline_input_submitted(self, message: MultilineInput.Submitted):
|
|
175
|
-
"""Handle submission from MultilineInput."""
|
|
176
|
-
self.on_submit_callback(message.value)
|
|
15
|
+
from titan_cli.ui.tui.widgets import Panel, PromptInput, PromptTextArea, PromptSelectionList, SelectionOption, PromptChoice, ChoiceOption
|
|
177
16
|
|
|
178
17
|
|
|
179
18
|
class TextualComponents:
|
|
180
19
|
"""
|
|
181
20
|
Textual UI utilities for workflow steps.
|
|
182
21
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
22
|
+
All text styling uses the theme system for consistent colors across themes.
|
|
23
|
+
|
|
24
|
+
Steps can use these utilities to:
|
|
25
|
+
- Display panels with consistent styling
|
|
26
|
+
- Append text with theme-based styling
|
|
186
27
|
- Request user input interactively
|
|
28
|
+
- Mount custom widgets to the output panel
|
|
187
29
|
|
|
188
30
|
Example:
|
|
189
|
-
from titan_cli.ui.tui.widgets import Panel, DimText
|
|
190
|
-
|
|
191
31
|
def my_step(ctx):
|
|
192
|
-
#
|
|
193
|
-
ctx.textual.
|
|
32
|
+
# Show a panel
|
|
33
|
+
ctx.textual.panel("Warning message", panel_type="warning")
|
|
34
|
+
|
|
35
|
+
# Append styled text (uses theme system)
|
|
36
|
+
ctx.textual.dim_text("Fetching data...")
|
|
37
|
+
ctx.textual.success_text("Operation completed!")
|
|
38
|
+
ctx.textual.error_text("Failed to connect")
|
|
39
|
+
ctx.textual.bold_primary_text("AI Analysis Results")
|
|
194
40
|
|
|
195
|
-
# Append
|
|
196
|
-
ctx.textual.text("
|
|
41
|
+
# Append plain text
|
|
42
|
+
ctx.textual.text("Processing...")
|
|
197
43
|
|
|
198
44
|
# Ask for input
|
|
199
45
|
response = ctx.textual.ask_confirm("Continue?", default=True)
|
|
@@ -209,6 +55,48 @@ class TextualComponents:
|
|
|
209
55
|
"""
|
|
210
56
|
self.app = app
|
|
211
57
|
self.output_widget = output_widget
|
|
58
|
+
self._active_step_container = None
|
|
59
|
+
|
|
60
|
+
def begin_step(self, step_name: str) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Begin a new step by creating a StepContainer and auto-scrolling to it.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
step_name: Name of the step
|
|
66
|
+
"""
|
|
67
|
+
from titan_cli.ui.tui.widgets import StepContainer
|
|
68
|
+
|
|
69
|
+
def _create_container():
|
|
70
|
+
container = StepContainer(step_name=step_name)
|
|
71
|
+
self.output_widget.mount(container)
|
|
72
|
+
self._active_step_container = container
|
|
73
|
+
# Auto-scroll to show the new step
|
|
74
|
+
self.output_widget._scroll_to_end()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
self.app.call_from_thread(_create_container)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def end_step(self, result_type: str) -> None:
|
|
82
|
+
"""
|
|
83
|
+
End the current step by updating its container color.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
result_type: One of 'success', 'skip', 'error'
|
|
87
|
+
"""
|
|
88
|
+
if not self._active_step_container:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
def _update_container():
|
|
92
|
+
if self._active_step_container:
|
|
93
|
+
self._active_step_container.set_result(result_type)
|
|
94
|
+
self._active_step_container = None
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
self.app.call_from_thread(_update_container)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
212
100
|
|
|
213
101
|
def mount(self, widget: Widget) -> None:
|
|
214
102
|
"""
|
|
@@ -222,7 +110,9 @@ class TextualComponents:
|
|
|
222
110
|
ctx.textual.mount(Panel("Success!", panel_type="success"))
|
|
223
111
|
"""
|
|
224
112
|
def _mount():
|
|
225
|
-
|
|
113
|
+
# Mount to active step container if it exists, otherwise to output widget
|
|
114
|
+
target = self._active_step_container if self._active_step_container else self.output_widget
|
|
115
|
+
target.mount(widget)
|
|
226
116
|
|
|
227
117
|
# call_from_thread already blocks until the function completes
|
|
228
118
|
try:
|
|
@@ -231,21 +121,44 @@ class TextualComponents:
|
|
|
231
121
|
# App is closing or worker was cancelled
|
|
232
122
|
pass
|
|
233
123
|
|
|
234
|
-
def
|
|
124
|
+
def scroll_to_end(self) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Scroll to the end of the output widget.
|
|
127
|
+
|
|
128
|
+
Useful for ensuring user sees newly added content in steps with lots of output.
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
ctx.textual.markdown(large_content)
|
|
132
|
+
ctx.textual.scroll_to_end() # Ensure user sees what comes next
|
|
133
|
+
"""
|
|
134
|
+
def _scroll():
|
|
135
|
+
self.output_widget._scroll_to_end()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
self.app.call_from_thread(_scroll)
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
def text(self, text: str) -> None:
|
|
235
143
|
"""
|
|
236
|
-
Append
|
|
144
|
+
Append plain text without styling.
|
|
145
|
+
|
|
146
|
+
For styled text, use specific methods: dim_text(), success_text(), etc.
|
|
237
147
|
|
|
238
148
|
Args:
|
|
239
149
|
text: Text to append
|
|
240
|
-
markup: Optional Rich markup style (e.g., "cyan", "bold green")
|
|
241
150
|
|
|
242
151
|
Example:
|
|
243
|
-
ctx.textual.text("
|
|
244
|
-
ctx.textual.text("
|
|
152
|
+
ctx.textual.text("Processing...")
|
|
153
|
+
ctx.textual.text("") # Empty line
|
|
245
154
|
"""
|
|
246
155
|
def _append():
|
|
247
|
-
|
|
248
|
-
|
|
156
|
+
# If there's an active step container, append to it; otherwise to output widget
|
|
157
|
+
if self._active_step_container:
|
|
158
|
+
from textual.widgets import Static
|
|
159
|
+
widget = Static(text)
|
|
160
|
+
widget.styles.height = "auto"
|
|
161
|
+
self._active_step_container.mount(widget)
|
|
249
162
|
else:
|
|
250
163
|
self.output_widget.append_output(text)
|
|
251
164
|
|
|
@@ -276,10 +189,10 @@ class TextualComponents:
|
|
|
276
189
|
md_widget.styles.margin = (0, 0, 1, 0)
|
|
277
190
|
|
|
278
191
|
def _mount():
|
|
279
|
-
# Mount
|
|
280
|
-
self.output_widget
|
|
281
|
-
|
|
282
|
-
|
|
192
|
+
# Mount to active step container if it exists, otherwise to output widget
|
|
193
|
+
target = self._active_step_container if self._active_step_container else self.output_widget
|
|
194
|
+
target.mount(md_widget)
|
|
195
|
+
# Note: Screen handles auto-scroll when step completes, not here
|
|
283
196
|
|
|
284
197
|
# call_from_thread already blocks until the function completes
|
|
285
198
|
try:
|
|
@@ -288,6 +201,126 @@ class TextualComponents:
|
|
|
288
201
|
# App is closing or worker was cancelled
|
|
289
202
|
pass
|
|
290
203
|
|
|
204
|
+
def panel(self, text: str, panel_type: str = "info") -> None:
|
|
205
|
+
"""
|
|
206
|
+
Show a panel with consistent styling.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
text: Text to display in the panel
|
|
210
|
+
panel_type: Type of panel - "info", "success", "warning", or "error"
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
ctx.textual.panel("Operation completed successfully!", panel_type="success")
|
|
214
|
+
ctx.textual.panel("Warning: This action cannot be undone", panel_type="warning")
|
|
215
|
+
"""
|
|
216
|
+
panel_widget = Panel(text=text, panel_type=panel_type)
|
|
217
|
+
self.mount(panel_widget)
|
|
218
|
+
|
|
219
|
+
def dim_text(self, text: str) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Append dim/muted text (uses theme system).
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
text: Text to display
|
|
225
|
+
|
|
226
|
+
Example:
|
|
227
|
+
ctx.textual.dim_text("Fetching versions for project: ECAPP")
|
|
228
|
+
"""
|
|
229
|
+
from titan_cli.ui.tui.widgets import DimText
|
|
230
|
+
widget = DimText(text)
|
|
231
|
+
widget.styles.height = "auto"
|
|
232
|
+
self.mount(widget)
|
|
233
|
+
|
|
234
|
+
def success_text(self, text: str) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Append success text (green, uses theme system).
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
text: Text to display
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
ctx.textual.success_text("Commit created: abc1234")
|
|
243
|
+
"""
|
|
244
|
+
from titan_cli.ui.tui.widgets import SuccessText
|
|
245
|
+
widget = SuccessText(text)
|
|
246
|
+
widget.styles.height = "auto"
|
|
247
|
+
self.mount(widget)
|
|
248
|
+
|
|
249
|
+
def error_text(self, text: str) -> None:
|
|
250
|
+
"""
|
|
251
|
+
Append error text (red, uses theme system).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
text: Text to display
|
|
255
|
+
|
|
256
|
+
Example:
|
|
257
|
+
ctx.textual.error_text("Failed to connect to API")
|
|
258
|
+
"""
|
|
259
|
+
from titan_cli.ui.tui.widgets import ErrorText
|
|
260
|
+
widget = ErrorText(text)
|
|
261
|
+
widget.styles.height = "auto"
|
|
262
|
+
self.mount(widget)
|
|
263
|
+
|
|
264
|
+
def warning_text(self, text: str) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Append warning text (yellow, uses theme system).
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
text: Text to display
|
|
270
|
+
|
|
271
|
+
Example:
|
|
272
|
+
ctx.textual.warning_text("This action will overwrite existing files")
|
|
273
|
+
"""
|
|
274
|
+
from titan_cli.ui.tui.widgets import WarningText
|
|
275
|
+
widget = WarningText(text)
|
|
276
|
+
widget.styles.height = "auto"
|
|
277
|
+
self.mount(widget)
|
|
278
|
+
|
|
279
|
+
def primary_text(self, text: str) -> None:
|
|
280
|
+
"""
|
|
281
|
+
Append primary colored text (uses theme system).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
text: Text to display
|
|
285
|
+
|
|
286
|
+
Example:
|
|
287
|
+
ctx.textual.primary_text("Processing items...")
|
|
288
|
+
"""
|
|
289
|
+
from titan_cli.ui.tui.widgets import PrimaryText
|
|
290
|
+
widget = PrimaryText(text)
|
|
291
|
+
widget.styles.height = "auto"
|
|
292
|
+
self.mount(widget)
|
|
293
|
+
|
|
294
|
+
def bold_text(self, text: str) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Append bold text.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
text: Text to display
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
ctx.textual.bold_text("Important: Read carefully")
|
|
303
|
+
"""
|
|
304
|
+
from titan_cli.ui.tui.widgets import BoldText
|
|
305
|
+
widget = BoldText(text)
|
|
306
|
+
widget.styles.height = "auto"
|
|
307
|
+
self.mount(widget)
|
|
308
|
+
|
|
309
|
+
def bold_primary_text(self, text: str) -> None:
|
|
310
|
+
"""
|
|
311
|
+
Append bold text with primary theme color.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
text: Text to display
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
ctx.textual.bold_primary_text("AI Analysis Results")
|
|
318
|
+
"""
|
|
319
|
+
from titan_cli.ui.tui.widgets import BoldPrimaryText
|
|
320
|
+
widget = BoldPrimaryText(text)
|
|
321
|
+
widget.styles.height = "auto"
|
|
322
|
+
self.mount(widget)
|
|
323
|
+
|
|
291
324
|
def ask_text(self, question: str, default: str = "") -> Optional[str]:
|
|
292
325
|
"""
|
|
293
326
|
Ask user for text input (blocks until user responds).
|
|
@@ -443,6 +476,130 @@ class TextualComponents:
|
|
|
443
476
|
# Invalid response, use default
|
|
444
477
|
return default
|
|
445
478
|
|
|
479
|
+
def ask_multiselect(
|
|
480
|
+
self,
|
|
481
|
+
question: str,
|
|
482
|
+
options: List[SelectionOption],
|
|
483
|
+
) -> List[Any]:
|
|
484
|
+
"""
|
|
485
|
+
Ask user to select multiple options from a list using checkboxes.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
question: Question to display
|
|
489
|
+
options: List of SelectionOption instances
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
List of selected values (the 'value' field from SelectionOption)
|
|
493
|
+
|
|
494
|
+
Example:
|
|
495
|
+
from titan_cli.ui.tui.widgets import SelectionOption
|
|
496
|
+
|
|
497
|
+
options = [
|
|
498
|
+
SelectionOption(value="option1", label="First Option", selected=True),
|
|
499
|
+
SelectionOption(value="option2", label="Second Option", selected=True),
|
|
500
|
+
SelectionOption(value="option3", label="Third Option", selected=False),
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
selected = ctx.textual.ask_multiselect(
|
|
504
|
+
"Select options to include:",
|
|
505
|
+
options
|
|
506
|
+
)
|
|
507
|
+
# selected might be ["option1", "option2"] if user didn't change anything
|
|
508
|
+
"""
|
|
509
|
+
result_container = {"result": None, "ready": threading.Event()}
|
|
510
|
+
|
|
511
|
+
def on_submit(selected_values: List[Any]):
|
|
512
|
+
result_container["result"] = selected_values
|
|
513
|
+
result_container["ready"].set()
|
|
514
|
+
|
|
515
|
+
# Create and mount the selection widget
|
|
516
|
+
selection_widget = PromptSelectionList(
|
|
517
|
+
question=question,
|
|
518
|
+
options=options,
|
|
519
|
+
on_submit=on_submit
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
self.mount(selection_widget)
|
|
523
|
+
|
|
524
|
+
# Wait for user to submit
|
|
525
|
+
result_container["ready"].wait()
|
|
526
|
+
|
|
527
|
+
# Remove the widget
|
|
528
|
+
def _remove():
|
|
529
|
+
try:
|
|
530
|
+
selection_widget.remove()
|
|
531
|
+
except Exception:
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
self.app.call_from_thread(_remove)
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
return result_container["result"]
|
|
540
|
+
|
|
541
|
+
def ask_choice(
|
|
542
|
+
self,
|
|
543
|
+
question: str,
|
|
544
|
+
options: List[ChoiceOption],
|
|
545
|
+
) -> Any:
|
|
546
|
+
"""
|
|
547
|
+
Ask user to select one option from multiple choices using buttons.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
question: Question to display
|
|
551
|
+
options: List of ChoiceOption instances
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
The selected value (the 'value' field from ChoiceOption)
|
|
555
|
+
|
|
556
|
+
Example:
|
|
557
|
+
from titan_cli.ui.tui.widgets import ChoiceOption
|
|
558
|
+
|
|
559
|
+
options = [
|
|
560
|
+
ChoiceOption(value="use", label="Use as-is", variant="primary"),
|
|
561
|
+
ChoiceOption(value="edit", label="Edit", variant="default"),
|
|
562
|
+
ChoiceOption(value="reject", label="Reject", variant="error"),
|
|
563
|
+
]
|
|
564
|
+
|
|
565
|
+
choice = ctx.textual.ask_choice(
|
|
566
|
+
"What would you like to do with this PR description?",
|
|
567
|
+
options
|
|
568
|
+
)
|
|
569
|
+
# choice might be "use", "edit", or "reject"
|
|
570
|
+
"""
|
|
571
|
+
result_container = {"result": None, "ready": threading.Event()}
|
|
572
|
+
|
|
573
|
+
def on_select(selected_value: Any):
|
|
574
|
+
result_container["result"] = selected_value
|
|
575
|
+
result_container["ready"].set()
|
|
576
|
+
|
|
577
|
+
# Create and mount the choice widget
|
|
578
|
+
choice_widget = PromptChoice(
|
|
579
|
+
question=question,
|
|
580
|
+
options=options,
|
|
581
|
+
on_select=on_select
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
self.mount(choice_widget)
|
|
585
|
+
|
|
586
|
+
# Wait for user to select
|
|
587
|
+
result_container["ready"].wait()
|
|
588
|
+
|
|
589
|
+
# Remove the widget
|
|
590
|
+
def _remove():
|
|
591
|
+
try:
|
|
592
|
+
choice_widget.remove()
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
self.app.call_from_thread(_remove)
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
return result_container["result"]
|
|
602
|
+
|
|
446
603
|
@contextmanager
|
|
447
604
|
def loading(self, message: str = "Loading..."):
|
|
448
605
|
"""
|
|
@@ -14,7 +14,7 @@ from titan_cli.core.workflows.workflow_registry import WorkflowRegistry
|
|
|
14
14
|
from titan_cli.core.plugins.plugin_registry import PluginRegistry
|
|
15
15
|
from titan_cli.core.workflows.models import WorkflowStepModel
|
|
16
16
|
from titan_cli.engine.context import WorkflowContext
|
|
17
|
-
from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip
|
|
17
|
+
from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip, is_exit
|
|
18
18
|
from titan_cli.engine.steps.command_step import execute_command_step as execute_external_command_step
|
|
19
19
|
from titan_cli.engine.steps.ai_assistant_step import execute_ai_assistant_step
|
|
20
20
|
|
|
@@ -238,7 +238,35 @@ class TextualWorkflowExecutor:
|
|
|
238
238
|
step_result = Error(f"An unexpected error occurred in step '{step_name}': {e}", e)
|
|
239
239
|
|
|
240
240
|
# Handle step result
|
|
241
|
-
if
|
|
241
|
+
if is_exit(step_result):
|
|
242
|
+
# Exit workflow immediately (not an error)
|
|
243
|
+
if step_result.metadata:
|
|
244
|
+
ctx.data.update(step_result.metadata)
|
|
245
|
+
|
|
246
|
+
# Post completion message
|
|
247
|
+
self._post_message_sync(
|
|
248
|
+
self.StepCompleted(
|
|
249
|
+
step_index=step_index,
|
|
250
|
+
step_id=step_id,
|
|
251
|
+
step_name=step_name
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Calculate is_nested before exiting workflow
|
|
256
|
+
# (check if there are parent workflows in the stack)
|
|
257
|
+
is_nested = len(ctx._workflow_stack) > 1 # >1 because current workflow is still in stack
|
|
258
|
+
|
|
259
|
+
# Post workflow completed message and exit
|
|
260
|
+
self._post_message_sync(
|
|
261
|
+
self.WorkflowCompleted(
|
|
262
|
+
workflow_name=workflow.name,
|
|
263
|
+
message=step_result.message,
|
|
264
|
+
is_nested=is_nested
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return Success(step_result.message, step_result.metadata)
|
|
268
|
+
|
|
269
|
+
elif is_error(step_result):
|
|
242
270
|
self._post_message_sync(
|
|
243
271
|
self.StepFailed(
|
|
244
272
|
step_index=step_index,
|
|
@@ -341,6 +369,14 @@ class TextualWorkflowExecutor:
|
|
|
341
369
|
f"Project step '{step_func_name}' not found in '.titan/steps/'.",
|
|
342
370
|
WorkflowExecutionError(f"Project step '{step_func_name}' not found")
|
|
343
371
|
)
|
|
372
|
+
elif plugin_name == "user":
|
|
373
|
+
# Handle virtual 'user' plugin for user-specific steps
|
|
374
|
+
step_func = self._workflow_registry.get_user_step(step_func_name)
|
|
375
|
+
if not step_func:
|
|
376
|
+
return Error(
|
|
377
|
+
f"User step '{step_func_name}' not found in '~/.titan/steps/'.",
|
|
378
|
+
WorkflowExecutionError(f"User step '{step_func_name}' not found")
|
|
379
|
+
)
|
|
344
380
|
elif plugin_name == "core":
|
|
345
381
|
# Handle virtual 'core' plugin for built-in core steps
|
|
346
382
|
step_func = self.CORE_STEPS.get(step_func_name)
|
|
@@ -382,7 +418,7 @@ class TextualWorkflowExecutor:
|
|
|
382
418
|
# Plugin and project steps receive only ctx (params are in ctx.data)
|
|
383
419
|
return step_func(ctx)
|
|
384
420
|
except Exception as e:
|
|
385
|
-
error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "core") else f"{plugin_name} step"
|
|
421
|
+
error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "user", "core") else f"{plugin_name} step"
|
|
386
422
|
return Error(f"Error executing step '{step_func_name}' from {error_source}: {e}", e)
|
|
387
423
|
|
|
388
424
|
def _execute_command_step(self, step_config: WorkflowStepModel, ctx: WorkflowContext) -> WorkflowResult:
|