galangal-orchestrate 0.13.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.
- galangal/__init__.py +36 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +167 -0
- galangal/ai/base.py +159 -0
- galangal/ai/claude.py +352 -0
- galangal/ai/codex.py +370 -0
- galangal/ai/gemini.py +43 -0
- galangal/ai/subprocess.py +254 -0
- galangal/cli.py +371 -0
- galangal/commands/__init__.py +27 -0
- galangal/commands/complete.py +367 -0
- galangal/commands/github.py +355 -0
- galangal/commands/init.py +177 -0
- galangal/commands/init_wizard.py +762 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +34 -0
- galangal/commands/prompts.py +89 -0
- galangal/commands/reset.py +41 -0
- galangal/commands/resume.py +30 -0
- galangal/commands/skip.py +62 -0
- galangal/commands/start.py +530 -0
- galangal/commands/status.py +44 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +15 -0
- galangal/config/defaults.py +183 -0
- galangal/config/loader.py +163 -0
- galangal/config/schema.py +330 -0
- galangal/core/__init__.py +33 -0
- galangal/core/artifacts.py +136 -0
- galangal/core/state.py +1097 -0
- galangal/core/tasks.py +454 -0
- galangal/core/utils.py +116 -0
- galangal/core/workflow/__init__.py +68 -0
- galangal/core/workflow/core.py +789 -0
- galangal/core/workflow/engine.py +781 -0
- galangal/core/workflow/pause.py +35 -0
- galangal/core/workflow/tui_runner.py +1322 -0
- galangal/exceptions.py +36 -0
- galangal/github/__init__.py +31 -0
- galangal/github/client.py +427 -0
- galangal/github/images.py +324 -0
- galangal/github/issues.py +298 -0
- galangal/logging.py +364 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +527 -0
- galangal/prompts/defaults/benchmark.md +34 -0
- galangal/prompts/defaults/contract.md +35 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +89 -0
- galangal/prompts/defaults/docs.md +104 -0
- galangal/prompts/defaults/migration.md +59 -0
- galangal/prompts/defaults/pm.md +110 -0
- galangal/prompts/defaults/pm_questions.md +53 -0
- galangal/prompts/defaults/preflight.md +32 -0
- galangal/prompts/defaults/qa.md +65 -0
- galangal/prompts/defaults/review.md +90 -0
- galangal/prompts/defaults/review_codex.md +99 -0
- galangal/prompts/defaults/security.md +84 -0
- galangal/prompts/defaults/test.md +91 -0
- galangal/results.py +176 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +126 -0
- galangal/ui/tui/__init__.py +56 -0
- galangal/ui/tui/adapters.py +168 -0
- galangal/ui/tui/app.py +902 -0
- galangal/ui/tui/entry.py +24 -0
- galangal/ui/tui/mixins.py +196 -0
- galangal/ui/tui/modals.py +339 -0
- galangal/ui/tui/styles/app.tcss +86 -0
- galangal/ui/tui/styles/modals.tcss +197 -0
- galangal/ui/tui/types.py +107 -0
- galangal/ui/tui/widgets.py +263 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +1072 -0
- galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
- galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
- galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
galangal/ui/tui/app.py
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Textual TUI application for workflow execution.
|
|
3
|
+
|
|
4
|
+
Layout:
|
|
5
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
6
|
+
│ Task: my-task Stage: DEV (1/5) Elapsed: 2:34 Turns: 5 │ Header
|
|
7
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
8
|
+
│ ● PM ━ ● DESIGN ━ ● DEV ━ ○ TEST ━ ○ QA ━ ○ DONE │ Progress
|
|
9
|
+
├────────────────────────────────────────────────────┬─────────────┤
|
|
10
|
+
│ │ Files │
|
|
11
|
+
│ Activity Log │ ─────────── │
|
|
12
|
+
│ 11:30:00 • Starting stage... │ 📖 file.py │
|
|
13
|
+
│ 11:30:01 📖 Read: file.py │ ✏️ test.py │
|
|
14
|
+
│ │ │
|
|
15
|
+
├────────────────────────────────────────────────────┴─────────────┤
|
|
16
|
+
│ ⠋ Running: waiting for API response │ Action
|
|
17
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
18
|
+
│ ^Q Quit ^D Verbose ^F Files │ Footer
|
|
19
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from textual.app import App, ComposeResult
|
|
29
|
+
from textual.binding import Binding
|
|
30
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
31
|
+
from textual.widgets import Footer, RichLog
|
|
32
|
+
|
|
33
|
+
from galangal.core.utils import debug_log
|
|
34
|
+
from galangal.ui.tui.adapters import PromptType, TUIAdapter, get_prompt_options
|
|
35
|
+
from galangal.ui.tui.mixins import WidgetAccessMixin
|
|
36
|
+
from galangal.ui.tui.modals import (
|
|
37
|
+
GitHubIssueOption,
|
|
38
|
+
GitHubIssueSelectModal,
|
|
39
|
+
MultilineInputModal,
|
|
40
|
+
PromptModal,
|
|
41
|
+
QuestionAnswerModal,
|
|
42
|
+
TextInputModal,
|
|
43
|
+
UserQuestionsModal,
|
|
44
|
+
)
|
|
45
|
+
from galangal.ui.tui.types import (
|
|
46
|
+
ActivityCategory,
|
|
47
|
+
ActivityEntry,
|
|
48
|
+
ActivityLevel,
|
|
49
|
+
export_activity_log,
|
|
50
|
+
)
|
|
51
|
+
from galangal.ui.tui.widgets import (
|
|
52
|
+
CurrentActionWidget,
|
|
53
|
+
ErrorPanelWidget,
|
|
54
|
+
FilesPanelWidget,
|
|
55
|
+
HeaderWidget,
|
|
56
|
+
StageProgressWidget,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
61
|
+
"""
|
|
62
|
+
Textual TUI application for workflow execution.
|
|
63
|
+
|
|
64
|
+
This is the main UI for interactive workflow execution. It displays:
|
|
65
|
+
- Header: Task name, stage, attempt count, elapsed time, turn count
|
|
66
|
+
- Progress bar: Visual representation of stage progression
|
|
67
|
+
- Activity log: Real-time updates of AI actions
|
|
68
|
+
- Files panel: List of files read/written
|
|
69
|
+
- Current action: Spinner with current activity
|
|
70
|
+
|
|
71
|
+
The app supports:
|
|
72
|
+
- Modal prompts for approvals and choices (PromptModal)
|
|
73
|
+
- Text input dialogs (TextInputModal, MultilineInputModal)
|
|
74
|
+
- Verbose mode for raw JSON output (Ctrl+D)
|
|
75
|
+
- Files panel toggle (Ctrl+F)
|
|
76
|
+
- Graceful quit (Ctrl+Q)
|
|
77
|
+
|
|
78
|
+
Threading Model:
|
|
79
|
+
The TUI runs in the main thread (Textual event loop). All UI updates
|
|
80
|
+
from background threads must use `call_from_thread()` to be thread-safe.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
task_name: Name of the current task.
|
|
84
|
+
current_stage: Current workflow stage.
|
|
85
|
+
verbose: If True, show raw JSON output instead of activity log.
|
|
86
|
+
_paused: Set to True when user requests pause.
|
|
87
|
+
_workflow_result: Result string set by workflow thread.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
TITLE = "Galangal"
|
|
91
|
+
CSS_PATH = "styles/app.tcss"
|
|
92
|
+
|
|
93
|
+
BINDINGS = [
|
|
94
|
+
Binding("ctrl+q", "quit_workflow", "^Q Quit", show=True),
|
|
95
|
+
Binding("ctrl+i", "interrupt_feedback", "^I Interrupt", show=True),
|
|
96
|
+
Binding("ctrl+n", "skip_stage", "^N Skip", show=True),
|
|
97
|
+
Binding("ctrl+b", "back_stage", "^B Back", show=True),
|
|
98
|
+
Binding("ctrl+e", "manual_edit", "^E Edit", show=True),
|
|
99
|
+
Binding("ctrl+d", "toggle_verbose", "^D Verbose", show=False),
|
|
100
|
+
Binding("ctrl+f", "toggle_files", "^F Files", show=False),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
task_name: str,
|
|
106
|
+
initial_stage: str,
|
|
107
|
+
max_retries: int = 5,
|
|
108
|
+
hidden_stages: frozenset[str] | None = None,
|
|
109
|
+
stage_durations: dict[str, int] | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
super().__init__()
|
|
112
|
+
self.task_name = task_name
|
|
113
|
+
self.current_stage = initial_stage
|
|
114
|
+
self._max_retries = max_retries
|
|
115
|
+
self._hidden_stages = hidden_stages or frozenset()
|
|
116
|
+
self._stage_durations = stage_durations or {}
|
|
117
|
+
self.verbose = False
|
|
118
|
+
self._start_time = time.time()
|
|
119
|
+
self._attempt = 1
|
|
120
|
+
self._turns = 0
|
|
121
|
+
|
|
122
|
+
# Raw lines storage for verbose replay
|
|
123
|
+
self._raw_lines: list[str] = []
|
|
124
|
+
self._activity_entries: list[ActivityEntry] = []
|
|
125
|
+
|
|
126
|
+
# Workflow control
|
|
127
|
+
self._paused = False
|
|
128
|
+
self._interrupt_requested = False
|
|
129
|
+
self._skip_stage_requested = False
|
|
130
|
+
self._back_stage_requested = False
|
|
131
|
+
self._manual_edit_requested = False
|
|
132
|
+
self._prompt_type = PromptType.NONE
|
|
133
|
+
self._prompt_callback: Callable[..., None] | None = None
|
|
134
|
+
self._active_prompt_screen: PromptModal | None = None
|
|
135
|
+
self._workflow_result: str | None = None
|
|
136
|
+
|
|
137
|
+
# Text input state
|
|
138
|
+
self._input_callback: Callable[..., None] | None = None
|
|
139
|
+
self._active_input_screen: TextInputModal | None = None
|
|
140
|
+
self._files_visible = True
|
|
141
|
+
|
|
142
|
+
def compose(self) -> ComposeResult:
|
|
143
|
+
with Container(id="workflow-root"):
|
|
144
|
+
yield HeaderWidget(id="header")
|
|
145
|
+
yield StageProgressWidget(id="progress")
|
|
146
|
+
with Container(id="main-content"):
|
|
147
|
+
yield ErrorPanelWidget(id="error-panel", classes="hidden")
|
|
148
|
+
with Horizontal(id="content-area"):
|
|
149
|
+
with VerticalScroll(id="activity-container"):
|
|
150
|
+
yield RichLog(id="activity-log", highlight=True, markup=True)
|
|
151
|
+
yield FilesPanelWidget(id="files-container")
|
|
152
|
+
yield CurrentActionWidget(id="current-action")
|
|
153
|
+
yield Footer()
|
|
154
|
+
|
|
155
|
+
def on_mount(self) -> None:
|
|
156
|
+
"""Initialize widgets."""
|
|
157
|
+
header = self.query_one("#header", HeaderWidget)
|
|
158
|
+
header.task_name = self.task_name
|
|
159
|
+
header.stage = self.current_stage
|
|
160
|
+
header.attempt = self._attempt
|
|
161
|
+
header.max_retries = self._max_retries
|
|
162
|
+
|
|
163
|
+
progress = self.query_one("#progress", StageProgressWidget)
|
|
164
|
+
progress.current_stage = self.current_stage
|
|
165
|
+
progress.hidden_stages = self._hidden_stages
|
|
166
|
+
progress.stage_durations = self._stage_durations
|
|
167
|
+
|
|
168
|
+
# Start timers
|
|
169
|
+
self.set_interval(1.0, self._update_elapsed)
|
|
170
|
+
self.set_interval(0.1, self._update_spinner)
|
|
171
|
+
|
|
172
|
+
def _update_elapsed(self) -> None:
|
|
173
|
+
"""Update elapsed time display."""
|
|
174
|
+
elapsed = int(time.time() - self._start_time)
|
|
175
|
+
if elapsed >= 3600:
|
|
176
|
+
hours, remainder = divmod(elapsed, 3600)
|
|
177
|
+
mins, secs = divmod(remainder, 60)
|
|
178
|
+
elapsed_str = f"{hours}:{mins:02d}:{secs:02d}"
|
|
179
|
+
else:
|
|
180
|
+
mins, secs = divmod(elapsed, 60)
|
|
181
|
+
elapsed_str = f"{mins}:{secs:02d}"
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
header = self.query_one("#header", HeaderWidget)
|
|
185
|
+
header.elapsed = elapsed_str
|
|
186
|
+
except Exception:
|
|
187
|
+
pass # Widget may not exist during shutdown
|
|
188
|
+
|
|
189
|
+
def _update_spinner(self) -> None:
|
|
190
|
+
"""Update action spinner."""
|
|
191
|
+
try:
|
|
192
|
+
action = self.query_one("#current-action", CurrentActionWidget)
|
|
193
|
+
action.spinner_frame += 1
|
|
194
|
+
except Exception:
|
|
195
|
+
pass # Widget may not exist during shutdown
|
|
196
|
+
|
|
197
|
+
# -------------------------------------------------------------------------
|
|
198
|
+
# Public API for workflow
|
|
199
|
+
# -------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def update_stage(self, stage: str, attempt: int = 1) -> None:
|
|
202
|
+
"""Update current stage display."""
|
|
203
|
+
self.current_stage = stage
|
|
204
|
+
self._attempt = attempt
|
|
205
|
+
|
|
206
|
+
def _update() -> None:
|
|
207
|
+
header = self._safe_query("#header", HeaderWidget)
|
|
208
|
+
if header:
|
|
209
|
+
header.stage = stage
|
|
210
|
+
header.attempt = attempt
|
|
211
|
+
|
|
212
|
+
progress = self._safe_query("#progress", StageProgressWidget)
|
|
213
|
+
if progress:
|
|
214
|
+
progress.current_stage = stage
|
|
215
|
+
|
|
216
|
+
self._safe_update(_update)
|
|
217
|
+
|
|
218
|
+
def update_hidden_stages(self, hidden_stages: frozenset[str]) -> None:
|
|
219
|
+
"""Update which stages are hidden in the progress bar."""
|
|
220
|
+
self._hidden_stages = hidden_stages
|
|
221
|
+
|
|
222
|
+
def _update() -> None:
|
|
223
|
+
progress = self._safe_query("#progress", StageProgressWidget)
|
|
224
|
+
if progress:
|
|
225
|
+
progress.hidden_stages = hidden_stages
|
|
226
|
+
|
|
227
|
+
self._safe_update(_update)
|
|
228
|
+
|
|
229
|
+
def set_status(self, status: str, detail: str = "") -> None:
|
|
230
|
+
"""Update current action display."""
|
|
231
|
+
|
|
232
|
+
def _update() -> None:
|
|
233
|
+
action = self._safe_query("#current-action", CurrentActionWidget)
|
|
234
|
+
if action:
|
|
235
|
+
action.action = status
|
|
236
|
+
action.detail = detail
|
|
237
|
+
|
|
238
|
+
self._safe_update(_update)
|
|
239
|
+
|
|
240
|
+
def set_turns(self, turns: int) -> None:
|
|
241
|
+
"""Update turn count."""
|
|
242
|
+
self._turns = turns
|
|
243
|
+
|
|
244
|
+
def _update() -> None:
|
|
245
|
+
header = self._safe_query("#header", HeaderWidget)
|
|
246
|
+
if header:
|
|
247
|
+
header.turns = turns
|
|
248
|
+
|
|
249
|
+
self._safe_update(_update)
|
|
250
|
+
|
|
251
|
+
def add_activity(
|
|
252
|
+
self,
|
|
253
|
+
activity: str,
|
|
254
|
+
icon: str = "•",
|
|
255
|
+
level: ActivityLevel = ActivityLevel.INFO,
|
|
256
|
+
category: ActivityCategory = ActivityCategory.SYSTEM,
|
|
257
|
+
details: str | None = None,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Add activity to log.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
activity: Message to display.
|
|
264
|
+
icon: Icon prefix for the entry.
|
|
265
|
+
level: Severity level (info, success, warning, error).
|
|
266
|
+
category: Category for filtering (stage, validation, claude, file, system).
|
|
267
|
+
details: Optional additional details for export.
|
|
268
|
+
"""
|
|
269
|
+
entry = ActivityEntry(
|
|
270
|
+
message=activity,
|
|
271
|
+
icon=icon,
|
|
272
|
+
level=level,
|
|
273
|
+
category=category,
|
|
274
|
+
details=details,
|
|
275
|
+
)
|
|
276
|
+
self._activity_entries.append(entry)
|
|
277
|
+
|
|
278
|
+
def _add() -> None:
|
|
279
|
+
# Only show activity in compact (non-verbose) mode
|
|
280
|
+
if not self.verbose:
|
|
281
|
+
log = self._safe_query("#activity-log", RichLog)
|
|
282
|
+
if log:
|
|
283
|
+
log.write(entry.format_display())
|
|
284
|
+
|
|
285
|
+
self._safe_update(_add)
|
|
286
|
+
|
|
287
|
+
def add_file(self, action: str, path: str) -> None:
|
|
288
|
+
"""Add file to files panel."""
|
|
289
|
+
|
|
290
|
+
def _add() -> None:
|
|
291
|
+
files = self._safe_query("#files-container", FilesPanelWidget)
|
|
292
|
+
if files:
|
|
293
|
+
files.add_file(action, path)
|
|
294
|
+
|
|
295
|
+
self._safe_update(_add)
|
|
296
|
+
|
|
297
|
+
def show_message(
|
|
298
|
+
self,
|
|
299
|
+
message: str,
|
|
300
|
+
style: str = "info",
|
|
301
|
+
category: ActivityCategory = ActivityCategory.SYSTEM,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""
|
|
304
|
+
Show a styled message.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
message: Message to display.
|
|
308
|
+
style: Style name (info, success, error, warning).
|
|
309
|
+
category: Category for filtering.
|
|
310
|
+
"""
|
|
311
|
+
# Log errors and warnings to debug log
|
|
312
|
+
if style in ("error", "warning"):
|
|
313
|
+
debug_log(f"[TUI {style.upper()}]", content=message)
|
|
314
|
+
|
|
315
|
+
icons = {"info": "ℹ", "success": "✓", "error": "✗", "warning": "⚠"}
|
|
316
|
+
levels = {
|
|
317
|
+
"info": ActivityLevel.INFO,
|
|
318
|
+
"success": ActivityLevel.SUCCESS,
|
|
319
|
+
"error": ActivityLevel.ERROR,
|
|
320
|
+
"warning": ActivityLevel.WARNING,
|
|
321
|
+
}
|
|
322
|
+
icon = icons.get(style, "•")
|
|
323
|
+
level = levels.get(style, ActivityLevel.INFO)
|
|
324
|
+
self.add_activity(message, icon, level=level, category=category)
|
|
325
|
+
|
|
326
|
+
def show_stage_complete(self, stage: str, success: bool, duration: int | None = None) -> None:
|
|
327
|
+
"""Show stage completion with optional duration."""
|
|
328
|
+
if success:
|
|
329
|
+
if duration is not None:
|
|
330
|
+
# Format duration
|
|
331
|
+
if duration >= 3600:
|
|
332
|
+
hours, remainder = divmod(duration, 3600)
|
|
333
|
+
mins, secs = divmod(remainder, 60)
|
|
334
|
+
duration_str = f"{hours}:{mins:02d}:{secs:02d}"
|
|
335
|
+
else:
|
|
336
|
+
mins, secs = divmod(duration, 60)
|
|
337
|
+
duration_str = f"{mins}:{secs:02d}"
|
|
338
|
+
self.show_message(
|
|
339
|
+
f"Stage {stage} completed ({duration_str})",
|
|
340
|
+
"success",
|
|
341
|
+
ActivityCategory.STAGE,
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
self.show_message(f"Stage {stage} completed", "success", ActivityCategory.STAGE)
|
|
345
|
+
else:
|
|
346
|
+
self.show_message(f"Stage {stage} failed", "error", ActivityCategory.STAGE)
|
|
347
|
+
|
|
348
|
+
def update_stage_durations(self, durations: dict[str, int]) -> None:
|
|
349
|
+
"""Update stage durations display in progress widget."""
|
|
350
|
+
|
|
351
|
+
def _update() -> None:
|
|
352
|
+
progress = self._safe_query("#progress", StageProgressWidget)
|
|
353
|
+
if progress:
|
|
354
|
+
progress.stage_durations = durations
|
|
355
|
+
|
|
356
|
+
self._safe_update(_update)
|
|
357
|
+
|
|
358
|
+
def show_workflow_complete(self) -> None:
|
|
359
|
+
"""Show workflow completion banner."""
|
|
360
|
+
self.add_activity("")
|
|
361
|
+
self.add_activity("[bold #b8bb26]════════════════════════════════════════[/]", "")
|
|
362
|
+
self.add_activity("[bold #b8bb26] WORKFLOW COMPLETE [/]", "")
|
|
363
|
+
self.add_activity("[bold #b8bb26]════════════════════════════════════════[/]", "")
|
|
364
|
+
self.add_activity("")
|
|
365
|
+
|
|
366
|
+
def show_error(self, message: str, details: str | None = None) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Show error prominently in dedicated error panel.
|
|
369
|
+
|
|
370
|
+
The error panel appears below the progress bar and above the activity log,
|
|
371
|
+
making errors highly visible. Also logs the error to the activity log.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
message: Short error message (displayed in bold red).
|
|
375
|
+
details: Optional detailed error information (truncated if too long).
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def _update() -> None:
|
|
379
|
+
panel = self._safe_query("#error-panel", ErrorPanelWidget)
|
|
380
|
+
if panel:
|
|
381
|
+
panel.error = message
|
|
382
|
+
panel.details = details
|
|
383
|
+
panel.remove_class("hidden")
|
|
384
|
+
|
|
385
|
+
self._safe_update(_update)
|
|
386
|
+
|
|
387
|
+
# Also add to activity log
|
|
388
|
+
self.add_activity(
|
|
389
|
+
message,
|
|
390
|
+
"✗",
|
|
391
|
+
level=ActivityLevel.ERROR,
|
|
392
|
+
category=ActivityCategory.SYSTEM,
|
|
393
|
+
details=details,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def clear_error(self) -> None:
|
|
397
|
+
"""Clear the error panel display."""
|
|
398
|
+
|
|
399
|
+
def _update() -> None:
|
|
400
|
+
panel = self._safe_query("#error-panel", ErrorPanelWidget)
|
|
401
|
+
if panel:
|
|
402
|
+
panel.error = None
|
|
403
|
+
panel.details = None
|
|
404
|
+
panel.add_class("hidden")
|
|
405
|
+
|
|
406
|
+
self._safe_update(_update)
|
|
407
|
+
|
|
408
|
+
def show_prompt(
|
|
409
|
+
self, prompt_type: PromptType, message: str, callback: Callable[..., None]
|
|
410
|
+
) -> None:
|
|
411
|
+
"""
|
|
412
|
+
Show a modal prompt for user choice.
|
|
413
|
+
|
|
414
|
+
Displays a modal dialog with options based on the prompt type.
|
|
415
|
+
The callback is invoked with the user's selection when they
|
|
416
|
+
choose an option or press Escape (returns "quit").
|
|
417
|
+
|
|
418
|
+
This method is thread-safe and can be called from background threads.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
prompt_type: Type of prompt determining available options.
|
|
422
|
+
message: Message to display in the modal.
|
|
423
|
+
callback: Function called with the selected option string.
|
|
424
|
+
"""
|
|
425
|
+
self._prompt_type = prompt_type
|
|
426
|
+
self._prompt_callback = callback
|
|
427
|
+
|
|
428
|
+
options = get_prompt_options(prompt_type)
|
|
429
|
+
|
|
430
|
+
def _show() -> None:
|
|
431
|
+
def _handle(result: str | None) -> None:
|
|
432
|
+
self._active_prompt_screen = None
|
|
433
|
+
self._prompt_callback = None
|
|
434
|
+
self._prompt_type = PromptType.NONE
|
|
435
|
+
if result:
|
|
436
|
+
callback(result)
|
|
437
|
+
|
|
438
|
+
screen = PromptModal(message, options)
|
|
439
|
+
self._active_prompt_screen = screen
|
|
440
|
+
self.push_screen(screen, _handle)
|
|
441
|
+
|
|
442
|
+
self._safe_update(_show)
|
|
443
|
+
|
|
444
|
+
def hide_prompt(self) -> None:
|
|
445
|
+
"""Hide prompt."""
|
|
446
|
+
self._prompt_type = PromptType.NONE
|
|
447
|
+
self._prompt_callback = None
|
|
448
|
+
|
|
449
|
+
def _hide() -> None:
|
|
450
|
+
if self._active_prompt_screen:
|
|
451
|
+
self._active_prompt_screen.dismiss(None)
|
|
452
|
+
self._active_prompt_screen = None
|
|
453
|
+
|
|
454
|
+
self._safe_update(_hide)
|
|
455
|
+
|
|
456
|
+
def show_text_input(self, label: str, default: str, callback: Callable[..., None]) -> None:
|
|
457
|
+
"""
|
|
458
|
+
Show a single-line text input modal.
|
|
459
|
+
|
|
460
|
+
Displays a modal with an input field. User submits with Enter,
|
|
461
|
+
cancels with Escape. Callback receives the text or None if cancelled.
|
|
462
|
+
|
|
463
|
+
This method is thread-safe and can be called from background threads.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
label: Prompt label displayed above the input field.
|
|
467
|
+
default: Default value pre-filled in the input.
|
|
468
|
+
callback: Function called with input text or None if cancelled.
|
|
469
|
+
"""
|
|
470
|
+
self._input_callback = callback
|
|
471
|
+
|
|
472
|
+
def _show() -> None:
|
|
473
|
+
def _handle(result: str | None) -> None:
|
|
474
|
+
self._active_input_screen = None
|
|
475
|
+
self._input_callback = None
|
|
476
|
+
callback(result if result else None)
|
|
477
|
+
|
|
478
|
+
screen = TextInputModal(label, default)
|
|
479
|
+
self._active_input_screen = screen
|
|
480
|
+
self.push_screen(screen, _handle)
|
|
481
|
+
|
|
482
|
+
self._safe_update(_show)
|
|
483
|
+
|
|
484
|
+
def hide_text_input(self) -> None:
|
|
485
|
+
"""Reset text input prompt."""
|
|
486
|
+
self._input_callback = None
|
|
487
|
+
|
|
488
|
+
def _hide() -> None:
|
|
489
|
+
if self._active_input_screen:
|
|
490
|
+
self._active_input_screen.dismiss(None)
|
|
491
|
+
self._active_input_screen = None
|
|
492
|
+
|
|
493
|
+
self._safe_update(_hide)
|
|
494
|
+
|
|
495
|
+
# -------------------------------------------------------------------------
|
|
496
|
+
# Async prompt methods (simplified threading model)
|
|
497
|
+
# -------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
async def prompt_async(self, prompt_type: PromptType, message: str) -> str:
|
|
500
|
+
"""
|
|
501
|
+
Show a modal prompt and await the result.
|
|
502
|
+
|
|
503
|
+
This is the async version of show_prompt() that eliminates the need
|
|
504
|
+
for callbacks and threading.Event coordination. Use this from async
|
|
505
|
+
workflow code instead of the callback-based version.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
prompt_type: Type of prompt determining available options.
|
|
509
|
+
message: Message to display in the modal.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
The selected option string (e.g., "yes", "no", "quit").
|
|
513
|
+
"""
|
|
514
|
+
future: asyncio.Future[str] = asyncio.Future()
|
|
515
|
+
|
|
516
|
+
def callback(result: str) -> None:
|
|
517
|
+
if not future.done():
|
|
518
|
+
# Callback runs in main thread, so set result directly
|
|
519
|
+
future.set_result(result)
|
|
520
|
+
|
|
521
|
+
self.show_prompt(prompt_type, message, callback)
|
|
522
|
+
return await future
|
|
523
|
+
|
|
524
|
+
async def text_input_async(self, label: str, default: str = "") -> str | None:
|
|
525
|
+
"""
|
|
526
|
+
Show a text input modal and await the result.
|
|
527
|
+
|
|
528
|
+
This is the async version of show_text_input() that eliminates the need
|
|
529
|
+
for callbacks and threading.Event coordination.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
label: Prompt label displayed above the input field.
|
|
533
|
+
default: Default value pre-filled in the input.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
The entered text, or None if cancelled.
|
|
537
|
+
"""
|
|
538
|
+
future: asyncio.Future[str | None] = asyncio.Future()
|
|
539
|
+
|
|
540
|
+
def callback(result: str | None) -> None:
|
|
541
|
+
if not future.done():
|
|
542
|
+
# Callback runs in main thread, so set result directly
|
|
543
|
+
future.set_result(result)
|
|
544
|
+
|
|
545
|
+
self.show_text_input(label, default, callback)
|
|
546
|
+
return await future
|
|
547
|
+
|
|
548
|
+
async def multiline_input_async(self, label: str, default: str = "") -> str | None:
|
|
549
|
+
"""
|
|
550
|
+
Show a multiline input modal and await the result.
|
|
551
|
+
|
|
552
|
+
This is the async version of show_multiline_input() that eliminates
|
|
553
|
+
the need for callbacks and threading.Event coordination.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
label: Prompt label displayed above the text area.
|
|
557
|
+
default: Default value pre-filled in the text area.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
The entered text, or None if cancelled.
|
|
561
|
+
"""
|
|
562
|
+
future: asyncio.Future[str | None] = asyncio.Future()
|
|
563
|
+
|
|
564
|
+
def callback(result: str | None) -> None:
|
|
565
|
+
if not future.done():
|
|
566
|
+
# Callback runs in main thread, so set result directly
|
|
567
|
+
future.set_result(result)
|
|
568
|
+
|
|
569
|
+
self.show_multiline_input(label, default, callback)
|
|
570
|
+
return await future
|
|
571
|
+
|
|
572
|
+
# -------------------------------------------------------------------------
|
|
573
|
+
# Discovery Q&A async methods
|
|
574
|
+
# -------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
async def question_answer_session_async(self, questions: list[str]) -> list[str] | None:
|
|
577
|
+
"""
|
|
578
|
+
Show a Q&A modal and await all answers.
|
|
579
|
+
|
|
580
|
+
Displays all questions and collects answers one at a time.
|
|
581
|
+
User answers each question sequentially.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
questions: List of questions to ask.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
List of answers (same length as questions), or None if cancelled.
|
|
588
|
+
"""
|
|
589
|
+
future: asyncio.Future[list[str] | None] = asyncio.Future()
|
|
590
|
+
|
|
591
|
+
def _show() -> None:
|
|
592
|
+
def _handle(result: list[str] | None) -> None:
|
|
593
|
+
if not future.done():
|
|
594
|
+
future.set_result(result)
|
|
595
|
+
|
|
596
|
+
screen = QuestionAnswerModal(questions)
|
|
597
|
+
self.push_screen(screen, _handle)
|
|
598
|
+
|
|
599
|
+
self._safe_update(_show)
|
|
600
|
+
return await future
|
|
601
|
+
|
|
602
|
+
async def ask_yes_no_async(self, prompt: str) -> bool:
|
|
603
|
+
"""
|
|
604
|
+
Show a simple yes/no prompt and await the result.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
prompt: Question to ask.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
True if user selected yes, False otherwise.
|
|
611
|
+
"""
|
|
612
|
+
result = await self.prompt_async(PromptType.YES_NO, prompt)
|
|
613
|
+
return result == "yes"
|
|
614
|
+
|
|
615
|
+
async def get_user_questions_async(self) -> list[str] | None:
|
|
616
|
+
"""
|
|
617
|
+
Show a modal for user to enter their own questions.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
List of questions (one per line), or None if cancelled/empty.
|
|
621
|
+
"""
|
|
622
|
+
future: asyncio.Future[list[str] | None] = asyncio.Future()
|
|
623
|
+
|
|
624
|
+
def _show() -> None:
|
|
625
|
+
def _handle(result: list[str] | None) -> None:
|
|
626
|
+
if not future.done():
|
|
627
|
+
future.set_result(result)
|
|
628
|
+
|
|
629
|
+
screen = UserQuestionsModal()
|
|
630
|
+
self.push_screen(screen, _handle)
|
|
631
|
+
|
|
632
|
+
self._safe_update(_show)
|
|
633
|
+
return await future
|
|
634
|
+
|
|
635
|
+
async def select_github_issue_async(self, issues: list[tuple[int, str]]) -> int | None:
|
|
636
|
+
"""
|
|
637
|
+
Show a modal for selecting a GitHub issue.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
issues: List of (issue_number, title) tuples.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Selected issue number, or None if cancelled.
|
|
644
|
+
"""
|
|
645
|
+
future: asyncio.Future[int | None] = asyncio.Future()
|
|
646
|
+
|
|
647
|
+
def _show() -> None:
|
|
648
|
+
def _handle(result: int | None) -> None:
|
|
649
|
+
if not future.done():
|
|
650
|
+
future.set_result(result)
|
|
651
|
+
|
|
652
|
+
options = [GitHubIssueOption(num, title) for num, title in issues]
|
|
653
|
+
screen = GitHubIssueSelectModal(options)
|
|
654
|
+
self.push_screen(screen, _handle)
|
|
655
|
+
|
|
656
|
+
self._safe_update(_show)
|
|
657
|
+
return await future
|
|
658
|
+
|
|
659
|
+
def show_multiline_input(self, label: str, default: str, callback: Callable[..., None]) -> None:
|
|
660
|
+
"""
|
|
661
|
+
Show a multi-line text input modal.
|
|
662
|
+
|
|
663
|
+
Displays a modal with a TextArea for multi-line input (task descriptions,
|
|
664
|
+
feedback, rejection reasons). User submits with Ctrl+S, cancels with Escape.
|
|
665
|
+
Callback receives the text or None if cancelled.
|
|
666
|
+
|
|
667
|
+
This method is thread-safe and can be called from background threads.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
label: Prompt label displayed above the text area.
|
|
671
|
+
default: Default value pre-filled in the text area.
|
|
672
|
+
callback: Function called with input text or None if cancelled.
|
|
673
|
+
"""
|
|
674
|
+
self._input_callback = callback
|
|
675
|
+
|
|
676
|
+
def _show() -> None:
|
|
677
|
+
def _handle(result: str | None) -> None:
|
|
678
|
+
self._active_input_screen = None
|
|
679
|
+
self._input_callback = None
|
|
680
|
+
callback(result if result else None)
|
|
681
|
+
|
|
682
|
+
screen = MultilineInputModal(label, default)
|
|
683
|
+
self._active_input_screen = screen
|
|
684
|
+
self.push_screen(screen, _handle)
|
|
685
|
+
|
|
686
|
+
self._safe_update(_show)
|
|
687
|
+
|
|
688
|
+
def show_github_issue_select(self, issues: list[tuple[int, str]], callback: Callable) -> None:
|
|
689
|
+
"""
|
|
690
|
+
Show a modal for selecting a GitHub issue.
|
|
691
|
+
|
|
692
|
+
This method is thread-safe and can be called from background threads.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
issues: List of (issue_number, title) tuples.
|
|
696
|
+
callback: Function called with selected issue number or None if cancelled.
|
|
697
|
+
"""
|
|
698
|
+
|
|
699
|
+
def _show() -> None:
|
|
700
|
+
def _handle(result: int | None) -> None:
|
|
701
|
+
callback(result)
|
|
702
|
+
|
|
703
|
+
options = [GitHubIssueOption(num, title) for num, title in issues]
|
|
704
|
+
screen = GitHubIssueSelectModal(options)
|
|
705
|
+
self.push_screen(screen, _handle)
|
|
706
|
+
|
|
707
|
+
self._safe_update(_show)
|
|
708
|
+
|
|
709
|
+
# -------------------------------------------------------------------------
|
|
710
|
+
# Actions
|
|
711
|
+
# -------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
def _text_input_active(self) -> bool:
|
|
714
|
+
"""Check if text input is currently active and should capture keys."""
|
|
715
|
+
return self._input_callback is not None or self._active_input_screen is not None
|
|
716
|
+
|
|
717
|
+
def check_action_quit_workflow(self) -> bool:
|
|
718
|
+
return not self._text_input_active()
|
|
719
|
+
|
|
720
|
+
def check_action_interrupt_feedback(self) -> bool:
|
|
721
|
+
return not self._text_input_active()
|
|
722
|
+
|
|
723
|
+
def check_action_skip_stage(self) -> bool:
|
|
724
|
+
return not self._text_input_active()
|
|
725
|
+
|
|
726
|
+
def check_action_back_stage(self) -> bool:
|
|
727
|
+
return not self._text_input_active()
|
|
728
|
+
|
|
729
|
+
def check_action_manual_edit(self) -> bool:
|
|
730
|
+
return not self._text_input_active()
|
|
731
|
+
|
|
732
|
+
def check_action_toggle_verbose(self) -> bool:
|
|
733
|
+
return not self._text_input_active()
|
|
734
|
+
|
|
735
|
+
def action_quit_workflow(self) -> None:
|
|
736
|
+
if self._active_prompt_screen:
|
|
737
|
+
self._active_prompt_screen.dismiss("quit")
|
|
738
|
+
return
|
|
739
|
+
if self._prompt_callback:
|
|
740
|
+
callback = self._prompt_callback
|
|
741
|
+
self.hide_prompt()
|
|
742
|
+
callback("quit")
|
|
743
|
+
return
|
|
744
|
+
self._paused = True
|
|
745
|
+
self._workflow_result = "paused"
|
|
746
|
+
self.exit()
|
|
747
|
+
|
|
748
|
+
def action_interrupt_feedback(self) -> None:
|
|
749
|
+
"""Interrupt current stage and request rollback to DEV with feedback."""
|
|
750
|
+
if self._active_prompt_screen or self._prompt_callback:
|
|
751
|
+
# Don't interrupt during prompts
|
|
752
|
+
return
|
|
753
|
+
self._interrupt_requested = True
|
|
754
|
+
self._paused = True # Stop Claude execution
|
|
755
|
+
|
|
756
|
+
def action_skip_stage(self) -> None:
|
|
757
|
+
"""Skip the current stage and advance to the next one."""
|
|
758
|
+
if self._active_prompt_screen or self._prompt_callback:
|
|
759
|
+
return
|
|
760
|
+
self._skip_stage_requested = True
|
|
761
|
+
self._paused = True
|
|
762
|
+
|
|
763
|
+
def action_back_stage(self) -> None:
|
|
764
|
+
"""Go back to the previous stage."""
|
|
765
|
+
if self._active_prompt_screen or self._prompt_callback:
|
|
766
|
+
return
|
|
767
|
+
self._back_stage_requested = True
|
|
768
|
+
self._paused = True
|
|
769
|
+
|
|
770
|
+
def action_manual_edit(self) -> None:
|
|
771
|
+
"""Pause workflow for manual editing, then resume."""
|
|
772
|
+
if self._active_prompt_screen or self._prompt_callback:
|
|
773
|
+
return
|
|
774
|
+
self._manual_edit_requested = True
|
|
775
|
+
self._paused = True
|
|
776
|
+
|
|
777
|
+
def add_raw_line(self, line: str) -> None:
|
|
778
|
+
"""Store raw line and display if in verbose mode."""
|
|
779
|
+
# Store for replay (keep last 500 lines)
|
|
780
|
+
self._raw_lines.append(line)
|
|
781
|
+
if len(self._raw_lines) > 500:
|
|
782
|
+
self._raw_lines = self._raw_lines[-500:]
|
|
783
|
+
|
|
784
|
+
def _add() -> None:
|
|
785
|
+
if self.verbose:
|
|
786
|
+
log = self._safe_query("#activity-log", RichLog)
|
|
787
|
+
if log:
|
|
788
|
+
display = line.strip()[:150] # Truncate to 150 chars
|
|
789
|
+
log.write(f"[#7c6f64]{display}[/]")
|
|
790
|
+
|
|
791
|
+
self._safe_update(_add)
|
|
792
|
+
|
|
793
|
+
def action_toggle_verbose(self) -> None:
|
|
794
|
+
self.verbose = not self.verbose
|
|
795
|
+
log = self.query_one("#activity-log", RichLog)
|
|
796
|
+
log.clear()
|
|
797
|
+
|
|
798
|
+
if self.verbose:
|
|
799
|
+
log.write("[#83a598]Switched to VERBOSE mode - showing raw JSON[/]")
|
|
800
|
+
# Replay last 30 raw lines
|
|
801
|
+
for line in self._raw_lines[-30:]:
|
|
802
|
+
display = line.strip()[:150]
|
|
803
|
+
log.write(f"[#7c6f64]{display}[/]")
|
|
804
|
+
else:
|
|
805
|
+
log.write("[#b8bb26]Switched to COMPACT mode[/]")
|
|
806
|
+
# Replay recent activity entries
|
|
807
|
+
for entry in self._activity_entries[-30:]:
|
|
808
|
+
log.write(entry.format_display())
|
|
809
|
+
|
|
810
|
+
def action_toggle_files(self) -> None:
|
|
811
|
+
self._files_visible = not self._files_visible
|
|
812
|
+
files = self.query_one("#files-container", FilesPanelWidget)
|
|
813
|
+
activity = self.query_one("#activity-container", VerticalScroll)
|
|
814
|
+
|
|
815
|
+
if self._files_visible:
|
|
816
|
+
files.display = True
|
|
817
|
+
files.styles.width = "25%"
|
|
818
|
+
activity.styles.width = "75%"
|
|
819
|
+
else:
|
|
820
|
+
files.display = False
|
|
821
|
+
activity.styles.width = "100%"
|
|
822
|
+
|
|
823
|
+
# -------------------------------------------------------------------------
|
|
824
|
+
# Activity log access
|
|
825
|
+
# -------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
@property
|
|
828
|
+
def activity_entries(self) -> list[ActivityEntry]:
|
|
829
|
+
"""Get all activity entries for filtering or export."""
|
|
830
|
+
return self._activity_entries.copy()
|
|
831
|
+
|
|
832
|
+
def export_activity_log(self, path: str | Path) -> None:
|
|
833
|
+
"""
|
|
834
|
+
Export activity log to a file.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
path: File path to write the log to.
|
|
838
|
+
"""
|
|
839
|
+
export_activity_log(self._activity_entries, Path(path))
|
|
840
|
+
|
|
841
|
+
def get_entries_by_level(self, level: ActivityLevel) -> list[ActivityEntry]:
|
|
842
|
+
"""Filter entries by severity level."""
|
|
843
|
+
return [e for e in self._activity_entries if e.level == level]
|
|
844
|
+
|
|
845
|
+
def get_entries_by_category(self, category: ActivityCategory) -> list[ActivityEntry]:
|
|
846
|
+
"""Filter entries by category."""
|
|
847
|
+
return [e for e in self._activity_entries if e.category == category]
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
class StageTUIApp(WorkflowTUIApp):
|
|
851
|
+
"""
|
|
852
|
+
Single-stage TUI application for `galangal run` command.
|
|
853
|
+
|
|
854
|
+
A simplified version of WorkflowTUIApp that executes a single stage
|
|
855
|
+
and exits. Used for manual stage re-runs outside the normal workflow.
|
|
856
|
+
|
|
857
|
+
The stage execution happens in a background thread, with the TUI
|
|
858
|
+
displaying progress until completion.
|
|
859
|
+
"""
|
|
860
|
+
|
|
861
|
+
def __init__(
|
|
862
|
+
self,
|
|
863
|
+
task_name: str,
|
|
864
|
+
stage: str,
|
|
865
|
+
branch: str,
|
|
866
|
+
attempt: int,
|
|
867
|
+
prompt: str,
|
|
868
|
+
):
|
|
869
|
+
super().__init__(task_name, stage)
|
|
870
|
+
self.branch = branch
|
|
871
|
+
self._attempt = attempt
|
|
872
|
+
self.prompt = prompt
|
|
873
|
+
self.result: tuple[bool, str] = (False, "")
|
|
874
|
+
|
|
875
|
+
def on_mount(self) -> None:
|
|
876
|
+
super().on_mount()
|
|
877
|
+
self._worker_thread = threading.Thread(target=self._execute_stage, daemon=True)
|
|
878
|
+
self._worker_thread.start()
|
|
879
|
+
|
|
880
|
+
def _execute_stage(self) -> None:
|
|
881
|
+
from galangal.ai import get_backend_with_fallback
|
|
882
|
+
from galangal.config.loader import get_config
|
|
883
|
+
|
|
884
|
+
config = get_config()
|
|
885
|
+
backend = get_backend_with_fallback(config.ai.default, config=config)
|
|
886
|
+
ui = TUIAdapter(self)
|
|
887
|
+
max_turns = backend.config.max_turns if backend.config else 200
|
|
888
|
+
|
|
889
|
+
self.result = backend.invoke(
|
|
890
|
+
prompt=self.prompt,
|
|
891
|
+
timeout=14400,
|
|
892
|
+
max_turns=max_turns,
|
|
893
|
+
ui=ui,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
success, _ = self.result
|
|
897
|
+
if success:
|
|
898
|
+
self.call_from_thread(self.add_activity, "[#b8bb26]Stage completed[/]", "✓")
|
|
899
|
+
else:
|
|
900
|
+
self.call_from_thread(self.add_activity, "[#fb4934]Stage failed[/]", "✗")
|
|
901
|
+
|
|
902
|
+
self.call_from_thread(self.set_timer, 1.5, self.exit)
|