titan-cli 0.1.5__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 (54) hide show
  1. titan_cli/core/workflows/project_step_source.py +52 -7
  2. titan_cli/core/workflows/workflow_filter_service.py +6 -4
  3. titan_cli/engine/__init__.py +5 -1
  4. titan_cli/engine/results.py +31 -1
  5. titan_cli/engine/steps/ai_assistant_step.py +18 -18
  6. titan_cli/engine/workflow_executor.py +7 -2
  7. titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
  8. titan_cli/ui/tui/screens/workflow_execution.py +22 -24
  9. titan_cli/ui/tui/screens/workflows.py +8 -4
  10. titan_cli/ui/tui/textual_components.py +293 -189
  11. titan_cli/ui/tui/textual_workflow_executor.py +30 -2
  12. titan_cli/ui/tui/theme.py +34 -5
  13. titan_cli/ui/tui/widgets/__init__.py +15 -0
  14. titan_cli/ui/tui/widgets/multiline_input.py +32 -0
  15. titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
  16. titan_cli/ui/tui/widgets/prompt_input.py +74 -0
  17. titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
  18. titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
  19. titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
  20. titan_cli/ui/tui/widgets/text.py +51 -130
  21. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/METADATA +5 -10
  22. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/RECORD +54 -41
  23. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/WHEEL +1 -1
  24. titan_plugin_git/clients/git_client.py +59 -2
  25. titan_plugin_git/plugin.py +10 -0
  26. titan_plugin_git/steps/ai_commit_message_step.py +8 -8
  27. titan_plugin_git/steps/branch_steps.py +6 -6
  28. titan_plugin_git/steps/checkout_step.py +66 -0
  29. titan_plugin_git/steps/commit_step.py +3 -3
  30. titan_plugin_git/steps/create_branch_step.py +131 -0
  31. titan_plugin_git/steps/diff_summary_step.py +11 -13
  32. titan_plugin_git/steps/pull_step.py +70 -0
  33. titan_plugin_git/steps/push_step.py +3 -3
  34. titan_plugin_git/steps/restore_original_branch_step.py +97 -0
  35. titan_plugin_git/steps/save_current_branch_step.py +82 -0
  36. titan_plugin_git/steps/status_step.py +23 -13
  37. titan_plugin_git/workflows/commit-ai.yaml +4 -3
  38. titan_plugin_github/steps/ai_pr_step.py +90 -22
  39. titan_plugin_github/steps/create_pr_step.py +8 -8
  40. titan_plugin_github/steps/github_prompt_steps.py +13 -13
  41. titan_plugin_github/steps/issue_steps.py +14 -15
  42. titan_plugin_github/steps/preview_step.py +8 -8
  43. titan_plugin_github/workflows/create-pr-ai.yaml +1 -11
  44. titan_plugin_jira/messages.py +12 -0
  45. titan_plugin_jira/plugin.py +4 -0
  46. titan_plugin_jira/steps/ai_analyze_issue_step.py +6 -6
  47. titan_plugin_jira/steps/get_issue_step.py +7 -7
  48. titan_plugin_jira/steps/list_versions_step.py +133 -0
  49. titan_plugin_jira/steps/prompt_select_issue_step.py +8 -9
  50. titan_plugin_jira/steps/search_jql_step.py +191 -0
  51. titan_plugin_jira/steps/search_saved_query_step.py +13 -13
  52. titan_plugin_jira/utils/__init__.py +1 -1
  53. {titan_cli-0.1.5.dist-info/licenses → titan_cli-0.1.6.dist-info}/LICENSE +0 -0
  54. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/entry_points.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  import importlib.util
2
+ import sys
2
3
  from pathlib import Path
3
4
  from typing import Callable, Dict, Any, Optional, List
4
5
  from dataclasses import dataclass
@@ -31,6 +32,7 @@ class BaseStepSource:
31
32
  def discover(self) -> List[StepInfo]:
32
33
  """
33
34
  Discovers all available step files in the project's .titan/steps directory.
35
+ Supports subdirectories (e.g., .titan/steps/jira/step.py).
34
36
  """
35
37
  if self._step_info_cache is not None:
36
38
  return self._step_info_cache
@@ -40,18 +42,18 @@ class BaseStepSource:
40
42
  return []
41
43
 
42
44
  discovered = []
