Coverage for little_loops / init / tui.py: 0%
278 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
1"""Interactive TUI for ll-init using questionary and rich."""
3from __future__ import annotations
5import sys
6from pathlib import Path
7from typing import Any
9import questionary
10from rich.console import Console
11from rich.panel import Panel
12from rich.table import Table
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]
28_DEFAULT_FEATURES: frozenset[str] = frozenset(
29 {"parallel", "product", "learning_tests", "analytics", "context_monitor"}
30)
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}
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]
52_HOST_LABELS: dict[str, str] = {
53 "claude-code": "Claude Code",
54 "codex": "Codex CLI",
55 "pi": "Pi",
56}
58# Sentinel used in curated command menus for the free-text fallthrough
59_CUSTOM_SENTINEL = "Custom…"
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]
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.
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()
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.
98 Args:
99 hosts: Detection-seeded default host list shown pre-checked.
100 When None, defaults to ["claude-code"].
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
112 from little_loops.init.detect import detect_project_type
114 console = Console()
115 ll_dir = project_root / ".ll"
116 config_path = ll_dir / "ll-config.json"
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
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", {})
130 console.print(
131 f"\n[bold blue]little-loops setup[/bold blue] — detected [cyan]{template.name}[/cyan]\n"
132 )
134 default_hosts: frozenset[str] = frozenset(hosts or ["claude-code"])
136 # --- Screen 1 / 6: Project Basics ---
137 console.rule("[bold]1 / 6 Project Basics[/bold]")
139 name = questionary.text("Project name:", default=project_root.name).ask()
140 if name is None:
141 return 130
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
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
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
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
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
180 # --- Screen 2 / 6: Scan ---
181 console.print()
182 console.rule("[bold]2 / 6 Scan[/bold]")
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
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
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
208 # --- Screen 3 / 6: Features ---
209 console.print()
210 console.rule("[bold]3 / 6 Features[/bold]")
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
222 selected_set = set(selected_features)
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
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
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
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
275 # --- Screen 4 / 6: Hosts ---
276 console.print()
277 console.rule("[bold]4 / 6 Hosts[/bold]")
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
289 # --- Screen 5 / 6: Settings target ---
290 console.print()
291 console.rule("[bold]5 / 6 Settings[/bold]")
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
313 # --- Screen 6 / 6: CLAUDE.md ---
314 console.print()
315 console.rule("[bold]6 / 6 CLAUDE.md[/bold]")
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"
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
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"
347 # --- Compute documents categories ---
348 from little_loops.init.detect import detect_documents
350 documents_categories: dict[str, Any] = {}
351 if "documents" in selected_set:
352 documents_categories = detect_documents(project_root)
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()]
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 )
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()
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
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
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
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 )
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
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
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
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
480 if "design_tokens" in selected_set:
481 config["design_tokens"] = {"enabled": True, "active": design_token_profile}
483 # GitHub sync
484 if "github_sync" in selected_set:
485 config["sync"] = {"enabled": True}
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
496 return config
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", {})
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")
516 table.add_row("Project", proj.get("name", project_root.name))
517 table.add_row("Source dir", proj.get("src_dir", ""))
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))
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))
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")
536 # New: sync / commands
537 if config.get("sync", {}).get("enabled"):
538 table.add_row("Sync", "GitHub sync enabled")
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))
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"))
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")
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))
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")
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)
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")
583 console.print(Panel(table, title="[bold]Configuration Summary[/bold]", border_style="blue"))
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 )
614 issues_base = project_root / config.get("issues", {}).get("base_dir", ".issues")
616 write_config(config, ll_dir)
617 make_issue_dirs(issues_base)
619 if config.get("product", {}).get("enabled"):
620 deploy_goals(ll_dir, templates_dir)
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)
626 if config.get("learning_tests", {}).get("enabled"):
627 make_learning_tests_dir(ll_dir)
629 update_gitignore(project_root)
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 )
642 if claude_md_opt_in:
643 write_claude_md(project_root)
645 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
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}")
653 console.print(f"\n[bold green]✓ little-loops initialized in {project_root}[/bold green]")
654 console.print(f" Config: [cyan]{config_path}[/cyan]")