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.
Files changed (79) hide show
  1. galangal/__init__.py +36 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +167 -0
  4. galangal/ai/base.py +159 -0
  5. galangal/ai/claude.py +352 -0
  6. galangal/ai/codex.py +370 -0
  7. galangal/ai/gemini.py +43 -0
  8. galangal/ai/subprocess.py +254 -0
  9. galangal/cli.py +371 -0
  10. galangal/commands/__init__.py +27 -0
  11. galangal/commands/complete.py +367 -0
  12. galangal/commands/github.py +355 -0
  13. galangal/commands/init.py +177 -0
  14. galangal/commands/init_wizard.py +762 -0
  15. galangal/commands/list.py +20 -0
  16. galangal/commands/pause.py +34 -0
  17. galangal/commands/prompts.py +89 -0
  18. galangal/commands/reset.py +41 -0
  19. galangal/commands/resume.py +30 -0
  20. galangal/commands/skip.py +62 -0
  21. galangal/commands/start.py +530 -0
  22. galangal/commands/status.py +44 -0
  23. galangal/commands/switch.py +28 -0
  24. galangal/config/__init__.py +15 -0
  25. galangal/config/defaults.py +183 -0
  26. galangal/config/loader.py +163 -0
  27. galangal/config/schema.py +330 -0
  28. galangal/core/__init__.py +33 -0
  29. galangal/core/artifacts.py +136 -0
  30. galangal/core/state.py +1097 -0
  31. galangal/core/tasks.py +454 -0
  32. galangal/core/utils.py +116 -0
  33. galangal/core/workflow/__init__.py +68 -0
  34. galangal/core/workflow/core.py +789 -0
  35. galangal/core/workflow/engine.py +781 -0
  36. galangal/core/workflow/pause.py +35 -0
  37. galangal/core/workflow/tui_runner.py +1322 -0
  38. galangal/exceptions.py +36 -0
  39. galangal/github/__init__.py +31 -0
  40. galangal/github/client.py +427 -0
  41. galangal/github/images.py +324 -0
  42. galangal/github/issues.py +298 -0
  43. galangal/logging.py +364 -0
  44. galangal/prompts/__init__.py +5 -0
  45. galangal/prompts/builder.py +527 -0
  46. galangal/prompts/defaults/benchmark.md +34 -0
  47. galangal/prompts/defaults/contract.md +35 -0
  48. galangal/prompts/defaults/design.md +54 -0
  49. galangal/prompts/defaults/dev.md +89 -0
  50. galangal/prompts/defaults/docs.md +104 -0
  51. galangal/prompts/defaults/migration.md +59 -0
  52. galangal/prompts/defaults/pm.md +110 -0
  53. galangal/prompts/defaults/pm_questions.md +53 -0
  54. galangal/prompts/defaults/preflight.md +32 -0
  55. galangal/prompts/defaults/qa.md +65 -0
  56. galangal/prompts/defaults/review.md +90 -0
  57. galangal/prompts/defaults/review_codex.md +99 -0
  58. galangal/prompts/defaults/security.md +84 -0
  59. galangal/prompts/defaults/test.md +91 -0
  60. galangal/results.py +176 -0
  61. galangal/ui/__init__.py +5 -0
  62. galangal/ui/console.py +126 -0
  63. galangal/ui/tui/__init__.py +56 -0
  64. galangal/ui/tui/adapters.py +168 -0
  65. galangal/ui/tui/app.py +902 -0
  66. galangal/ui/tui/entry.py +24 -0
  67. galangal/ui/tui/mixins.py +196 -0
  68. galangal/ui/tui/modals.py +339 -0
  69. galangal/ui/tui/styles/app.tcss +86 -0
  70. galangal/ui/tui/styles/modals.tcss +197 -0
  71. galangal/ui/tui/types.py +107 -0
  72. galangal/ui/tui/widgets.py +263 -0
  73. galangal/validation/__init__.py +5 -0
  74. galangal/validation/runner.py +1072 -0
  75. galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
  76. galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
  77. galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
  78. galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
  79. 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)