43
- for step_file in self._steps_dir.glob("*.py"):
44
- if step_file.name not in self.EXCLUDED_FILES:
45
+ for step_file in self._steps_dir.glob("**/*.py"):
46
+ if step_file.name not in self.EXCLUDED_FILES and not any(part.startswith("__") for part in step_file.parts):
45
47
  step_name = step_file.stem
46
48
  discovered.append(StepInfo(name=step_name, path=step_file))
47
-
49
+
48
50
  self._step_info_cache = discovered
49
51
  return discovered
50
52
 
51
53
  def get_step(self, step_name: str) -> Optional[StepFunction]:
52
54
  """
53
55
  Retrieves a step function by name, loading it from its file if necessary.
54
- Searches all Python files in the directory for the function.
56
+ Searches all Python files in the directory (including subdirectories) for the function.
55
57
  """
56
58
  if step_name in self._step_function_cache:
57
59
  return self._step_function_cache[step_name]
@@ -59,9 +61,9 @@ class BaseStepSource:
59
61
  if not self._steps_dir.is_dir():
60
62
  return None
61
63
 
62
- # Search all Python files for the function
63
- for step_file in self._steps_dir.glob("*.py"):
64
- if step_file.name in self.EXCLUDED_FILES:
64
+ # Search all Python files (including subdirectories) for the function
65
+ for step_file in self._steps_dir.glob("**/*.py"):
66
+ if step_file.name in self.EXCLUDED_FILES or any(part.startswith("__") for part in step_file.parts):
65
67
  continue
66
68
 
67
69
  try:
@@ -88,11 +90,54 @@ class BaseStepSource:
88
90
  class ProjectStepSource(BaseStepSource):
89
91
  """
90
92
  Discovers and loads Python step functions from a project's .titan/steps/ directory.
93
+ Supports relative imports by properly setting module __package__ attribute.
91
94
  """
92
95
  def __init__(self, project_root: Path):
93
96
  steps_dir = project_root / ".titan" / "steps"
94
97
  super().__init__(steps_dir)
95
98
  self._project_root = project_root
99
+ self._titan_dir = project_root / ".titan"
100
+
101
+ # Add .titan directory to sys.path to enable absolute imports
102
+ titan_dir_str = str(self._titan_dir)
103
+ if titan_dir_str not in sys.path:
104
+ sys.path.insert(0, titan_dir_str)
105
+
106
+ def get_step(self, step_name: str) -> Optional[StepFunction]:
107
+ """
108
+ Retrieves a step function by name.
109
+ Project steps should use absolute imports from .titan/ as the root.
110
+ """
111
+ if step_name in self._step_function_cache:
112
+ return self._step_function_cache[step_name]
113
+
114
+ if not self._steps_dir.is_dir():
115
+ return None
116
+
117
+ # Search all Python files for the function
118
+ for step_file in self._steps_dir.glob("**/*.py"):
119
+ if step_file.name in self.EXCLUDED_FILES or any(part.startswith("__") for part in step_file.parts):
120
+ continue
121
+
122
+ try:
123
+ # Use a unique module name to avoid conflicts
124
+ module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
125
+ spec = importlib.util.spec_from_file_location(module_name, step_file)
126
+ if spec and spec.loader:
127
+ module = importlib.util.module_from_spec(spec)
128
+ spec.loader.exec_module(module)
129
+
130
+ # Look for the function
131
+ step_func = getattr(module, step_name, None)
132
+ if callable(step_func):
133
+ self._step_function_cache[step_name] = step_func
134
+ return step_func
135
+
136
+ except Exception:
137
+ # Continue searching other files
138
+ continue
139
+
140
+ return None
96
141
 
97
142
 
98
143
  class UserStepSource(BaseStepSource):
@@ -45,10 +45,12 @@ class WorkflowFilterService:
45
45
  if wf_info.required_plugins:
46
46
  # Filter out core/project pseudo-plugins
47
47
  real_plugins = [p for p in wf_info.required_plugins if p not in ["core", "project"]]
48
- if real_plugins:
49
- # Use the first real plugin
50
- primary_plugin = sorted(real_plugins)[0]
51
- return primary_plugin.capitalize()
48
+ # If workflow uses multiple plugins, treat as custom/project workflow
49
+ if len(real_plugins) > 1:
50
+ return "Project"
51
+ elif len(real_plugins) == 1:
52
+ # Single plugin dependency
53
+ return real_plugins[0].capitalize()
52
54
  # No plugin dependencies, it's a custom workflow
