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.
Files changed (61) hide show
  1. titan_cli/core/config.py +3 -1
  2. titan_cli/core/workflows/__init__.py +2 -1
  3. titan_cli/core/workflows/project_step_source.py +95 -32
  4. titan_cli/core/workflows/workflow_filter_service.py +16 -8
  5. titan_cli/core/workflows/workflow_registry.py +12 -1
  6. titan_cli/core/workflows/workflow_sources.py +1 -1
  7. titan_cli/engine/__init__.py +5 -1
  8. titan_cli/engine/results.py +31 -1
  9. titan_cli/engine/steps/ai_assistant_step.py +47 -12
  10. titan_cli/engine/workflow_executor.py +13 -3
  11. titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
  12. titan_cli/ui/tui/screens/workflow_execution.py +28 -50
  13. titan_cli/ui/tui/screens/workflows.py +8 -4
  14. titan_cli/ui/tui/textual_components.py +342 -185
  15. titan_cli/ui/tui/textual_workflow_executor.py +39 -3
  16. titan_cli/ui/tui/theme.py +34 -5
  17. titan_cli/ui/tui/widgets/__init__.py +17 -0
  18. titan_cli/ui/tui/widgets/multiline_input.py +32 -0
  19. titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
  20. titan_cli/ui/tui/widgets/prompt_input.py +74 -0
  21. titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
  22. titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
  23. titan_cli/ui/tui/widgets/step_container.py +70 -0
  24. titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
  25. titan_cli/ui/tui/widgets/text.py +51 -130
  26. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/METADATA +3 -5
  27. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/RECORD +61 -46
  28. titan_plugin_git/clients/git_client.py +140 -5
  29. titan_plugin_git/plugin.py +13 -0
  30. titan_plugin_git/steps/ai_commit_message_step.py +39 -34
  31. titan_plugin_git/steps/branch_steps.py +18 -37
  32. titan_plugin_git/steps/checkout_step.py +66 -0
  33. titan_plugin_git/steps/commit_step.py +18 -22
  34. titan_plugin_git/steps/create_branch_step.py +131 -0
  35. titan_plugin_git/steps/diff_summary_step.py +180 -0
  36. titan_plugin_git/steps/pull_step.py +70 -0
  37. titan_plugin_git/steps/push_step.py +27 -11
  38. titan_plugin_git/steps/restore_original_branch_step.py +97 -0
  39. titan_plugin_git/steps/save_current_branch_step.py +82 -0
  40. titan_plugin_git/steps/status_step.py +32 -25
  41. titan_plugin_git/workflows/commit-ai.yaml +9 -3
  42. titan_plugin_github/agents/pr_agent.py +15 -2
  43. titan_plugin_github/steps/ai_pr_step.py +99 -40
  44. titan_plugin_github/steps/create_pr_step.py +18 -8
  45. titan_plugin_github/steps/github_prompt_steps.py +53 -1
  46. titan_plugin_github/steps/issue_steps.py +31 -18
  47. titan_plugin_github/steps/preview_step.py +15 -4
  48. titan_plugin_github/utils.py +5 -4
  49. titan_plugin_github/workflows/create-pr-ai.yaml +6 -11
  50. titan_plugin_jira/messages.py +12 -0
  51. titan_plugin_jira/plugin.py +4 -0
  52. titan_plugin_jira/steps/ai_analyze_issue_step.py +12 -7
  53. titan_plugin_jira/steps/get_issue_step.py +17 -13
  54. titan_plugin_jira/steps/list_versions_step.py +133 -0
  55. titan_plugin_jira/steps/prompt_select_issue_step.py +20 -8
  56. titan_plugin_jira/steps/search_jql_step.py +191 -0
  57. titan_plugin_jira/steps/search_saved_query_step.py +26 -24
  58. titan_plugin_jira/utils/__init__.py +1 -1
  59. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/LICENSE +0 -0
  60. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/WHEEL +0 -0
  61. {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, Callable
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 Input, LoadingIndicator, Static, Markdown, TextArea
13
+ from textual.widgets import LoadingIndicator, Static, Markdown
14
14
  from textual.containers import Container
15
- from textual.message import Message
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
- Steps import widgets directly (Panel, DimText, etc.) and use these utilities to:
184
- - Mount widgets to the output panel
185
- - Append simple text with markup
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
- # Mount a panel widget
193
- ctx.textual.mount(Panel("Warning message", panel_type="warning"))
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 inline text
196
- ctx.textual.text("Analyzing changes...")
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
- self.output_widget.mount(widget)
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 text(self, text: str, markup: str = "") -> None:
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 inline text with optional Rich markup.
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("Analyzing changes...", markup="cyan")
244
- ctx.textual.text("Done!")
152
+ ctx.textual.text("Processing...")
153
+ ctx.textual.text("") # Empty line
245
154
  """
246
155
  def _append():
247
- if markup:
248
- self.output_widget.append_output(f"[{markup}]{text}[/{markup}]")
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 markdown to output
280
- self.output_widget.mount(md_widget)
281
- # Trigger autoscroll after mounting
282
- self.output_widget._scroll_to_end()
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 is_error(step_result):
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: