Coverage for little_loops / init / tui.py: 0%

278 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -0500

1"""Interactive TUI for ll-init using questionary and rich.""" 

2 

3from __future__ import annotations 

4 

5import sys 

6from pathlib import Path 

7from typing import Any 

8 

9import questionary 

10from rich.console import Console 

11from rich.panel import Panel 

12from rich.table import Table 

13 

14# Feature choices in display order for the multi-select screen 

15_FEATURE_CHOICES: list[tuple[str, str]] = [ 

16 ("Parallel processing (ll-parallel)", "parallel"), 

17 ("Product analysis (ll-scan-product)", "product"), 

18 ("Document tracking", "documents"), 

19 ("Design tokens", "design_tokens"), 

20 ("Learning tests registry", "learning_tests"), 

21 ("Analytics capture", "analytics"), 

22 ("Context monitor (auto-handoff)", "context_monitor"), 

23 ("GitHub sync (ll-sync)", "github_sync"), 

24 ("Confidence gate", "confidence_gate"), 

25 ("TDD mode", "tdd"), 

26] 

27 

28_DEFAULT_FEATURES: frozenset[str] = frozenset( 

29 {"parallel", "product", "learning_tests", "analytics", "context_monitor"} 

30) 

31 

32_FEATURE_LABELS: dict[str, str] = { 

33 "parallel": "Parallel processing", 

34 "product": "Product analysis", 

35 "documents": "Document tracking", 

36 "design_tokens": "Design tokens", 

37 "learning_tests": "Learning tests", 

38 "analytics": "Analytics", 

39 "context_monitor": "Context monitor", 

40 "github_sync": "GitHub sync", 

41 "confidence_gate": "Confidence gate", 

42 "tdd": "TDD mode", 

43} 

44 

45# Host choices for the multi-select screen 

46_HOST_CHOICES: list[tuple[str, str]] = [ 

47 ("Claude Code (global plugin — no adapter file needed)", "claude-code"), 

48 ("Codex CLI (writes .codex/hooks.json)", "codex"), 

49 ("Pi (not yet available — EPIC-1622)", "pi"), 

50] 

51 

52_HOST_LABELS: dict[str, str] = { 

53 "claude-code": "Claude Code", 

54 "codex": "Codex CLI", 

55 "pi": "Pi", 

56} 

57 

58# Sentinel used in curated command menus for the free-text fallthrough 

59_CUSTOM_SENTINEL = "Custom…" 

60 

61# Named design-token profiles (discovered from templates/design-tokens/profiles/) 

62_DESIGN_TOKEN_PROFILES: list[tuple[str, str]] = [ 

63 ("Default", "default"), 

64 ("Editorial Mono", "editorial-mono"), 

65 ("Warm Paper", "warm-paper"), 

66 ("Custom path…", "_custom"), 

67] 

68 

69 

70def _ask_command(label: str, default: str, options: list[str] | None) -> str | None: 

71 """Ask for a command field using a curated menu when options are provided. 

72 

73 Falls through to free-text if the user selects "Custom…" or if no options are given. 

74 Returns None on Ctrl-C. 

75 """ 

76 if options: 

77 sel_default = default if default in options else options[0] 

78 choices = [questionary.Choice(o, value=o) for o in options] 

79 choices.append(questionary.Choice(_CUSTOM_SENTINEL, value=_CUSTOM_SENTINEL)) 

80 chosen = questionary.select(label, choices=choices, default=sel_default).ask() 

81 if chosen is None: 

82 return None 

83 if chosen == _CUSTOM_SENTINEL: 

84 return questionary.text(f"{label} (enter custom value):", default=default).ask() 

85 return chosen 

86 return questionary.text(label, default=default).ask() 

87 

88 

89def run_tui( 

90 project_root: Path, 

91 templates_dir: Path, 

92 plugin_root: Path, 

93 force: bool = False, 

94 hosts: list[str] | None = None, 

95) -> int: 

96 """Run the interactive TUI for ll-init. 

97 

98 Args: 

99 hosts: Detection-seeded default host list shown pre-checked. 

100 When None, defaults to ["claude-code"]. 

101 

102 Returns: 

103 0 on success, 1 on user-abort/config-exists/error, 130 on Ctrl-C. 

104 """ 