53
55
  return "Custom"
54
56
 
@@ -5,7 +5,7 @@ This module provides the execution engine for composing and running workflows
5
5
  using the Atomic Steps Pattern.
6
6
 
7
7
  Core components:
8
- - WorkflowResult types (Success, Error, Skip)
8
+ - WorkflowResult types (Success, Error, Skip, Exit)
9
9
  - WorkflowContext for dependency injection
10
10
  - WorkflowContextBuilder for fluent API
11
11
  - WorkflowExecutor for executing YAML workflows (see workflow_executor.py)
@@ -16,9 +16,11 @@ from .results import (
16
16
  Success,
17
17
  Error,
18
18
  Skip,
19
+ Exit,
19
20
  is_success,
20
21
  is_error,
21
22
  is_skip,
23
+ is_exit,
22
24
  )
23
25
  from .context import WorkflowContext
24
26
  from .builder import WorkflowContextBuilder
@@ -29,10 +31,12 @@ __all__ = [
29
31
  "Success",
30
32
  "Error",
31
33
  "Skip",
34
+ "Exit",
32
35
  # Helper functions
33
36
  "is_success",
34
37
  "is_error",
35
38
  "is_skip",
39
+ "is_exit",
36
40
  # Context & builder
37
41
  "WorkflowContext",
38
42
  "WorkflowContextBuilder",
@@ -68,8 +68,33 @@ class Skip:
68
68
  metadata: Optional[dict[str, Any]] = None
69
69
 
70
70
 
71
+ @dataclass(frozen=True)
72
+ class Exit:
73
+ """
74
+ Exit the entire workflow early (not an error).
75
+
76
+ Use when the workflow should stop because it's not needed:
77
+ - No changes to commit
78
+ - Nothing to do
79
+ - Preconditions not met
80
+
81
+ This exits the ENTIRE workflow, not just the current step.
82
+
83
+ Attributes:
84
+ message: Why the workflow is exiting (required)
85
+ metadata: Metadata to auto-merge into ctx.data
86
+
87
+ Examples:
88
+ >>> if not has_changes:
89
+ >>> return Exit("No changes to commit")
90
+ >>> return Exit("Already up to date", metadata={"status": "clean"})
91
+ """
92
+ message: str
93
+ metadata: Optional[dict[str, Any]] = None
94
+
95
+
71
96
  # Type alias for workflow results
72
- WorkflowResult = Union[Success, Error, Skip]
97
+ WorkflowResult = Union[Success, Error, Skip, Exit]
73
98
 
74
99
 
75
100
  # ============================================================================
@@ -89,3 +114,8 @@ def is_error(result: WorkflowResult) -> bool:
89
114
  def is_skip(result: WorkflowResult) -> bool:
90
115
  """Check if result is Skip."""
91
116
  return isinstance(result, Skip)
117
+
118
+
119
+ def is_exit(result: WorkflowResult) -> bool:
120
+ """Check if result is Exit."""
121
+ return isinstance(result, Exit)
@@ -56,13 +56,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
56
56
  # Validate cli_preference
57
57
  VALID_CLI_PREFERENCES = {"auto", "claude", "gemini"}
58
58
  if cli_preference not in VALID_CLI_PREFERENCES:
59
- ctx.textual.text(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}", markup="red")
59
+ ctx.textual.error_text(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
60
60
  ctx.textual.end_step("error")
61
61
  return Error(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
62
62
 
63
63
  # Validate required parameters
64
64
  if not context_key:
65
- ctx.textual.text(msg.AIAssistant.CONTEXT_KEY_REQUIRED, markup="red")
65
+ ctx.textual.error_text(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
66
66
  ctx.textual.end_step("error")
67
67
  return Error(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
68
68
 
@@ -79,7 +79,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
79
79
  else:
80
80
  friendly_msg = "No issues to fix - skipping AI assistance"
81
81
 
82
- ctx.textual.text(friendly_msg, markup="dim")
82
+ ctx.textual.dim_text(friendly_msg)
83
83
  ctx.textual.end_step("skip")
84
84
  return Skip(friendly_msg)
85
85
 
@@ -96,11 +96,11 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
96
96
  context_str = json.dumps(context_data, indent=2)
97
97
  prompt = prompt_template.format(context=context_str)
98
98
  except KeyError as e:
99
- ctx.textual.text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e), markup="red")
99
+ ctx.textual.error_text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
100
100
  ctx.textual.end_step("error")
101
101
  return Error(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
102
102
  except Exception as e:
103
- ctx.textual.text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e), markup="red")
103
+ ctx.textual.error_text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
104
104
  ctx.textual.end_step("error")
105
105
  return Error(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
106
106
 
@@ -113,10 +113,10 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
113
113
  )
114
114
  if not should_launch:
115
115
  if fail_on_decline:
116
- ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED, markup="yellow")
116
+ ctx.textual.warning_text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
117
117
  ctx.textual.end_step("error")
118
118
  return Error(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
119
- ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED, markup="dim")
119
+ ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
120
120
  ctx.textual.end_step("skip")
121
121
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
122
122
 
@@ -142,7 +142,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
142
142
  available_launchers[cli_name] = launcher
143
143
 
144
144
  if not available_launchers:
145
- ctx.textual.text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND, markup="yellow")
145
+ ctx.textual.warning_text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
146
146
  ctx.textual.end_step("skip")
