galangal-orchestrate 0.2.11__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.
Potentially problematic release.
This version of galangal-orchestrate might be problematic. Click here for more details.
- galangal/__init__.py +8 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +6 -0
- galangal/ai/base.py +55 -0
- galangal/ai/claude.py +278 -0
- galangal/ai/gemini.py +38 -0
- galangal/cli.py +296 -0
- galangal/commands/__init__.py +42 -0
- galangal/commands/approve.py +187 -0
- galangal/commands/complete.py +268 -0
- galangal/commands/init.py +173 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +40 -0
- galangal/commands/prompts.py +98 -0
- galangal/commands/reset.py +43 -0
- galangal/commands/resume.py +29 -0
- galangal/commands/skip.py +216 -0
- galangal/commands/start.py +144 -0
- galangal/commands/status.py +62 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +13 -0
- galangal/config/defaults.py +133 -0
- galangal/config/loader.py +113 -0
- galangal/config/schema.py +155 -0
- galangal/core/__init__.py +18 -0
- galangal/core/artifacts.py +66 -0
- galangal/core/state.py +248 -0
- galangal/core/tasks.py +170 -0
- galangal/core/workflow.py +835 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +166 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +39 -0
- galangal/prompts/defaults/docs.md +46 -0
- galangal/prompts/defaults/pm.md +75 -0
- galangal/prompts/defaults/qa.md +49 -0
- galangal/prompts/defaults/review.md +65 -0
- galangal/prompts/defaults/security.md +68 -0
- galangal/prompts/defaults/test.md +59 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +123 -0
- galangal/ui/tui.py +1065 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +395 -0
- galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
- galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
- galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.2.11.dist-info/licenses/LICENSE +674 -0
galangal/ui/tui.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Textual TUI for workflow execution display.
|
|
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 threading
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import Optional, Callable
|
|
27
|
+
from enum import Enum
|
|
28
|
+
|
|
29
|
+
from rich.text import Text
|
|
30
|
+
from textual.app import App, ComposeResult
|
|
31
|
+
from textual.widgets import Footer, Static, RichLog, Input
|
|
32
|
+
from textual.binding import Binding
|
|
33
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
|
|
34
|
+
from textual.reactive import reactive
|
|
35
|
+
from textual.screen import ModalScreen
|
|
36
|
+
|
|
37
|
+
from galangal.ai.claude import ClaudeBackend
|
|
38
|
+
from galangal.core.state import Stage, STAGE_ORDER
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PromptType(Enum):
|
|
42
|
+
"""Types of prompts the TUI can show."""
|
|
43
|
+
NONE = "none"
|
|
44
|
+
PLAN_APPROVAL = "plan_approval"
|
|
45
|
+
DESIGN_APPROVAL = "design_approval"
|
|
46
|
+
COMPLETION = "completion"
|
|
47
|
+
TEXT_INPUT = "text_input"
|
|
48
|
+
PREFLIGHT_RETRY = "preflight_retry"
|
|
49
|
+
STAGE_FAILURE = "stage_failure"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StageUI:
|
|
53
|
+
"""Interface for stage execution UI updates."""
|
|
54
|
+
|
|
55
|
+
def set_status(self, status: str, detail: str = "") -> None:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def add_activity(self, activity: str, icon: str = "•") -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def add_raw_line(self, line: str) -> None:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def set_turns(self, turns: int) -> None:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def finish(self, success: bool) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# Custom Widgets
|
|
73
|
+
# =============================================================================
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HeaderWidget(Static):
|
|
77
|
+
"""Fixed header showing task info."""
|
|
78
|
+
|
|
79
|
+
task_name: reactive[str] = reactive("")
|
|
80
|
+
stage: reactive[str] = reactive("")
|
|
81
|
+
attempt: reactive[int] = reactive(1)
|
|
82
|
+
max_retries: reactive[int] = reactive(5)
|
|
83
|
+
elapsed: reactive[str] = reactive("0:00")
|
|
84
|
+
turns: reactive[int] = reactive(0)
|
|
85
|
+
status: reactive[str] = reactive("starting")
|
|
86
|
+
|
|
87
|
+
def render(self) -> Text:
|
|
88
|
+
text = Text()
|
|
89
|
+
|
|
90
|
+
# Row 1: Task, Stage, Attempt
|
|
91
|
+
text.append("Task: ", style="#928374")
|
|
92
|
+
text.append(self.task_name[:30], style="bold #83a598")
|
|
93
|
+
text.append(" Stage: ", style="#928374")
|
|
94
|
+
text.append(f"{self.stage}", style="bold #fabd2f")
|
|
95
|
+
text.append(f" ({self.attempt}/{self.max_retries})", style="#928374")
|
|
96
|
+
text.append(" Elapsed: ", style="#928374")
|
|
97
|
+
text.append(self.elapsed, style="bold #ebdbb2")
|
|
98
|
+
text.append(" Turns: ", style="#928374")
|
|
99
|
+
text.append(str(self.turns), style="bold #b8bb26")
|
|
100
|
+
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StageProgressWidget(Static):
|
|
105
|
+
"""Centered stage progress bar with full names."""
|
|
106
|
+
|
|
107
|
+
current_stage: reactive[str] = reactive("PM")
|
|
108
|
+
skipped_stages: reactive[frozenset] = reactive(frozenset())
|
|
109
|
+
hidden_stages: reactive[frozenset] = reactive(frozenset())
|
|
110
|
+
|
|
111
|
+
# Full stage display names
|
|
112
|
+
STAGE_DISPLAY = {
|
|
113
|
+
"PM": "PM",
|
|
114
|
+
"DESIGN": "DESIGN",
|
|
115
|
+
"PREFLIGHT": "PREFLIGHT",
|
|
116
|
+
"DEV": "DEV",
|
|
117
|
+
"MIGRATION": "MIGRATION",
|
|
118
|
+
"TEST": "TEST",
|
|
119
|
+
"CONTRACT": "CONTRACT",
|
|
120
|
+
"QA": "QA",
|
|
121
|
+
"BENCHMARK": "BENCHMARK",
|
|
122
|
+
"SECURITY": "SECURITY",
|
|
123
|
+
"REVIEW": "REVIEW",
|
|
124
|
+
"DOCS": "DOCS",
|
|
125
|
+
"COMPLETE": "COMPLETE",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
STAGE_COMPACT = {
|
|
129
|
+
"PM": "PM",
|
|
130
|
+
"DESIGN": "DSGN",
|
|
131
|
+
"PREFLIGHT": "PREF",
|
|
132
|
+
"DEV": "DEV",
|
|
133
|
+
"MIGRATION": "MIGR",
|
|
134
|
+
"TEST": "TEST",
|
|
135
|
+
"CONTRACT": "CNTR",
|
|
136
|
+
"QA": "QA",
|
|
137
|
+
"BENCHMARK": "BENCH",
|
|
138
|
+
"SECURITY": "SEC",
|
|
139
|
+
"REVIEW": "RVW",
|
|
140
|
+
"DOCS": "DOCS",
|
|
141
|
+
"COMPLETE": "DONE",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def render(self) -> Text:
|
|
145
|
+
text = Text(justify="center")
|
|
146
|
+
|
|
147
|
+
# Filter out hidden stages (task type + config skips)
|
|
148
|
+
visible_stages = [s for s in STAGE_ORDER if s.value not in self.hidden_stages]
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
current_idx = next(
|
|
152
|
+
i for i, s in enumerate(visible_stages)
|
|
153
|
+
if s.value == self.current_stage
|
|
154
|
+
)
|
|
155
|
+
except StopIteration:
|
|
156
|
+
current_idx = 0
|
|
157
|
+
|
|
158
|
+
width = self.size.width or 0
|
|
159
|
+
use_window = width and width < 70
|
|
160
|
+
use_compact = width and width < 110
|
|
161
|
+
display_names = self.STAGE_COMPACT if use_compact else self.STAGE_DISPLAY
|
|
162
|
+
|
|
163
|
+
stages = visible_stages
|
|
164
|
+
if use_window:
|
|
165
|
+
start = max(current_idx - 2, 0)
|
|
166
|
+
end = min(current_idx + 3, len(stages))
|
|
167
|
+
items: list[Optional[int]] = []
|
|
168
|
+
if start > 0:
|
|
169
|
+
items.append(None)
|
|
170
|
+
items.extend(range(start, end))
|
|
171
|
+
if end < len(stages):
|
|
172
|
+
items.append(None)
|
|
173
|
+
else:
|
|
174
|
+
items = list(range(len(stages)))
|
|
175
|
+
|
|
176
|
+
for idx, stage_idx in enumerate(items):
|
|
177
|
+
if idx > 0:
|
|
178
|
+
text.append(" ━ ", style="#504945")
|
|
179
|
+
if stage_idx is None:
|
|
180
|
+
text.append("...", style="#504945")
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
stage = stages[stage_idx]
|
|
184
|
+
name = display_names.get(stage.value, stage.value)
|
|
185
|
+
|
|
186
|
+
if stage.value in self.skipped_stages:
|
|
187
|
+
text.append(f"⊘ {name}", style="#504945 strike")
|
|
188
|
+
elif stage_idx < current_idx:
|
|
189
|
+
text.append(f"● {name}", style="#b8bb26")
|
|
190
|
+
elif stage_idx == current_idx:
|
|
191
|
+
text.append(f"◉ {name}", style="bold #fabd2f")
|
|
192
|
+
else:
|
|
193
|
+
text.append(f"○ {name}", style="#504945")
|
|
194
|
+
|
|
195
|
+
return text
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class CurrentActionWidget(Static):
|
|
199
|
+
"""Shows the current action with animated spinner."""
|
|
200
|
+
|
|
201
|
+
action: reactive[str] = reactive("")
|
|
202
|
+
detail: reactive[str] = reactive("")
|
|
203
|
+
spinner_frame: reactive[int] = reactive(0)
|
|
204
|
+
|
|
205
|
+
SPINNERS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
206
|
+
|
|
207
|
+
def render(self) -> Text:
|
|
208
|
+
text = Text()
|
|
209
|
+
if self.action:
|
|
210
|
+
spinner = self.SPINNERS[self.spinner_frame % len(self.SPINNERS)]
|
|
211
|
+
text.append(f"{spinner} ", style="#83a598")
|
|
212
|
+
text.append(self.action, style="bold #ebdbb2")
|
|
213
|
+
if self.detail:
|
|
214
|
+
detail = self.detail
|
|
215
|
+
width = self.size.width or 0
|
|
216
|
+
if width:
|
|
217
|
+
reserved = len(self.action) + 4
|
|
218
|
+
max_detail = max(width - reserved, 0)
|
|
219
|
+
if max_detail and len(detail) > max_detail:
|
|
220
|
+
if max_detail > 3:
|
|
221
|
+
detail = detail[: max_detail - 3] + "..."
|
|
222
|
+
else:
|
|
223
|
+
detail = ""
|
|
224
|
+
if not detail:
|
|
225
|
+
return text
|
|
226
|
+
text.append(f": {detail}", style="#928374")
|
|
227
|
+
else:
|
|
228
|
+
text.append("○ Idle", style="#504945")
|
|
229
|
+
return text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class FilesPanelWidget(Static):
|
|
233
|
+
"""Panel showing files that have been read/written."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, **kwargs):
|
|
236
|
+
super().__init__(**kwargs)
|
|
237
|
+
self._files: list[tuple[str, str]] = []
|
|
238
|
+
|
|
239
|
+
def add_file(self, action: str, path: str) -> None:
|
|
240
|
+
"""Add a file operation."""
|
|
241
|
+
entry = (action, path)
|
|
242
|
+
if entry not in self._files:
|
|
243
|
+
self._files.append(entry)
|
|
244
|
+
self.refresh()
|
|
245
|
+
|
|
246
|
+
def render(self) -> Text:
|
|
247
|
+
width = self.size.width or 24
|
|
248
|
+
divider_width = max(width - 1, 1)
|
|
249
|
+
text = Text()
|
|
250
|
+
text.append("Files\n", style="bold #928374")
|
|
251
|
+
text.append("─" * divider_width + "\n", style="#504945")
|
|
252
|
+
|
|
253
|
+
if not self._files:
|
|
254
|
+
text.append("(none yet)", style="#504945 italic")
|
|
255
|
+
else:
|
|
256
|
+
# Show last 20 files
|
|
257
|
+
for action, path in self._files[-20:]:
|
|
258
|
+
display_path = path
|
|
259
|
+
if "/" in display_path:
|
|
260
|
+
parts = display_path.split("/")
|
|
261
|
+
display_path = "/".join(parts[-2:])
|
|
262
|
+
max_len = max(width - 4, 1)
|
|
263
|
+
if len(display_path) > max_len:
|
|
264
|
+
if max_len > 3:
|
|
265
|
+
display_path = display_path[: max_len - 3] + "..."
|
|
266
|
+
else:
|
|
267
|
+
display_path = display_path[:max_len]
|
|
268
|
+
icon = "✏️" if action == "write" else "📖"
|
|
269
|
+
color = "#b8bb26" if action == "write" else "#83a598"
|
|
270
|
+
text.append(f"{icon} ", style=color)
|
|
271
|
+
text.append(f"{display_path}\n", style="#ebdbb2")
|
|
272
|
+
|
|
273
|
+
return text
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# =============================================================================
|
|
277
|
+
# Prompt Modal
|
|
278
|
+
# =============================================================================
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True)
|
|
282
|
+
class PromptOption:
|
|
283
|
+
key: str
|
|
284
|
+
label: str
|
|
285
|
+
result: str
|
|
286
|
+
color: str
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class PromptModal(ModalScreen):
|
|
290
|
+
"""Modal prompt for multi-choice selections."""
|
|
291
|
+
|
|
292
|
+
CSS = """
|
|
293
|
+
PromptModal {
|
|
294
|
+
align: center middle;
|
|
295
|
+
layout: vertical;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#prompt-dialog {
|
|
299
|
+
width: 70%;
|
|
300
|
+
max-width: 80;
|
|
301
|
+
min-width: 40;
|
|
302
|
+
background: #3c3836;
|
|
303
|
+
border: round #504945;
|
|
304
|
+
padding: 1 2;
|
|
305
|
+
layout: vertical;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#prompt-message {
|
|
309
|
+
color: #ebdbb2;
|
|
310
|
+
text-style: bold;
|
|
311
|
+
margin-bottom: 1;
|
|
312
|
+
text-wrap: wrap;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#prompt-options {
|
|
316
|
+
color: #ebdbb2;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#prompt-hint {
|
|
320
|
+
color: #7c6f64;
|
|
321
|
+
margin-top: 1;
|
|
322
|
+
}
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
BINDINGS = [
|
|
326
|
+
Binding("1", "choose_1", show=False),
|
|
327
|
+
Binding("2", "choose_2", show=False),
|
|
328
|
+
Binding("3", "choose_3", show=False),
|
|
329
|
+
Binding("y", "choose_yes", show=False),
|
|
330
|
+
Binding("n", "choose_no", show=False),
|
|
331
|
+
Binding("q", "choose_quit", show=False),
|
|
332
|
+
Binding("escape", "choose_quit", show=False),
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
def __init__(self, message: str, options: list[PromptOption]):
|
|
336
|
+
super().__init__()
|
|
337
|
+
self._message = message
|
|
338
|
+
self._options = options
|
|
339
|
+
self._key_map = {option.key: option.result for option in options}
|
|
340
|
+
|
|
341
|
+
def compose(self) -> ComposeResult:
|
|
342
|
+
options_text = "\n".join(
|
|
343
|
+
f"[{option.color}]{option.key}[/] {option.label}" for option in self._options
|
|
344
|
+
)
|
|
345
|
+
with Vertical(id="prompt-dialog"):
|
|
346
|
+
yield Static(self._message, id="prompt-message")
|
|
347
|
+
yield Static(Text.from_markup(options_text), id="prompt-options")
|
|
348
|
+
yield Static("Press 1-3 to choose, Esc to cancel", id="prompt-hint")
|
|
349
|
+
|
|
350
|
+
def _submit_key(self, key: str) -> None:
|
|
351
|
+
result = self._key_map.get(key)
|
|
352
|
+
if result:
|
|
353
|
+
self.dismiss(result)
|
|
354
|
+
|
|
355
|
+
def action_choose_1(self) -> None:
|
|
356
|
+
self._submit_key("1")
|
|
357
|
+
|
|
358
|
+
def action_choose_2(self) -> None:
|
|
359
|
+
self._submit_key("2")
|
|
360
|
+
|
|
361
|
+
def action_choose_3(self) -> None:
|
|
362
|
+
self._submit_key("3")
|
|
363
|
+
|
|
364
|
+
def action_choose_yes(self) -> None:
|
|
365
|
+
self.dismiss("yes")
|
|
366
|
+
|
|
367
|
+
def action_choose_no(self) -> None:
|
|
368
|
+
self.dismiss("no")
|
|
369
|
+
|
|
370
|
+
def action_choose_quit(self) -> None:
|
|
371
|
+
self.dismiss("quit")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# =============================================================================
|
|
375
|
+
# Text Input Modal
|
|
376
|
+
# =============================================================================
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class TextInputModal(ModalScreen):
|
|
380
|
+
"""Modal for collecting short text input."""
|
|
381
|
+
|
|
382
|
+
CSS = """
|
|
383
|
+
TextInputModal {
|
|
384
|
+
align: center middle;
|
|
385
|
+
layout: vertical;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#text-input-dialog {
|
|
389
|
+
width: 70%;
|
|
390
|
+
max-width: 80;
|
|
391
|
+
min-width: 40;
|
|
392
|
+
background: #3c3836;
|
|
393
|
+
border: round #504945;
|
|
394
|
+
padding: 1 2;
|
|
395
|
+
layout: vertical;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#text-input-label {
|
|
399
|
+
color: #ebdbb2;
|
|
400
|
+
text-style: bold;
|
|
401
|
+
margin-bottom: 1;
|
|
402
|
+
text-wrap: wrap;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#text-input-field {
|
|
406
|
+
width: 100%;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#text-input-hint {
|
|
410
|
+
color: #7c6f64;
|
|
411
|
+
margin-top: 1;
|
|
412
|
+
}
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
BINDINGS = [
|
|
416
|
+
Binding("escape", "cancel", show=False),
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
def __init__(self, label: str, default: str = ""):
|
|
420
|
+
super().__init__()
|
|
421
|
+
self._label = label
|
|
422
|
+
self._default = default
|
|
423
|
+
|
|
424
|
+
def compose(self) -> ComposeResult:
|
|
425
|
+
with Vertical(id="text-input-dialog"):
|
|
426
|
+
yield Static(self._label, id="text-input-label")
|
|
427
|
+
yield Input(value=self._default, placeholder=self._label, id="text-input-field")
|
|
428
|
+
yield Static("Press Enter to submit, Esc to cancel", id="text-input-hint")
|
|
429
|
+
|
|
430
|
+
def on_mount(self) -> None:
|
|
431
|
+
field = self.query_one("#text-input-field", Input)
|
|
432
|
+
self.set_focus(field)
|
|
433
|
+
field.cursor_position = len(field.value)
|
|
434
|
+
|
|
435
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
436
|
+
if event.input.id == "text-input-field":
|
|
437
|
+
value = event.value.strip()
|
|
438
|
+
self.dismiss(value if value else None)
|
|
439
|
+
|
|
440
|
+
def action_cancel(self) -> None:
|
|
441
|
+
self.dismiss(None)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# =============================================================================
|
|
445
|
+
# Main TUI App
|
|
446
|
+
# =============================================================================
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class WorkflowTUIApp(App):
|
|
450
|
+
"""Textual app for entire workflow execution with panels."""
|
|
451
|
+
|
|
452
|
+
TITLE = "Galangal"
|
|
453
|
+
|
|
454
|
+
CSS = """
|
|
455
|
+
Screen {
|
|
456
|
+
background: #282828;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#workflow-root {
|
|
460
|
+
layout: grid;
|
|
461
|
+
grid-size: 1;
|
|
462
|
+
grid-rows: 2 2 1fr 1 auto;
|
|
463
|
+
height: 100%;
|
|
464
|
+
width: 100%;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#header {
|
|
468
|
+
background: #3c3836;
|
|
469
|
+
padding: 0 2;
|
|
470
|
+
border-bottom: solid #504945;
|
|
471
|
+
content-align: left middle;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#progress {
|
|
475
|
+
background: #282828;
|
|
476
|
+
padding: 0 2;
|
|
477
|
+
content-align: center middle;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#main-content {
|
|
481
|
+
layout: horizontal;
|
|
482
|
+
height: 100%;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#activity-container {
|
|
486
|
+
width: 75%;
|
|
487
|
+
border-right: solid #504945;
|
|
488
|
+
height: 100%;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#files-container {
|
|
492
|
+
width: 25%;
|
|
493
|
+
padding: 0 1;
|
|
494
|
+
background: #1d2021;
|
|
495
|
+
height: 100%;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#activity-log {
|
|
499
|
+
background: #282828;
|
|
500
|
+
scrollbar-color: #fe8019;
|
|
501
|
+
scrollbar-background: #3c3836;
|
|
502
|
+
height: 100%;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#current-action {
|
|
506
|
+
background: #3c3836;
|
|
507
|
+
padding: 0 2;
|
|
508
|
+
border-top: solid #504945;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
Footer {
|
|
512
|
+
background: #1d2021;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
Footer > .footer--key {
|
|
516
|
+
background: #d3869b;
|
|
517
|
+
color: #1d2021;
|
|
518
|
+
}
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
BINDINGS = [
|
|
522
|
+
Binding("ctrl+q", "quit_workflow", "^Q Quit", show=True),
|
|
523
|
+
Binding("ctrl+d", "toggle_verbose", "^D Verbose", show=True),
|
|
524
|
+
Binding("ctrl+f", "toggle_files", "^F Files", show=True),
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
def __init__(
|
|
528
|
+
self,
|
|
529
|
+
task_name: str,
|
|
530
|
+
initial_stage: str,
|
|
531
|
+
max_retries: int = 5,
|
|
532
|
+
hidden_stages: frozenset = None,
|
|
533
|
+
):
|
|
534
|
+
super().__init__()
|
|
535
|
+
self.task_name = task_name
|
|
536
|
+
self.current_stage = initial_stage
|
|
537
|
+
self._max_retries = max_retries
|
|
538
|
+
self._hidden_stages = hidden_stages or frozenset()
|
|
539
|
+
self.verbose = False
|
|
540
|
+
self._start_time = time.time()
|
|
541
|
+
self._attempt = 1
|
|
542
|
+
self._turns = 0
|
|
543
|
+
|
|
544
|
+
# Raw lines storage for verbose replay
|
|
545
|
+
self._raw_lines: list[str] = []
|
|
546
|
+
self._activity_lines: list[tuple[str, str]] = [] # (icon, message)
|
|
547
|
+
|
|
548
|
+
# Workflow control
|
|
549
|
+
self._prompt_type = PromptType.NONE
|
|
550
|
+
self._prompt_callback: Optional[Callable] = None
|
|
551
|
+
self._active_prompt_screen: Optional[PromptModal] = None
|
|
552
|
+
self._workflow_result: Optional[str] = None
|
|
553
|
+
self._paused = False
|
|
554
|
+
|
|
555
|
+
# Text input state
|
|
556
|
+
self._input_callback: Optional[Callable] = None
|
|
557
|
+
self._active_input_screen: Optional[TextInputModal] = None
|
|
558
|
+
self._files_visible = True
|
|
559
|
+
|
|
560
|
+
def compose(self) -> ComposeResult:
|
|
561
|
+
with Container(id="workflow-root"):
|
|
562
|
+
yield HeaderWidget(id="header")
|
|
563
|
+
yield StageProgressWidget(id="progress")
|
|
564
|
+
with Horizontal(id="main-content"):
|
|
565
|
+
with VerticalScroll(id="activity-container"):
|
|
566
|
+
yield RichLog(id="activity-log", highlight=True, markup=True)
|
|
567
|
+
yield FilesPanelWidget(id="files-container")
|
|
568
|
+
yield CurrentActionWidget(id="current-action")
|
|
569
|
+
yield Footer()
|
|
570
|
+
|
|
571
|
+
def on_mount(self) -> None:
|
|
572
|
+
"""Initialize widgets."""
|
|
573
|
+
header = self.query_one("#header", HeaderWidget)
|
|
574
|
+
header.task_name = self.task_name
|
|
575
|
+
header.stage = self.current_stage
|
|
576
|
+
header.attempt = self._attempt
|
|
577
|
+
header.max_retries = self._max_retries
|
|
578
|
+
|
|
579
|
+
progress = self.query_one("#progress", StageProgressWidget)
|
|
580
|
+
progress.current_stage = self.current_stage
|
|
581
|
+
progress.hidden_stages = self._hidden_stages
|
|
582
|
+
|
|
583
|
+
# Start timers
|
|
584
|
+
self.set_interval(1.0, self._update_elapsed)
|
|
585
|
+
self.set_interval(0.1, self._update_spinner)
|
|
586
|
+
|
|
587
|
+
def _update_elapsed(self) -> None:
|
|
588
|
+
"""Update elapsed time display."""
|
|
589
|
+
elapsed = int(time.time() - self._start_time)
|
|
590
|
+
if elapsed >= 3600:
|
|
591
|
+
hours, remainder = divmod(elapsed, 3600)
|
|
592
|
+
mins, secs = divmod(remainder, 60)
|
|
593
|
+
elapsed_str = f"{hours}:{mins:02d}:{secs:02d}"
|
|
594
|
+
else:
|
|
595
|
+
mins, secs = divmod(elapsed, 60)
|
|
596
|
+
elapsed_str = f"{mins}:{secs:02d}"
|
|
597
|
+
|
|
598
|
+
header = self.query_one("#header", HeaderWidget)
|
|
599
|
+
header.elapsed = elapsed_str
|
|
600
|
+
|
|
601
|
+
def _update_spinner(self) -> None:
|
|
602
|
+
"""Update action spinner."""
|
|
603
|
+
action = self.query_one("#current-action", CurrentActionWidget)
|
|
604
|
+
action.spinner_frame += 1
|
|
605
|
+
|
|
606
|
+
# -------------------------------------------------------------------------
|
|
607
|
+
# Public API for workflow
|
|
608
|
+
# -------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
def update_stage(self, stage: str, attempt: int = 1) -> None:
|
|
611
|
+
"""Update current stage display."""
|
|
612
|
+
self.current_stage = stage
|
|
613
|
+
self._attempt = attempt
|
|
614
|
+
|
|
615
|
+
def _update():
|
|
616
|
+
header = self.query_one("#header", HeaderWidget)
|
|
617
|
+
header.stage = stage
|
|
618
|
+
header.attempt = attempt
|
|
619
|
+
|
|
620
|
+
progress = self.query_one("#progress", StageProgressWidget)
|
|
621
|
+
progress.current_stage = stage
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
self.call_from_thread(_update)
|
|
625
|
+
except Exception:
|
|
626
|
+
_update()
|
|
627
|
+
|
|
628
|
+
def set_status(self, status: str, detail: str = "") -> None:
|
|
629
|
+
"""Update current action display."""
|
|
630
|
+
def _update():
|
|
631
|
+
action = self.query_one("#current-action", CurrentActionWidget)
|
|
632
|
+
action.action = status
|
|
633
|
+
action.detail = detail
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
self.call_from_thread(_update)
|
|
637
|
+
except Exception:
|
|
638
|
+
_update()
|
|
639
|
+
|
|
640
|
+
def set_turns(self, turns: int) -> None:
|
|
641
|
+
"""Update turn count."""
|
|
642
|
+
self._turns = turns
|
|
643
|
+
|
|
644
|
+
def _update():
|
|
645
|
+
header = self.query_one("#header", HeaderWidget)
|
|
646
|
+
header.turns = turns
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
self.call_from_thread(_update)
|
|
650
|
+
except Exception:
|
|
651
|
+
_update()
|
|
652
|
+
|
|
653
|
+
def add_activity(self, activity: str, icon: str = "•") -> None:
|
|
654
|
+
"""Add activity to log."""
|
|
655
|
+
# Store for replay when toggling modes
|
|
656
|
+
self._activity_lines.append((icon, activity))
|
|
657
|
+
|
|
658
|
+
def _add():
|
|
659
|
+
# Only show activity in compact (non-verbose) mode
|
|
660
|
+
if not self.verbose:
|
|
661
|
+
log = self.query_one("#activity-log", RichLog)
|
|
662
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
663
|
+
log.write(f"[#928374]{timestamp}[/] {icon} {activity}")
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
self.call_from_thread(_add)
|
|
667
|
+
except Exception:
|
|
668
|
+
_add()
|
|
669
|
+
|
|
670
|
+
def add_file(self, action: str, path: str) -> None:
|
|
671
|
+
"""Add file to files panel."""
|
|
672
|
+
def _add():
|
|
673
|
+
files = self.query_one("#files-container", FilesPanelWidget)
|
|
674
|
+
files.add_file(action, path)
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
self.call_from_thread(_add)
|
|
678
|
+
except Exception:
|
|
679
|
+
_add()
|
|
680
|
+
|
|
681
|
+
def show_message(self, message: str, style: str = "info") -> None:
|
|
682
|
+
"""Show a styled message."""
|
|
683
|
+
icons = {"info": "ℹ", "success": "✓", "error": "✗", "warning": "⚠"}
|
|
684
|
+
colors = {"info": "#83a598", "success": "#b8bb26", "error": "#fb4934", "warning": "#fabd2f"}
|
|
685
|
+
icon = icons.get(style, "•")
|
|
686
|
+
color = colors.get(style, "#ebdbb2")
|
|
687
|
+
self.add_activity(f"[{color}]{message}[/]", icon)
|
|
688
|
+
|
|
689
|
+
def show_stage_complete(self, stage: str, success: bool) -> None:
|
|
690
|
+
"""Show stage completion."""
|
|
691
|
+
if success:
|
|
692
|
+
self.show_message(f"Stage {stage} completed", "success")
|
|
693
|
+
else:
|
|
694
|
+
self.show_message(f"Stage {stage} failed", "error")
|
|
695
|
+
|
|
696
|
+
def show_workflow_complete(self) -> None:
|
|
697
|
+
"""Show workflow completion banner."""
|
|
698
|
+
self.add_activity("")
|
|
699
|
+
self.add_activity("[bold #b8bb26]════════════════════════════════════════[/]", "")
|
|
700
|
+
self.add_activity("[bold #b8bb26] WORKFLOW COMPLETE [/]", "")
|
|
701
|
+
self.add_activity("[bold #b8bb26]════════════════════════════════════════[/]", "")
|
|
702
|
+
self.add_activity("")
|
|
703
|
+
|
|
704
|
+
def show_prompt(self, prompt_type: PromptType, message: str, callback: Callable) -> None:
|
|
705
|
+
"""Show a prompt."""
|
|
706
|
+
self._prompt_type = prompt_type
|
|
707
|
+
self._prompt_callback = callback
|
|
708
|
+
|
|
709
|
+
options = {
|
|
710
|
+
PromptType.PLAN_APPROVAL: [
|
|
711
|
+
PromptOption("1", "Approve", "yes", "#b8bb26"),
|
|
712
|
+
PromptOption("2", "Reject", "no", "#fb4934"),
|
|
713
|
+
PromptOption("3", "Quit", "quit", "#fabd2f"),
|
|
714
|
+
],
|
|
715
|
+
PromptType.DESIGN_APPROVAL: [
|
|
716
|
+
PromptOption("1", "Approve", "yes", "#b8bb26"),
|
|
717
|
+
PromptOption("2", "Reject", "no", "#fb4934"),
|
|
718
|
+
PromptOption("3", "Quit", "quit", "#fabd2f"),
|
|
719
|
+
],
|
|
720
|
+
PromptType.COMPLETION: [
|
|
721
|
+
PromptOption("1", "Create PR", "yes", "#b8bb26"),
|
|
722
|
+
PromptOption("2", "Back to DEV", "no", "#fb4934"),
|
|
723
|
+
PromptOption("3", "Quit", "quit", "#fabd2f"),
|
|
724
|
+
],
|
|
725
|
+
PromptType.PREFLIGHT_RETRY: [
|
|
726
|
+
PromptOption("1", "Retry", "retry", "#b8bb26"),
|
|
727
|
+
PromptOption("2", "Quit", "quit", "#fb4934"),
|
|
728
|
+
],
|
|
729
|
+
PromptType.STAGE_FAILURE: [
|
|
730
|
+
PromptOption("1", "Retry", "retry", "#b8bb26"),
|
|
731
|
+
PromptOption("2", "Fix in DEV", "fix_in_dev", "#fabd2f"),
|
|
732
|
+
PromptOption("3", "Quit", "quit", "#fb4934"),
|
|
733
|
+
],
|
|
734
|
+
}.get(prompt_type, [
|
|
735
|
+
PromptOption("1", "Yes", "yes", "#b8bb26"),
|
|
736
|
+
PromptOption("2", "No", "no", "#fb4934"),
|
|
737
|
+
PromptOption("3", "Quit", "quit", "#fabd2f"),
|
|
738
|
+
])
|
|
739
|
+
|
|
740
|
+
def _show():
|
|
741
|
+
try:
|
|
742
|
+
def _handle(result: Optional[str]) -> None:
|
|
743
|
+
self._active_prompt_screen = None
|
|
744
|
+
self._prompt_callback = None
|
|
745
|
+
self._prompt_type = PromptType.NONE
|
|
746
|
+
if result:
|
|
747
|
+
callback(result)
|
|
748
|
+
|
|
749
|
+
screen = PromptModal(message, options)
|
|
750
|
+
self._active_prompt_screen = screen
|
|
751
|
+
self.push_screen(screen, _handle)
|
|
752
|
+
except Exception as e:
|
|
753
|
+
# Log error to activity - use internal method to avoid threading issues
|
|
754
|
+
log = self.query_one("#activity-log", RichLog)
|
|
755
|
+
log.write(f"[#fb4934]⚠ Prompt error: {e}[/]")
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
self.call_from_thread(_show)
|
|
759
|
+
except Exception:
|
|
760
|
+
# Direct call as fallback
|
|
761
|
+
_show()
|
|
762
|
+
|
|
763
|
+
def hide_prompt(self) -> None:
|
|
764
|
+
"""Hide prompt."""
|
|
765
|
+
self._prompt_type = PromptType.NONE
|
|
766
|
+
self._prompt_callback = None
|
|
767
|
+
|
|
768
|
+
def _hide():
|
|
769
|
+
if self._active_prompt_screen:
|
|
770
|
+
self._active_prompt_screen.dismiss(None)
|
|
771
|
+
self._active_prompt_screen = None
|
|
772
|
+
|
|
773
|
+
try:
|
|
774
|
+
self.call_from_thread(_hide)
|
|
775
|
+
except Exception:
|
|
776
|
+
_hide()
|
|
777
|
+
|
|
778
|
+
def show_text_input(self, label: str, default: str, callback: Callable) -> None:
|
|
779
|
+
"""Show text input prompt."""
|
|
780
|
+
self._input_callback = callback
|
|
781
|
+
|
|
782
|
+
def _show():
|
|
783
|
+
try:
|
|
784
|
+
def _handle(result: Optional[str]) -> None:
|
|
785
|
+
self._active_input_screen = None
|
|
786
|
+
self._input_callback = None
|
|
787
|
+
callback(result if result else None)
|
|
788
|
+
|
|
789
|
+
screen = TextInputModal(label, default)
|
|
790
|
+
self._active_input_screen = screen
|
|
791
|
+
self.push_screen(screen, _handle)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
log = self.query_one("#activity-log", RichLog)
|
|
794
|
+
log.write(f"[#fb4934]⚠ Input error: {e}[/]")
|
|
795
|
+
|
|
796
|
+
try:
|
|
797
|
+
self.call_from_thread(_show)
|
|
798
|
+
except Exception:
|
|
799
|
+
_show()
|
|
800
|
+
|
|
801
|
+
def hide_text_input(self) -> None:
|
|
802
|
+
"""Reset text input prompt."""
|
|
803
|
+
self._input_callback = None
|
|
804
|
+
|
|
805
|
+
def _hide():
|
|
806
|
+
if self._active_input_screen:
|
|
807
|
+
self._active_input_screen.dismiss(None)
|
|
808
|
+
self._active_input_screen = None
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
self.call_from_thread(_hide)
|
|
812
|
+
except Exception:
|
|
813
|
+
_hide()
|
|
814
|
+
|
|
815
|
+
# -------------------------------------------------------------------------
|
|
816
|
+
# Actions
|
|
817
|
+
# -------------------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
def _text_input_active(self) -> bool:
|
|
820
|
+
"""Check if text input is currently active and should capture keys."""
|
|
821
|
+
return self._input_callback is not None or self._active_input_screen is not None
|
|
822
|
+
|
|
823
|
+
def check_action_quit_workflow(self) -> bool:
|
|
824
|
+
return not self._text_input_active()
|
|
825
|
+
|
|
826
|
+
def check_action_toggle_verbose(self) -> bool:
|
|
827
|
+
return not self._text_input_active()
|
|
828
|
+
|
|
829
|
+
def action_quit_workflow(self) -> None:
|
|
830
|
+
if self._active_prompt_screen:
|
|
831
|
+
self._active_prompt_screen.dismiss("quit")
|
|
832
|
+
return
|
|
833
|
+
if self._prompt_callback:
|
|
834
|
+
callback = self._prompt_callback
|
|
835
|
+
self.hide_prompt()
|
|
836
|
+
callback("quit")
|
|
837
|
+
return
|
|
838
|
+
self._paused = True
|
|
839
|
+
self._workflow_result = "paused"
|
|
840
|
+
self.exit()
|
|
841
|
+
|
|
842
|
+
def add_raw_line(self, line: str) -> None:
|
|
843
|
+
"""Store raw line and display if in verbose mode."""
|
|
844
|
+
# Store for replay (keep last 500 lines)
|
|
845
|
+
self._raw_lines.append(line)
|
|
846
|
+
if len(self._raw_lines) > 500:
|
|
847
|
+
self._raw_lines = self._raw_lines[-500:]
|
|
848
|
+
|
|
849
|
+
def _add():
|
|
850
|
+
if self.verbose:
|
|
851
|
+
log = self.query_one("#activity-log", RichLog)
|
|
852
|
+
display = line.strip()[:150] # Truncate to 150 chars
|
|
853
|
+
log.write(f"[#7c6f64]{display}[/]")
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
self.call_from_thread(_add)
|
|
857
|
+
except Exception:
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
def action_toggle_verbose(self) -> None:
|
|
861
|
+
self.verbose = not self.verbose
|
|
862
|
+
log = self.query_one("#activity-log", RichLog)
|
|
863
|
+
log.clear()
|
|
864
|
+
|
|
865
|
+
if self.verbose:
|
|
866
|
+
log.write("[#83a598]Switched to VERBOSE mode - showing raw JSON[/]")
|
|
867
|
+
# Replay last 30 raw lines
|
|
868
|
+
for line in self._raw_lines[-30:]:
|
|
869
|
+
display = line.strip()[:150]
|
|
870
|
+
log.write(f"[#7c6f64]{display}[/]")
|
|
871
|
+
else:
|
|
872
|
+
log.write("[#b8bb26]Switched to COMPACT mode[/]")
|
|
873
|
+
# Replay recent activity
|
|
874
|
+
for icon, activity in self._activity_lines[-30:]:
|
|
875
|
+
log.write(f" {icon} {activity}")
|
|
876
|
+
|
|
877
|
+
def action_toggle_files(self) -> None:
|
|
878
|
+
self._files_visible = not self._files_visible
|
|
879
|
+
files = self.query_one("#files-container", FilesPanelWidget)
|
|
880
|
+
activity = self.query_one("#activity-container", VerticalScroll)
|
|
881
|
+
|
|
882
|
+
if self._files_visible:
|
|
883
|
+
files.display = True
|
|
884
|
+
files.styles.width = "25%"
|
|
885
|
+
activity.styles.width = "75%"
|
|
886
|
+
else:
|
|
887
|
+
files.display = False
|
|
888
|
+
activity.styles.width = "100%"
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# =============================================================================
|
|
892
|
+
# TUI Adapter for ClaudeBackend
|
|
893
|
+
# =============================================================================
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
class TUIAdapter(StageUI):
|
|
897
|
+
"""Adapter to connect ClaudeBackend to TUI."""
|
|
898
|
+
|
|
899
|
+
def __init__(self, app: WorkflowTUIApp):
|
|
900
|
+
self.app = app
|
|
901
|
+
|
|
902
|
+
def set_status(self, status: str, detail: str = "") -> None:
|
|
903
|
+
self.app.set_status(status, detail)
|
|
904
|
+
|
|
905
|
+
def add_activity(self, activity: str, icon: str = "•") -> None:
|
|
906
|
+
self.app.add_activity(activity, icon)
|
|
907
|
+
|
|
908
|
+
# Track file operations
|
|
909
|
+
if "Read:" in activity or "📖" in activity:
|
|
910
|
+
path = activity.split(":")[-1].strip() if ":" in activity else activity
|
|
911
|
+
self.app.add_file("read", path)
|
|
912
|
+
elif "Edit:" in activity or "Write:" in activity or "✏️" in activity:
|
|
913
|
+
path = activity.split(":")[-1].strip() if ":" in activity else activity
|
|
914
|
+
self.app.add_file("write", path)
|
|
915
|
+
|
|
916
|
+
def add_raw_line(self, line: str) -> None:
|
|
917
|
+
"""Pass raw line to app for storage and display."""
|
|
918
|
+
self.app.add_raw_line(line)
|
|
919
|
+
|
|
920
|
+
def set_turns(self, turns: int) -> None:
|
|
921
|
+
self.app.set_turns(turns)
|
|
922
|
+
|
|
923
|
+
def finish(self, success: bool) -> None:
|
|
924
|
+
pass
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# =============================================================================
|
|
928
|
+
# Simple Console UI (fallback)
|
|
929
|
+
# =============================================================================
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
class SimpleConsoleUI(StageUI):
|
|
933
|
+
"""Simple console-based UI without Textual."""
|
|
934
|
+
|
|
935
|
+
def __init__(self, task_name: str, stage: str):
|
|
936
|
+
from rich.console import Console
|
|
937
|
+
self.console = Console()
|
|
938
|
+
self.task_name = task_name
|
|
939
|
+
self.stage = stage
|
|
940
|
+
self.turns = 0
|
|
941
|
+
|
|
942
|
+
def set_status(self, status: str, detail: str = "") -> None:
|
|
943
|
+
self.console.print(f"[dim]{status}: {detail}[/dim]")
|
|
944
|
+
|
|
945
|
+
def add_activity(self, activity: str, icon: str = "•") -> None:
|
|
946
|
+
self.console.print(f" {icon} {activity}")
|
|
947
|
+
|
|
948
|
+
def add_raw_line(self, line: str) -> None:
|
|
949
|
+
pass
|
|
950
|
+
|
|
951
|
+
def set_turns(self, turns: int) -> None:
|
|
952
|
+
self.turns = turns
|
|
953
|
+
self.console.print(f"[dim]Turn {turns}[/dim]")
|
|
954
|
+
|
|
955
|
+
def finish(self, success: bool) -> None:
|
|
956
|
+
if success:
|
|
957
|
+
self.console.print(f"[green]✓ {self.stage} completed[/green]")
|
|
958
|
+
else:
|
|
959
|
+
self.console.print(f"[red]✗ {self.stage} failed[/red]")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
# =============================================================================
|
|
963
|
+
# Legacy single-stage TUI (backward compatible)
|
|
964
|
+
# =============================================================================
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class StageTUIApp(WorkflowTUIApp):
|
|
968
|
+
"""Single-stage TUI app."""
|
|
969
|
+
|
|
970
|
+
def __init__(
|
|
971
|
+
self,
|
|
972
|
+
task_name: str,
|
|
973
|
+
stage: str,
|
|
974
|
+
branch: str,
|
|
975
|
+
attempt: int,
|
|
976
|
+
prompt: str,
|
|
977
|
+
):
|
|
978
|
+
super().__init__(task_name, stage)
|
|
979
|
+
self.branch = branch
|
|
980
|
+
self._attempt = attempt
|
|
981
|
+
self.prompt = prompt
|
|
982
|
+
self.result: tuple[bool, str] = (False, "")
|
|
983
|
+
|
|
984
|
+
def on_mount(self) -> None:
|
|
985
|
+
super().on_mount()
|
|
986
|
+
self._worker_thread = threading.Thread(target=self._execute_stage, daemon=True)
|
|
987
|
+
self._worker_thread.start()
|
|
988
|
+
|
|
989
|
+
def _execute_stage(self) -> None:
|
|
990
|
+
backend = ClaudeBackend()
|
|
991
|
+
ui = TUIAdapter(self)
|
|
992
|
+
|
|
993
|
+
self.result = backend.invoke(
|
|
994
|
+
prompt=self.prompt,
|
|
995
|
+
timeout=14400,
|
|
996
|
+
max_turns=200,
|
|
997
|
+
ui=ui,
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
success, _ = self.result
|
|
1001
|
+
if success:
|
|
1002
|
+
self.call_from_thread(self.add_activity, "[#b8bb26]Stage completed[/]", "✓")
|
|
1003
|
+
else:
|
|
1004
|
+
self.call_from_thread(self.add_activity, "[#fb4934]Stage failed[/]", "✗")
|
|
1005
|
+
|
|
1006
|
+
self.call_from_thread(self.set_timer, 1.5, self.exit)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# =============================================================================
|
|
1010
|
+
# Entry Points
|
|
1011
|
+
# =============================================================================
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _run_simple_mode(
|
|
1015
|
+
task_name: str,
|
|
1016
|
+
stage: str,
|
|
1017
|
+
attempt: int,
|
|
1018
|
+
prompt: str,
|
|
1019
|
+
) -> tuple[bool, str]:
|
|
1020
|
+
"""Run stage without TUI."""
|
|
1021
|
+
from rich.console import Console
|
|
1022
|
+
console = Console()
|
|
1023
|
+
console.print(f"\n[bold]Running {stage}[/bold] (attempt {attempt})")
|
|
1024
|
+
|
|
1025
|
+
backend = ClaudeBackend()
|
|
1026
|
+
ui = SimpleConsoleUI(task_name, stage)
|
|
1027
|
+
|
|
1028
|
+
result = backend.invoke(
|
|
1029
|
+
prompt=prompt,
|
|
1030
|
+
timeout=14400,
|
|
1031
|
+
max_turns=200,
|
|
1032
|
+
ui=ui,
|
|
1033
|
+
)
|
|
1034
|
+
ui.finish(result[0])
|
|
1035
|
+
return result
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def run_stage_with_tui(
|
|
1039
|
+
task_name: str,
|
|
1040
|
+
stage: str,
|
|
1041
|
+
branch: str,
|
|
1042
|
+
attempt: int,
|
|
1043
|
+
prompt: str,
|
|
1044
|
+
) -> tuple[bool, str]:
|
|
1045
|
+
"""Run a single stage with TUI."""
|
|
1046
|
+
import os
|
|
1047
|
+
|
|
1048
|
+
if os.environ.get("GALANGAL_NO_TUI"):
|
|
1049
|
+
return _run_simple_mode(task_name, stage, attempt, prompt)
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
app = StageTUIApp(
|
|
1053
|
+
task_name=task_name,
|
|
1054
|
+
stage=stage,
|
|
1055
|
+
branch=branch,
|
|
1056
|
+
attempt=attempt,
|
|
1057
|
+
prompt=prompt,
|
|
1058
|
+
)
|
|
1059
|
+
app.run()
|
|
1060
|
+
return app.result
|
|
1061
|
+
except Exception as e:
|
|
1062
|
+
from rich.console import Console
|
|
1063
|
+
console = Console()
|
|
1064
|
+
console.print(f"[yellow]TUI error: {e}. Falling back to simple mode.[/yellow]")
|
|
1065
|
+
return _run_simple_mode(task_name, stage, attempt, prompt)
|