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

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] 

24 

25_DEFAULT_FEATURES: frozenset[str] = frozenset( 

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

27) 

28 

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} 

38 

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] 

45 

46_HOST_LABELS: dict[str, str] = { 

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

48 "codex": "Codex CLI", 

49 "pi": "Pi", 

50} 

51 

52 

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. 

61 

62 Args: 

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

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

65 

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 

75 

76 from little_loops.init.detect import detect_project_type 

77 

78 console = Console() 

79 ll_dir = project_root / ".ll" 

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

81 

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 

89 

90 template = detect_project_type(project_root, templates_dir) 

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

92 

93 console.print( 

94 f"\n[bold blue]little-loops setup[/bold blue]" 

95 f" — detected [cyan]{template.name}[/cyan]\n" 

96 ) 

97 

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

99 

100 # --- Screen 1: Project basics --- 

101 console.rule("[bold]1 / 4 Project Basics[/bold]") 

102 

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

104 if name is None: 

105 return 130 

106 

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 

112 

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 

118 

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 

124 

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 

131 

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 

138 

139 # --- Screen 2: Features --- 

140 console.print() 

141 console.rule("[bold]2 / 4 Features[/bold]") 

142 

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 

152 

153 selected_set = set(selected_features) 

154 

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 

168 

169 # --- Screen 3: Hosts --- 

170 console.print() 

171 console.rule("[bold]3 / 4 Hosts[/bold]") 

172 

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 

182 

183 # --- Screen 4: Settings target --- 

184 console.print() 

185 console.rule("[bold]4 / 4 Settings[/bold]") 

186 

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 

202 

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 ) 

215 

216 # --- Summary --- 

217 console.print() 

218 _render_summary(console, config, project_root, selected_set, selected_hosts, settings_target) 

219 console.print() 

220 

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 

229 

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 

244 

245 

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 

259 

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 ) 

271 

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 

280 

281 # Optional sections from feature toggles 

282 if "parallel" in selected_set and parallel_workers != 4: 

283 config["parallel"] = {"max_workers": parallel_workers} 

284 

285 if "documents" in selected_set: 

286 config["documents"] = {"enabled": True} 

287 

288 if "design_tokens" in selected_set: 

289 config["design_tokens"] = {"enabled": True} 

290 

291 return config 

292 

293 

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", {}) 

304 

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") 

308 

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

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

311 

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)) 

321 

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)) 

325 

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") 

328 

329 sf = ( 

330 ".claude/settings.local.json" 

331 if settings_target == "local" 

332 else ".claude/settings.json" 

333 ) 

334 table.add_row("Settings", sf) 

335 

336 console.print( 

337 Panel(table, title="[bold]Configuration Summary[/bold]", border_style="blue") 

338 ) 

339 

340 

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 ) 

365 

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

367 

368 write_config(config, ll_dir) 

369 make_issue_dirs(issues_base) 

370 

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

372 deploy_goals(ll_dir, templates_dir) 

373 

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

375 make_learning_tests_dir(ll_dir) 

376 

377 update_gitignore(project_root) 

378 

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) 

385 

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

387 

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}") 

393 

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]")