ralph-code 0.5.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.
ralph/app.py ADDED
@@ -0,0 +1,1328 @@
1
+ """Main application class with Rich UI for ralph-coding."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ import questionary
8
+ from questionary import Style
9
+ from rich.console import Console
10
+ from rich.live import Live
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from .colors import (
15
+ # Polar Night (backgrounds)
16
+ NORD1,
17
+ NORD3,
18
+ # Snow Storm (text)
19
+ NORD4,
20
+ NORD5,
21
+ NORD6,
22
+ # Frost (accents)
23
+ NORD8,
24
+ NORD10,
25
+ # Aurora (states)
26
+ NORD11,
27
+ NORD12,
28
+ NORD13,
29
+ NORD14,
30
+ NORD15,
31
+ )
32
+ from .config import get_config
33
+ from .harness import Harness, HarnessDetector
34
+ from .spinner import RichSpinner, SpinnerStyle
35
+ from .user_stories import UserStory
36
+ from .workflow import SPINNER_MESSAGES, SPINNER_STATES, WorkflowEngine, WorkflowState
37
+
38
+
39
+ # Ralph Wiggum ASCII art - Using Nord Theme colors
40
+ # NORD15 (Aurora Purple) for the face, NORD8 (Frost Cyan) for eyes and tagline
41
+ RALPH_ART = rf"""
42
+ [bold {NORD15}] _______________
43
+ / \
44
+ | [/bold {NORD15}][bold {NORD8}]●[/bold {NORD8}][bold {NORD15}] [/bold {NORD15}][bold {NORD8}]●[/bold {NORD8}][bold {NORD15}] |
45
+ | [/bold {NORD15}][bold {NORD11}]>[/bold {NORD11}][bold {NORD15}] |
46
+ | \______/ |
47
+ \_______________/
48
+ | |
49
+ /| |\
50
+ / | | \
51
+ [/bold {NORD15}][bold {NORD8}] "I'm helping!"[/bold {NORD8}]
52
+ """
53
+
54
+ RALPH_ART_SMALL = f"""[bold {NORD15}] ___
55
+ (o o) [/bold {NORD15}][bold {NORD8}]"I'm helping!"[/bold {NORD8}]
56
+ [bold {NORD15}] \\\\[/bold {NORD15}][bold {NORD11}]>[/bold {NORD11}][bold {NORD15}]/[/bold {NORD15}]"""
57
+
58
+ # Custom style for questionary using Nord Theme colors
59
+ MENU_STYLE = Style([
60
+ ('qmark', f'fg:{NORD15} bold'), # Aurora purple for question marks
61
+ ('question', f'fg:{NORD6} bold'), # Snow storm (brightest) for question text
62
+ ('answer', f'fg:{NORD8} bold'), # Frost cyan for answers
63
+ ('pointer', f'fg:{NORD14} bold'), # Aurora green for pointer
64
+ ('highlighted', f'fg:{NORD8} bold'), # Frost cyan for highlighted items
65
+ ('selected', f'fg:{NORD10}'), # Frost blue for selected items
66
+ ('separator', f'fg:{NORD3}'), # Polar night (lighter) for separators
67
+ ('instruction', f'fg:{NORD3}'), # Polar night (lighter) for instructions
68
+ ])
69
+
70
+
71
+ class RalphApp:
72
+ """Main application with Rich terminal UI."""
73
+
74
+ def __init__(self, project_dir: Path, debug: bool = False):
75
+ self.project_dir = project_dir.resolve()
76
+ self.debug = debug
77
+ self.console = Console()
78
+ self._config = get_config()
79
+ self._workflow: WorkflowEngine | None = None
80
+ self._running = False
81
+ self._last_output = ""
82
+ self._live: Live | None = None
83
+ self._last_status_key: tuple[object, ...] | None = None
84
+ self._spinner = RichSpinner(style=SpinnerStyle.DOTS_6, spinner_color=NORD8)
85
+ self._state_message = ""
86
+ self._harness_available = False # Set by _validate_harness()
87
+
88
+ def _get_status_key(self) -> tuple[object, ...]:
89
+ """Get a hashable key representing current status for change detection."""
90
+ workflow = self._get_workflow()
91
+ stats = workflow.prd_manager.get_stats()
92
+ return (
93
+ workflow.state,
94
+ workflow.is_paused,
95
+ workflow.current_task.id if workflow.current_task else None,
96
+ self._state_message,
97
+ self._last_output,
98
+ workflow.error_message,
99
+ tuple(stats.items()),
100
+ workflow.completed_this_session,
101
+ )
102
+
103
+ def _update_live(self) -> None:
104
+ """Update live display only if content has changed."""
105
+ if not self._live:
106
+ return
107
+ status_key = self._get_status_key()
108
+ if status_key != self._last_status_key:
109
+ self._last_status_key = status_key
110
+ self._live.update(self._build_live_status())
111
+
112
+ def _on_state_change(self, state: WorkflowState, message: str) -> None:
113
+ """Handle workflow state changes."""
114
+ if message:
115
+ self._state_message = message
116
+ else:
117
+ self._state_message = SPINNER_MESSAGES.get(state, "")
118
+ self._update_live()
119
+
120
+ def _on_output(self, message: str) -> None:
121
+ """Handle output from the workflow."""
122
+ self._last_output = message
123
+ self._update_live()
124
+
125
+ def _get_workflow(self) -> WorkflowEngine:
126
+ """Get or create the workflow engine."""
127
+ if self._workflow is None:
128
+ self._workflow = WorkflowEngine(
129
+ self.project_dir,
130
+ debug=self.debug,
131
+ on_state_change=self._on_state_change,
132
+ on_output=self._on_output,
133
+ use_spinner=False, # App uses its own Live display
134
+ )
135
+ return self._workflow
136
+
137
+ def _show_header(self) -> None:
138
+ """Show the Ralph header with ASCII art using Nord theme colors."""
139
+ self.console.print(Panel(
140
+ RALPH_ART,
141
+ title=f"[bold {NORD15}]RALPH CODING[/bold {NORD15}]",
142
+ subtitle=f"[bold {NORD6}]{self.project_dir.name}[/bold {NORD6}]",
143
+ border_style=NORD8,
144
+ ))
145
+
146
+ def _validate_harness(self) -> bool:
147
+ """
148
+ Validate that the configured harness exists and is usable.
149
+
150
+ Checks if the harness path exists and is executable. If not,
151
+ offers auto-detect or reconfigure options.
152
+
153
+ Returns:
154
+ True if a valid harness is available, False otherwise.
155
+ """
156
+ harness = Harness.from_config(self._config.harness)
157
+
158
+ if harness.is_available:
159
+ self._harness_available = True
160
+ return True
161
+
162
+ # Harness not available - show warning
163
+ self.console.print(f"\n[{NORD11} bold]⚠ Warning: Harness not found[/{NORD11} bold]")
164
+ self.console.print(f"[{NORD4}]Configured harness '[{NORD8}]{self._config.harness}[/{NORD8}]' is not available.[/{NORD4}]")
165
+ self.console.print(f"[{NORD3}]The path does not exist or is not executable.[/{NORD3}]\n")
166
+
167
+ # Try auto-detection
168
+ detector = HarnessDetector()
169
+ alternatives = detector.detect_all()
170
+
171
+ if alternatives:
172
+ # Found alternatives - offer to use them
173
+ self.console.print(f"[{NORD14}]Found available alternatives:[/{NORD14}]")
174
+ choices = []
175
+ for alt in alternatives:
176
+ self.console.print(f" [{NORD8}]• {alt.name}[/{NORD8}] [{NORD3}]({alt.path})[/{NORD3}]")
177
+ choices.append({"name": f"Use {alt.name} ({alt.path})", "value": alt.path})
178
+
179
+ choices.append({"name": "Enter custom path", "value": "custom"})
180
+ choices.append({"name": "Continue without harness (workflow disabled)", "value": "skip"})
181
+
182
+ self.console.print()
183
+ result: str | None = questionary.select(
184
+ "What would you like to do?",
185
+ choices=choices,
186
+ style=MENU_STYLE,
187
+ ).ask()
188
+
189
+ if result is None:
190
+ # User cancelled
191
+ self._harness_available = False
192
+ return False
193
+ elif result == "skip":
194
+ self.console.print(f"[{NORD13}]Continuing without harness. Workflow commands will be disabled.[/{NORD13}]\n")
195
+ self._harness_available = False
196
+ return False
197
+ elif result == "custom":
198
+ custom_path = questionary.text(
199
+ "Enter harness path or command:",
200
+ style=MENU_STYLE,
201
+ ).ask()
202
+ if custom_path:
203
+ self._config.harness = custom_path
204
+ # Recursively validate the new path
205
+ return self._validate_harness()
206
+ else:
207
+ self._harness_available = False
208
+ return False
209
+ else:
210
+ # User selected an alternative
211
+ self._config.harness = result
212
+ self.console.print(f"[{NORD14}]Harness updated to: {result}[/{NORD14}]\n")
213
+ self._harness_available = True
214
+ return True
215
+ else:
216
+ # No alternatives found
217
+ self.console.print(f"[{NORD13}]No alternative harnesses detected on PATH.[/{NORD13}]\n")
218
+
219
+ choices = [
220
+ {"name": "Enter custom path", "value": "custom"},
221
+ {"name": "Continue without harness (workflow disabled)", "value": "skip"},
222
+ ]
223
+
224
+ result = questionary.select(
225
+ "What would you like to do?",
226
+ choices=choices,
227
+ style=MENU_STYLE,
228
+ ).ask()
229
+
230
+ if result is None or result == "skip":
231
+ self.console.print(f"[{NORD13}]Continuing without harness. Workflow commands will be disabled.[/{NORD13}]\n")
232
+ self._harness_available = False
233
+ return False
234
+ elif result == "custom":
235
+ custom_path = questionary.text(
236
+ "Enter harness path or command:",
237
+ style=MENU_STYLE,
238
+ ).ask()
239
+ if custom_path:
240
+ self._config.harness = custom_path
241
+ # Recursively validate the new path
242
+ return self._validate_harness()
243
+ else:
244
+ self._harness_available = False
245
+ return False
246
+
247
+ self._harness_available = False
248
+ return False
249
+
250
+ def _build_status_panel(self) -> Panel:
251
+ """Build the status panel with Nord theme colors."""
252
+ workflow = self._get_workflow()
253
+ stats = workflow.prd_manager.get_stats()
254
+
255
+ lines = []
256
+
257
+ # PRD stats: Unspecced | Questions | Pending | In Progress | Complete | Errored
258
+ # PRD status colors (US-007):
259
+ # unspecced = dim gray (NORD3)
260
+ # questions = aurora yellow (NORD13)
261
+ # pending = aurora orange (NORD12)
262
+ # in_progress = aurora green (NORD14)
263
+ # completed = aurora green dim (NORD14 dim)
264
+ # errored = aurora red (NORD11)
265
+ parts = []
266
+ if stats['unspecced'] > 0:
267
+ parts.append(f"[{NORD3}]{stats['unspecced']} unspecced[/{NORD3}]")
268
+ if stats['questions'] > 0:
269
+ parts.append(f"[{NORD13}]{stats['questions']} questions[/{NORD13}]")
270
+ parts.append(f"[{NORD12}]{stats['pending']} pending[/{NORD12}]")
271
+ parts.append(f"[{NORD14}]{stats['in_progress']} in progress[/{NORD14}]")
272
+ parts.append(f"[dim {NORD14}]{stats['completed']} complete[/dim {NORD14}]")
273
+ if stats['errored'] > 0:
274
+ parts.append(f"[{NORD11}]{stats['errored']} errored[/{NORD11}]")
275
+ lines.append(f"[{NORD8}]PRDs:[/{NORD8}] [{NORD4}]{' | '.join(parts)}[/{NORD4}]")
276
+
277
+ # Story progress (if tasks.json exists)
278
+ completed, total, percent = workflow.get_story_progress()
279
+ if total > 0:
280
+ lines.append(f"[{NORD8}]Stories:[/{NORD8}] [{NORD4}]{percent}% ({completed}/{total} complete)[/{NORD4}]")
281
+
282
+ # Activity detail (only show if not idle)
283
+ if workflow.state not in (WorkflowState.IDLE, WorkflowState.PAUSED, WorkflowState.QUESTIONS):
284
+ activity = workflow.state.value.replace("_", " ").title()
285
+ if workflow.current_story:
286
+ lines.append(f"[{NORD3}]Activity:[/{NORD3}] [{NORD5}]{activity} - {workflow.current_story.id}: {workflow.current_story.title}[/{NORD5}]")
287
+ elif workflow.current_task:
288
+ lines.append(f"[{NORD3}]Activity:[/{NORD3}] [{NORD5}]{activity} - {workflow.current_task.name}[/{NORD5}]")
289
+ else:
290
+ lines.append(f"[{NORD3}]Activity:[/{NORD3}] [{NORD5}]{activity}[/{NORD5}]")
291
+
292
+ # Questions indicator - Nord aurora orange for attention
293
+ if workflow.state == WorkflowState.QUESTIONS:
294
+ lines.append(f"[{NORD12} bold]*** PRDs HAVE OPEN QUESTIONS ***[/{NORD12} bold]")
295
+
296
+ # Last output - Nord polar night for dim text
297
+ if self._last_output:
298
+ lines.append(f"[{NORD3}]{self._last_output}[/{NORD3}]")
299
+
300
+ # Paused indicator - Nord aurora yellow for warning
301
+ if workflow.is_paused:
302
+ lines.append(f"[{NORD13} bold]*** WORKFLOW PAUSED ***[/{NORD13} bold]")
303
+
304
+ # Harness unavailable indicator - Nord aurora red for warning
305
+ if not self._harness_available:
306
+ lines.append(f"[{NORD11} bold]*** NO HARNESS - WORKFLOW DISABLED ***[/{NORD11} bold]")
307
+
308
+ # Error message - Nord aurora red for errors
309
+ if workflow.error_message:
310
+ lines.append(f"[{NORD11}]Error: {workflow.error_message}[/{NORD11}]")
311
+
312
+ content = "\n".join(lines)
313
+ # Panel with Nord frost blue border and Snow Storm title
314
+ return Panel(
315
+ content,
316
+ title=f"[{NORD6} bold]Status[/{NORD6} bold]",
317
+ border_style=NORD10,
318
+ )
319
+
320
+ def _main_menu(self) -> str | None:
321
+ """Show the main menu and return the selected action."""
322
+ workflow = self._get_workflow()
323
+ has_questions = len(workflow.get_prds_with_questions()) > 0
324
+
325
+ # Check if we have stories to manage
326
+ has_stories = workflow.story_manager.has_tasks()
327
+
328
+ # If no harness available, show limited menu
329
+ if not self._harness_available:
330
+ choices = [
331
+ {"name": "Add PRD", "value": "add"},
332
+ {"name": "List PRDs", "value": "list"},
333
+ ]
334
+ if has_stories:
335
+ choices.append({"name": "Manage stories", "value": "stories"})
336
+ choices.extend([
337
+ {"name": "Settings", "value": "settings"},
338
+ {"name": "Quit", "value": "quit"},
339
+ ])
340
+ elif workflow.state == WorkflowState.QUESTIONS or has_questions:
341
+ # Questions need to be answered before continuing
342
+ choices = [
343
+ {"name": "Answer PRD questions", "value": "answer"},
344
+ {"name": "Add PRD", "value": "add"},
345
+ {"name": "List PRDs", "value": "list"},
346
+ ]
347
+ if has_stories:
348
+ choices.append({"name": "Manage stories", "value": "stories"})
349
+ choices.extend([
350
+ {"name": "Settings", "value": "settings"},
351
+ {"name": "Quit", "value": "quit"},
352
+ ])
353
+ elif workflow.is_paused:
354
+ choices = [
355
+ {"name": "Resume workflow", "value": "resume"},
356
+ {"name": "Add PRD", "value": "add"},
357
+ {"name": "List PRDs", "value": "list"},
358
+ ]
359
+ if has_stories:
360
+ choices.append({"name": "Manage stories", "value": "stories"})
361
+ choices.extend([
362
+ {"name": "Settings", "value": "settings"},
363
+ {"name": "Quit", "value": "quit"},
364
+ ])
365
+ else:
366
+ choices = [
367
+ {"name": "Run workflow", "value": "run"},
368
+ {"name": "Add PRD", "value": "add"},
369
+ {"name": "List PRDs", "value": "list"},
370
+ ]
371
+ if has_stories:
372
+ choices.append({"name": "Manage stories", "value": "stories"})
373
+ choices.extend([
374
+ {"name": "Pause workflow", "value": "pause"},
375
+ {"name": "Settings", "value": "settings"},
376
+ {"name": "Quit", "value": "quit"},
377
+ ])
378
+
379
+ result: str | None = questionary.select(
380
+ "What would you like to do?",
381
+ choices=choices,
382
+ style=MENU_STYLE,
383
+ instruction="(Use arrow keys, then Enter)",
384
+ ).ask()
385
+ return result
386
+
387
+ def _show_add_task_menu(self) -> None:
388
+ """Show the add task menu."""
389
+ self.console.print()
390
+
391
+ task_type = questionary.select(
392
+ "What type of PRD?",
393
+ choices=[
394
+ {"name": "Quick task (creates .txt for later speccing)", "value": "thin"},
395
+ {"name": "Cancel", "value": "cancel"},
396
+ ],
397
+ style=MENU_STYLE,
398
+ ).ask()
399
+
400
+ if task_type == "cancel" or task_type is None:
401
+ return
402
+
403
+ if task_type == "thin":
404
+ name = questionary.text(
405
+ "PRD name (will be used as filename):",
406
+ style=MENU_STYLE,
407
+ ).ask()
408
+
409
+ if not name:
410
+ return
411
+
412
+ description = questionary.text(
413
+ "Task description:",
414
+ style=MENU_STYLE,
415
+ ).ask()
416
+
417
+ if description:
418
+ workflow = self._get_workflow()
419
+ # Create a .txt file in the PRD folder
420
+ from .prd_manager import slugify
421
+ slug = slugify(name)
422
+ prd_file = workflow.prd_manager.prd_dir / f"{slug}.txt"
423
+ prd_file.write_text(description)
424
+ workflow.prd_manager.reload()
425
+ self.console.print(f"\n[green]Added PRD: {prd_file.name}[/green]")
426
+
427
+ def _show_settings_menu(self) -> None:
428
+ """Show the settings menu."""
429
+ while True:
430
+ self.console.print()
431
+
432
+ setting = questionary.select(
433
+ "Settings",
434
+ choices=[
435
+ {"name": f"Worker model: {self._config.worker_model}", "value": "worker_model"},
436
+ {"name": f"Summary model: {self._config.summary_model}", "value": "summary_model"},
437
+ {"name": f"Harness: {self._config.harness}", "value": "harness"},
438
+ {"name": f"Max iterations: {self._config.max_iterations}", "value": "max_iter"},
439
+ {"name": f"Max story attempts: {self._config.max_story_attempts}", "value": "max_attempts"},
440
+ {"name": f"Branch prefix: {self._config.branch_prefix}", "value": "branch_prefix"},
441
+ {"name": f"On error: {self._config.on_error}", "value": "on_error"},
442
+ {"name": f"Auto spec: {self._config.auto_spec_without_oversight}", "value": "auto_spec"},
443
+ {"name": f"Wait on rate limit: {self._config.wait_on_rate_limit}", "value": "rate_limit"},
444
+ {"name": f"Pause on completion: {self._config.pause_on_completion}", "value": "pause_completion"},
445
+ {"name": f"Always build tests: {self._config.always_build_tests}", "value": "tests"},
446
+ {"name": "Back", "value": "back"},
447
+ ],
448
+ style=MENU_STYLE,
449
+ ).ask()
450
+
451
+ if setting == "back" or setting is None:
452
+ break
453
+ elif setting == "worker_model":
454
+ self._show_model_selection("worker")
455
+ elif setting == "summary_model":
456
+ self._show_model_selection("summary")
457
+ elif setting == "harness":
458
+ self._show_harness_selection()
459
+ elif setting == "max_iter":
460
+ max_iter = questionary.text(
461
+ "Max iterations:",
462
+ default=str(self._config.max_iterations),
463
+ style=MENU_STYLE,
464
+ ).ask()
465
+ if max_iter and max_iter.isdigit():
466
+ self._config.max_iterations = int(max_iter)
467
+ elif setting == "max_attempts":
468
+ max_attempts = questionary.text(
469
+ "Max story attempts (before marking blocked):",
470
+ default=str(self._config.max_story_attempts),
471
+ style=MENU_STYLE,
472
+ ).ask()
473
+ if max_attempts and max_attempts.isdigit():
474
+ self._config.max_story_attempts = int(max_attempts)
475
+ elif setting == "branch_prefix":
476
+ prefix = questionary.text(
477
+ "Branch prefix (e.g., 'ralph' creates 'ralph/feature-name'):",
478
+ default=self._config.branch_prefix,
479
+ style=MENU_STYLE,
480
+ ).ask()
481
+ if prefix:
482
+ self._config.branch_prefix = prefix
483
+ self.console.print(f"[cyan]Branch prefix: {prefix}[/cyan]")
484
+ elif setting == "on_error":
485
+ on_error = questionary.select(
486
+ "On error:",
487
+ choices=["block", "retry", "pause", "skip"],
488
+ default=self._config.on_error,
489
+ style=MENU_STYLE,
490
+ ).ask()
491
+ if on_error:
492
+ self._config.on_error = on_error
493
+ elif setting == "auto_spec":
494
+ self._config.auto_spec_without_oversight = not self._config.auto_spec_without_oversight
495
+ self.console.print(f"[cyan]Auto spec: {self._config.auto_spec_without_oversight}[/cyan]")
496
+ elif setting == "rate_limit":
497
+ self._config.wait_on_rate_limit = not self._config.wait_on_rate_limit
498
+ self.console.print(f"[cyan]Wait on rate limit: {self._config.wait_on_rate_limit}[/cyan]")
499
+ elif setting == "pause_completion":
500
+ self._config.pause_on_completion = not self._config.pause_on_completion
501
+ self.console.print(f"[cyan]Pause on completion: {self._config.pause_on_completion}[/cyan]")
502
+ elif setting == "tests":
503
+ self._config.always_build_tests = not self._config.always_build_tests
504
+ self.console.print(f"[cyan]Always build tests: {self._config.always_build_tests}[/cyan]")
505
+
506
+ def _show_model_selection(self, role: str) -> None:
507
+ """Show harness-specific model selection menu."""
508
+ self.console.print()
509
+
510
+ # Get the current harness and query for supported models
511
+ harness = Harness.from_config(self._config.harness)
512
+ supported_models = harness.get_supported_models()
513
+ current_model = (
514
+ self._config.worker_model if role == "worker" else self._config.summary_model
515
+ )
516
+ role_label = "Worker model" if role == "worker" else "Summary model"
517
+
518
+ # For unknown harnesses (custom) with no models, fall back to text input
519
+ if not supported_models:
520
+ self.console.print(
521
+ f"[{NORD13}]No predefined models for harness '{harness.name}'.[/{NORD13}]"
522
+ )
523
+ model_name: str | None = questionary.text(
524
+ f"Enter {role_label.lower()} name:",
525
+ default=current_model,
526
+ style=MENU_STYLE,
527
+ ).ask()
528
+ if model_name and model_name.strip():
529
+ if role == "worker":
530
+ self._config.worker_model = model_name.strip()
531
+ else:
532
+ self._config.summary_model = model_name.strip()
533
+ self.console.print(
534
+ f"[{NORD14}]{role_label} set to: {model_name.strip()}[/{NORD14}]"
535
+ )
536
+ return
537
+
538
+ # Build choices (model name only)
539
+ from questionary import Choice
540
+
541
+ choices: list[Choice] = []
542
+
543
+ for model_name, label in supported_models:
544
+ choices.append(Choice(title=model_name, value=model_name))
545
+
546
+ # No models available - show alert and return
547
+ if not choices:
548
+ self.console.bell()
549
+ self.console.print(
550
+ f"[{NORD11}]No models available for harness '{harness.name}'.[/{NORD11}]"
551
+ )
552
+ return
553
+
554
+ # Get the valid model values (excluding cancel)
555
+ valid_model_values: list[str] = [str(c.value) for c in choices]
556
+
557
+ # Add cancel option
558
+ choices.append(Choice(title="Cancel", value="cancel"))
559
+
560
+ # Determine the default - MUST be a value that exists in choices
561
+ if current_model in valid_model_values:
562
+ default_choice = current_model
563
+ else:
564
+ # Current model not valid - use first available model as default
565
+ default_choice = valid_model_values[0]
566
+ if role == "worker":
567
+ self._config.worker_model = default_choice
568
+ else:
569
+ self._config.summary_model = default_choice
570
+ self.console.print(
571
+ f"[{NORD13}]{role_label} '{current_model}' not supported by {harness.name}. "
572
+ f"Reset to: {default_choice}[/{NORD13}]"
573
+ )
574
+
575
+ try:
576
+ selected: str | None = questionary.select(
577
+ f"Select {role_label.lower()} (current: {current_model}):",
578
+ choices=choices,
579
+ default=default_choice,
580
+ style=MENU_STYLE,
581
+ ).ask()
582
+ except ValueError as e:
583
+ # Debug info to diagnose questionary issues
584
+ self.console.print(f"[{NORD11}]Error in model selection:[/{NORD11}]")
585
+ self.console.print(f" default_choice: {default_choice!r}")
586
+ self.console.print(f" valid_model_values: {valid_model_values!r}")
587
+ choice_info = [(c.title, c.value) for c in choices]
588
+ self.console.print(f" choices: {choice_info!r}")
589
+ self.console.print(f" harness: {harness.name} (type={harness.type})")
590
+ self.console.print(f" {role_label.lower()}: {current_model!r}")
591
+ raise e
592
+
593
+ if selected and selected != "cancel":
594
+ if role == "worker":
595
+ self._config.worker_model = selected
596
+ else:
597
+ self._config.summary_model = selected
598
+ self.console.print(f"[{NORD14}]{role_label} set to: {selected}[/{NORD14}]")
599
+
600
+ def _show_harness_selection(self) -> None:
601
+ """Show harness selection submenu with auto-detect, custom, and detected options."""
602
+ self.console.print()
603
+
604
+ # Run auto-detection to find available harnesses
605
+ detector = HarnessDetector()
606
+ detected_harnesses = detector.detect_all()
607
+
608
+ # Build choices list
609
+ choices: list[dict[str, str]] = []
610
+
611
+ # Add Auto-detect option first
612
+ choices.append({
613
+ "name": "Auto-detect (scan PATH for available tools)",
614
+ "value": "auto-detect",
615
+ })
616
+
617
+ # Add detected harnesses with full paths
618
+ for harness in detected_harnesses:
619
+ choices.append({
620
+ "name": f"{harness.name} ({harness.path})",
621
+ "value": harness.path,
622
+ })
623
+
624
+ # Add Custom option
625
+ choices.append({
626
+ "name": "Custom (enter path manually)",
627
+ "value": "custom",
628
+ })
629
+
630
+ # Add Cancel option
631
+ choices.append({
632
+ "name": "Cancel",
633
+ "value": "cancel",
634
+ })
635
+
636
+ result: str | None = questionary.select(
637
+ f"Select harness (current: {self._config.harness}):",
638
+ choices=choices,
639
+ style=MENU_STYLE,
640
+ ).ask()
641
+
642
+ if result is None or result == "cancel":
643
+ return
644
+
645
+ if result == "auto-detect":
646
+ # Show auto-detection results
647
+ self._show_autodetect_results(detected_harnesses)
648
+ elif result == "custom":
649
+ # Prompt for custom path
650
+ self._prompt_custom_harness()
651
+ else:
652
+ # User selected a detected harness - save it
653
+ self._set_harness(result)
654
+
655
+ def _show_autodetect_results(self, detected_harnesses: list[Harness]) -> None:
656
+ """Show auto-detection results and allow selection."""
657
+ self.console.print()
658
+
659
+ if not detected_harnesses:
660
+ self.console.print(f"[{NORD13}]No harnesses detected on PATH.[/{NORD13}]")
661
+ self.console.print(f"[{NORD3}]Looked for: claude, codex[/{NORD3}]")
662
+ self.console.print()
663
+
664
+ # Offer custom path option
665
+ enter_custom = questionary.confirm(
666
+ "Would you like to enter a custom harness path?",
667
+ default=True,
668
+ style=MENU_STYLE,
669
+ ).ask()
670
+
671
+ if enter_custom:
672
+ self._prompt_custom_harness()
673
+ return
674
+
675
+ # Show detected harnesses
676
+ self.console.print(f"[{NORD14}]Detected harnesses:[/{NORD14}]")
677
+ for harness in detected_harnesses:
678
+ self.console.print(f" [{NORD8}]{harness.name}[/{NORD8}] [{NORD3}]({harness.path})[/{NORD3}]")
679
+ self.console.print()
680
+
681
+ # Build selection choices
682
+ choices: list[dict[str, str]] = []
683
+ for harness in detected_harnesses:
684
+ choices.append({
685
+ "name": f"{harness.name} ({harness.path})",
686
+ "value": harness.path,
687
+ })
688
+ choices.append({"name": "Cancel", "value": "cancel"})
689
+
690
+ selected: str | None = questionary.select(
691
+ "Select a harness to use:",
692
+ choices=choices,
693
+ style=MENU_STYLE,
694
+ ).ask()
695
+
696
+ if selected and selected != "cancel":
697
+ self._set_harness(selected)
698
+
699
+ def _prompt_custom_harness(self) -> None:
700
+ """Prompt user to enter a custom harness path."""
701
+ self.console.print()
702
+
703
+ custom_path: str | None = questionary.text(
704
+ "Enter harness path or command:",
705
+ style=MENU_STYLE,
706
+ ).ask()
707
+
708
+ if custom_path and custom_path.strip():
709
+ self._set_harness(custom_path.strip())
710
+
711
+ def _set_harness(self, harness_path: str) -> None:
712
+ """Set and validate a harness path, updating model if needed."""
713
+ self._config.harness = harness_path
714
+
715
+ # Validate the harness
716
+ harness_obj = Harness.from_config(harness_path)
717
+ if harness_obj.is_available:
718
+ self._harness_available = True
719
+ self.console.print(f"[{NORD14}]Harness set to: {harness_path}[/{NORD14}]")
720
+ else:
721
+ self._harness_available = False
722
+ self.console.print(f"[{NORD11}]Warning: Harness '{harness_path}' not found or not executable[/{NORD11}]")
723
+ self.console.print(f"[{NORD13}]The harness will be saved, but workflow will be disabled until a valid harness is configured.[/{NORD13}]")
724
+
725
+ # Check if current models are valid for the new harness
726
+ current_worker = self._config.worker_model
727
+ if not harness_obj.is_model_supported(current_worker):
728
+ default_worker = harness_obj.get_default_worker_model()
729
+ if default_worker:
730
+ self._config.worker_model = default_worker
731
+ self.console.print(
732
+ f"[{NORD13}]Worker model '{current_worker}' not supported by {harness_obj.name}. "
733
+ f"Switched to: {default_worker}[/{NORD13}]"
734
+ )
735
+ else:
736
+ self.console.print(
737
+ f"[{NORD13}]Note: Worker model '{current_worker}' may not be supported by {harness_obj.name}. "
738
+ f"You may need to configure a worker model in Settings.[/{NORD13}]"
739
+ )
740
+
741
+ current_summary = self._config.summary_model
742
+ if not harness_obj.is_model_supported(current_summary):
743
+ default_summary = harness_obj.get_default_summary_model()
744
+ if default_summary:
745
+ self._config.summary_model = default_summary
746
+ self.console.print(
747
+ f"[{NORD13}]Summary model '{current_summary}' not supported by {harness_obj.name}. "
748
+ f"Switched to: {default_summary}[/{NORD13}]"
749
+ )
750
+ else:
751
+ self.console.print(
752
+ f"[{NORD13}]Note: Summary model '{current_summary}' may not be supported by {harness_obj.name}. "
753
+ f"You may need to configure a summary model in Settings.[/{NORD13}]"
754
+ )
755
+
756
+ def _show_prd_list(self) -> None:
757
+ """Show the current PRD list with Nord theme colors."""
758
+ workflow = self._get_workflow()
759
+ prds = workflow.prd_manager.get_all_prds()
760
+
761
+ self.console.print()
762
+
763
+ if not prds:
764
+ self.console.print(f"[{NORD3}]No PRDs yet. Add some tasks to get started![/{NORD3}]")
765
+ return
766
+
767
+ # Table with Nord frost blue border and Snow Storm title
768
+ table = Table(title=f"[{NORD6}]PRDs[/{NORD6}]", border_style=NORD10)
769
+ table.add_column("Status", style=NORD8, width=12)
770
+ table.add_column("Name", style=NORD4)
771
+ table.add_column("Type", style=NORD3, width=8)
772
+
773
+ # Status badges using Nord Aurora colors (US-007):
774
+ # unspecced = dim gray (NORD3)
775
+ # questions = aurora yellow (NORD13)
776
+ # pending = aurora orange (NORD12)
777
+ # in_progress = aurora green (NORD14)
778
+ # completed = aurora green dim (dim NORD14)
779
+ # errored = aurora red (NORD11)
780
+ status_styles = {
781
+ "unspecced": NORD3, # dim gray
782
+ "questions": NORD13, # aurora yellow
783
+ "pending": NORD12, # aurora orange
784
+ "in_progress": NORD14, # aurora green
785
+ "completed": f"dim {NORD14}", # aurora green dim
786
+ "errored": NORD11, # aurora red
787
+ }
788
+
789
+ for prd in prds:
790
+ status_style = status_styles.get(prd.status, NORD4)
791
+ prd_type = "specced" if prd.is_specced else "unspecced"
792
+ table.add_row(
793
+ f"[{status_style}]{prd.status}[/{status_style}]",
794
+ prd.name,
795
+ prd_type,
796
+ )
797
+
798
+ self.console.print(table)
799
+
800
+ def _show_manage_stories(self) -> None:
801
+ """Show UI to manage user stories (mark as passed/unblock)."""
802
+ workflow = self._get_workflow()
803
+
804
+ if not workflow.story_manager.has_tasks():
805
+ self.console.print(f"[{NORD3}]No stories to manage.[/{NORD3}]")
806
+ return
807
+
808
+ tasks_file = workflow.story_manager.get_tasks_file()
809
+ if not tasks_file:
810
+ return
811
+
812
+ # Outer loop: story selection list
813
+ while True:
814
+ # Reload stories to reflect any changes made
815
+ workflow.story_manager.reload()
816
+ tasks_file = workflow.story_manager.get_tasks_file()
817
+ if not tasks_file:
818
+ return
819
+ stories = tasks_file.user_stories
820
+
821
+ self.console.print()
822
+
823
+ # Build story choices (plain text - questionary doesn't support Rich markup)
824
+ max_attempts = self._config.max_story_attempts
825
+ choices = []
826
+ for story in stories:
827
+ if story.passes:
828
+ status = "PASSED"
829
+ elif story.needs_intervention:
830
+ status = "NEEDS HELP"
831
+ elif story.blocked or story.attempts >= max_attempts:
832
+ status = "BLOCKED"
833
+ elif story.attempts > 0:
834
+ status = f"{story.attempts} attempts"
835
+ else:
836
+ status = "pending"
837
+
838
+ choices.append({
839
+ "name": f"{story.id}: {story.title} ({status})",
840
+ "value": story.id,
841
+ })
842
+ choices.append({"name": "Back", "value": "back"})
843
+
844
+ story_id = questionary.select(
845
+ "Select a story to manage:",
846
+ choices=choices,
847
+ style=MENU_STYLE,
848
+ ).ask()
849
+
850
+ if story_id == "back" or story_id is None:
851
+ return
852
+
853
+ # Find the story
854
+ story_match = next((s for s in stories if s.id == story_id), None)
855
+ if not story_match:
856
+ continue
857
+ story = story_match
858
+
859
+ # Inner loop: story actions until user selects Back
860
+ while True:
861
+ # Show story details
862
+ self.console.print(f"\n[{NORD6} bold]{story.id}: {story.title}[/{NORD6} bold]")
863
+ self.console.print(f"[{NORD4}]{story.description}[/{NORD4}]")
864
+ self.console.print(f"\n[{NORD8}]Acceptance Criteria:[/{NORD8}]")
865
+ for criterion in story.acceptance_criteria:
866
+ self.console.print(f" [{NORD4}]- {criterion}[/{NORD4}]")
867
+ intervention_str = f" | Needs Help: {story.needs_intervention}" if story.needs_intervention else ""
868
+ self.console.print(f"\n[{NORD3}]Attempts: {story.attempts} | Passes: {story.passes} | Blocked: {story.blocked}{intervention_str}[/{NORD3}]")
869
+ max_attempts = self._config.max_story_attempts
870
+
871
+ # Show action choices
872
+ action_choices = []
873
+ if story.notes:
874
+ action_choices.append({"name": "View notes", "value": "notes"})
875
+ action_choices.append({"name": "Edit acceptance criteria", "value": "edit_criteria"})
876
+ if not story.passes:
877
+ action_choices.append({"name": "Mark as PASSED", "value": "pass"})
878
+ if story.needs_intervention:
879
+ action_choices.append({"name": "Mark intervention complete (retry)", "value": "resolve_intervention"})
880
+ if not story.passes and story.attempts >= max_attempts and not story.blocked:
881
+ action_choices.append({"name": "Retry (reset attempts)", "value": "retry"})
882
+ if story.blocked and not story.needs_intervention:
883
+ action_choices.append({"name": "Unblock (reset attempts)", "value": "unblock"})
884
+ if story.passes:
885
+ action_choices.append({"name": "Mark as NOT passed", "value": "unpass"})
886
+ action_choices.append({"name": "Back", "value": "back"})
887
+
888
+ action = questionary.select(
889
+ "What would you like to do?",
890
+ choices=action_choices,
891
+ style=MENU_STYLE,
892
+ ).ask()
893
+
894
+ if action == "back" or action is None:
895
+ break
896
+ elif action == "notes":
897
+ self._view_story_notes(story)
898
+ elif action == "edit_criteria":
899
+ self._edit_acceptance_criteria(story, workflow)
900
+ elif action == "pass":
901
+ story.passes = True
902
+ story.blocked = False
903
+ workflow.story_manager.update_story(story)
904
+ self.console.print(f"[{NORD14}]Marked {story.id} as PASSED[/{NORD14}]")
905
+ elif action == "resolve_intervention":
906
+ story.blocked = False
907
+ story.needs_intervention = False
908
+ story.attempts = 0
909
+ workflow.story_manager.update_story(story)
910
+ self.console.print(f"[{NORD14}]Intervention resolved for {story.id} (ready for retry)[/{NORD14}]")
911
+ elif action == "retry":
912
+ story.blocked = False
913
+ story.needs_intervention = False
914
+ story.attempts = 0
915
+ workflow.story_manager.update_story(story)
916
+ self.console.print(f"[{NORD14}]Reset attempts for {story.id} (ready for retry)[/{NORD14}]")
917
+ elif action == "unblock":
918
+ story.blocked = False
919
+ story.needs_intervention = False
920
+ story.attempts = 0
921
+ workflow.story_manager.update_story(story)
922
+ self.console.print(f"[{NORD14}]Unblocked {story.id} (attempts reset)[/{NORD14}]")
923
+ elif action == "unpass":
924
+ story.passes = False
925
+ workflow.story_manager.update_story(story)
926
+ self.console.print(f"[{NORD13}]Marked {story.id} as NOT passed[/{NORD13}]")
927
+
928
+ def _view_story_notes(self, story: UserStory) -> None:
929
+ """View story notes in a pager for scrolling."""
930
+ if not story.notes:
931
+ self.console.print(f"[{NORD3}]No notes for this story.[/{NORD3}]")
932
+ return
933
+
934
+ # Build plain text notes content (pager doesn't handle Rich markup well)
935
+ notes_content = f"Notes for {story.id}: {story.title}\n"
936
+ notes_content += "=" * 60 + "\n\n"
937
+ notes_content += story.notes
938
+ notes_content += "\n\n" + "=" * 60
939
+ notes_content += "\n(Press 'q' to exit)"
940
+
941
+ # Use pager for scrollable output - plain text mode
942
+ with self.console.pager():
943
+ self.console.print(notes_content)
944
+
945
+ def _edit_acceptance_criteria(
946
+ self, story: UserStory, workflow: WorkflowEngine
947
+ ) -> None:
948
+ """Edit acceptance criteria for a story."""
949
+ self.console.print(f"\n[{NORD8}]Editing acceptance criteria for {story.id}[/{NORD8}]")
950
+ self.console.print(f"[{NORD3}]Current criteria:[/{NORD3}]")
951
+
952
+ for i, criterion in enumerate(story.acceptance_criteria, 1):
953
+ self.console.print(f" [{NORD4}]{i}. {criterion}[/{NORD4}]")
954
+
955
+ self.console.print()
956
+
957
+ while True:
958
+ action = questionary.select(
959
+ "What would you like to do?",
960
+ choices=[
961
+ {"name": "Edit a criterion", "value": "edit"},
962
+ {"name": "Add a criterion", "value": "add"},
963
+ {"name": "Remove a criterion", "value": "remove"},
964
+ {"name": "Done", "value": "done"},
965
+ ],
966
+ style=MENU_STYLE,
967
+ ).ask()
968
+
969
+ if action == "done" or action is None:
970
+ break
971
+ elif action == "edit":
972
+ self._edit_single_criterion(story, workflow)
973
+ elif action == "add":
974
+ self._add_criterion(story, workflow)
975
+ elif action == "remove":
976
+ self._remove_criterion(story, workflow)
977
+
978
+ def _edit_single_criterion(
979
+ self, story: UserStory, workflow: WorkflowEngine
980
+ ) -> None:
981
+ """Edit a single acceptance criterion."""
982
+ if not story.acceptance_criteria:
983
+ self.console.print(f"[{NORD13}]No criteria to edit.[/{NORD13}]")
984
+ return
985
+
986
+ # Build choices for criteria
987
+ choices = [
988
+ {"name": f"{i}. {c}", "value": i - 1}
989
+ for i, c in enumerate(story.acceptance_criteria, 1)
990
+ ]
991
+ choices.append({"name": "Cancel", "value": -1})
992
+
993
+ idx = questionary.select(
994
+ "Select criterion to edit:",
995
+ choices=choices,
996
+ style=MENU_STYLE,
997
+ ).ask()
998
+
999
+ if idx is None or idx == -1:
1000
+ return
1001
+
1002
+ current = story.acceptance_criteria[idx]
1003
+ new_value = questionary.text(
1004
+ "Edit criterion:",
1005
+ default=current,
1006
+ style=MENU_STYLE,
1007
+ ).ask()
1008
+
1009
+ if new_value and new_value.strip() and new_value != current:
1010
+ story.acceptance_criteria[idx] = new_value.strip()
1011
+ workflow.story_manager.update_story(story)
1012
+ self.console.print(f"[{NORD14}]Criterion updated.[/{NORD14}]")
1013
+
1014
+ def _add_criterion(self, story: UserStory, workflow: WorkflowEngine) -> None:
1015
+ """Add a new acceptance criterion."""
1016
+ new_criterion = questionary.text(
1017
+ "Enter new criterion:",
1018
+ style=MENU_STYLE,
1019
+ ).ask()
1020
+
1021
+ if new_criterion and new_criterion.strip():
1022
+ story.acceptance_criteria.append(new_criterion.strip())
1023
+ workflow.story_manager.update_story(story)
1024
+ self.console.print(f"[{NORD14}]Criterion added.[/{NORD14}]")
1025
+
1026
+ def _remove_criterion(
1027
+ self, story: UserStory, workflow: WorkflowEngine
1028
+ ) -> None:
1029
+ """Remove an acceptance criterion."""
1030
+ if not story.acceptance_criteria:
1031
+ self.console.print(f"[{NORD13}]No criteria to remove.[/{NORD13}]")
1032
+ return
1033
+
1034
+ if len(story.acceptance_criteria) == 1:
1035
+ self.console.print(
1036
+ f"[{NORD11}]Cannot remove the last criterion. Stories must have at least one.[/{NORD11}]"
1037
+ )
1038
+ return
1039
+
1040
+ # Build choices for criteria
1041
+ choices = [
1042
+ {"name": f"{i}. {c}", "value": i - 1}
1043
+ for i, c in enumerate(story.acceptance_criteria, 1)
1044
+ ]
1045
+ choices.append({"name": "Cancel", "value": -1})
1046
+
1047
+ idx = questionary.select(
1048
+ "Select criterion to remove:",
1049
+ choices=choices,
1050
+ style=MENU_STYLE,
1051
+ ).ask()
1052
+
1053
+ if idx is None or idx == -1:
1054
+ return
1055
+
1056
+ removed = story.acceptance_criteria.pop(idx)
1057
+ workflow.story_manager.update_story(story)
1058
+ self.console.print(f"[{NORD14}]Removed: {removed}[/{NORD14}]")
1059
+
1060
+ def _show_answer_questions(self) -> None:
1061
+ """Show UI to answer PRD questions."""
1062
+ workflow = self._get_workflow()
1063
+ prds_with_questions = workflow.get_prds_with_questions()
1064
+
1065
+ if not prds_with_questions:
1066
+ self.console.print("[dim]No PRDs have open questions.[/dim]")
1067
+ return
1068
+
1069
+ for prd in prds_with_questions:
1070
+ self.console.print(f"\n[bold cyan]PRD: {prd.name}[/bold cyan]")
1071
+ self.console.print(f"[dim]{prd.description[:200]}...[/dim]\n")
1072
+
1073
+ answers = []
1074
+ for question in prd.questions:
1075
+ self.console.print(f"[yellow]Q: {question.question}[/yellow]")
1076
+
1077
+ if question.options:
1078
+ # Multi-choice question
1079
+ answer = questionary.select(
1080
+ "Select an answer:",
1081
+ choices=question.options + ["Other (type answer)"],
1082
+ style=MENU_STYLE,
1083
+ ).ask()
1084
+
1085
+ if answer is None:
1086
+ return
1087
+
1088
+ if answer == "Other (type answer)":
1089
+ answer = questionary.text(
1090
+ "Your answer:",
1091
+ style=MENU_STYLE,
1092
+ ).ask()
1093
+ if answer is None:
1094
+ return
1095
+ else:
1096
+ # Free-form question
1097
+ answer = questionary.text(
1098
+ "Your answer:",
1099
+ style=MENU_STYLE,
1100
+ ).ask()
1101
+ if answer is None:
1102
+ return
1103
+
1104
+ answers.append(answer)
1105
+ self.console.print(f"[green]A: {answer}[/green]\n")
1106
+
1107
+ # Confirm and save
1108
+ confirm = questionary.confirm(
1109
+ f"Save answers for '{prd.name}'?",
1110
+ default=True,
1111
+ style=MENU_STYLE,
1112
+ ).ask()
1113
+
1114
+ if confirm is None:
1115
+ return
1116
+
1117
+ if confirm:
1118
+ workflow.answer_questions(prd, answers)
1119
+ self.console.print(f"[green]Saved answers for {prd.name}[/green]")
1120
+
1121
+ # Resume workflow if it was paused for questions
1122
+ if workflow.state == WorkflowState.QUESTIONS:
1123
+ workflow.resume()
1124
+
1125
+ def run(self) -> None:
1126
+ """Run the main application loop."""
1127
+ self._running = True
1128
+ workflow = self._get_workflow()
1129
+
1130
+ # Show header
1131
+ self._show_header()
1132
+
1133
+ if self.debug:
1134
+ self.console.print("[yellow]Debug mode enabled[/yellow]\n")
1135
+
1136
+ # Validate harness on startup
1137
+ self._validate_harness()
1138
+
1139
+ # Notify if workflow was paused from previous session
1140
+ if workflow.is_paused:
1141
+ self.console.print("[yellow]Workflow was paused from previous session.[/yellow]")
1142
+ if workflow.current_task:
1143
+ self.console.print(f"[yellow]PRD in progress: {workflow.current_task.name}[/yellow]")
1144
+ self.console.print()
1145
+
1146
+ while self._running:
1147
+ # Show status
1148
+ self.console.print(self._build_status_panel())
1149
+
1150
+ # Get user choice
1151
+ try:
1152
+ choice = self._main_menu()
1153
+
1154
+ if choice is None or choice == "quit":
1155
+ confirm = questionary.confirm(
1156
+ "Are you sure you want to quit?",
1157
+ default=False,
1158
+ style=MENU_STYLE,
1159
+ ).ask()
1160
+ if confirm:
1161
+ self._running = False
1162
+ elif choice == "add":
1163
+ self._show_add_task_menu()
1164
+ elif choice == "answer":
1165
+ self._show_answer_questions()
1166
+ elif choice == "settings":
1167
+ self._show_settings_menu()
1168
+ elif choice == "run" or choice == "resume":
1169
+ self._run_workflow()
1170
+ elif choice == "pause":
1171
+ workflow.pause()
1172
+ self.console.print("[yellow]Paused[/yellow]")
1173
+ elif choice == "list":
1174
+ self._show_prd_list()
1175
+ elif choice == "stories":
1176
+ self._show_manage_stories()
1177
+
1178
+ except KeyboardInterrupt:
1179
+ self.console.print("\n[yellow]Interrupted[/yellow]")
1180
+ confirm = questionary.confirm(
1181
+ "Are you sure you want to quit?",
1182
+ default=False,
1183
+ style=MENU_STYLE,
1184
+ ).ask()
1185
+ if confirm:
1186
+ self._running = False
1187
+
1188
+ # Goodbye message with Ralph - Nord theme colors
1189
+ self.console.print(Panel(
1190
+ RALPH_ART_SMALL + f"\n[{NORD4}]Goodbye![/{NORD4}]",
1191
+ border_style=NORD8,
1192
+ ))
1193
+
1194
+ def _build_live_status(self) -> Panel:
1195
+ """Build a live status panel for workflow execution with Nord theme colors."""
1196
+ workflow = self._get_workflow()
1197
+ stats = workflow.prd_manager.get_stats()
1198
+
1199
+ # State colors using Nord palette (US-006)
1200
+ state_colors = {
1201
+ WorkflowState.IDLE: NORD3, # dim (Polar Night)
1202
+ WorkflowState.SPECCING: NORD8, # frost (Frost cyan)
1203
+ WorkflowState.QUESTIONS: NORD8, # frost (Frost cyan)
1204
+ WorkflowState.CONVERTING: NORD8, # frost (Frost cyan)
1205
+ WorkflowState.PICKING: NORD5, # snow (Snow Storm)
1206
+ WorkflowState.IMPLEMENTING: NORD14, # aurora green
1207
+ WorkflowState.REVIEWING: NORD8, # frost cyan
1208
+ WorkflowState.TESTING: NORD10, # frost blue
1209
+ WorkflowState.COMMITTING: NORD15, # aurora purple
1210
+ WorkflowState.ARCHIVING: NORD14, # aurora green
1211
+ WorkflowState.PAUSED: NORD13, # aurora yellow
1212
+ WorkflowState.ERROR: NORD11, # aurora red
1213
+ }
1214
+ state_color = state_colors.get(workflow.state, NORD4)
1215
+
1216
+ lines = []
1217
+
1218
+ # Current state with spinner for active states
1219
+ is_active = (workflow.state in SPINNER_STATES) and (not workflow.is_paused)
1220
+ if is_active:
1221
+ # Update spinner message and get current frame
1222
+ message = self._state_message or SPINNER_MESSAGES.get(
1223
+ workflow.state, workflow.state.value.upper()
1224
+ )
1225
+ self._spinner.message = message
1226
+ self._spinner.spinner_color = state_color
1227
+ spinner_frame = self._spinner.current_frame
1228
+ self._spinner.next_frame()
1229
+ lines.append(f"[bold {state_color}]{spinner_frame} {message}[/bold {state_color}]")
1230
+ else:
1231
+ lines.append(f"[bold {state_color}]● {workflow.state.value.upper()}[/bold {state_color}]")
1232
+
1233
+ # Current PRD - Snow Storm for text
1234
+ if workflow.current_task:
1235
+ lines.append(f" [{NORD6} bold]PRD:[/{NORD6} bold] [{NORD4}]{workflow.current_task.name}[/{NORD4}]")
1236
+
1237
+ # Current story
1238
+ if workflow.current_story:
1239
+ lines.append(f" [{NORD6} bold]Story:[/{NORD6} bold] [{NORD4}]{workflow.current_story.id} - {workflow.current_story.title}[/{NORD4}]")
1240
+
1241
+ # Last output - Polar Night for dim text
1242
+ if self._last_output:
1243
+ lines.append(f" [{NORD3}]{self._last_output}[/{NORD3}]")
1244
+
1245
+ lines.append("")
1246
+
1247
+ # PRD stats: Unspecced | Questions | Pending | In Progress | Complete | Errored
1248
+ # PRD status colors (US-007):
1249
+ # unspecced = dim gray (NORD3)
1250
+ # questions = aurora yellow (NORD13)
1251
+ # pending = aurora orange (NORD12)
1252
+ # in_progress = aurora green (NORD14)
1253
+ # completed = aurora green dim (NORD14 dim)
1254
+ # errored = aurora red (NORD11)
1255
+ parts = []
1256
+ if stats['unspecced'] > 0:
1257
+ parts.append(f"[{NORD3}]{stats['unspecced']} unspecced[/{NORD3}]")
1258
+ if stats['questions'] > 0:
1259
+ parts.append(f"[{NORD13}]{stats['questions']} questions[/{NORD13}]")
1260
+ parts.append(f"[{NORD12}]{stats['pending']} pending[/{NORD12}]")
1261
+ parts.append(f"[{NORD14}]{stats['in_progress']} in progress[/{NORD14}]")
1262
+ parts.append(f"[dim {NORD14}]{stats['completed']} complete[/dim {NORD14}]")
1263
+ if stats['errored'] > 0:
1264
+ parts.append(f"[{NORD11}]{stats['errored']} errored[/{NORD11}]")
1265
+ lines.append(f"[{NORD6} bold]PRDs:[/{NORD6} bold] [{NORD4}]{' | '.join(parts)}[/{NORD4}]")
1266
+
1267
+ # Story progress (if tasks.json exists)
1268
+ story_completed, story_total, story_percent = workflow.get_story_progress()
1269
+ if story_total > 0:
1270
+ lines.append(f"[{NORD6} bold]Stories:[/{NORD6} bold] [{NORD4}]{story_percent}% ({story_completed}/{story_total} complete)[/{NORD4}]")
1271
+
1272
+ lines.append(f"[{NORD3}]Session: {workflow.completed_this_session} PRDs completed[/{NORD3}]")
1273
+
1274
+ # Error message - Nord aurora red
1275
+ if workflow.error_message:
1276
+ lines.append(f"\n[{NORD11}]Error: {workflow.error_message}[/{NORD11}]")
1277
+
1278
+ lines.append(f"\n[{NORD3}]Press Ctrl+C to pause[/{NORD3}]")
1279
+
1280
+ content = "\n".join(lines)
1281
+ # Panel with Nord aurora green border (active workflow) and Snow Storm title
1282
+ return Panel(
1283
+ content,
1284
+ title=f"[{NORD6} bold]Workflow Running[/{NORD6} bold]",
1285
+ border_style=NORD14,
1286
+ )
1287
+
1288
+ def _run_workflow(self) -> None:
1289
+ """Run the workflow engine."""
1290
+ workflow = self._get_workflow()
1291
+
1292
+ if workflow.is_paused:
1293
+ workflow.resume()
1294
+
1295
+ self.console.print()
1296
+ self._last_status_key = None # Reset change detection
1297
+
1298
+ try:
1299
+ # Higher refresh rate for spinner animation
1300
+ with Live(self._build_live_status(), refresh_per_second=10, console=self.console) as live:
1301
+ self._live = live
1302
+ self._spinner.reset()
1303
+ while workflow.step():
1304
+ # Always update to animate spinner during active states
1305
+ live.update(self._build_live_status())
1306
+ self._live = None
1307
+
1308
+ except KeyboardInterrupt:
1309
+ self._live = None
1310
+ workflow.pause()
1311
+ self.console.print("\n[yellow]Paused by user[/yellow]")
1312
+
1313
+ # Show final status
1314
+ if workflow.state == WorkflowState.IDLE:
1315
+ stats = workflow.prd_manager.get_stats()
1316
+ if stats["pending"] == 0 and stats["unspecced"] == 0:
1317
+ self.console.print("\n[green]All PRDs completed![/green]")
1318
+ else:
1319
+ self.console.print(f"\n[yellow]Paused. {stats['pending']} PRDs pending.[/yellow]")
1320
+
1321
+
1322
+ def main(project_dir: Path | None = None, debug: bool = False) -> None:
1323
+ """Main entry point for the application."""
1324
+ if project_dir is None:
1325
+ project_dir = Path.cwd()
1326
+
1327
+ app = RalphApp(project_dir, debug=debug)
1328
+ app.run()