Coverage for little_loops / init / tui.py: 0%
141 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -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]
25_DEFAULT_FEATURES: frozenset[str] = frozenset(
26 {"parallel", "product", "learning_tests", "analytics", "context_monitor"}
27)
29_FEATURE_LABELS: dict[str, str] = {
30 "parallel": "Parallel processing",
31 "product": "Product analysis",
32 "documents": "Document tracking",
33 "design_tokens": "Design tokens",
34 "learning_tests": "Learning tests",
35 "analytics": "Analytics",
36 "context_monitor": "Context monitor",
37}
39# Host choices for the multi-select screen
40_HOST_CHOICES: list[tuple[str, str]] = [
41 ("Claude Code (global plugin — no adapter file needed)", "claude-code"),
42 ("Codex CLI (writes .codex/hooks.json)", "codex"),
43 ("Pi (not yet available — EPIC-1622)", "pi"),
44]
46_HOST_LABELS: dict[str, str] = {
47 "claude-code": "Claude Code",
48 "codex": "Codex CLI",
49 "pi": "Pi",
50}
53def run_tui(
54 project_root: Path,
55 templates_dir: Path,
56 plugin_root: Path,
57 force: bool = False,
58 hosts: list[str] | None = None,
59) -> int:
60 """Run the interactive TUI for ll-init.
62 Args:
63 hosts: Detection-seeded default host list shown pre-checked.
64 When None, defaults to ["claude-code"].
66 Returns:
67 0 on success, 1 on user-abort/config-exists/error, 130 on Ctrl-C.
68 """
69 if not sys.stdin.isatty():
70 print(
71 "stdin is not a TTY. Run 'll-init --yes' for non-interactive setup.",
72 file=sys.stderr,
73 )
74 return 1
76 from little_loops.init.detect import detect_project_type
78 console = Console()
79 ll_dir = project_root / ".ll"
80 config_path = ll_dir / "ll-config.json"
82 if config_path.exists() and not force:
83 print(
84 f"Configuration already exists at {config_path}\n"
85 "Use --force to overwrite, or edit the existing file directly.",
86 file=sys.stderr,
87 )
88 return 1
90 template = detect_project_type(project_root, templates_dir)
91 project_data = template.data.get("project", {})
93 console.print(
94 f"\n[bold blue]little-loops setup[/bold blue]"
95 f" — detected [cyan]{template.name}[/cyan]\n"
96 )
98 default_hosts: frozenset[str] = frozenset(hosts or ["claude-code"])
100 # --- Screen 1: Project basics ---
101 console.rule("[bold]1 / 4 Project Basics[/bold]")
103 name = questionary.text("Project name:", default=project_root.name).ask()
104 if name is None:
105 return 130
107 src_dir = questionary.text(
108 "Source directory:", default=project_data.get("src_dir", "src/")
109 ).ask()
110 if src_dir is None:
111 return 130
113 test_cmd = questionary.text(
114 "Test command:", default=project_data.get("test_cmd") or ""
115 ).ask()
116 if test_cmd is None:
117 return 130
119 lint_cmd = questionary.text(
120 "Lint command:", default=project_data.get("lint_cmd") or ""
121 ).ask()
122 if lint_cmd is None:
123 return 130
125 type_cmd = questionary.text(
126 "Type-check command (optional):",
127 default=project_data.get("type_cmd") or "",
128 ).ask()
129 if type_cmd is None:
130 return 130
132 format_cmd = questionary.text(
133 "Format command (optional):",
134 default=project_data.get("format_cmd") or "",
135 ).ask()
136 if format_cmd is None:
137 return 130
139 # --- Screen 2: Features ---
140 console.print()
141 console.rule("[bold]2 / 4 Features[/bold]")
143 selected_features: list[str] | None = questionary.checkbox(
144 "Enable features:",
145 choices=[
146 questionary.Choice(label, value=val, checked=(val in _DEFAULT_FEATURES))
147 for label, val in _FEATURE_CHOICES
148 ],
149 ).ask()
150 if selected_features is None:
151 return 130
153 selected_set = set(selected_features)
155 # Conditional: parallel worker count (only when parallel is selected)
156 parallel_workers: int = 4
157 if "parallel" in selected_set:
158 workers_str = questionary.text("Max parallel workers:", default="4").ask()
159 if workers_str is None:
160 return 130
161 try:
162 parallel_workers = int(workers_str)
163 if parallel_workers < 1:
164 raise ValueError("must be positive")
165 except ValueError:
166 console.print("[yellow]Invalid worker count; defaulting to 4.[/yellow]")
167 parallel_workers = 4
169 # --- Screen 3: Hosts ---
170 console.print()
171 console.rule("[bold]3 / 4 Hosts[/bold]")
173 selected_hosts: list[str] | None = questionary.checkbox(
174 "Which host harnesses should ll-init wire adapters for?",
175 choices=[
176 questionary.Choice(label, value=val, checked=(val in default_hosts))
177 for label, val in _HOST_CHOICES
178 ],
179 ).ask()
180 if selected_hosts is None:
181 return 130
183 # --- Screen 4: Settings target ---
184 console.print()
185 console.rule("[bold]4 / 4 Settings[/bold]")
187 settings_target: str | None = questionary.select(
188 "Where should ll tool permissions be written?",
189 choices=[
190 questionary.Choice(
191 ".claude/settings.local.json (recommended — gitignored)",
192 value="local",
193 ),
194 questionary.Choice(
195 ".claude/settings.json (shared with team)",
196 value="shared",
197 ),
198 ],
199 ).ask()
200 if settings_target is None:
201 return 130
203 # --- Build config ---
204 config = _build_final_config(
205 template=template,
206 name=name,
207 src_dir=src_dir,
208 test_cmd=test_cmd,
209 lint_cmd=lint_cmd,
210 type_cmd=type_cmd,
211 format_cmd=format_cmd,
212 selected_set=selected_set,
213 parallel_workers=parallel_workers,
214 )
216 # --- Summary ---
217 console.print()
218 _render_summary(console, config, project_root, selected_set, selected_hosts, settings_target)
219 console.print()
221 confirmed: bool | None = questionary.confirm(
222 "Apply this configuration?", default=True
223 ).ask()
224 if confirmed is None:
225 return 130
226 if not confirmed:
227 console.print("[yellow]Aborted — no changes made.[/yellow]")
228 return 1
230 # --- Apply ---
231 _apply_config(
232 config=config,
233 project_root=project_root,
234 ll_dir=ll_dir,
235 config_path=config_path,
236 templates_dir=templates_dir,
237 plugin_root=plugin_root,
238 hosts=selected_hosts,
239 settings_target=settings_target,
240 force=force,
241 console=console,
242 )
243 return 0
246def _build_final_config(
247 template: Any,
248 name: str,
249 src_dir: str,
250 test_cmd: str,
251 lint_cmd: str,
252 type_cmd: str,
253 format_cmd: str,
254 selected_set: set[str],
255 parallel_workers: int,
256) -> dict[str, Any]:
257 """Build the ll-config.json dict from TUI answers."""
258 from little_loops.init.core import build_config
260 config = build_config(
261 template,
262 {
263 "project_name": name,
264 "src_dir": src_dir,
265 "product_enabled": "product" in selected_set,
266 "analytics_enabled": "analytics" in selected_set,
267 "context_monitor_enabled": "context_monitor" in selected_set,
268 "learning_tests_enabled": "learning_tests" in selected_set,
269 },
270 )
272 # Apply command overrides (None for cleared/empty fields)
273 for key, val in [
274 ("test_cmd", test_cmd),
275 ("lint_cmd", lint_cmd),
276 ("type_cmd", type_cmd),
277 ("format_cmd", format_cmd),
278 ]:
279 config["project"][key] = val or None
281 # Optional sections from feature toggles
282 if "parallel" in selected_set and parallel_workers != 4:
283 config["parallel"] = {"max_workers": parallel_workers}
285 if "documents" in selected_set:
286 config["documents"] = {"enabled": True}
288 if "design_tokens" in selected_set:
289 config["design_tokens"] = {"enabled": True}
291 return config
294def _render_summary(
295 console: Console,
296 config: dict[str, Any],
297 project_root: Path,
298 selected_set: set[str],
299 selected_hosts: list[str],
300 settings_target: str,
301) -> None:
302 """Render a rich bordered summary panel of the proposed configuration."""
303 proj = config.get("project", {})
305 table = Table(show_header=False, box=None, padding=(0, 1))
306 table.add_column("Key", style="bold cyan", min_width=14)
307 table.add_column("Value")
309 table.add_row("Project", proj.get("name", project_root.name))
310 table.add_row("Source dir", proj.get("src_dir", ""))
312 for field, label in [
313 ("test_cmd", "Test"),
314 ("lint_cmd", "Lint"),
315 ("type_cmd", "Type-check"),
316 ("format_cmd", "Format"),
317 ]:
318 val = proj.get(field)
319 if val:
320 table.add_row(label, str(val))
322 enabled = [_FEATURE_LABELS[k] for k in _FEATURE_LABELS if k in selected_set]
323 if enabled:
324 table.add_row("Features", ", ".join(enabled))
326 host_labels = [_HOST_LABELS.get(h, h) for h in selected_hosts]
327 table.add_row("Hosts", ", ".join(host_labels) if host_labels else "none")
329 sf = (
330 ".claude/settings.local.json"
331 if settings_target == "local"
332 else ".claude/settings.json"
333 )
334 table.add_row("Settings", sf)
336 console.print(
337 Panel(table, title="[bold]Configuration Summary[/bold]", border_style="blue")
338 )
341def _apply_config(
342 config: dict[str, Any],
343 project_root: Path,
344 ll_dir: Path,
345 config_path: Path,
346 templates_dir: Path,
347 plugin_root: Path,
348 hosts: list[str],
349 settings_target: str,
350 force: bool,
351 console: Console,
352) -> None:
353 """Write all ll-init artifacts to disk."""
354 from little_loops import __version__
355 from little_loops.init.cli import _dispatch_host_adapters
356 from little_loops.init.validate import validate_deps
357 from little_loops.init.writers import (
358 deploy_goals,
359 make_issue_dirs,
360 make_learning_tests_dir,
361 merge_settings,
362 update_gitignore,
363 write_config,
364 )
366 issues_base = project_root / config.get("issues", {}).get("base_dir", ".issues")
368 write_config(config, ll_dir)
369 make_issue_dirs(issues_base)
371 if config.get("product", {}).get("enabled"):
372 deploy_goals(ll_dir, templates_dir)
374 if config.get("learning_tests", {}).get("enabled"):
375 make_learning_tests_dir(ll_dir)
377 update_gitignore(project_root)
379 settings_file = (
380 ".claude/settings.local.json"
381 if settings_target == "local"
382 else ".claude/settings.json"
383 )
384 merge_settings(project_root, settings_file=settings_file)
386 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
388 warnings = validate_deps(config, __version__, project_root)
389 for w in warnings:
390 console.print(f"[yellow]Warning: {w.message}[/yellow]")
391 if w.install_hint:
392 console.print(f" Install/fix: {w.install_hint}")
394 console.print(
395 f"\n[bold green]✓ little-loops initialized in {project_root}[/bold green]"
396 )
397 console.print(f" Config: [cyan]{config_path}[/cyan]")