105 if not sys.stdin.isatty(): 

106 print( 

107 "stdin is not a TTY. Run 'll-init --yes' for non-interactive setup.", 

108 file=sys.stderr, 

109 ) 

110 return 1 

111 

112 from little_loops.init.detect import detect_project_type 

113 

114 console = Console() 

115 ll_dir = project_root / ".ll" 

116 config_path = ll_dir / "ll-config.json" 

117 

118 if config_path.exists() and not force: 

119 print( 

120 f"Configuration already exists at {config_path}\n" 

121 "Use --force to overwrite, or edit the existing file directly.", 

122 file=sys.stderr, 

123 ) 

124 return 1 

125 

126 template = detect_project_type(project_root, templates_dir) 

127 project_data = template.data.get("project", {}) 

128 cmd_options: dict[str, list[str]] = template.meta.get("command_options", {}) 

129 

130 console.print( 

131 f"\n[bold blue]little-loops setup[/bold blue] — detected [cyan]{template.name}[/cyan]\n" 

132 ) 

133 

134 default_hosts: frozenset[str] = frozenset(hosts or ["claude-code"]) 

135 

136 # --- Screen 1 / 6: Project Basics --- 

137 console.rule("[bold]1 / 6 Project Basics[/bold]") 

138 

139 name = questionary.text("Project name:", default=project_root.name).ask() 

140 if name is None: 

141 return 130 

142 

143 src_dir = questionary.text( 

144 "Source directory:", default=project_data.get("src_dir", "src/") 

145 ).ask() 

146 if src_dir is None: 

147 return 130 

148 

149 test_cmd = _ask_command( 

150 "Test command:", 

151 default=project_data.get("test_cmd") or "", 

152 options=cmd_options.get("test_cmd"), 

153 ) 

154 if test_cmd is None: 

155 return 130 

156 

157 lint_cmd = _ask_command( 

158 "Lint command:", 

159 default=project_data.get("lint_cmd") or "", 

160 options=cmd_options.get("lint_cmd"), 

161 ) 

162 if lint_cmd is None: 

163 return 130 

164 

165 type_cmd = questionary.text( 

166 "Type-check command (optional):", 

167 default=project_data.get("type_cmd") or "", 

168 ).ask() 

169 if type_cmd is None: 

170 return 130 

171 

172 format_cmd = _ask_command( 

173 "Format command (optional):", 

174 default=project_data.get("format_cmd") or "", 

175 options=cmd_options.get("format_cmd"), 

176 ) 

177 if format_cmd is None: 

178 return 130 

179 

180 # --- Screen 2 / 6: Scan --- 

181 console.print() 

182 console.rule("[bold]2 / 6 Scan[/bold]") 

183 

184 _scan_data = template.data.get("scan", {}) 

185 _default_focus = ", ".join(_scan_data.get("focus_dirs", ["src/"])) 

186 focus_dirs_str = questionary.text( 

187 "Focus directories (comma-separated):", 

188 default=_default_focus, 

189 ).ask() 

190 if focus_dirs_str is None: 

191 return 130 

192 

193 add_excludes: bool | None = questionary.confirm( 

194 "Add custom exclude patterns?", default=False 

195 ).ask() 

196 if add_excludes is None: 

197 return 130 

198 

199 custom_excludes_str = "" 

200 if add_excludes: 

201 custom_excludes_str = questionary.text( 

202 "Custom exclude patterns (comma-separated glob patterns):", 

203 default="", 

204 ).ask() 

205 if custom_excludes_str is None: 

206 return 130 

207 

208 # --- Screen 3 / 6: Features --- 

209 console.print() 

210 console.rule("[bold]3 / 6 Features[/bold]") 

211 

212 selected_features: list[str] | None = questionary.checkbox( 

213 "Enable features:", 

214 choices=[ 

215 questionary.Choice(label, value=val, checked=(val in _DEFAULT_FEATURES)) 

216 for label, val in _FEATURE_CHOICES 

217 ], 

218 ).ask() 

219 if selected_features is None: 

