titan-cli 0.1.4__py3-none-any.whl → 0.1.5__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 (39) 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 +48 -30
  4. titan_cli/core/workflows/workflow_filter_service.py +14 -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/steps/ai_assistant_step.py +42 -7
  8. titan_cli/engine/workflow_executor.py +6 -1
  9. titan_cli/ui/tui/screens/workflow_execution.py +8 -28
  10. titan_cli/ui/tui/textual_components.py +59 -6
  11. titan_cli/ui/tui/textual_workflow_executor.py +9 -1
  12. titan_cli/ui/tui/widgets/__init__.py +2 -0
  13. titan_cli/ui/tui/widgets/step_container.py +70 -0
  14. {titan_cli-0.1.4.dist-info → titan_cli-0.1.5.dist-info}/METADATA +6 -3
  15. {titan_cli-0.1.4.dist-info → titan_cli-0.1.5.dist-info}/RECORD +39 -37
  16. {titan_cli-0.1.4.dist-info → titan_cli-0.1.5.dist-info}/WHEEL +1 -1
  17. titan_plugin_git/clients/git_client.py +82 -4
  18. titan_plugin_git/plugin.py +3 -0
  19. titan_plugin_git/steps/ai_commit_message_step.py +33 -28
  20. titan_plugin_git/steps/branch_steps.py +18 -37
  21. titan_plugin_git/steps/commit_step.py +18 -22
  22. titan_plugin_git/steps/diff_summary_step.py +182 -0
  23. titan_plugin_git/steps/push_step.py +27 -11
  24. titan_plugin_git/steps/status_step.py +15 -18
  25. titan_plugin_git/workflows/commit-ai.yaml +5 -0
  26. titan_plugin_github/agents/pr_agent.py +15 -2
  27. titan_plugin_github/steps/ai_pr_step.py +12 -21
  28. titan_plugin_github/steps/create_pr_step.py +17 -7
  29. titan_plugin_github/steps/github_prompt_steps.py +52 -0
  30. titan_plugin_github/steps/issue_steps.py +28 -14
  31. titan_plugin_github/steps/preview_step.py +11 -0
  32. titan_plugin_github/utils.py +5 -4
  33. titan_plugin_github/workflows/create-pr-ai.yaml +5 -0
  34. titan_plugin_jira/steps/ai_analyze_issue_step.py +8 -3
  35. titan_plugin_jira/steps/get_issue_step.py +16 -12
  36. titan_plugin_jira/steps/prompt_select_issue_step.py +22 -9
  37. titan_plugin_jira/steps/search_saved_query_step.py +21 -19
  38. {titan_cli-0.1.4.dist-info → titan_cli-0.1.5.dist-info}/entry_points.txt +0 -0
  39. {titan_cli-0.1.4.dist-info → titan_cli-0.1.5.dist-info/licenses}/LICENSE +0 -0
titan_cli/core/config.py CHANGED
@@ -4,7 +4,7 @@ from typing import Optional, List
4
4
  import tomli
5
5
  from .models import TitanConfigModel
6
6
  from .plugins.plugin_registry import PluginRegistry
7
- from .workflows import WorkflowRegistry, ProjectStepSource
7
+ from .workflows import WorkflowRegistry, ProjectStepSource, UserStepSource
8
8
  from .secrets import SecretManager
9
9
  from .errors import ConfigParseError, ConfigWriteError
10
10
 
@@ -75,10 +75,12 @@ class TitanConfig:
75
75
  # Use current working directory for workflows
76
76
  workflow_path = Path.cwd()
77
77
  project_step_source = ProjectStepSource(project_root=workflow_path)
78
+ user_step_source = UserStepSource()
78
79
  self._workflow_registry = WorkflowRegistry(
79
80
  project_root=workflow_path,
80
81
  plugin_registry=self.registry,
81
82
  project_step_source=project_step_source,
83
+ user_step_source=user_step_source,
82
84
  config=self
83
85
  )
84
86
 
@@ -10,7 +10,7 @@ Similar to plugins system, but for workflows:
10
10
  from .workflow_registry import WorkflowRegistry, ParsedWorkflow
11
11
  from .workflow_sources import WorkflowInfo
12
12
  from .workflow_exceptions import WorkflowNotFoundError, WorkflowExecutionError
