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
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
  ]
@@ -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
@@ -17,21 +18,21 @@ class StepInfo:
17
18
  name: str
18
19
  path: Path
19
20
 
20
- class ProjectStepSource:
21
+ class BaseStepSource:
21
22
  """
22
- Discovers and loads Python step functions from a project's .titan/steps/ directory.
23
+ Base class for discovering and loading Python step functions.
23
24
  """
24
- def __init__(self, project_root: Path):
25
- self._project_root = project_root
26
- self._steps_dir = self._project_root / ".titan" / "steps"
25
+ EXCLUDED_FILES = {"__init__.py", "__pycache__"}
26
+
27
+ def __init__(self, steps_dir: Path):
28
+ self._steps_dir = steps_dir
27
29
  self._step_info_cache: Optional[List[StepInfo]] = None
28
30
  self._step_function_cache: Dict[str, StepFunction] = {}
29
31
 
30
- EXCLUDED_FILES = {"__init__.py", "__pycache__"}
31
-
32
32
  def discover(self) -> List[StepInfo]:
33
33
  """
34
34
  Discovers all available step files in the project's .titan/steps directory.
35
+ Supports subdirectories (e.g., .titan/steps/jira/step.py).
35
36
  """
36
37
  if self._step_info_cache is not None:
37
38
  return self._step_info_cache
@@ -41,46 +42,108 @@ class ProjectStepSource:
41
42
  return []
42
43
 
43
44
  discovered = []
44
- for step_file in self._steps_dir.glob("*.py"):
45
- 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):
46
47
  step_name = step_file.stem
47
48
  discovered.append(StepInfo(name=step_name, path=step_file))
48
-
49
+
49
50
  self._step_info_cache = discovered
50
51
  return discovered
51
52
 
52
53
  def get_step(self, step_name: str) -> Optional[StepFunction]:
53
54
  """
54
55
  Retrieves a step function by name, loading it from its file if necessary.
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]
58
60
 
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)
61
+ if not self._steps_dir.is_dir():
62
+ return None
62
63
 
63
- if not step_info:
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):
67
+ continue
68
+
69
+ try:
70
+ # Use a unique module name to avoid conflicts
71
+ module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
72
+ spec = importlib.util.spec_from_file_location(module_name, step_file)
73
+ if spec and spec.loader:
74
+ module = importlib.util.module_from_spec(spec)
75
+ spec.loader.exec_module(module)
76
+
77
+ # Look for the function in this module
78
+ step_func = getattr(module, step_name, None)
79
+ if callable(step_func):
80
+ self._step_function_cache[step_name] = step_func
81
+ return step_func
82
+
83
+ except Exception:
84
+ # Continue searching other files
85
+ continue
86
+
87
+ return None
88
+
89
+
90
+ class ProjectStepSource(BaseStepSource):
91
+ """
92
+ Discovers and loads Python step functions from a project's .titan/steps/ directory.
93
+ Supports relative imports by properly setting module __package__ attribute.
94
+ """
95
+ def __init__(self, project_root: Path):
96
+ steps_dir = project_root / ".titan" / "steps"
97
+ super().__init__(steps_dir)
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():
64
115
  return None
65
116
 
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}")
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
84
139
 
85
140
  return None
86
141
 
142
+
143
+ class UserStepSource(BaseStepSource):
144
+ """
145
+ Discovers and loads Python step functions from user's ~/.titan/steps/ directory.
146
+ """
147
+ def __init__(self):
148
+ steps_dir = Path.home() / ".titan" / "steps"
149
+ super().__init__(steps_dir)
@@ -36,15 +36,23 @@ 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 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()
54
+ # No plugin dependencies, it's a custom workflow
55
+ return "Custom"
48
56
 
49
57
  # Fallback for other sources
50
58
  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
@@ -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)
@@ -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.error_text(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
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.error_text(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
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.dim_text(friendly_msg)
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.error_text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
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.error_text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
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.warning_text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
117
+ ctx.textual.end_step("error")
94
118
  return Error(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
119
+ ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
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.warning_text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
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:
@@ -125,7 +151,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
125
151
  else:
126
152
  # Show available CLIs with numbers
127
153
  ctx.textual.text("") # spacing
128
- ctx.textual.text(msg.AIAssistant.SELECT_ASSISTANT_CLI, markup="bold cyan")
154
+ ctx.textual.bold_primary_text(msg.AIAssistant.SELECT_ASSISTANT_CLI)
129
155
 
130
156
  cli_options = list(available_launchers.keys())
131
157
  for idx, cli_name in enumerate(cli_options, 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.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
166
+ ctx.textual.end_step("skip")
139
167
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
140
168
 
141
169
  try:
@@ -143,28 +171,31 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
143
171
  if 0 <= choice_idx < len(cli_options):
144
172
  cli_to_launch = cli_options[choice_idx]
145
173
  else:
146
- ctx.textual.text("Invalid option selected", markup="red")
174
+ ctx.textual.error_text("Invalid option selected")
175
+ ctx.textual.end_step("skip")
147
176
  return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
148
177
  except ValueError:
149
- ctx.textual.text("Invalid input - must be a number", markup="red")
178
+ ctx.textual.error_text("Invalid input - must be a number")
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.error_text(f"Unknown CLI to launch: {cli_to_launch}")
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
- 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))
162
193
 
163
194
  # Show prompt preview
164
195
  prompt_preview_text = msg.AIAssistant.PROMPT_PREVIEW.format(
165
196
  prompt_preview=f"{prompt[:100]}..." if len(prompt) > 100 else prompt
166
197
  )
167
- ctx.textual.text(prompt_preview_text, markup="dim")
198
+ ctx.textual.dim_text(prompt_preview_text)
168
199
  ctx.textual.text("") # spacing
169
200
 
170
201
  project_root = ctx.get("project_root", ".")
@@ -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.success_text(msg.AIAssistant.BACK_IN_TITAN)
181
212
 
182
213
  if exit_code != 0:
214
+ ctx.textual.warning_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
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.success_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
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})
@@ -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
@@ -128,6 +133,11 @@ class WorkflowExecutor:
128
133
  step_func = self._workflow_registry.get_project_step(step_func_name)
129
134
  if not step_func:
130
135
  return Error(f"Project step '{step_func_name}' not found in '.titan/steps/'.", WorkflowExecutionError(f"Project step '{step_func_name}' not found"))
136
+ elif plugin_name == "user":
137
+ # Handle virtual 'user' plugin for user-specific steps
138
+ step_func = self._workflow_registry.get_user_step(step_func_name)
139
+ if not step_func:
140
+ return Error(f"User step '{step_func_name}' not found in '~/.titan/steps/'.", WorkflowExecutionError(f"User step '{step_func_name}' not found"))
131
141
  elif plugin_name == "core":
132
142
  # Handle virtual 'core' plugin for built-in core steps
133
143
  step_func = self.CORE_STEPS.get(step_func_name)
@@ -160,7 +170,7 @@ class WorkflowExecutor:
160
170
  # Plugin and project steps receive only ctx (params are in ctx.data)
161
171
  return step_func(ctx)
162
172
  except Exception as e:
163
- error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "core") else f"{plugin_name} step"
173
+ error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "user", "core") else f"{plugin_name} step"
164
174
  return Error(f"Error executing step '{step_func_name}' from {error_source}: {e}", e)
165
175
 
166
176
 
@@ -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