220 return 130 

221 

222 selected_set = set(selected_features) 

223 

224 # Conditional: parallel worker count + worktree copy files 

225 parallel_workers: int = 4 

226 worktree_copy_files: list[str] = [] 

227 if "parallel" in selected_set: 

228 workers_str = questionary.text("Max parallel workers:", default="4").ask() 

229 if workers_str is None: 

230 return 130 

231 try: 

232 parallel_workers = int(workers_str) 

233 if parallel_workers < 1: 

234 raise ValueError("must be positive") 

235 except ValueError: 

236 console.print("[yellow]Invalid worker count; defaulting to 4.[/yellow]") 

237 parallel_workers = 4 

238 

239 wt_files: list[str] | None = questionary.checkbox( 

240 "Copy these files into each worktree:", 

241 choices=[ 

242 questionary.Choice(".env", value=".env"), 

243 questionary.Choice(".env.local", value=".env.local"), 

244 questionary.Choice(".secrets", value=".secrets"), 

245 ], 

246 ).ask() 

247 if wt_files is None: 

248 return 130 

249 worktree_copy_files = wt_files 

250 

251 # Conditional: design-token profile picker 

252 design_token_profile = "default" 

253 if "design_tokens" in selected_set: 

254 _profile: str | None = questionary.select( 

255 "Design-token profile:", 

256 choices=[questionary.Choice(label, value=val) for label, val in _DESIGN_TOKEN_PROFILES], 

257 ).ask() 

258 if _profile is None: 

259 return 130 

260 if _profile == "_custom": 

261 _custom_path = questionary.text("Custom profile path:").ask() 

262 if _custom_path is None: 

263 return 130 

264 design_token_profile = _custom_path 

265 else: 

266 design_token_profile = _profile 

267 

268 # Session digest toggle (always asked) 

269 session_digest_enabled: bool | None = questionary.confirm( 

270 "Enable ambient session digest?", default=True 

271 ).ask() 

272 if session_digest_enabled is None: 

273 return 130 

274 

275 # --- Screen 4 / 6: Hosts --- 

276 console.print() 

277 console.rule("[bold]4 / 6 Hosts[/bold]") 

278 

279 selected_hosts: list[str] | None = questionary.checkbox( 

280 "Which host harnesses should ll-init wire adapters for?", 

281 choices=[ 

282 questionary.Choice(label, value=val, checked=(val in default_hosts)) 

283 for label, val in _HOST_CHOICES 

284 ], 

285 ).ask() 

286 if selected_hosts is None: 

287 return 130 

288 

289 # --- Screen 5 / 6: Settings target --- 

290 console.print() 

291 console.rule("[bold]5 / 6 Settings[/bold]") 

292 

293 settings_target: str | None = questionary.select( 

294 "Where should ll tool permissions be written?", 

295 choices=[ 

296 questionary.Choice( 

297 ".claude/settings.local.json (recommended — gitignored)", 

298 value="local", 

299 ), 

300 questionary.Choice( 

301 ".claude/settings.json (shared with team)", 

302 value="shared", 

303 ), 

304 questionary.Choice( 

305 "Skip — don't write tool permissions", 

306 value="skip", 

307 ), 

308 ], 

309 ).ask() 

310 if settings_target is None: 

311 return 130 

312 

313 # --- Screen 6 / 6: CLAUDE.md --- 

314 console.print() 

315 console.rule("[bold]6 / 6 CLAUDE.md[/bold]") 

316 

317 _dot_claude_md = project_root / ".claude" / "CLAUDE.md" 

318 _root_claude_md = project_root / "CLAUDE.md" 

319 _claude_md_section_present = False 

320 _yes_label = "Yes, create .claude/CLAUDE.md" 

321 

322 for _candidate in (_dot_claude_md, _root_claude_md): 

323 if _candidate.exists(): 

324 if "## little-loops" in _candidate.read_text(encoding="utf-8"): 

325 _claude_md_section_present = True 

326 else: 

327 _rel = str(_candidate.relative_to(project_root)) 

328 _yes_label = f"Yes, append to {_rel}" 

329 break 

330 