147
147
  return Skip(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
148
148
 
@@ -151,7 +151,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
151
151
  else:
152
152
  # Show available CLIs with numbers
153
153
  ctx.textual.text("") # spacing
154
- ctx.textual.text(msg.AIAssistant.SELECT_ASSISTANT_CLI, markup="bold cyan")
154
+ ctx.textual.bold_primary_text(msg.AIAssistant.SELECT_ASSISTANT_CLI)
155
155
 
156
156
  cli_options = list(available_launchers.keys())
157
157
  for idx, cli_name in enumerate(cli_options, 1):
@@ -162,7 +162,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
162
162
  choice_str = ctx.textual.ask_text("Select option (or press Enter to cancel):", default="")
163
163
 
164
164
  if not choice_str or choice_str.strip() == "":
165
- ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED, markup="dim")
165
+ ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
166
166
  ctx.textual.end_step("skip")
167
167
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
168
168
 
@@ -171,17 +171,17 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
171
171
  if 0 <= choice_idx < len(cli_options):
172
172
  cli_to_launch = cli_options[choice_idx]
173
173
  else:
174
- ctx.textual.text("Invalid option selected", markup="red")
174
+ ctx.textual.error_text("Invalid option selected")
175
175
  ctx.textual.end_step("skip")
176
176
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
177
177
  except ValueError:
178
- ctx.textual.text("Invalid input - must be a number", markup="red")
178
+ ctx.textual.error_text("Invalid input - must be a number")
179
179
  ctx.textual.end_step("skip")
180
180
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
181
181
 
182
182
  # Validate selection
183
183
  if cli_to_launch not in available_launchers:
184
- ctx.textual.text(f"Unknown CLI to launch: {cli_to_launch}", markup="red")
184
+ ctx.textual.error_text(f"Unknown CLI to launch: {cli_to_launch}")
185
185
  ctx.textual.end_step("error")
186
186
  return Error(f"Unknown CLI to launch: {cli_to_launch}")
187
187
 
@@ -189,13 +189,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
189
189
 
190
190
  # Launch the CLI
191
191
  ctx.textual.text("") # spacing
192
- ctx.textual.text(msg.AIAssistant.LAUNCHING_ASSISTANT.format(cli_name=cli_name), markup="cyan")
192
+ ctx.textual.primary_text(msg.AIAssistant.LAUNCHING_ASSISTANT.format(cli_name=cli_name))
193
193
 
194
194
  # Show prompt preview
195
195
  prompt_preview_text = msg.AIAssistant.PROMPT_PREVIEW.format(
196
196
  prompt_preview=f"{prompt[:100]}..." if len(prompt) > 100 else prompt
197
197
  )
198
- ctx.textual.text(prompt_preview_text, markup="dim")
198
+ ctx.textual.dim_text(prompt_preview_text)
199
199
  ctx.textual.text("") # spacing
200
200
 
201
201
  project_root = ctx.get("project_root", ".")
@@ -208,13 +208,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
208
208
  )
209
209
 
210
210
  ctx.textual.text("") # spacing
