titan-cli 0.1.0__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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow Execution Screen
|
|
3
|
+
|
|
4
|
+
Screen for executing workflows and displaying progress in real-time.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, List, Optional, Dict
|
|
8
|
+
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
from textual.containers import Container, VerticalScroll
|
|
13
|
+
from textual.worker import Worker, WorkerState
|
|
14
|
+
|
|
15
|
+
from titan_cli.ui.tui.widgets import HeaderWidget
|
|
16
|
+
from titan_cli.ui.tui.icons import Icons
|
|
17
|
+
|
|
18
|
+
from titan_cli.core.secrets import SecretManager
|
|
19
|
+
from titan_cli.core.workflows import ParsedWorkflow
|
|
20
|
+
from titan_cli.engine.builder import WorkflowContextBuilder
|
|
21
|
+
from titan_cli.core.workflows.workflow_exceptions import (
|
|
22
|
+
WorkflowNotFoundError,
|
|
23
|
+
WorkflowExecutionError,
|
|
24
|
+
)
|
|
25
|
+
from titan_cli.ui.tui.textual_workflow_executor import TextualWorkflowExecutor
|
|
26
|
+
from titan_cli.ui.tui.widgets.text import DimText
|
|
27
|
+
from .base import BaseScreen
|
|
28
|
+
|
|
29
|
+
from textual.containers import Horizontal
|
|
30
|
+
|
|
31
|
+
class WorkflowExecutionScreen(BaseScreen):
|
|
32
|
+
"""
|
|
33
|
+
Screen for executing a workflow with real-time progress display.
|
|
34
|
+
|
|
35
|
+
The internal structure (progress tracking, output handling, etc.)
|
|
36
|
+
will be implemented separately.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
BINDINGS = [
|
|
40
|
+
("escape", "cancel_execution", "Cancel"),
|
|
41
|
+
("q", "cancel_execution", "Cancel"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
CSS = """
|
|
45
|
+
WorkflowExecutionScreen {
|
|
46
|
+
align: center middle;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#workflow-description {
|
|
50
|
+
width: 100%;
|
|
51
|
+
text-align: center;
|
|
52
|
+
padding-bottom: 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#execution-container {
|
|
56
|
+
width: 100%;
|
|
57
|
+
height: 1fr;
|
|
58
|
+
background: $surface-lighten-1;
|
|
59
|
+
padding: 0 2 1 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#steps-panel {
|
|
63
|
+
width: 20%;
|
|
64
|
+
height: 100%;
|
|
65
|
+
border: round $primary;
|
|
66
|
+
border-title-align: center;
|
|
67
|
+
background: $surface-lighten-1;
|
|
68
|
+
padding: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#steps-content {
|
|
72
|
+
padding: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#workflow-execution-panel {
|
|
76
|
+
width: 80%;
|
|
77
|
+
height: 100%;
|
|
78
|
+
border: round $primary;
|
|
79
|
+
border-title-align: center;
|
|
80
|
+
background: $surface-lighten-1;
|
|
81
|
+
padding: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#execution-content {
|
|
85
|
+
padding: 1;
|
|
86
|
+
}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, config, workflow_name: str, **kwargs):
|
|
90
|
+
super().__init__(
|
|
91
|
+
config,
|
|
92
|
+
title=f"{Icons.WORKFLOW} Executing: {workflow_name}",
|
|
93
|
+
show_back=True,
|
|
94
|
+
**kwargs
|
|
95
|
+
)
|
|
96
|
+
self.workflow_name = workflow_name
|
|
97
|
+
self.workflow: Optional[ParsedWorkflow] = None
|
|
98
|
+
self._worker: Optional[Worker] = None
|
|
99
|
+
self._original_cwd = os.getcwd()
|
|
100
|
+
self._should_auto_back = False # Flag to trigger auto-back when worker finishes
|
|
101
|
+
|
|
102
|
+
def compose_content(self) -> ComposeResult:
|
|
103
|
+
"""Compose the workflow execution screen."""
|
|
104
|
+
with Container(id="execution-container"):
|
|
105
|
+
yield DimText(id="workflow-description")
|
|
106
|
+
with Horizontal():
|
|
107
|
+
left_panel = VerticalScroll(id="steps-panel")
|
|
108
|
+
left_panel.border_title = "Steps"
|
|
109
|
+
with left_panel:
|
|
110
|
+
yield StepsContent(id="steps-content")
|
|
111
|
+
|
|
112
|
+
right_panel = VerticalScroll(id="workflow-execution-panel")
|
|
113
|
+
right_panel.border_title = "Workflow Execution"
|
|
114
|
+
with right_panel:
|
|
115
|
+
yield WorkflowExecutionContent(id="execution-content")
|
|
116
|
+
|
|
117
|
+
def on_mount(self) -> None:
|
|
118
|
+
"""Start workflow execution when screen is mounted."""
|
|
119
|
+
self._load_and_execute_workflow()
|
|
120
|
+
|
|
121
|
+
def _load_and_execute_workflow(self) -> None:
|
|
122
|
+
"""Load and execute the workflow."""
|
|
123
|
+
try:
|
|
124
|
+
# Load workflow
|
|
125
|
+
self.workflow = self.config.workflows.get_workflow(self.workflow_name)
|
|
126
|
+
# if not self.workflow:
|
|
127
|
+
# TODO Create empty error screen
|
|
128
|
+
# self._update_workflow_info(
|
|
129
|
+
# f"[red]Error: Workflow '{self.workflow_name}' not found[/red]"
|
|
130
|
+
# )
|
|
131
|
+
# return
|
|
132
|
+
|
|
133
|
+
self._update_header_title(f"{Icons.WORKFLOW} {self.workflow.name}")
|
|
134
|
+
self._update_description(self.workflow.description or "")
|
|
135
|
+
|
|
136
|
+
# Create step widgets for non-hook steps
|
|
137
|
+
steps_widget = self.query_one("#steps-content", StepsContent)
|
|
138
|
+
steps_widget.set_steps(self.workflow.steps)
|
|
139
|
+
|
|
140
|
+
self._output("Preparing to execute workflow...")
|
|
141
|
+
|
|
142
|
+
# Execute workflow in background thread (not async worker)
|
|
143
|
+
self._worker = self.run_worker(
|
|
144
|
+
self._execute_workflow,
|
|
145
|
+
name="workflow_executor",
|
|
146
|
+
thread=True
|
|
147
|
+
)
|
|
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
|
+
# )
|
|
159
|
+
|
|
160
|
+
def _execute_workflow(self) -> None:
|
|
161
|
+
"""Execute the workflow in a background thread."""
|
|
162
|
+
try:
|
|
163
|
+
# We're already in the project directory (current working directory)
|
|
164
|
+
# No need to change directory
|
|
165
|
+
|
|
166
|
+
# Create secret manager for current project
|
|
167
|
+
from pathlib import Path
|
|
168
|
+
secrets = SecretManager(project_path=Path.cwd())
|
|
169
|
+
|
|
170
|
+
# Build workflow context (without UI - executor handles messaging)
|
|
171
|
+
ctx_builder = WorkflowContextBuilder(
|
|
172
|
+
plugin_registry=self.config.registry,
|
|
173
|
+
secrets=secrets,
|
|
174
|
+
ai_config=self.config.config.ai,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Add AI if configured
|
|
178
|
+
ctx_builder.with_ai()
|
|
179
|
+
|
|
180
|
+
# Add registered plugins to context
|
|
181
|
+
for plugin_name in self.config.registry.list_installed():
|
|
182
|
+
plugin = self.config.registry.get_plugin(plugin_name)
|
|
183
|
+
if plugin and hasattr(ctx_builder, f"with_{plugin_name}"):
|
|
184
|
+
try:
|
|
185
|
+
client = plugin.get_client()
|
|
186
|
+
getattr(ctx_builder, f"with_{plugin_name}")(client)
|
|
187
|
+
except Exception:
|
|
188
|
+
# Plugin client initialization failed - workflow steps
|
|
189
|
+
# using this plugin will fail gracefully
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
# Build context and create executor
|
|
193
|
+
execution_context = ctx_builder.build()
|
|
194
|
+
executor = TextualWorkflowExecutor(
|
|
195
|
+
plugin_registry=self.config.registry,
|
|
196
|
+
workflow_registry=self.config.workflows,
|
|
197
|
+
message_target=self # Pass self to receive messages
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Execute workflow (this is synchronous and may take time)
|
|
201
|
+
executor.execute(self.workflow, execution_context)
|
|
202
|
+
|
|
203
|
+
except (WorkflowNotFoundError, WorkflowExecutionError) as e:
|
|
204
|
+
self._output(f"\n[red]{Icons.ERROR} Workflow failed: {e}[/red]")
|
|
205
|
+
self._output("[dim]Press ESC or Q to return[/dim]")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
self._output(f"\n[red]{Icons.ERROR} Unexpected error: {type(e).__name__}: {e}[/red]")
|
|
208
|
+
self._output("[dim]Press ESC or Q to return[/dim]")
|
|
209
|
+
finally:
|
|
210
|
+
# Restore original working directory
|
|
211
|
+
os.chdir(self._original_cwd)
|
|
212
|
+
|
|
213
|
+
def _update_header_title(self, title: str) -> None:
|
|
214
|
+
"""Update the header title."""
|
|
215
|
+
try:
|
|
216
|
+
header = self.query_one(HeaderWidget)
|
|
217
|
+
header.title = title
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
def _update_description(self, description: str) -> None:
|
|
222
|
+
"""Update the workflow description."""
|
|
223
|
+
try:
|
|
224
|
+
header = self.query_one("#workflow-description", DimText)
|
|
225
|
+
header.update(description)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def _output(self, text: str) -> None:
|
|
230
|
+
"""Helper to append output to execution widget."""
|
|
231
|
+
try:
|
|
232
|
+
execution_widget = self.query_one("#execution-content", WorkflowExecutionContent)
|
|
233
|
+
execution_widget.append_output(text)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
# Generic message handler for TextualWorkflowExecutor events
|
|
238
|
+
def on_textual_workflow_executor_workflow_started(
|
|
239
|
+
self, message: TextualWorkflowExecutor.WorkflowStarted
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Handle workflow started event."""
|
|
242
|
+
self._handle_workflow_event(message)
|
|
243
|
+
|
|
244
|
+
def on_textual_workflow_executor_step_started(
|
|
245
|
+
self, message: TextualWorkflowExecutor.StepStarted
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Handle step started event."""
|
|
248
|
+
self._handle_workflow_event(message)
|
|
249
|
+
|
|
250
|
+
def on_textual_workflow_executor_step_completed(
|
|
251
|
+
self, message: TextualWorkflowExecutor.StepCompleted
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Handle step completed event."""
|
|
254
|
+
self._handle_workflow_event(message)
|
|
255
|
+
|
|
256
|
+
def on_textual_workflow_executor_step_failed(
|
|
257
|
+
self, message: TextualWorkflowExecutor.StepFailed
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Handle step failed event."""
|
|
260
|
+
self._handle_workflow_event(message)
|
|
261
|
+
|
|
262
|
+
def on_textual_workflow_executor_step_skipped(
|
|
263
|
+
self, message: TextualWorkflowExecutor.StepSkipped
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Handle step skipped event."""
|
|
266
|
+
self._handle_workflow_event(message)
|
|
267
|
+
|
|
268
|
+
def on_textual_workflow_executor_workflow_completed(
|
|
269
|
+
self, message: TextualWorkflowExecutor.WorkflowCompleted
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Handle workflow completed event."""
|
|
272
|
+
self._handle_workflow_event(message)
|
|
273
|
+
|
|
274
|
+
def on_textual_workflow_executor_workflow_failed(
|
|
275
|
+
self, message: TextualWorkflowExecutor.WorkflowFailed
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Handle workflow failed event."""
|
|
278
|
+
self._handle_workflow_event(message)
|
|
279
|
+
|
|
280
|
+
def _handle_workflow_event(self, message) -> None:
|
|
281
|
+
"""Generic handler that delegates to widgets."""
|
|
282
|
+
try:
|
|
283
|
+
from titan_cli.ui.tui.textual_workflow_executor import TextualWorkflowExecutor
|
|
284
|
+
|
|
285
|
+
# Update steps widget for step-related and workflow events
|
|
286
|
+
if hasattr(message, 'step_id') or isinstance(message, (TextualWorkflowExecutor.WorkflowStarted, TextualWorkflowExecutor.WorkflowCompleted)):
|
|
287
|
+
steps_widget = self.query_one("#steps-content", StepsContent)
|
|
288
|
+
steps_widget.handle_event(message)
|
|
289
|
+
|
|
290
|
+
# Update execution widget for output
|
|
291
|
+
execution_widget = self.query_one("#execution-content", WorkflowExecutionContent)
|
|
292
|
+
execution_widget.handle_event(message)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
def _schedule_auto_back(self) -> None:
|
|
297
|
+
"""Schedule auto-back - will poll worker state until it finishes."""
|
|
298
|
+
# import time
|
|
299
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
300
|
+
# f.write(f"[{time.time():.3f}] SCREEN: Auto-back scheduled, starting polling\n")
|
|
301
|
+
self._should_auto_back = True
|
|
302
|
+
# Start polling worker state
|
|
303
|
+
self._poll_worker_and_pop()
|
|
304
|
+
|
|
305
|
+
def _poll_worker_and_pop(self) -> None:
|
|
306
|
+
"""Poll worker state and pop screen when finished."""
|
|
307
|
+
# Check if worker is still running
|
|
308
|
+
if self._worker and self._worker.state == WorkerState.RUNNING:
|
|
309
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
310
|
+
# f.write(f"[{time.time():.3f}] SCREEN: Worker still running, will check again in 0.1s\n")
|
|
311
|
+
# Worker still running, check again in 100ms
|
|
312
|
+
self.set_timer(0.1, self._poll_worker_and_pop)
|
|
313
|
+
else:
|
|
314
|
+
# Worker finished, safe to pop
|
|
315
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
316
|
+
# f.write(f"[{time.time():.3f}] SCREEN: Worker finished, popping screen now\n")
|
|
317
|
+
self.app.pop_screen()
|
|
318
|
+
|
|
319
|
+
def action_cancel_execution(self) -> None:
|
|
320
|
+
"""Cancel workflow execution and go back."""
|
|
321
|
+
# Cancel worker if running
|
|
322
|
+
if self._worker and self._worker.state == WorkerState.RUNNING:
|
|
323
|
+
# Try to cancel, but don't wait for it to finish
|
|
324
|
+
# The worker thread may be blocked, so we just move on
|
|
325
|
+
try:
|
|
326
|
+
self._worker.cancel()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
# Restore working directory
|
|
331
|
+
try:
|
|
332
|
+
os.chdir(self._original_cwd)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
# Pop screen immediately without waiting for worker
|
|
337
|
+
self.app.pop_screen()
|
|
338
|
+
|
|
339
|
+
class StepsContent(Widget):
|
|
340
|
+
"""Widget to display workflow steps and their statuses."""
|
|
341
|
+
|
|
342
|
+
DEFAULT_CSS = """
|
|
343
|
+
StepsContent {
|
|
344
|
+
width: 100%;
|
|
345
|
+
height: auto;
|
|
346
|
+
layout: vertical;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.step-widget {
|
|
350
|
+
width: 100%;
|
|
351
|
+
height: auto;
|
|
352
|
+
padding: 0 1 1 1;
|
|
353
|
+
}
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(self, **kwargs):
|
|
357
|
+
super().__init__(**kwargs)
|
|
358
|
+
self.steps: List[Dict[str, Any]] = []
|
|
359
|
+
self._step_widgets: Dict[str, Static] = {}
|
|
360
|
+
self._workflow_stack: List[Dict[str, Any]] = [] # Stack to track nested workflows
|
|
361
|
+
|
|
362
|
+
def set_steps(self, steps: List[Dict[str, Any]]) -> None:
|
|
363
|
+
"""Set the steps to display."""
|
|
364
|
+
self.steps = steps
|
|
365
|
+
|
|
366
|
+
for idx, step_data in enumerate(steps):
|
|
367
|
+
if step_data.get("hook"):
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
step_id = step_data.get("id") or f"step_{idx}"
|
|
371
|
+
step_name = step_data.get("name") or step_id
|
|
372
|
+
|
|
373
|
+
step_widget = Static(f"{Icons.PENDING} {step_name}", classes="step-widget")
|
|
374
|
+
self._step_widgets[step_id] = step_widget
|
|
375
|
+
self.mount(step_widget)
|
|
376
|
+
|
|
377
|
+
def update_step(self, step_id: str, text: str) -> None:
|
|
378
|
+
"""Update a specific step's display."""
|
|
379
|
+
if step_id in self._step_widgets:
|
|
380
|
+
self._step_widgets[step_id].update(text)
|
|
381
|
+
|
|
382
|
+
def set_step_running(self, step_id: str, step_name: str) -> None:
|
|
383
|
+
"""Mark a step as running."""
|
|
384
|
+
self.update_step(step_id, f"{Icons.RUNNING} [cyan]{step_name}[/cyan]")
|
|
385
|
+
|
|
386
|
+
def set_step_success(self, step_id: str, step_name: str) -> None:
|
|
387
|
+
"""Mark a step as successful."""
|
|
388
|
+
self.update_step(step_id, f"{Icons.SUCCESS} [green]{step_name}[/green]")
|
|
389
|
+
|
|
390
|
+
def set_step_failed(self, step_id: str, step_name: str) -> None:
|
|
391
|
+
"""Mark a step as failed."""
|
|
392
|
+
self.update_step(step_id, f"{Icons.ERROR} [red]{step_name}[/red]")
|
|
393
|
+
|
|
394
|
+
def set_step_skipped(self, step_id: str, step_name: str) -> None:
|
|
395
|
+
"""Mark a step as skipped."""
|
|
396
|
+
self.update_step(step_id, f"{Icons.SKIPPED} [yellow]{step_name}[/yellow]")
|
|
397
|
+
|
|
398
|
+
def handle_event(self, message) -> None:
|
|
399
|
+
"""Handle workflow events generically."""
|
|
400
|
+
from titan_cli.ui.tui.textual_workflow_executor import TextualWorkflowExecutor
|
|
401
|
+
|
|
402
|
+
if isinstance(message, TextualWorkflowExecutor.WorkflowStarted):
|
|
403
|
+
# If it's a nested workflow, save current state and show nested steps
|
|
404
|
+
if message.is_nested:
|
|
405
|
+
# Save current state
|
|
406
|
+
self._workflow_stack.append({
|
|
407
|
+
'steps': self.steps,
|
|
408
|
+
'widgets': self._step_widgets.copy()
|
|
409
|
+
})
|
|
410
|
+
# Clear current widgets
|
|
411
|
+
for widget in self._step_widgets.values():
|
|
412
|
+
widget.remove()
|
|
413
|
+
self._step_widgets.clear()
|
|
414
|
+
# Set nested workflow steps
|
|
415
|
+
self.set_steps(message.steps)
|
|
416
|
+
|
|
417
|
+
elif isinstance(message, TextualWorkflowExecutor.WorkflowCompleted):
|
|
418
|
+
# If nested workflow completed, restore parent workflow steps
|
|
419
|
+
if message.is_nested and self._workflow_stack:
|
|
420
|
+
# Clear nested widgets
|
|
421
|
+
for widget in self._step_widgets.values():
|
|
422
|
+
widget.remove()
|
|
423
|
+
self._step_widgets.clear()
|
|
424
|
+
|
|
425
|
+
# Restore parent workflow state
|
|
426
|
+
parent_state = self._workflow_stack.pop()
|
|
427
|
+
self.steps = parent_state['steps']
|
|
428
|
+
self._step_widgets = parent_state['widgets']
|
|
429
|
+
|
|
430
|
+
# Re-mount parent widgets
|
|
431
|
+
for widget in self._step_widgets.values():
|
|
432
|
+
self.mount(widget)
|
|
433
|
+
|
|
434
|
+
elif isinstance(message, TextualWorkflowExecutor.StepStarted):
|
|
435
|
+
self.update_step(message.step_id, f"{Icons.RUNNING} [cyan]{message.step_name}[/cyan]")
|
|
436
|
+
elif isinstance(message, TextualWorkflowExecutor.StepCompleted):
|
|
437
|
+
self.update_step(message.step_id, f"{Icons.SUCCESS} [green]{message.step_name}[/green]")
|
|
438
|
+
elif isinstance(message, TextualWorkflowExecutor.StepFailed):
|
|
439
|
+
self.update_step(message.step_id, f"{Icons.ERROR} [red]{message.step_name}[/red]")
|
|
440
|
+
elif isinstance(message, TextualWorkflowExecutor.StepSkipped):
|
|
441
|
+
self.update_step(message.step_id, f"{Icons.SKIPPED} [yellow]{message.step_name}[/yellow]")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class WorkflowExecutionContent(Widget):
|
|
445
|
+
"""Widget to display workflow execution output."""
|
|
446
|
+
|
|
447
|
+
# Allow children to receive focus (for input widgets)
|
|
448
|
+
can_focus_children = True
|
|
449
|
+
|
|
450
|
+
DEFAULT_CSS = """
|
|
451
|
+
WorkflowExecutionContent {
|
|
452
|
+
width: 100%;
|
|
453
|
+
height: auto;
|
|
454
|
+
layout: vertical;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
WorkflowExecutionContent > Static {
|
|
458
|
+
width: 100%;
|
|
459
|
+
height: auto;
|
|
460
|
+
}
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def __init__(self, **kwargs):
|
|
464
|
+
super().__init__(**kwargs)
|
|
465
|
+
self._workflow_depth = 0 # Track nested workflow depth
|
|
466
|
+
|
|
467
|
+
def compose(self) -> ComposeResult:
|
|
468
|
+
"""Compose the execution content."""
|
|
469
|
+
# Don't yield anything - content will be mounted dynamically
|
|
470
|
+
return
|
|
471
|
+
yield # Make this a generator
|
|
472
|
+
|
|
473
|
+
def append_output(self, text: str) -> None:
|
|
474
|
+
"""Append text to the output."""
|
|
475
|
+
# Mount each line as a separate Static widget to preserve order
|
|
476
|
+
try:
|
|
477
|
+
text_widget = Static(text)
|
|
478
|
+
self.mount(text_widget)
|
|
479
|
+
# Auto-scroll to show new content
|
|
480
|
+
self._scroll_to_end()
|
|
481
|
+
except Exception:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
def _scroll_to_end(self) -> None:
|
|
485
|
+
"""Scroll the parent container to show the end."""
|
|
486
|
+
try:
|
|
487
|
+
# Get the parent VerticalScroll container
|
|
488
|
+
parent = self.parent
|
|
489
|
+
if parent and hasattr(parent, 'scroll_end'):
|
|
490
|
+
parent.scroll_end(animate=False)
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
def on_descendant_mount(self, event) -> None:
|
|
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
|
|
498
|
+
|
|
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):
|
|
502
|
+
self._scroll_to_end()
|
|
503
|
+
|
|
504
|
+
def handle_event(self, message) -> None:
|
|
505
|
+
"""Handle workflow events generically."""
|
|
506
|
+
from titan_cli.ui.tui.textual_workflow_executor import TextualWorkflowExecutor
|
|
507
|
+
from titan_cli.ui.tui.widgets import Panel
|
|
508
|
+
|
|
509
|
+
if isinstance(message, TextualWorkflowExecutor.WorkflowStarted):
|
|
510
|
+
# Track nested workflow depth
|
|
511
|
+
if message.is_nested:
|
|
512
|
+
self._workflow_depth += 1
|
|
513
|
+
self.append_output(f"\n[bold cyan]🚀 Starting workflow: {message.workflow_name}[/bold cyan]")
|
|
514
|
+
|
|
515
|
+
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]")
|
|
524
|
+
|
|
525
|
+
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")
|
|
532
|
+
|
|
533
|
+
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
|
+
|
|
541
|
+
if message.on_error == "continue":
|
|
542
|
+
indent = " " * self._workflow_depth if self._workflow_depth > 0 else ""
|
|
543
|
+
self.append_output(f"[yellow]{indent} {Icons.WARNING} Continuing despite error[/yellow]\n")
|
|
544
|
+
else:
|
|
545
|
+
self.append_output("")
|
|
546
|
+
|
|
547
|
+
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
|
|
554
|
+
|
|
555
|
+
elif isinstance(message, TextualWorkflowExecutor.WorkflowCompleted):
|
|
556
|
+
# Track nested workflow depth
|
|
557
|
+
if message.is_nested and self._workflow_depth > 0:
|
|
558
|
+
self._workflow_depth -= 1
|
|
559
|
+
|
|
560
|
+
# DEBUG: Log receipt
|
|
561
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
562
|
+
# f.write(f"[{time.time():.3f}] SCREEN: Received WorkflowCompleted, is_nested={message.is_nested}\n")
|
|
563
|
+
|
|
564
|
+
# Show success toast instead of inline message
|
|
565
|
+
try:
|
|
566
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
567
|
+
# f.write(f"[{time.time():.3f}] SCREEN: About to call notify\n")
|
|
568
|
+
self.app.notify(f"✨ Workflow completed: {message.workflow_name}", severity="information", timeout=5)
|
|
569
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
570
|
+
# f.write(f"[{time.time():.3f}] SCREEN: notify called successfully\n")
|
|
571
|
+
except Exception:
|
|
572
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
573
|
+
# f.write(f"[{time.time():.3f}] SCREEN: notify failed: {e}\n")
|
|
574
|
+
# Fallback if notify fails
|
|
575
|
+
self.append_output(f"\n[bold green]✨ Workflow completed: {message.workflow_name}[/bold green]")
|
|
576
|
+
|
|
577
|
+
# Schedule auto-back after a short delay (only if not nested)
|
|
578
|
+
if not message.is_nested:
|
|
579
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
580
|
+
# f.write(f"[{time.time():.3f}] SCREEN: Setting timer for auto-back flag\n")
|
|
581
|
+
# Don't pop immediately - wait for worker to finish, then pop
|
|
582
|
+
self.set_timer(3.0, self._schedule_auto_back)
|
|
583
|
+
else:
|
|
584
|
+
# with open("/tmp/titan_debug.log", "a") as f:
|
|
585
|
+
# f.write(f"[{time.time():.3f}] SCREEN: Workflow is nested, skipping auto-back\n")
|
|
586
|
+
pass
|
|
587
|
+
|
|
588
|
+
elif isinstance(message, TextualWorkflowExecutor.WorkflowFailed):
|
|
589
|
+
# Show error toast for workflow failure
|
|
590
|
+
self.app.notify(f"❌ Workflow failed at step: {message.step_name}", severity="error", timeout=10)
|
|
591
|
+
self.append_output(f"[red]{message.error_message}[/red]")
|
|
592
|
+
|