331 claude_md_opt_in = False 

332 if _claude_md_section_present: 

333 console.print("[dim]CLAUDE.md already contains a ## little-loops section — skipping.[/dim]") 

334 else: 

335 _claude_md_choice: str | None = questionary.select( 

336 "Append ll- CLI commands to CLAUDE.md?", 

337 choices=[ 

338 questionary.Choice(_yes_label, value="yes"), 

339 questionary.Choice("Skip", value="skip"), 

340 ], 

341 default="yes", 

342 ).ask() 

343 if _claude_md_choice is None: 

344 return 130 

345 claude_md_opt_in = _claude_md_choice == "yes" 

346 

347 # --- Compute documents categories --- 

348 from little_loops.init.detect import detect_documents 

349 

350 documents_categories: dict[str, Any] = {} 

351 if "documents" in selected_set: 

352 documents_categories = detect_documents(project_root) 

353 

354 # --- Parse scan inputs --- 

355 scan_focus_dirs = [d.strip() for d in focus_dirs_str.split(",") if d.strip()] 

356 scan_custom_excludes = [p.strip() for p in custom_excludes_str.split(",") if p.strip()] 

357 

358 # --- Build config --- 

359 config = _build_final_config( 

360 template=template, 

361 name=name, 

362 src_dir=src_dir, 

363 test_cmd=test_cmd, 

364 lint_cmd=lint_cmd, 

365 type_cmd=type_cmd, 

366 format_cmd=format_cmd, 

367 selected_set=selected_set, 

368 parallel_workers=parallel_workers, 

369 scan_focus_dirs=scan_focus_dirs, 

370 scan_custom_excludes=scan_custom_excludes, 

371 worktree_copy_files=worktree_copy_files, 

372 design_token_profile=design_token_profile, 

373 documents_categories=documents_categories, 

374 session_digest_enabled=bool(session_digest_enabled), 

375 ) 

376 

377 # --- Summary --- 

378 console.print() 

379 _render_summary( 

380 console, 

381 config, 

382 project_root, 

383 selected_set, 

384 selected_hosts, 

385 settings_target, 

386 claude_md_opt_in=claude_md_opt_in, 

387 claude_md_section_present=_claude_md_section_present, 

388 ) 

389 console.print() 

390 

391 confirmed: bool | None = questionary.confirm("Apply this configuration?", default=True).ask() 

392 if confirmed is None: 

393 return 130 

394 if not confirmed: 

395 console.print("[yellow]Aborted — no changes made.[/yellow]") 

396 return 1 

397 

398 # --- Apply --- 

399 _apply_config( 

400 config=config, 

401 project_root=project_root, 

402 ll_dir=ll_dir, 

403 config_path=config_path, 

404 templates_dir=templates_dir, 

405 plugin_root=plugin_root, 

406 hosts=selected_hosts, 

407 settings_target=settings_target, 

408 force=force, 

409 console=console, 

410 claude_md_opt_in=claude_md_opt_in, 

411 ) 

412 return 0 

413 

414 

415def _build_final_config( 

416 template: Any, 

417 name: str, 

418 src_dir: str, 

419 test_cmd: str, 

420 lint_cmd: str, 

421 type_cmd: str, 

422 format_cmd: str, 

423 selected_set: set[str], 

424 parallel_workers: int, 

425 scan_focus_dirs: list[str] | None = None, 

426 scan_custom_excludes: list[str] | None = None, 

427 worktree_copy_files: list[str] | None = None, 

428 design_token_profile: str = "default", 

429 documents_categories: dict[str, Any] | None = None, 

430 session_digest_enabled: bool = True, 

431) -> dict[str, Any]: 

432 """Build the ll-config.json dict from TUI answers.""" 

433 from little_loops.init.core import build_config 

434 

435 config = build_config( 

436 template, 

437 { 

438 "project_name": name, 

439 "src_dir": src_dir, 

440 "product_enabled": "product" in selected_set, 

441 "analytics_enabled": "analytics" in selected_set, 

442 "context_monitor_enabled": "context_monitor" in selected_set, 

443 "learning_tests_enabled": "learning_tests" in selected_set, 

444 "session_digest_enabled": session_digest_enabled, 

445 }, 

446 ) 