13
- from .project_step_source import ProjectStepSource
13
+ from .project_step_source import ProjectStepSource, UserStepSource
14
14
 
15
15
  __all__ = [
16
16
  "WorkflowRegistry",
@@ -19,4 +19,5 @@ __all__ = [
19
19
  "WorkflowNotFoundError",
20
20
  "WorkflowExecutionError",
21
21
  "ProjectStepSource",
22
+ "UserStepSource",
22
23
  ]
@@ -17,18 +17,17 @@ class StepInfo:
17
17
  name: str
18
18
  path: Path
19
19
 
20
- class ProjectStepSource:
20
+ class BaseStepSource:
21
21
  """
22
- Discovers and loads Python step functions from a project's .titan/steps/ directory.
22
+ Base class for discovering and loading Python step functions.
23
23
  """
24
- def __init__(self, project_root: Path):
25
- self._project_root = project_root
26
- self._steps_dir = self._project_root / ".titan" / "steps"
24
+ EXCLUDED_FILES = {"__init__.py", "__pycache__"}
25
+
26
+ def __init__(self, steps_dir: Path):
27
+ self._steps_dir = steps_dir
27
28
  self._step_info_cache: Optional[List[StepInfo]] = None
28
29
  self._step_function_cache: Dict[str, StepFunction] = {}
29
30
 
30
- EXCLUDED_FILES = {"__init__.py", "__pycache__"}
31
-
32
31
  def discover(self) -> List[StepInfo]:
33
32
  """
34
33
  Discovers all available step files in the project's .titan/steps directory.
@@ -52,35 +51,54 @@ class ProjectStepSource:
52
51
  def get_step(self, step_name: str) -> Optional[StepFunction]:
53
52
  """
54
53
  Retrieves a step function by name, loading it from its file if necessary.
54
+ Searches all Python files in the directory for the function.
55
55
  """
56
56
  if step_name in self._step_function_cache:
57
57
  return self._step_function_cache[step_name]
58
58
 
59
- # Find the step info from the discovered list
60
- discovered_steps = self.discover()
61
- step_info = next((s for s in discovered_steps if s.name == step_name), None)
62
-
63
- if not step_info:
59
+ if not self._steps_dir.is_dir():
64
60
  return None
65
61
 
66
- try:
67
- spec = importlib.util.spec_from_file_location(step_name, step_info.path)
68
- if spec and spec.loader:
69
- module = importlib.util.module_from_spec(spec)
70
- spec.loader.exec_module(module)
71
-
72
- # Convention: the step function has the same name as the file
73
- step_func = getattr(module, step_name, None)
74
- if callable(step_func):
75
- self._step_function_cache[step_name] = step_func
76
- return step_func
77
- else:
78
- # Optional: Log a warning if a file exists but the function doesn't
79
- pass
80
-
81
- except Exception as e:
82
- # Optional: Log a more detailed error
83
- print(f"Error loading project step '{step_name}': {e}")
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:
65
+ continue
66
+
67
+ try:
68
+ # Use a unique module name to avoid conflicts
69
+ module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
70
+ spec = importlib.util.spec_from_file_location(module_name, step_file)
71
+ if spec and spec.loader:
72
+ module = importlib.util.module_from_spec(spec)
73
+ spec.loader.exec_module(module)
74
+
75
+ # Look for the function in this module
76
+ step_func = getattr(module, step_name, None)
77
+ if callable(step_func):
78
+ self._step_function_cache[step_name] = step_func
79
+ return step_func
80
+
81
+ except Exception:
82
+ # Continue searching other files
83
+ continue
84
84
 
85
85
  return None
86
86
 
87
+
88
+ class ProjectStepSource(BaseStepSource):
89
+ """
90
+ Discovers and loads Python step functions from a project's .titan/steps/ directory.
91
+ """
92
+ def __init__(self, project_root: Path):
93
+ steps_dir = project_root / ".titan" / "steps"
94
+ super().__init__(steps_dir)
95
+ self._project_root = project_root
96
+
97
+
98
+ class UserStepSource(BaseStepSource):
99
+ """
100
+ Discovers and loads Python step functions from user's ~/.titan/steps/ directory.
101
+ """
102
+ def __init__(self):
103
+ steps_dir = Path.home() / ".titan" / "steps"
104
+ super().__init__(steps_dir)
@@ -36,15 +36,21 @@ class WorkflowFilterService:
36
36
  plugin_name = wf_info.source.split(":", 1)[1]
37
37
  return plugin_name.capitalize()
38
38
 
39
- # For project/user workflows, check which plugin they use
40
- if wf_info.source in ["project", "user"]:
39
+ # User workflows always go to "Personal" category
40
+ if wf_info.source == "user":
41
+ return "Personal"
42
+
43
+ # For project workflows, check which plugin they use
44
+ if wf_info.source == "project":
41
45
  if wf_info.required_plugins:
42
- # Use the first required plugin (most workflows use only one)
43
- primary_plugin = sorted(wf_info.required_plugins)[0]
44
- return primary_plugin.capitalize()
45
- else:
46
- # No plugin dependencies, it's a custom workflow
47
- return "Custom"
46
+ # Filter out core/project pseudo-plugins
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()
52
+ # No plugin dependencies, it's a custom workflow
53
+ return "Custom"
48
54
 
49
55
  # Fallback for other sources
50
56
  return wf_info.source.capitalize()
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7
7
  from copy import deepcopy
8
8
 
9
9
  from titan_cli.core.plugins.plugin_registry import PluginRegistry
10
- from titan_cli.core.workflows.project_step_source import ProjectStepSource, StepFunction
10
+ from titan_cli.core.workflows.project_step_source import ProjectStepSource, UserStepSource, StepFunction
11
11
 
12
12
  from .workflow_sources import (
13
13
  WorkflowSource,
@@ -48,6 +48,7 @@ class WorkflowRegistry:
48
48
  project_root: Path,
49
49
  plugin_registry: PluginRegistry,
50
50
  project_step_source: ProjectStepSource,
51
+ user_step_source: UserStepSource = None,
51
52
  config: Any = None
52
53
  ):
53
54
  """
@@ -57,11 +58,13 @@ class WorkflowRegistry:
57
58
  project_root: Root path of the current project.
58
59
  plugin_registry: Registry of installed plugins.
59
60
  project_step_source: Source for discovering project-specific steps.
61
+ user_step_source: Source for discovering user-specific steps (~/.titan/steps/).
60
62
  config: TitanConfig instance (optional, for filtering by enabled plugins).
61
63
  """
62
64
  self.project_root = project_root
63
65
  self.plugin_registry = plugin_registry
64
66
  self._project_step_source = project_step_source
67
+ self._user_step_source = user_step_source
65
68
  self._config = config
66
69
 
67
70
  # Define the base path for system workflows, assuming it's in the root of the package
@@ -416,4 +419,12 @@ class WorkflowRegistry:
416
419
  """
417
420
  return self._project_step_source.get_step(step_name)
418
421
 
422
+ def get_user_step(self, step_name: str) -> Optional[StepFunction]:
423
+ """
424
+ Retrieves a loaded user step function by its name from the user step source.
425
+ """
426
+ if self._user_step_source:
427
+ return self._user_step_source.get_step(step_name)
428
+ return None
429
+
419
430
 
@@ -45,7 +45,7 @@ def _parse_workflow_info(file: Path, source_name: str, plugin_registry: PluginRe
45
45
  steps = config.get("steps", [])
46
46
  if isinstance(steps, list):
47
47
  for step in steps:
48
- if isinstance(step, dict) and "plugin" in step and step["plugin"] not in ["core", "project"]:
48
+ if isinstance(step, dict) and "plugin" in step and step["plugin"] not in ["core", "project", "user"]:
49
49
  required_plugins.add(step["plugin"])
50
50
 
51
51
  # Check 'extends' field for plugin dependencies
@@ -8,7 +8,7 @@ Can be used after linting, testing, builds, or any step that produces
8
8
  errors or context that could benefit from AI assistance.
9
9
  """
10
10
 
11
- import json
11
+ import json
12
12
 
13
13
  from titan_cli.core.workflows.models import WorkflowStepModel
14
14
  from titan_cli.engine.context import WorkflowContext
@@ -43,6 +43,9 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
43
43
  if not ctx.textual:
44
44
  return Error(msg.AIAssistant.UI_CONTEXT_NOT_AVAILABLE)
45
45
 
46
+ # Begin step container - use step name from workflow
47
+ ctx.textual.begin_step(step.name or "AI Code Assistant")
48
+
46
49
  # Get parameters
47
50
  context_key = step.params.get("context_key")
48
51
  prompt_template = step.params.get("prompt_template", "{context}")
@@ -53,17 +56,32 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
53
56
  # Validate cli_preference
54
57
  VALID_CLI_PREFERENCES = {"auto", "claude", "gemini"}
55
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")
60
+ ctx.textual.end_step("error")
56
61
  return Error(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
57
62
 
58
63
  # Validate required parameters
59
64
  if not context_key:
65
+ ctx.textual.text(msg.AIAssistant.CONTEXT_KEY_REQUIRED, markup="red")
66
+ ctx.textual.end_step("error")
60
67
  return Error(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
61
68
 
62
69
  # Get context data
63
70
  context_data = ctx.data.get(context_key)
64
71
  if not context_data:
65
- # No context to work with - skip silently
66
- return Skip(msg.AIAssistant.NO_DATA_IN_CONTEXT.format(context_key=context_key))
72
+ # No context to work with - skip silently with user-friendly message
73
+ # Infer what we're skipping based on step name
74
+ step_name = step.name or "AI Code Assistant"
75
+ if "lint" in step_name.lower():
76
+ friendly_msg = "No linting issues found - skipping AI assistance"
77
+ elif "test" in step_name.lower():
78
+ friendly_msg = "No test failures found - skipping AI assistance"
79
+ else:
80
+ friendly_msg = "No issues to fix - skipping AI assistance"
81
+
82
+ ctx.textual.text(friendly_msg, markup="dim")
83
+ ctx.textual.end_step("skip")
84
+ return Skip(friendly_msg)
67
85
 
68
86
  # Clear the context data immediately to prevent contamination of subsequent steps
69
87
  if context_key in ctx.data:
@@ -78,8 +96,12 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
78
96
  context_str = json.dumps(context_data, indent=2)
79
97
  prompt = prompt_template.format(context=context_str)
80
98
  except KeyError as e:
99
+ ctx.textual.text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e), markup="red")
100
+ ctx.textual.end_step("error")
81
101
  return Error(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
82
102
  except Exception as e:
103
+ ctx.textual.text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e), markup="red")
104
+ ctx.textual.end_step("error")
83
105
  return Error(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
84
106
 
85
107
  # Ask for confirmation if needed
@@ -91,7 +113,11 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
91
113
  )
92
114
  if not should_launch:
93
115
  if fail_on_decline:
116
+ ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED, markup="yellow")
117
+ ctx.textual.end_step("error")
94
118
  return Error(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
119
+ ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED, markup="dim")
120
+ ctx.textual.end_step("skip")
95
121
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
96
122
 
97
123
  # Determine which CLI to use
@@ -116,8 +142,8 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
116
142
  available_launchers[cli_name] = launcher
117
143
 
118
144
  if not available_launchers:
119
- from titan_cli.ui.tui.widgets import Panel
120
- ctx.textual.mount(Panel(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND, panel_type="warning"))
145
+ ctx.textual.text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND, markup="yellow")
146
+ ctx.textual.end_step("skip")
121
147
  return Skip(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
122
148
 
123
149
  if len(available_launchers) == 1:
@@ -136,6 +162,8 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
136
162
  choice_str = ctx.textual.ask_text("Select option (or press Enter to cancel):", default="")
137
163
 
138
164
  if not choice_str or choice_str.strip() == "":
165
+ ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED, markup="dim")
166
+ ctx.textual.end_step("skip")
139
167
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
140
168
 
141
169
  try:
@@ -144,19 +172,22 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
144
172
  cli_to_launch = cli_options[choice_idx]
145
173
  else:
146
174
  ctx.textual.text("Invalid option selected", markup="red")
175
+ ctx.textual.end_step("skip")
147
176
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
148
177
  except ValueError:
149
178
  ctx.textual.text("Invalid input - must be a number", markup="red")
179
+ ctx.textual.end_step("skip")
150
180
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
151
181
 
152
182
  # Validate selection
153
183
  if cli_to_launch not in available_launchers:
184
+ ctx.textual.text(f"Unknown CLI to launch: {cli_to_launch}", markup="red")
185
+ ctx.textual.end_step("error")
154
186
  return Error(f"Unknown CLI to launch: {cli_to_launch}")
155
187
 
156
188
  cli_name = CLI_REGISTRY[cli_to_launch].get("display_name", cli_to_launch)
157
189
 
158
190
  # Launch the CLI
159
- from titan_cli.ui.tui.widgets import Panel
160
191
  ctx.textual.text("") # spacing
161
192
  ctx.textual.text(msg.AIAssistant.LAUNCHING_ASSISTANT.format(cli_name=cli_name), markup="cyan")
162
193
 
@@ -177,9 +208,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
177
208
  )
178
209
 
179
210
  ctx.textual.text("") # spacing
180
- ctx.textual.mount(Panel(msg.AIAssistant.BACK_IN_TITAN, panel_type="success"))
211
+ ctx.textual.text(msg.AIAssistant.BACK_IN_TITAN, markup="green")
181
212
 
182
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")
215
+ ctx.textual.end_step("error")
183
216
  return Error(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
184
217
 
218
+ ctx.textual.text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), markup="green")
219
+ ctx.textual.end_step("success")
185
220
  return Success(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), metadata={"ai_exit_code": exit_code})
@@ -128,6 +128,11 @@ class WorkflowExecutor:
128
128
  step_func = self._workflow_registry.get_project_step(step_func_name)
129
129
  if not step_func:
130
130
  return Error(f"Project step '{step_func_name}' not found in '.titan/steps/'.", WorkflowExecutionError(f"Project step '{step_func_name}' not found"))
131
+ elif plugin_name == "user":
132
+ # Handle virtual 'user' plugin for user-specific steps
133
+ step_func = self._workflow_registry.get_user_step(step_func_name)
134
+ if not step_func:
135
+ return Error(f"User step '{step_func_name}' not found in '~/.titan/steps/'.", WorkflowExecutionError(f"User step '{step_func_name}' not found"))
131
136
  elif plugin_name == "core":
132
137
  # Handle virtual 'core' plugin for built-in core steps
133
138
  step_func = self.CORE_STEPS.get(step_func_name)
@@ -160,7 +165,7 @@ class WorkflowExecutor:
160
165
  # Plugin and project steps receive only ctx (params are in ctx.data)
161
166
  return step_func(ctx)
162
167
  except Exception as e:
163
- error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "core") else f"{plugin_name} step"
168
+ error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "user", "core") else f"{plugin_name} step"
164
169
  return Error(f"Error executing step '{step_func_name}' from {error_source}: {e}", e)
165
170
 
166
171
 
@@ -504,7 +504,6 @@ class WorkflowExecutionContent(Widget):
504
504
  def handle_event(self, message) -> None:
505
505
  """Handle workflow events generically."""
506
506
  from titan_cli.ui.tui.textual_workflow_executor import TextualWorkflowExecutor
507
- from titan_cli.ui.tui.widgets import Panel
508
507
 
509
508
  if isinstance(message, TextualWorkflowExecutor.WorkflowStarted):
510
509
  # Track nested workflow depth
@@ -513,31 +512,16 @@ class WorkflowExecutionContent(Widget):
513
512
  self.append_output(f"\n[bold cyan]🚀 Starting workflow: {message.workflow_name}[/bold cyan]")
514
513
 
515
514
  elif isinstance(message, TextualWorkflowExecutor.StepStarted):
516
- # Format differently for nested workflows
517
- if self._workflow_depth > 0:
518
- # Nested workflow: show with indentation, no step number
519
- indent = " " * self._workflow_depth
520
- self.append_output(f"[cyan]{indent}→ Step {message.step_index}: {message.step_name}[/cyan]")
521
- else:
522
- # Top-level workflow: show with step number
523
- self.append_output(f"[cyan]→ Step {message.step_index}: {message.step_name}[/cyan]")
515
+ # StepContainer now handles step titles, so we don't display anything here
516
+ pass
524
517
 
525
518
  elif isinstance(message, TextualWorkflowExecutor.StepCompleted):
526
- # Apply indentation for nested workflows
527
- if self._workflow_depth > 0:
528
- indent = " " * self._workflow_depth
529
- self.append_output(f"[green]{indent}{Icons.SUCCESS} Completed: {message.step_name}[/green]\n")
530
- else:
531
- self.append_output(f"[green]{Icons.SUCCESS} Completed: {message.step_name}[/green]\n")
519
+ # StepContainer now handles step completion (green border), so we don't display anything here
520
+ pass
532
521
 
533
522
  elif isinstance(message, TextualWorkflowExecutor.StepFailed):
534
- # Mount error panel
535
- try:
536
- self.mount(Panel(f"Failed: {message.step_name} - {message.error_message}", panel_type="error"))
537
- self._scroll_to_end()
538
- except Exception:
539
- pass
540
-
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"
541
525
  if message.on_error == "continue":
542
526
  indent = " " * self._workflow_depth if self._workflow_depth > 0 else ""
543
527
  self.append_output(f"[yellow]{indent} {Icons.WARNING} Continuing despite error[/yellow]\n")
@@ -545,12 +529,8 @@ class WorkflowExecutionContent(Widget):
545
529
  self.append_output("")
546
530
 
547
531
  elif isinstance(message, TextualWorkflowExecutor.StepSkipped):
548
- # Mount warning panel for skipped steps
549
- try:
550
- self.mount(Panel(f"Skipped: {message.step_name}", panel_type="warning"))
551
- self._scroll_to_end()
552
- except Exception:
553
- pass
532
+ # StepContainer now handles step skips (yellow border), so we don't display the panel
533
+ pass
554
534
 
555
535
  elif isinstance(message, TextualWorkflowExecutor.WorkflowCompleted):
556
536
  # Track nested workflow depth
@@ -209,6 +209,46 @@ class TextualComponents:
209
209
  """
210
210
  self.app = app
211
211
  self.output_widget = output_widget
212
+ self._active_step_container = None
213
+
214
+ def begin_step(self, step_name: str) -> None:
215
+ """
216
+ Begin a new step by creating a StepContainer.
217
+
218
+ Args:
219
+ step_name: Name of the step
220
+ """
221
+ from titan_cli.ui.tui.widgets import StepContainer
222
+
223
+ def _create_container():
224
+ container = StepContainer(step_name=step_name)
225
+ self.output_widget.mount(container)
226
+ self._active_step_container = container
227
+
228
+ try:
229
+ self.app.call_from_thread(_create_container)
230
+ except Exception:
231
+ pass
232
+
233
+ def end_step(self, result_type: str) -> None:
234
+ """
235
+ End the current step by updating its container color.
236
+
237
+ Args:
238
+ result_type: One of 'success', 'skip', 'error'
239
+ """
240
+ if not self._active_step_container:
241
+ return
242
+
243
+ def _update_container():
244
+ if self._active_step_container:
245
+ self._active_step_container.set_result(result_type)
246
+ self._active_step_container = None
247
+
248
+ try:
249
+ self.app.call_from_thread(_update_container)
250
+ except Exception:
251
+ pass
212
252
 
213
253
  def mount(self, widget: Widget) -> None:
214
254
  """
@@ -222,7 +262,9 @@ class TextualComponents:
222
262
  ctx.textual.mount(Panel("Success!", panel_type="success"))
223
263
  """
224
264
  def _mount():
225
- self.output_widget.mount(widget)
265
+ # Mount to active step container if it exists, otherwise to output widget
266
+ target = self._active_step_container if self._active_step_container else self.output_widget
267
+ target.mount(widget)
226
268
 
227
269
  # call_from_thread already blocks until the function completes
228
270
  try:
@@ -244,10 +286,20 @@ class TextualComponents:
244
286
  ctx.textual.text("Done!")
245
287
  """
246
288
  def _append():
247
- if markup:
248
- self.output_widget.append_output(f"[{markup}]{text}[/{markup}]")
289
+ # If there's an active step container, append to it; otherwise to output widget
290
+ if self._active_step_container:
291
+ from textual.widgets import Static
292
+ if markup:
293
+ widget = Static(f"[{markup}]{text}[/{markup}]")
294
+ else:
295
+ widget = Static(text)
296
+ widget.styles.height = "auto"
297
+ self._active_step_container.mount(widget)
249
298
  else:
250
- self.output_widget.append_output(text)
299
+ if markup:
300
+ self.output_widget.append_output(f"[{markup}]{text}[/{markup}]")
301
+ else:
302
+ self.output_widget.append_output(text)
251
303
 
252
304
  # call_from_thread already blocks until the function completes
253
305
  try:
@@ -276,8 +328,9 @@ class TextualComponents:
276
328
  md_widget.styles.margin = (0, 0, 1, 0)
277
329
 
278
330
  def _mount():
279
- # Mount markdown to output
280
- self.output_widget.mount(md_widget)
331
+ # Mount to active step container if it exists, otherwise to output widget
332
+ target = self._active_step_container if self._active_step_container else self.output_widget
333
+ target.mount(md_widget)
281
334
  # Trigger autoscroll after mounting
282
335
  self.output_widget._scroll_to_end()
283
336
 
@@ -341,6 +341,14 @@ class TextualWorkflowExecutor:
341
341
  f"Project step '{step_func_name}' not found in '.titan/steps/'.",
342
342
  WorkflowExecutionError(f"Project step '{step_func_name}' not found")
343
343
  )
344
+ elif plugin_name == "user":
345
+ # Handle virtual 'user' plugin for user-specific steps
346
+ step_func = self._workflow_registry.get_user_step(step_func_name)
347
+ if not step_func:
348
+ return Error(
349
+ f"User step '{step_func_name}' not found in '~/.titan/steps/'.",
350
+ WorkflowExecutionError(f"User step '{step_func_name}' not found")
351
+ )
344
352
  elif plugin_name == "core":
345
353
  # Handle virtual 'core' plugin for built-in core steps
346
354
  step_func = self.CORE_STEPS.get(step_func_name)
@@ -382,7 +390,7 @@ class TextualWorkflowExecutor:
382
390
  # Plugin and project steps receive only ctx (params are in ctx.data)
383
391
  return step_func(ctx)
384
392
  except Exception as e:
385
- error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "core") else f"{plugin_name} step"
393
+ error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "user", "core") else f"{plugin_name} step"
386
394
  return Error(f"Error executing step '{step_func_name}' from {error_source}: {e}", e)
387
395
 
388
396
  def _execute_command_step(self, step_config: WorkflowStepModel, ctx: WorkflowContext) -> WorkflowResult:
@@ -8,6 +8,7 @@ from .header import HeaderWidget
8
8
  from .panel import Panel
9
9
  from .table import Table
10
10
  from .button import Button
11
+ from .step_container import StepContainer
11
12
  from .text import (
12
13
  Text,
13
14
  DimText,
@@ -27,6 +28,7 @@ __all__ = [
27
28
  "Panel",
28
29
  "Table",
29
30
  "Button",
31
+ "StepContainer",
30
32
  "Text",
31
33
  "DimText",
32
34
  "BoldText",
@@ -0,0 +1,70 @@
1
+ """
2
+ Step Container Widget
3
+
4
+ A container that groups all output from a workflow step, with a titled border
5
+ that changes color based on the step result (success, skip, error).
6
+ """
7
+ from textual.containers import VerticalScroll
8
+
9
+
10
+ class StepContainer(VerticalScroll):
11
+ """
12
+ Container for step output with colored border and title.
13
+
14
+ The border color changes based on step result:
15
+ - Running: cyan (default)
16
+ - Success: green
17
+ - Skip: yellow
18
+ - Error: red
19
+ """
20
+
21
+ DEFAULT_CSS = """
22
+ StepContainer {
23
+ width: 100%;
24
+ height: auto;
25
+ border: round $accent;
26
+ padding: 1 2;
27
+ margin: 1 0;
28
+ }
29
+
30
+ StepContainer.running {
31
+ border: round $accent;
32
+ }
33
+
34
+ StepContainer.success {
35
+ border: round $success;
36
+ }
37
+
38
+ StepContainer.skip {
39
+ border: round $warning;
40
+ }
41
+
42
+ StepContainer.error {
43
+ border: round $error;
44
+ }
45
+
46
+ StepContainer > Static {
47
+ color: initial;
48
+ }
49
+ """
50
+
51
+ def __init__(self, step_name: str, **kwargs):
52
+ super().__init__(**kwargs)
53
+ self.border_title = step_name
54
+ self.add_class("running")
55
+
56
+ def set_result(self, result_type: str):
57
+ """
58
+ Update the border color based on step result.
59
+
60
+ Args:
61
+ result_type: One of 'success', 'skip', 'error'
62
+ """
63
+ # Remove all result classes
64
+ self.remove_class("running", "success", "skip", "error")
65
+
66
+ # Add the new result class
67
+ if result_type in ["success", "skip", "error"]:
68
+ self.add_class(result_type)
69
+ else:
70
+ self.add_class("running")