Coverage for little_loops / cli / loop / info.py: 5%

504 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -0500

1"""ll-loop info subcommands: list, history, show.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import os 

7from datetime import datetime 

8from pathlib import Path 

9from typing import Any 

10 

11from little_loops.cli.loop._helpers import ( 

12 get_builtin_loops_dir, 

13 load_loop_with_spec, 

14 resolve_loop_path, 

15) 

16from little_loops.cli.loop.layout import ( # noqa: F401 

17 _EDGE_LABEL_COLORS, 

18 _box_inner_lines, 

19 _colorize_diagram_labels, 

20 _colorize_label, 

21 _render_fsm_diagram, 

22) 

23from little_loops.cli.output import colorize, print_json, terminal_width 

24from little_loops.fsm.schema import FSMLoop, StateConfig 

25from little_loops.logger import Logger 

26 

27 

28def _load_loop_meta(path: Path) -> str: 

29 """Return description from a loop YAML file.""" 

30 import yaml 

31 

32 try: 

33 with open(path) as f: 

34 spec = yaml.safe_load(f) or {} 

35 desc_raw = spec.get("description", "") or "" 

36 return desc_raw.splitlines()[0] if desc_raw.strip() else "" 

37 except Exception: 

38 return "" 

39 

40 

41def cmd_list( 

42 args: argparse.Namespace, 

43 loops_dir: Path, 

44) -> int: 

45 """List loops.""" 

46 status_filter = getattr(args, "status", None) 

47 if getattr(args, "running", False) or status_filter: 

48 from little_loops.fsm.persistence import list_running_loops 

49 

50 states = list_running_loops(loops_dir) 

51 if status_filter: 

52 states = [s for s in states if s.status == status_filter] 

53 if not states: 

54 if status_filter: 

55 print(f"No loops with status: {status_filter}") 

56 return 1 

57 print("No running loops") 

58 return 0 

59 if getattr(args, "json", False): 

60 print_json([s.to_dict() for s in states]) 

61 return 0 

62 print(colorize("Running loops:", "1")) 

63 _STATUS_COLORS = {"running": "32", "interrupted": "33", "stopped": "2"} 

64 for state in states: 

65 elapsed_s = (state.accumulated_ms or 0) // 1000 

66 elapsed_str = ( 

67 f"{elapsed_s}s" if elapsed_s < 60 else f"{elapsed_s // 60}m {elapsed_s % 60}s" 

68 ) 

69 name_str = colorize(state.loop_name, "1") 

70 state_str = colorize(state.current_state, "34") 

71 status_color = _STATUS_COLORS.get(state.status, "2") 

72 status_str = colorize(f"[{state.status}]", status_color) 

73 elapsed_colored = colorize(elapsed_str, "2") 

74 print( 

75 f" {name_str}: {state_str} (iteration {state.iteration})" 

76 f" {status_str} {elapsed_colored}" 

77 ) 

78 return 0 

79 

80 builtin_only = getattr(args, "builtin", False) 

81 

82 # Collect project loops (skipped when --builtin is set) 

83 project_names: set[str] = set() 

84 yaml_files: list[Path] = [] 

85 if not builtin_only and loops_dir.exists(): 

86 yaml_files = list(loops_dir.glob("*.yaml")) 

87 project_names = {p.stem for p in yaml_files} 

88 

89 # Collect built-in loops (excluding those overridden by project) 

90 builtin_dir = get_builtin_loops_dir() 

91 builtin_files: list[Path] = [] 

92 if builtin_dir.exists(): 

93 builtin_files = [ 

94 f for f in sorted(builtin_dir.glob("*.yaml")) if f.stem not in project_names 

95 ] 

96 

97 if not yaml_files and not builtin_files: 

98 if getattr(args, "json", False): 

99 print_json([]) 

100 return 0 

101 print("No loops available") 

102 return 0 

103 

104 if getattr(args, "json", False): 

105 items: list[dict[str, Any]] = [{"name": p.stem, "path": str(p)} for p in sorted(yaml_files)] 

106 items += [{"name": p.stem, "path": str(p), "built_in": True} for p in builtin_files] 

107 print_json(items) 

108 return 0 

109 

110 if yaml_files and builtin_files: 

111 print(colorize(f"Project loops ({len(yaml_files)}):", "1")) 

112 for path in sorted(yaml_files): 

113 desc = _load_loop_meta(path) 

114 name_str = colorize(path.stem, "36;1") 

115 desc_str = f" {colorize(desc, '2')}" if desc else "" 

116 print(f" {name_str}{desc_str}") 

117 print() 

118 print(colorize(f"Built-in loops ({len(builtin_files)}):", "1")) 

119 for path in builtin_files: 

120 desc = _load_loop_meta(path) 

121 name_str = colorize(path.stem, "36;1") 

122 desc_str = f" {colorize(desc, '2')}" if desc else "" 

123 tag_str = colorize("[built-in]", "2") 

124 print(f" {name_str}{desc_str} {tag_str}") 

125 elif yaml_files: 

126 print(colorize("Available loops:", "1")) 

127 for path in sorted(yaml_files): 

128 desc = _load_loop_meta(path) 

129 name_str = colorize(path.stem, "36;1") 

130 desc_str = f" {colorize(desc, '2')}" if desc else "" 

131 print(f" {name_str}{desc_str}") 

132 else: 

133 print(colorize("Available loops:", "1")) 

134 for path in builtin_files: 

135 desc = _load_loop_meta(path) 

136 name_str = colorize(path.stem, "36;1") 

137 desc_str = f" {colorize(desc, '2')}" if desc else "" 

138 tag_str = colorize("[built-in]", "2") 

139 print(f" {name_str}{desc_str} {tag_str}") 

140 return 0 

141 

142 

143_EVENT_TYPE_WIDTH = 16 # width of "handoff_detected" 

144 

145 

146def _truncate(text: str, max_len: int) -> str: 

147 """Truncate text to max_len with ellipsis.""" 

148 if max_len < 1: 

149 return "" 

150 if len(text) <= max_len: 

151 return text 

152 return text[: max_len - 1] + "\u2026" 

153 

154 

155def _format_history_event( 

156 event: dict[str, Any], verbose: bool, width: int, full: bool = False 

157) -> str | None: 

158 """Format a single history event. Returns None to skip the event.""" 

159 raw_ts = event.get("ts", "") 

160 try: 

161 ts = datetime.fromisoformat(raw_ts).strftime("%H:%M:%S") 

162 except (ValueError, TypeError): 

163 ts = raw_ts[:8] if len(raw_ts) >= 8 else raw_ts.ljust(8) 

164 

165 event_type = event.get("event", "unknown") 

166 

167 if event_type == "action_output" and not verbose: 

168 return None 

169 

170 ts_str = colorize(ts, "2") 

171 etype_padded = event_type.ljust(_EVENT_TYPE_WIDTH) 

172 etype_color = "0" 

173 detail = "" 

174 extra_lines: list[str] = [] 

175 

176 # Indentation prefix for verbose sub-lines (aligns under event detail column) 

177 _indent = " " * (8 + 2 + _EVENT_TYPE_WIDTH + 2) 

178 

179 if event_type == "loop_start": 

180 etype_color = "1" 

181 detail = event.get("loop", "") 

182 

183 elif event_type == "loop_complete": 

184 etype_color = "1" 

185 final_state = event.get("final_state", "") 

186 iterations = event.get("iterations", "") 

187 terminated_by = event.get("terminated_by", "") 

188 detail = f"{final_state} {iterations} iter [{terminated_by}]" 

189 

190 elif event_type == "loop_resume": 

191 etype_color = "1" 

192 from_state = event.get("from_state", "") 

193 iteration = event.get("iteration", "") 

194 detail = f"from={from_state} iter={iteration}" 

195 

196 elif event_type == "state_enter": 

197 etype_color = "34" 

198 state = event.get("state", "") 

199 iteration = event.get("iteration", "") 

200 detail = f"{colorize(state, '1')} (iter {iteration})" 

201 

202 elif event_type == "action_start": 

203 action = event.get("action", "") 

204 is_prompt = event.get("is_prompt", False) 

205 kind_label = "prompt" if is_prompt else "shell" 

206 kind_str = colorize(f"[{kind_label}]", "2") 

207 first_line = ( 

208 next((ln.strip() for ln in action.splitlines() if ln.strip()), "") 

209 if is_prompt 

210 else action 

211 ) 

212 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len(kind_label) - 2 - 2 

213 detail = f"{_truncate(first_line, max(avail, 20))} {kind_str}" 

214 

215 elif event_type == "action_output": 

216 # Only reached in verbose mode 

217 etype_color = "2" 

218 detail = colorize("\u2502 " + event.get("line", ""), "2") 

219 

220 elif event_type == "action_complete": 

221 exit_code = event.get("exit_code", 0) 

222 duration_ms = event.get("duration_ms", 0) 

223 if exit_code == 0: 

224 etype_color = "2" 

225 status_str = colorize("\u2713", "32") 

226 else: 

227 etype_color = "38;5;208" 

228 status_str = colorize(f"\u2717 exit={exit_code}", "38;5;208") 

229 detail = f"{status_str} {duration_ms}ms" 

230 is_prompt = event.get("is_prompt", False) 

231 session_jsonl = event.get("session_jsonl") if is_prompt else None 

232 if session_jsonl: 

233 session_display = session_jsonl if verbose else os.path.basename(session_jsonl) 

234 detail += f" session={colorize(session_display, '2')}" 

235 if verbose: 

236 output_preview = event.get("output_preview", "") 

237 if output_preview: 

238 avail_w = width - len(_indent) - 2 

239 preview_text = ( 

240 output_preview if full else _truncate(output_preview, max(avail_w, 40)) 

241 ) 

242 for preview_line in preview_text.splitlines()[:5]: 

243 extra_lines.append(colorize(_indent + "\u2502 " + preview_line, "2")) 

244 

245 elif event_type == "evaluate": 

246 verdict = event.get("verdict", "") 

247 confidence = event.get("confidence", "") 

248 reason = event.get("reason", "") 

249 if verdict == "yes": 

250 etype_color = "32" 

251 verdict_str = colorize("\u2713 yes", "32") 

252 else: 

253 etype_color = "38;5;208" 

254 verdict_str = colorize(f"\u2717 {verdict}", "38;5;208") 

255 conf_part = f" confidence={confidence}" if confidence != "" else "" 

256 avail = width - 8 - 2 - _EVENT_TYPE_WIDTH - 2 - len("\u2713 yes") - len(conf_part) - 2 

257 reason_part = f" {_truncate(reason, max(avail, 20))}" if reason else "" 

258 detail = f"{verdict_str}{conf_part}{reason_part}" 

259 if verbose: 

260 llm_model = event.get("llm_model", "") 

261 llm_latency_ms = event.get("llm_latency_ms", "") 

262 llm_prompt = event.get("llm_prompt", "") 

263 llm_raw_output = event.get("llm_raw_output", "") 

264 if llm_model or llm_prompt: 

265 meta_parts = [] 

266 if llm_model: 

267 meta_parts.append(f"model={llm_model}") 

268 if llm_latency_ms != "": 

269 meta_parts.append(f"latency={llm_latency_ms}ms") 

270 meta_str = " ".join(meta_parts) 

271 extra_lines.append( 

272 colorize(_indent + colorize("LLM Call", "2") + " " + meta_str, "2") 

273 ) 

274 avail_w = width - len(_indent) - len("Prompt: ") - 2 

275 if llm_prompt: 

276 prompt_text = llm_prompt if full else _truncate(llm_prompt, max(avail_w, 40)) 

277 extra_lines.append(colorize(_indent + "Prompt: " + prompt_text, "2")) 

278 if llm_raw_output: 

279 resp_text = ( 

280 llm_raw_output if full else _truncate(llm_raw_output, max(avail_w, 40)) 

281 ) 

282 extra_lines.append(colorize(_indent + "Response: " + resp_text, "2")) 

283 

284 elif event_type == "route": 

285 etype_color = "2" 

286 from_state = event.get("from", "") 

287 to_state = event.get("to", "") 

288 detail = f"{from_state} \u2192 {colorize(to_state, '34')}" 

289 

290 elif event_type == "handoff_detected": 

291 etype_color = "33" 

292 detail = f"state={event.get('state', '')} iter={event.get('iteration', '')}" 

293 

294 else: 

295 details = {k: v for k, v in event.items() if k not in ("event", "ts")} 

296 detail = " ".join(f"{k}={v}" for k, v in details.items()) 

297 

298 etype_str = colorize(etype_padded, etype_color) 

299 main_line = f"{ts_str} {etype_str} {detail}" 

300 if extra_lines: 

301 return "\n".join([main_line] + extra_lines) 

302 return main_line 

303 

304 

305def _format_duration(ms: int) -> str: 

306 """Format milliseconds as a human-readable duration.""" 

307 if ms < 1000: 

308 return f"{ms}ms" 

309 s = ms // 1000 

310 if s < 60: 

311 return f"{s}s" 

312 m, s = divmod(s, 60) 

313 if m < 60: 

314 return f"{m}m{s:02d}s" 

315 h, m = divmod(m, 60) 

316 return f"{h}h{m:02d}m{s:02d}s" 

317 

318 

319def _list_archived_runs(loop_name: str, loops_dir: Path, as_json: bool) -> int: 

320 """List archived runs for a loop.""" 

321 import json as _json 

322 

323 from little_loops.fsm.persistence import HISTORY_DIR, LoopState 

324 

325 history_base = loops_dir / HISTORY_DIR / loop_name 

326 if not history_base.exists(): 

327 print(f"No history for: {loop_name}") 

328 return 0 

329 

330 # Collect (run_id, state_or_None) pairs sorted newest first by run_id 

331 runs: list[tuple[str, LoopState | None]] = [] 

332 for run_dir in sorted(history_base.iterdir(), key=lambda d: d.name, reverse=True): 

333 if not run_dir.is_dir(): 

334 continue 

335 state_file = run_dir / "state.json" 

336 state: LoopState | None = None 

337 if state_file.exists(): 

338 try: 

339 data = _json.loads(state_file.read_text()) 

340 state = LoopState.from_dict(data) 

341 except (ValueError, KeyError): 

342 pass 

343 runs.append((run_dir.name, state)) 

344 

345 if not runs: 

346 print(f"No history for: {loop_name}") 

347 return 0 

348 

349 if as_json: 

350 print( 

351 _json.dumps( 

352 [ 

353 { 

354 "run_id": rid, 

355 "status": s.status if s else None, 

356 "started_at": s.started_at if s else None, 

357 "iterations": s.iteration if s else None, 

358 "duration_ms": s.accumulated_ms if s else None, 

359 } 

360 for rid, s in runs 

361 ], 

362 indent=2, 

363 ) 

364 ) 

365 return 0 

366 

367 status_colors = { 

368 "completed": "\033[32m", 

369 "failed": "\033[31m", 

370 "interrupted": "\033[33m", 

371 "awaiting_continuation": "\033[36m", 

372 "timed_out": "\033[33m", 

373 "running": "\033[34m", 

374 } 

375 reset = "\033[0m" 

376 

377 print(f"Archived runs for: {loop_name} ({len(runs)} total)") 

378 print() 

379 

380 for run_id, state in runs: 

381 if state is not None: 

382 color = status_colors.get(state.status, "") 

383 status_str = f"{color}{state.status}{reset}" 

384 duration_str = _format_duration(state.accumulated_ms) if state.accumulated_ms else "?" 

385 started = state.started_at[:19].replace("T", " ") if state.started_at else "?" 

386 iters = f"{state.iteration} iters" 

387 else: 

388 status_str = "unknown" 

389 duration_str = "?" 

390 started = "?" 

391 iters = "?" 

392 print(f" {run_id} {status_str} {started} {iters} {duration_str}") 

393 

394 print() 

395 print(f"To view events: ll-loop history {loop_name} <run-id>") 

396 return 0 

397 

398 

399def cmd_history( 

400 loop_name: str, 

401 run_id: str | None, 

402 args: argparse.Namespace, 

403 loops_dir: Path, 

404) -> int: 

405 """Show loop history. 