447 

448 # Apply command overrides (None for cleared/empty fields) 

449 for key, val in [ 

450 ("test_cmd", test_cmd), 

451 ("lint_cmd", lint_cmd), 

452 ("type_cmd", type_cmd), 

453 ("format_cmd", format_cmd), 

454 ]: 

455 config["project"][key] = val or None 

456 

457 # Update scan section with TUI-provided values 

458 if scan_focus_dirs: 

459 config["scan"]["focus_dirs"] = scan_focus_dirs 

460 if scan_custom_excludes: 

461 existing = list(config["scan"].get("exclude_patterns", [])) 

462 config["scan"]["exclude_patterns"] = existing + scan_custom_excludes 

463 

464 # Optional sections from feature toggles 

465 if "parallel" in selected_set: 

466 parallel_section: dict[str, Any] = {} 

467 if parallel_workers != 4: 

468 parallel_section["max_workers"] = parallel_workers 

469 if worktree_copy_files: 

470 parallel_section["worktree_copy_files"] = list(worktree_copy_files) 

471 if parallel_section: 

472 config["parallel"] = parallel_section 

473 

474 if "documents" in selected_set: 

475 doc_section: dict[str, Any] = {"enabled": True} 

476 if documents_categories: 

477 doc_section["categories"] = documents_categories 

478 config["documents"] = doc_section 

479 

480 if "design_tokens" in selected_set: 

481 config["design_tokens"] = {"enabled": True, "active": design_token_profile} 

482 

483 # GitHub sync 

484 if "github_sync" in selected_set: 

485 config["sync"] = {"enabled": True} 

486 

487 # commands block (confidence_gate + tdd_mode) 

488 commands: dict[str, Any] = {} 

489 if "confidence_gate" in selected_set: 

490 commands["confidence_gate"] = {"enabled": True, "readiness_threshold": 85} 

491 if "tdd" in selected_set: 

492 commands["tdd_mode"] = True 

493 if commands: 

494 config["commands"] = commands 

495 

496 return config 

497 

498 

499def _render_summary( 

500 console: Console, 

501 config: dict[str, Any], 

502 project_root: Path, 

503 selected_set: set[str], 

504 selected_hosts: list[str], 

505 settings_target: str, 

506 claude_md_opt_in: bool = False, 

507 claude_md_section_present: bool = False, 

508) -> None: 

509 """Render a rich bordered summary panel of the proposed configuration.""" 

510 proj = config.get("project", {}) 

511 

512 table = Table(show_header=False, box=None, padding=(0, 1)) 

513 table.add_column("Key", style="bold cyan", min_width=14) 

514 table.add_column("Value") 

515 

516 table.add_row("Project", proj.get("name", project_root.name)) 

517 table.add_row("Source dir", proj.get("src_dir", "")) 

518 

519 for field, label in [ 

520 ("test_cmd", "Test"), 

521 ("lint_cmd", "Lint"), 

522 ("type_cmd", "Type-check"), 

523 ("format_cmd", "Format"), 

524 ]: 

525 val = proj.get(field) 

526 if val: 

527 table.add_row(label, str(val)) 

528 

529 enabled = [_FEATURE_LABELS[k] for k in _FEATURE_LABELS if k in selected_set] 

530 if enabled: 

531 table.add_row("Features", ", ".join(enabled)) 

532 

533 host_labels = [_HOST_LABELS.get(h, h) for h in selected_hosts] 

534 table.add_row("Hosts", ", ".join(host_labels) if host_labels else "none") 

535 

536 # New: sync / commands 

537 if config.get("sync", {}).get("enabled"): 

538 table.add_row("Sync", "GitHub sync enabled") 

539 

540 cmds = config.get("commands", {}) 

541 cmd_parts = [] 

542 if cmds.get("confidence_gate", {}).get("enabled"): 

543 cmd_parts.append("confidence gate") 

544 if cmds.get("tdd_mode"): 

545 cmd_parts.append("TDD mode") 

546 if cmd_parts: 

547 table.add_row("Commands", ", ".join(cmd_parts)) 