211
- ctx.textual.text(msg.AIAssistant.BACK_IN_TITAN, markup="green")
211
+ ctx.textual.success_text(msg.AIAssistant.BACK_IN_TITAN)
212
212
 
213
213
  if exit_code != 0:
214
- ctx.textual.text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), markup="yellow")
214
+ ctx.textual.warning_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
215
215
  ctx.textual.end_step("error")
216
216
  return Error(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
217
217
 
218
- ctx.textual.text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), markup="green")
218
+ ctx.textual.success_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
219
219
  ctx.textual.end_step("success")
220
220
  return Success(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), metadata={"ai_exit_code": exit_code})
@@ -4,7 +4,7 @@ from typing import Any, Dict, Optional
4
4
  from titan_cli.core.workflows import ParsedWorkflow
5
5
  from titan_cli.core.workflows.workflow_exceptions import WorkflowExecutionError
6
6
  from titan_cli.engine.context import WorkflowContext
7
- from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip
7
+ from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip, is_exit
8
8
  from titan_cli.core.workflows.workflow_registry import WorkflowRegistry
9
9
  from titan_cli.core.plugins.plugin_registry import PluginRegistry
10
10
  from titan_cli.core.workflows.models import WorkflowStepModel
@@ -75,7 +75,12 @@ class WorkflowExecutor:
75
75
  step_result = Error(f"An unexpected error occurred in step '{step_name}': {e}", e)
76
76
 
77
77
  # Handle step result
78
- if is_error(step_result):
78
+ if is_exit(step_result):
79
+ # Exit immediately - workflow is done (not an error)
80
+ if step_result.metadata:
81
+ ctx.data.update(step_result.metadata)
82
+ return Success(step_result.message, step_result.metadata)
83
+ elif is_error(step_result):
79
84
  if step_config.on_error == "fail":
80
85
  return Error(f"Workflow failed at step '{step_name}'", step_result.exception)
81
86
  # else: on_error == "continue" - continue to next step
@@ -567,6 +567,22 @@ class PluginConfigWizardScreen(BaseScreen):
567
567
  # Save secrets
568
568
  project_name = self.config.get_project_name()
569
569
  for secret_key, secret_value in secrets_to_save.items():
570
+ # Clean up old secrets for this key to avoid conflicts
571
+ # This prevents accumulation of stale tokens from previous configurations
572
+ if self.plugin_name == "jira" and "api_token" in secret_key:
573
+ # Delete all possible old JIRA token variants
574
+ old_keys = [
575
+ secret_key, # Generic: jira_api_token
576
+ f"{project_name}_{secret_key}" if project_name else None, # Project-specific
577
+ ]
578
+ for old_key in old_keys:
579
+ if old_key:
580
+ try:
581
+ self.config.secrets.delete(old_key, scope="user")
582
+ except Exception:
583
+ pass # Ignore errors if key doesn't exist
584
+
585
+ # Save the new secret
570
586
  keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
571
587
  self.config.secrets.set(keychain_key, secret_value, scope="user")
572
588
 
@@ -146,16 +146,16 @@ class WorkflowExecutionScreen(BaseScreen):
146
146
  thread=True
147
147
  )
148
148
 
149
- except (WorkflowNotFoundError, WorkflowExecutionError):
150
- pass
151
- # TODO Create empty error screen
152
- # self._update_workflow_info(f"[red]Error: {e}[/red]")
153
- except Exception:
154
- pass
155
- # TODO Create empty error screen
156
- # self._update_workflow_info(
157
- # f"[red]Unexpected error: {type(e).__name__} - {e}[/red]"
158
- # )
149
+ except (WorkflowNotFoundError, WorkflowExecutionError) as e:
150
+ self._output(f"[red]{Icons.ERROR} Error loading workflow: {e}[/red]")
151
+ self._output("[dim]Press ESC or Q to return[/dim]")
152
+ except Exception as e:
153
+ import traceback
154
+ error_details = traceback.format_exc()
155
+ self._output(f"[red]{Icons.ERROR} Unexpected error loading workflow:[/red]")
156
+ self._output(f"[red]{type(e).__name__}: {e}[/red]")
157
+ self._output(f"[dim]{error_details}[/dim]")
158
+ self._output("[dim]Press ESC or Q to return[/dim]")
159
159
 