406 

407 Without run_id: lists all archived runs with status and duration. 

408 With run_id: shows events for that specific archived run. 

409 """ 

410 tail = getattr(args, "tail", 50) 

411 full = getattr(args, "full", False) 

412 verbose = getattr(args, "verbose", False) or full 

413 as_json = getattr(args, "json", False) 

414 

415 if run_id is None: 

416 return _list_archived_runs(loop_name, loops_dir, as_json) 

417 

418 # Show events for a specific archived run 

419 from little_loops.fsm.persistence import get_archived_events 

420 

421 events = get_archived_events(loop_name, run_id, loops_dir) 

422 

423 if not events: 

424 print(f"No events found for run: {loop_name}/{run_id}") 

425 return 1 

426 

427 w = terminal_width() 

428 if not verbose: 

429 events = [e for e in events if e.get("event") != "action_output"] 

430 if as_json: 

431 print_json(events[-tail:]) 

432 return 0 

433 for event in events[-tail:]: 

434 line = _format_history_event(event, verbose, w, full=full) 

435 if line is not None: 

436 print(line) 

437 

438 return 0 

439 

440 

441# --------------------------------------------------------------------------- 

442# FSM diagram renderer — delegated to layout module (re-exported above) 

443# --------------------------------------------------------------------------- 

444 

445 

446# --------------------------------------------------------------------------- 

447# State overview table 

448# --------------------------------------------------------------------------- 

449 

450 

451def _compact_transitions(state: StateConfig) -> str: 

452 """Return a compact transition string for the overview table.""" 

453 raw: list[tuple[str, str]] = [] 

454 for label, target in [ 

455 ("yes", state.on_yes), 

456 ("no", state.on_no), 

457 ("error", state.on_error), 

458 ("partial", state.on_partial), 

459 ("next", state.next), 

460 ]: 

461 if target: 

462 raw.append((label, target)) 

463 if state.route: 

464 for verdict, target in state.route.routes.items(): 

465 raw.append((verdict, target)) 

466 if state.route.default: 

467 raw.append(("_", state.route.default)) 

468 if not raw: 

469 return "\u2014" 

470 # Group by target, preserving first-seen order 

471 seen: list[str] = [] 

472 by_target: dict[str, list[str]] = {} 

473 for label, target in raw: 

474 if target not in by_target: 

475 seen.append(target) 

476 by_target[target] = [] 

477 by_target[target].append(label) 

478 return ", ".join(f"{'/'.join(by_target[t])}\u2192{t}" for t in seen) 

479 

480 

481def _print_state_overview_table(fsm: FSMLoop) -> None: 

482 """Print a compact summary table of all states.""" 

483 rows: list[tuple[str, str, str, str]] = [] 

484 for name, state in fsm.states.items(): 

485 # State name column 

486 state_col = f"\u2192 {name}" if name == fsm.initial else f" {name}" 

487 

488 # Type column 

489 if state.terminal: 

490 type_col = "\u2014" 

491 elif state.action_type: 

492 type_col = state.action_type 

493 elif state.action: 

494 type_col = "shell" 

495 else: 

496 type_col = "\u2014" 

497 

498 # Action preview column 

499 if state.terminal: 

500 action_col = "(terminal)" 

501 elif state.action: 

502 src_lines = [ln.rstrip() for ln in state.action.strip().splitlines() if ln.rstrip()] 

503 action_col = src_lines[0] if src_lines else "\u2014" 

504 else: 

505 action_col = "\u2014" 

506 

507 # Transitions column 

508 trans_col = _compact_transitions(state) 

509 rows.append((state_col, type_col, action_col, trans_col)) 

510 

511 if not rows: 

512 return 

513 

514 tw = terminal_width() 

515 headers = ("State", "Type", "Action Preview", "Transitions") 

516 col0_w = max(len(headers[0]), max(len(r[0]) for r in rows)) 

517 col1_w = max(len(headers[1]), max(len(r[1]) for r in rows)) 

518 # Remaining width split between action preview and transitions 

519 fixed = col0_w + col1_w + 10 # margins + separators 

520 remaining = max(20, tw - fixed) 

521 col2_w = min(50, max(len(headers[2]), max(len(r[2]) for r in rows)), remaining * 3 // 5) 

522 col2_w = max(10, col2_w) 

523 col3_w = max(10, remaining - col2_w) 

524 

525 print(f" {headers[0]:<{col0_w}} {headers[1]:<{col1_w}} {headers[2]:<{col2_w}} {headers[3]}") 

526 dash = "\u2500" 

527 print(f" {dash * col0_w} {dash * col1_w} {dash * col2_w} {dash * col3_w}") 

528 for state_col, type_col, action_col, trans_col in rows: 

529 if len(action_col) > col2_w: 

530 action_col = action_col[: col2_w - 1] + "\u2026" 

531 if len(trans_col) > col3_w: 

532 trans_col = trans_col[: col3_w - 1] + "\u2026" 

533 colored_type = colorize(type_col, "2") if type_col == "\u2014" else type_col 

534 print( 

535 f" {state_col:<{col0_w}} {colored_type:<{col1_w}} " 

536 f"{action_col:<{col2_w}} {trans_col}" 

537 ) 

538 

539 

540# --------------------------------------------------------------------------- 

541# cmd_show 

542# --------------------------------------------------------------------------- 

543 

544_EVALUATE_TYPE_DISPLAY: dict[str, str] = { 

545 "llm": "LLM", 

546 "llm_structured": "LLM (structured)", 

547 "exit_code": "exit code", 

548 "output_numeric": "numeric", 

549 "output_contains": "contains", 

550 "output_json": "JSON", 

551 "convergence": "convergence", 

552} 

553 

554 

555def _humanize_evaluate_type(ev_type: str) -> str: 

556 return _EVALUATE_TYPE_DISPLAY.get(ev_type, ev_type) 

557 

558 

559def cmd_show( 

560 loop_name: str, 

561 args: argparse.Namespace, 

562 loops_dir: Path, 

563 logger: Logger, 

564) -> int: 

565 """Show loop details and structure.""" 

566 try: 

567 path = resolve_loop_path(loop_name, loops_dir) 

568 fsm, spec = load_loop_with_spec(loop_name, loops_dir, logger) 

569 except FileNotFoundError as e: 

570 logger.error(str(e)) 

571 return 1 

572 except ValueError as e: 

573 logger.error(f"Invalid loop: {e}") 

574 return 1 

575 

576 if getattr(args, "json", False): 

577 print_json(fsm.to_dict()) 

578 return 0 

579 

580 tw = terminal_width() 

581 

582 # Compute stats for header 

583 n_states = len(fsm.states) 

584 n_transitions = sum( 

585 bool(s.on_yes) 

586 + bool(s.on_no) 

587 + bool(s.on_error) 

588 + bool(s.on_partial) 

589 + bool(s.next) 

590 + bool(s.on_maintain) 

591 + (len(s.route.routes) + bool(s.route.default) if s.route else 0) 

592 for s in fsm.states.values() 

593 ) 

594 

595 # --- Compact metadata header --- 

596 # Line 1: ── name ───────── N states · M transitions ── 

597 stats_parts: list[str] = [] 

598 stats_parts.append(f"{n_states} states") 

599 stats_parts.append(f"{n_transitions} transitions") 

600 stats_str = " \u00b7 ".join(stats_parts) 

601 

602 header_left = f"\u2500\u2500 {loop_name} " 

603 header_right = f" {stats_str} \u2500\u2500" 

604 dashes = "\u2500" * max(0, tw - len(header_left) - len(header_right)) 

605 print(f"{header_left}{dashes}{header_right}") 

606 

607 # Line 2: source · max: N iter · handoff: X [· optional fields] 

608 config_parts: list[str] = [str(path), f"max: {fsm.max_iterations} iter"] 

609 config_parts.append(f"handoff: {fsm.on_handoff}") 

610 if fsm.timeout: 

611 config_parts.append(f"timeout: {fsm.timeout}s") 

612 if fsm.backoff: 

613 config_parts.append(f"backoff: {fsm.backoff}s") 

614 if fsm.maintain: 

615 config_parts.append("maintain: yes") 

616 if fsm.context: 

617 config_parts.append(f"context: {', '.join(fsm.context.keys())}") 

618 if fsm.scope: 

619 config_parts.append(f"scope: {', '.join(fsm.scope)}") 

620 llm = fsm.llm 

621 llm_parts = [] 

622 if llm.model != "sonnet": 

623 llm_parts.append(f"model={llm.model}") 

624 if llm.max_tokens != 256: 

625 llm_parts.append(f"max_tokens={llm.max_tokens}") 

626 if llm.timeout != 30: 

627 llm_parts.append(f"timeout={llm.timeout}s") 

628 if llm_parts: 

629 config_parts.append(f"llm: {', '.join(llm_parts)}") 

630 print(" " + " \u00b7 ".join(config_parts)) 

631 

632 # --- Description --- 

633 description = spec.get("description", "").strip() 

634 if description: 

635 print() 

636 print("Description:") 

637 for line in description.splitlines(): 

638 print(f" {line}") 

639 

640 # --- ASCII FSM Diagram --- 

641 verbose = getattr(args, "verbose", False) 

642 print() 

643 print("Diagram:") 

644 diagram = _render_fsm_diagram(fsm, verbose=verbose) 

645 if diagram: 

646 print(diagram) 

647 

648 # --- State overview table --- 

649 print() 

650 _print_state_overview_table(fsm) 

651 

652 # --- States & Transitions (verbose only) --- 

653 if verbose: 

654 print() 

655 print("States:") 

656 first_state = True 

657 for name, state in fsm.states.items(): 

658 if not first_state: 

659 print() 

660 first_state = False 

661 

662 # Improved state section header: ── name ──── MARKERS · type ── 

663 right_parts = [] 

664 if name == fsm.initial: 

665 right_parts.append("INITIAL") 

666 if state.terminal: 

667 right_parts.append("TERMINAL") 

668 if state.action_type: 

669 right_parts.append(state.action_type) 

670 right_info = " \u00b7 ".join(right_parts) 

671 inner_left = f"\u2500\u2500 {name} " 

672 inner_right = f" {right_info} \u2500\u2500" if right_info else " \u2500\u2500" 

673 fill = "\u2500" * max(0, tw - 2 - len(inner_left) - len(inner_right)) 

674 print(f" {inner_left}{fill}{inner_right}") 

675 

676 if state.action: 

677 if verbose: 

678 indented = "\n ".join(state.action.strip().splitlines()) 

679 print(f" action:\n {indented}") 

680 elif state.action_type == "prompt": 

681 lines_act = state.action.strip().splitlines() 

682 preview = "\n ".join(lines_act[:3]) 

683 if len(lines_act) > 3 or len(state.action) > 200: 

684 preview += " ..." 

685 print(f" action:\n {preview}") 

686 else: # shell, slash_command, or None 

687 action_display = ( 

688 state.action[:70] + "..." if len(state.action) > 70 else state.action 

689 ) 

690 print(f" action: {action_display}") 

691 if state.evaluate: 

692 ev = state.evaluate 

693 print(f" evaluate: {_humanize_evaluate_type(ev.type)}") 

694 if ev.prompt: 

695 if verbose: 

696 print(" prompt:") 

697 for line in ev.prompt.strip().splitlines(): 

698 print(f" \u2502 {line}") 

699 else: 

700 ev_lines = ev.prompt.strip().splitlines() 

701 preview = ev_lines[0][:100] + ( 

702 " ..." if len(ev_lines) > 1 or len(ev_lines[0]) > 100 else "" 

703 ) 

704 print(f" prompt: {preview}") 

705 if ev.min_confidence != 0.5: 

706 print(f" min_confidence: {ev.min_confidence}") 

707 if ev.operator: 

708 print(f" operator: {ev.operator} {ev.target}") 

709 if ev.pattern: 

710 print(f" pattern: {ev.pattern}") 

711 if state.capture: 

712 print(f" capture: {state.capture}") 

713 if state.timeout: 

714 print(f" timeout: {state.timeout}s") 

715 # Collect (label, target) pairs 

716 raw_transitions: list[tuple[str, str]] = [] 

717 for label, target in [ 

718 ("yes", state.on_yes), 

719 ("no", state.on_no), 

720 ("error", state.on_error), 

721 ("partial", state.on_partial), 

722 ("next", state.next), 

723 ("maintain", state.on_maintain), 

724 ]: 

725 if target: 

726 raw_transitions.append((label, target)) 

727 if state.route: 

728 for verdict, target in state.route.routes.items(): 

729 raw_transitions.append((verdict, target)) 

730 if state.route.default: 

731 raw_transitions.append(("_", state.route.default)) 

732 # Group by target, preserving first-seen order 

733 target_labels: dict[str, list[str]] = {} 

734 seen_targets: list[str] = [] 

735 for label, target in raw_transitions: 

736 if target not in target_labels: 

737 target_labels[target] = [] 

738 seen_targets.append(target) 

739 target_labels[target].append(label) 

740 transitions = [ 

741 f"{_colorize_label('/'.join(target_labels[t]))} \u2500\u2500\u2192 {t}" 

742 for t in seen_targets 

743 ] 

744 if transitions: 

745 print(" Transitions:") 

746 for t in transitions: 

747 print(f" {t}") 

748 

749 # --- Commands --- 

750 print() 

751 print("Commands:") 

752 cmds = [ 

753 (f"ll-loop run {loop_name}", "run"), 

754 (f"ll-loop test {loop_name}", "single test iteration"), 

755 (f"ll-loop stop {loop_name}", "stop a running loop"), 

756 (f"ll-loop status {loop_name}", "check if running"), 

757 (f"ll-loop history {loop_name}", "execution history"), 

758 ] 

759 col_width = max(len(c) for c, _ in cmds) + 2 

760 for cmd, comment in cmds: 

761 print(f" {cmd:<{col_width}} # {comment}") 

762 

763 return 0