548 

549 # Design-token profile 

550 dt = config.get("design_tokens", {}) 

551 if dt.get("enabled"): 

552 table.add_row("Design tokens", dt.get("active", "default")) 

553 

554 # Documents categories 

555 doc_cats = config.get("documents", {}).get("categories", {}) 

556 if doc_cats: 

557 table.add_row("Documents", f"{len(doc_cats)} categories detected") 

558 

559 # Worktree copy files 

560 wt_files = config.get("parallel", {}).get("worktree_copy_files", []) 

561 if wt_files: 

562 table.add_row("Worktree files", ", ".join(wt_files)) 

563 

564 # Session digest 

565 sd_enabled = config.get("history", {}).get("session_digest", {}).get("enabled", True) 

566 table.add_row("Session digest", "on" if sd_enabled else "off") 

567 

568 if settings_target == "skip": 

569 sf = "Skip — no permissions written" 

570 elif settings_target == "local": 

571 sf = ".claude/settings.local.json" 

572 else: 

573 sf = ".claude/settings.json" 

574 table.add_row("Settings", sf) 

575 

576 if claude_md_section_present: 

577 table.add_row("CLAUDE.md", "Already present — skipped") 

578 elif claude_md_opt_in: 

579 table.add_row("CLAUDE.md", "Append ll- CLI commands") 

580 else: 

581 table.add_row("CLAUDE.md", "Skip") 

582 

583 console.print(Panel(table, title="[bold]Configuration Summary[/bold]", border_style="blue")) 

584 

585 

586def _apply_config( 

587 config: dict[str, Any], 

588 project_root: Path, 

589 ll_dir: Path, 

590 config_path: Path, 

591 templates_dir: Path, 

592 plugin_root: Path, 

593 hosts: list[str], 

594 settings_target: str, 

595 force: bool, 

596 console: Console, 

597 claude_md_opt_in: bool = False, 

598) -> None: 

599 """Write all ll-init artifacts to disk.""" 

600 from little_loops import __version__ 

601 from little_loops.init.cli import _dispatch_host_adapters 

602 from little_loops.init.validate import validate_deps 

603 from little_loops.init.writers import ( 

604 deploy_design_tokens, 

605 deploy_goals, 

606 make_issue_dirs, 

607 make_learning_tests_dir, 

608 merge_settings, 

609 update_gitignore, 

610 write_claude_md, 

611 write_config, 

612 ) 

613 

614 issues_base = project_root / config.get("issues", {}).get("base_dir", ".issues") 

615 

616 write_config(config, ll_dir) 

617 make_issue_dirs(issues_base) 

618 

619 if config.get("product", {}).get("enabled"): 

620 deploy_goals(ll_dir, templates_dir) 

621 

622 if config.get("design_tokens", {}).get("enabled"): 

623 active_profile = config["design_tokens"].get("active", "default") 

624 deploy_design_tokens(ll_dir, templates_dir, active_profile=active_profile) 

625 

626 if config.get("learning_tests", {}).get("enabled"): 

627 make_learning_tests_dir(ll_dir) 

628 

629 update_gitignore(project_root) 

630 

631 if settings_target != "skip": 

632 settings_file = ( 

633 ".claude/settings.local.json" if settings_target == "local" else ".claude/settings.json" 

634 ) 

635 extra_permissions: list[str] | None = None 

636 if config.get("learning_tests", {}).get("enabled"): 

637 extra_permissions = ["Skill(ll:explore-api)"] 

638 merge_settings( 

639 project_root, settings_file=settings_file, extra_permissions=extra_permissions 

640 ) 

641 

642 if claude_md_opt_in: 

643 write_claude_md(project_root) 

644 

645 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force) 

646 

647 warnings = validate_deps(config, __version__, project_root) 

648 for w in warnings: 

649 console.print(f"[yellow]Warning: {w.message}[/yellow]") 

650 if w.install_hint: 

651 console.print(f" Install/fix: {w.install_hint}") 

652 

653 console.print(f"\n[bold green]✓ little-loops initialized in {project_root}[/bold green]") 

654 console.print(f" Config: [cyan]{config_path}[/cyan]")