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.

Files changed (49) hide show
  1. galangal/__init__.py +8 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +6 -0
  4. galangal/ai/base.py +55 -0
  5. galangal/ai/claude.py +278 -0
  6. galangal/ai/gemini.py +38 -0
  7. galangal/cli.py +296 -0
  8. galangal/commands/__init__.py +42 -0
  9. galangal/commands/approve.py +187 -0
  10. galangal/commands/complete.py +268 -0
  11. galangal/commands/init.py +173 -0
  12. galangal/commands/list.py +20 -0
  13. galangal/commands/pause.py +40 -0
  14. galangal/commands/prompts.py +98 -0
  15. galangal/commands/reset.py +43 -0
  16. galangal/commands/resume.py +29 -0
  17. galangal/commands/skip.py +216 -0
  18. galangal/commands/start.py +144 -0
  19. galangal/commands/status.py +62 -0
  20. galangal/commands/switch.py +28 -0
  21. galangal/config/__init__.py +13 -0
  22. galangal/config/defaults.py +133 -0
  23. galangal/config/loader.py +113 -0
  24. galangal/config/schema.py +155 -0
  25. galangal/core/__init__.py +18 -0
  26. galangal/core/artifacts.py +66 -0
  27. galangal/core/state.py +248 -0
  28. galangal/core/tasks.py +170 -0
  29. galangal/core/workflow.py +835 -0
  30. galangal/prompts/__init__.py +5 -0
  31. galangal/prompts/builder.py +166 -0
  32. galangal/prompts/defaults/design.md +54 -0
  33. galangal/prompts/defaults/dev.md +39 -0
  34. galangal/prompts/defaults/docs.md +46 -0
  35. galangal/prompts/defaults/pm.md +75 -0
  36. galangal/prompts/defaults/qa.md +49 -0
  37. galangal/prompts/defaults/review.md +65 -0
  38. galangal/prompts/defaults/security.md +68 -0
  39. galangal/prompts/defaults/test.md +59 -0
  40. galangal/ui/__init__.py +5 -0
  41. galangal/ui/console.py +123 -0
  42. galangal/ui/tui.py +1065 -0
  43. galangal/validation/__init__.py +5 -0
  44. galangal/validation/runner.py +395 -0
  45. galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
  46. galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
  47. galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
  48. galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
  49. 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)