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

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

2

3 from __future__ import annotations

4

5 import argparse

6 import os

7 from datetime import datetime

8 from pathlib import Path

9 from typing import Any

10

11 from little_loops.cli.loop._helpers import (

12 get_builtin_loops_dir,

13 load_loop_with_spec,

14 resolve_loop_path,

15)

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

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

24 from little_loops.fsm.schema import FSMLoop, StateConfig

25 from little_loops.logger import Logger

26

27

28 def _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

41 def 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

146 def _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

155 def _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

305 def _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

319 def _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

399 def 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

451 def _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

481 def _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

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

556 return _EVALUATE_TYPE_DISPLAY.get(ev_type, ev_type)

557

558

559 def 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