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
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""ll-loop info subcommands: list, history, show."""
3from __future__ import annotations
5import argparse
6import os
7from datetime import datetime
8from pathlib import Path
9from typing import Any
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
28def _load_loop_meta(path: Path) -> str:
29 """Return description from a loop YAML file."""
30 import yaml
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 ""
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
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
80 builtin_only = getattr(args, "builtin", False)
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}
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 ]
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
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
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
143_EVENT_TYPE_WIDTH = 16 # width of "handoff_detected"
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"
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)
165 event_type = event.get("event", "unknown")
167 if event_type == "action_output" and not verbose:
168 return None
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] = []
176 # Indentation prefix for verbose sub-lines (aligns under event detail column)
177 _indent = " " * (8 + 2 + _EVENT_TYPE_WIDTH + 2)
179 if event_type == "loop_start":
180 etype_color = "1"
181 detail = event.get("loop", "")
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}]"
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}"
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})"
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}"
215 elif event_type == "action_output":
216 # Only reached in verbose mode
217 etype_color = "2"
218 detail = colorize("\u2502 " + event.get("line", ""), "2")
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"))
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"))
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')}"
290 elif event_type == "handoff_detected":
291 etype_color = "33"
292 detail = f"state={event.get('state', '')} iter={event.get('iteration', '')}"
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())
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
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"
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
323 from little_loops.fsm.persistence import HISTORY_DIR, LoopState
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
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))
345 if not runs:
346 print(f"No history for: {loop_name}")
347 return 0
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
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"
377 print(f"Archived runs for: {loop_name} ({len(runs)} total)")
378 print()
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}")
394 print()
395 print(f"To view events: ll-loop history {loop_name} <run-id>")
396 return 0
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.
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)
415 if run_id is None:
416 return _list_archived_runs(loop_name, loops_dir, as_json)
418 # Show events for a specific archived run
419 from little_loops.fsm.persistence import get_archived_events
421 events = get_archived_events(loop_name, run_id, loops_dir)
423 if not events:
424 print(f"No events found for run: {loop_name}/{run_id}")
425 return 1
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)
438 return 0
441# ---------------------------------------------------------------------------
442# FSM diagram renderer — delegated to layout module (re-exported above)
443# ---------------------------------------------------------------------------
446# ---------------------------------------------------------------------------
447# State overview table
448# ---------------------------------------------------------------------------
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)
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}"
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"
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"
507 # Transitions column
508 trans_col = _compact_transitions(state)
509 rows.append((state_col, type_col, action_col, trans_col))
511 if not rows:
512 return
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)
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 )
540# ---------------------------------------------------------------------------
541# cmd_show
542# ---------------------------------------------------------------------------
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}
555def _humanize_evaluate_type(ev_type: str) -> str:
556 return _EVALUATE_TYPE_DISPLAY.get(ev_type, ev_type)
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
576 if getattr(args, "json", False):
577 print_json(fsm.to_dict())
578 return 0
580 tw = terminal_width()
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 )
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)
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}")
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))
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}")
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)
648 # --- State overview table ---
649 print()
650 _print_state_overview_table(fsm)
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
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}")
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}")
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}")
763 return 0