160
160
  def _execute_workflow(self) -> None:
161
161
  """Execute the workflow in a background thread."""
@@ -493,12 +493,14 @@ class WorkflowExecutionContent(Widget):
493
493
 
494
494
  def on_descendant_mount(self, event) -> None:
495
495
  """Auto-scroll when any widget is mounted as a descendant."""
496
- # Don't auto-scroll if we're mounting a PromptInput (it will handle its own scroll)
497
- from titan_cli.ui.tui.textual_components import PromptInput
496
+ from titan_cli.ui.tui.widgets import PromptInput, StepContainer
498
497
 
499
- # Skip scroll only if the widget itself is a PromptInput
500
- # (not if it's a child of PromptInput, to avoid blocking scroll after PromptInput is removed)
501
- if not isinstance(event.widget, PromptInput):
498
+ # Auto-scroll for StepContainers (new steps being added)
499
+ # StepContainer itself triggers scroll via begin_step(), but this is a safety net
500
+ if isinstance(event.widget, StepContainer):
501
+ self._scroll_to_end()
502
+ # Also scroll for other widgets, but skip PromptInput (it handles its own scroll)
503
+ elif not isinstance(event.widget, PromptInput):
502
504
  self._scroll_to_end()
503
505
 
504
506
  def handle_event(self, message) -> None:
@@ -520,13 +522,9 @@ class WorkflowExecutionContent(Widget):
520
522
  pass
521
523
 
522
524
  elif isinstance(message, TextualWorkflowExecutor.StepFailed):
523
- # StepContainer now handles step failures (red border), so we don't display the panel
524
- # Only show "continuing despite error" message if on_error is "continue"
525
- if message.on_error == "continue":
526
- indent = " " * self._workflow_depth if self._workflow_depth > 0 else ""
527
- self.append_output(f"[yellow]{indent} {Icons.WARNING} Continuing despite error[/yellow]\n")
528
- else:
529
- self.append_output("")
525
+ # Steps handle their own error display via ctx.textual
526
+ # Executor only coordinates execution, no visual output needed
527
+ pass
530
528
 
531
529
  elif isinstance(message, TextualWorkflowExecutor.StepSkipped):
532
530
  # StepContainer now handles step skips (yellow border), so we don't display the panel
@@ -566,7 +564,7 @@ class WorkflowExecutionContent(Widget):
566
564
  pass
567
565
 
568
566
  elif isinstance(message, TextualWorkflowExecutor.WorkflowFailed):
569
- # Show error toast for workflow failure
567
+ # Steps handle their own error display via ctx.textual
568
+ # Just show a simple notification without duplicating the error message
570
569
  self.app.notify(f"❌ Workflow failed at step: {message.step_name}", severity="error", timeout=10)
571
- self.append_output(f"[red]{message.error_message}[/red]")
572
570
 
@@ -16,6 +16,7 @@ from titan_cli.core.workflows.workflow_filter_service import WorkflowFilterServi
16
16
  from titan_cli.core.workflows.workflow_sources import WorkflowInfo
17
17
  from titan_cli.ui.tui.screens.workflow_execution import WorkflowExecutionScreen
18
18
  from titan_cli.ui.tui.icons import Icons
19
+ from titan_cli.ui.tui.widgets import StyledOption
19
20
  from .base import BaseScreen
20
21
 
21
22
  class WorkflowsScreen(BaseScreen):
@@ -170,11 +171,14 @@ class WorkflowsScreen(BaseScreen):
170
171
  workflows_to_show = workflows
171
172
 
172
173
  for wf_info in workflows_to_show:
173
- label = f"{wf_info.name.capitalize()}"
174
- description = f"{wf_info.description}"
175
- options.append(
176
- Option(f"{label}\n[dim]{description}[/dim]", id=wf_info.name)
174
+ styled_opt = StyledOption(
175
+ id=wf_info.name,
176
+ title=wf_info.name.capitalize(),
177
+ description=wf_info.description
177
178
  )
179
+ # Convert StyledOption to Option with markup
180
+ prompt = f"[bold]{styled_opt.title}[/bold]\n[dim]{styled_opt.description}[/dim]"
181
+ options.append(Option(prompt, id=styled_opt.id))
178
182
 
179
183
  return options if options else [Option("No workflows found", id="none", disabled=True)]
180
184