titan-cli 0.1.3__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 (42) hide show
  1. titan_cli/core/config.py +3 -1
  2. titan_cli/core/plugins/models.py +35 -7
  3. titan_cli/core/plugins/plugin_registry.py +11 -2
  4. titan_cli/core/workflows/__init__.py +2 -1
  5. titan_cli/core/workflows/project_step_source.py +48 -30
  6. titan_cli/core/workflows/workflow_filter_service.py +14 -8
  7. titan_cli/core/workflows/workflow_registry.py +12 -1
  8. titan_cli/core/workflows/workflow_sources.py +1 -1
  9. titan_cli/engine/steps/ai_assistant_step.py +42 -7
  10. titan_cli/engine/workflow_executor.py +6 -1
  11. titan_cli/ui/tui/screens/plugin_config_wizard.py +40 -9
  12. titan_cli/ui/tui/screens/workflow_execution.py +8 -28
  13. titan_cli/ui/tui/textual_components.py +59 -6
  14. titan_cli/ui/tui/textual_workflow_executor.py +9 -1
  15. titan_cli/ui/tui/widgets/__init__.py +2 -0
  16. titan_cli/ui/tui/widgets/step_container.py +70 -0
  17. {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/METADATA +6 -3
  18. {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/RECORD +42 -40
  19. {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/WHEEL +1 -1
  20. titan_plugin_git/clients/git_client.py +82 -4
  21. titan_plugin_git/plugin.py +3 -0
  22. titan_plugin_git/steps/ai_commit_message_step.py +33 -28
  23. titan_plugin_git/steps/branch_steps.py +18 -37
  24. titan_plugin_git/steps/commit_step.py +18 -22
  25. titan_plugin_git/steps/diff_summary_step.py +182 -0
  26. titan_plugin_git/steps/push_step.py +27 -11
  27. titan_plugin_git/steps/status_step.py +15 -18
  28. titan_plugin_git/workflows/commit-ai.yaml +5 -0
  29. titan_plugin_github/agents/pr_agent.py +15 -2
  30. titan_plugin_github/steps/ai_pr_step.py +12 -21
  31. titan_plugin_github/steps/create_pr_step.py +17 -7
  32. titan_plugin_github/steps/github_prompt_steps.py +52 -0
  33. titan_plugin_github/steps/issue_steps.py +28 -14
  34. titan_plugin_github/steps/preview_step.py +11 -0
  35. titan_plugin_github/utils.py +5 -4
  36. titan_plugin_github/workflows/create-pr-ai.yaml +5 -0
  37. titan_plugin_jira/steps/ai_analyze_issue_step.py +8 -3
  38. titan_plugin_jira/steps/get_issue_step.py +16 -12
  39. titan_plugin_jira/steps/prompt_select_issue_step.py +22 -9
  40. titan_plugin_jira/steps/search_saved_query_step.py +21 -19
  41. {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/entry_points.txt +0 -0
  42. {titan_cli-0.1.3.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
 
@@ -30,15 +30,43 @@ class JiraPluginConfig(BaseModel):
30
30
  Credentials (base_url, email, api_token) should be configured at global level (~/.titan/config.toml).
31
31
  Project-specific settings (default_project) can override at project level (.titan/config.toml).
32
32
  """
33
- base_url: Optional[str] = Field(None, description="JIRA instance URL (e.g., 'https://jira.company.com')")
34
- email: Optional[str] = Field(None, description="User email for authentication")
33
+ base_url: Optional[str] = Field(
34
+ None,
35
+ description="JIRA instance URL (e.g., 'https://jira.company.com')",
36
+ json_schema_extra={"config_scope": "global"}
37
+ )
38
+ email: Optional[str] = Field(
39
+ None,
40
+ description="User email for authentication",
41
+ json_schema_extra={"config_scope": "global"}
42
+ )
35
43
  # api_token is stored in secrets, not in config.toml
36
44
  # It appears in the JSON schema for interactive configuration but is optional in the model
37
- api_token: Optional[str] = Field(None, description="JIRA API token (Personal Access Token)", json_schema_extra={"format": "password", "required_in_schema": True})
38
- default_project: Optional[str] = Field(None, description="Default JIRA project key (e.g., 'ECAPP', 'PROJ')")
39
- timeout: int = Field(30, description="Request timeout in seconds")
40
- enable_cache: bool = Field(True, description="Enable caching for API responses")
41
- cache_ttl: int = Field(300, description="Cache time-to-live in seconds")
45
+ api_token: Optional[str] = Field(
46
+ None,
47
+ description="JIRA API token (Personal Access Token)",
48
+ json_schema_extra={"format": "password", "required_in_schema": True}
49
+ )
50
+ default_project: Optional[str] = Field(
51
+ None,
52
+ description="Default JIRA project key (e.g., 'ECAPP', 'PROJ')",
53
+ json_schema_extra={"config_scope": "project"}
54
+ )
55
+ timeout: int = Field(
56
+ 30,
57
+ description="Request timeout in seconds",
58
+ json_schema_extra={"config_scope": "global"}
59
+ )
60
+ enable_cache: bool = Field(
61
+ True,
62
+ description="Enable caching for API responses",
63
+ json_schema_extra={"config_scope": "global"}
64
+ )
65
+ cache_ttl: int = Field(
66
+ 300,
67
+ description="Cache time-to-live in seconds",
68
+ json_schema_extra={"config_scope": "global"}
69
+ )
42
70
 
43
71
  @field_validator('base_url')
44
72
  @classmethod
@@ -20,10 +20,19 @@ class PluginRegistry:
20
20
  logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
21
21
 
22
22
  discovered = entry_points(group='titan.plugins')
23
- self._discovered_plugin_names = [ep.name for ep in discovered]
24
- logger.debug(f"PluginRegistry.discover() - Found {len(self._discovered_plugin_names)} plugins: {self._discovered_plugin_names}")
25
23
 
24
+ # Deduplicate entry points (can happen in dev mode with editable installs)
25
+ seen = {}
26
+ unique_eps = []
26
27
  for ep in discovered:
28
+ if ep.name not in seen:
29
+ seen[ep.name] = ep
30
+ unique_eps.append(ep)
31
+
32
+ self._discovered_plugin_names = [ep.name for ep in unique_eps]
33
+ logger.debug(f"PluginRegistry.discover() - Found {len(self._discovered_plugin_names)} plugins: {self._discovered_plugin_names}")
34
+
35
+ for ep in unique_eps:
27
36
  try:
28
37
  logger.debug(f"Loading plugin: {ep.name}")
29
38
  plugin_class = ep.load()
@@ -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
 
@@ -494,22 +494,43 @@ class PluginConfigWizardScreen(BaseScreen):
494
494
 
495
495
  try:
496
496
  project_cfg_path = self.config.project_config_path
497
+ global_cfg_path = self.config._global_config_path
498
+
497
499
  if not project_cfg_path:
498
500
  self.app.notify("No project config found", severity="error")
499
501
  return
500
502
 
503
+ # Load existing configs
501
504
  project_cfg_dict = {}
502
505
  if project_cfg_path.exists():
503
506
  with open(project_cfg_path, "rb") as f:
504
507
  project_cfg_dict = tomli.load(f)
505
508
 
506
- plugins_table = project_cfg_dict.setdefault("plugins", {})
507
- plugin_specific_table = plugins_table.setdefault(self.plugin_name, {})
508
- plugin_config_table = plugin_specific_table.setdefault("config", {})
509
+ global_cfg_dict = {}
510
+ if global_cfg_path.exists():
511
+ with open(global_cfg_path, "rb") as f:
512
+ global_cfg_dict = tomli.load(f)
513
+
514
+ # Prepare plugin tables
515
+ project_plugins_table = project_cfg_dict.setdefault("plugins", {})
516
+ project_plugin_table = project_plugins_table.setdefault(self.plugin_name, {})
517
+ project_config_table = project_plugin_table.setdefault("config", {})
518
+
519
+ global_plugins_table = global_cfg_dict.setdefault("plugins", {})
520
+ global_plugin_table = global_plugins_table.setdefault(self.plugin_name, {})
521
+ global_config_table = global_plugin_table.setdefault("config", {})
509
522
 
510
- # Separate secrets from regular config
523
+ # Get field metadata from schema
524
+ field_scopes = {}
525
+ if self.schema and "properties" in self.schema:
526
+ for field_name, field_info in self.schema["properties"].items():
527
+ scope = field_info.get("config_scope", "project") # Default to project
528
+ field_scopes[field_name] = scope
529
+
530
+ # Separate secrets and config by scope
511
531
  secrets_to_save = {}
512
- config_values = {}
532
+ global_config_values = {}
533
+ project_config_values = {}
513
534
 
514
535
  for field_name, value in self.config_data.items():
515
536
  if isinstance(value, dict) and value.get("_is_secret"):
@@ -524,15 +545,25 @@ class PluginConfigWizardScreen(BaseScreen):
524
545
  else:
525
546
  secrets_to_save[secret_key] = value["_value"]
526
547
  else:
527
- config_values[field_name] = value
548
+ # Route to global or project based on field scope
549
+ scope = field_scopes.get(field_name, "project")
550
+ if scope == "global":
551
+ global_config_values[field_name] = value
552
+ else:
553
+ project_config_values[field_name] = value
528
554
 
529
- # Update config
530
- plugin_config_table.update(config_values)
555
+ # Update configs
556
+ project_config_table.update(project_config_values)
557
+ global_config_table.update(global_config_values)
531
558
 
532
- # Write config file
559
+ # Write config files
533
560
  with open(project_cfg_path, "wb") as f:
534
561
  tomli_w.dump(project_cfg_dict, f)
535
562
 
563
+ if global_config_values: # Only write global if there are global values
564
+ with open(global_cfg_path, "wb") as f:
565
+ tomli_w.dump(global_cfg_dict, f)
566
+
536
567
  # Save secrets
537
568
  project_name = self.config.get_project_name()
538
569
  for secret_key, secret_value in secrets_to_save.items():
@@ -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