Coverage for little_loops / cli / loop / layout.py: 4%

1223 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""FSM diagram layout engine. 

2 

3Implements adaptive layout for FSM diagrams using a Sugiyama-inspired 

4layered graph drawing approach. Supports topology detection, vertical 

5linear chains, side-by-side branching, and margin back-edge rendering. 

6 

7Extracted from info.py and extended with adaptive layout capabilities. 

8""" 

9 

10from __future__ import annotations 

11 

12import re 

13from collections import deque 

14 

15from wcwidth import wcswidth as _wcswidth 

16from wcwidth import wcwidth as _wcwidth 

17 

18from little_loops.cli.output import colorize, strip_ansi, terminal_width 

19from little_loops.fsm.schema import FSMLoop, StateConfig 

20 

21# --------------------------------------------------------------------------- 

22# Edge label colorization 

23# --------------------------------------------------------------------------- 

24 

25_EDGE_LABEL_COLORS: dict[str, str] = { 

26 "yes": "32", 

27 "no": "38;5;208", 

28 "error": "31", 

29 "partial": "33", 

30 "next": "2", 

31 "_": "2", 

32 "blocked": "31", 

33 "retry_exhausted": "38;5;208", 

34 "rate_limit_exhausted": "38;5;214", 

35 "throttle_hard": "38;5;196", 

36} 

37 

38 

39def _colorize_label(label: str) -> str: 

40 """Colorize a (possibly compound) edge label like 'no/error'.""" 

41 parts = label.split("/") 

42 code = "" 

43 for part in parts: 

44 if part in ("no", "error"): 

45 code = _EDGE_LABEL_COLORS["no"] 

46 break 

47 if part == "partial" and not code: 

48 code = _EDGE_LABEL_COLORS["partial"] 

49 elif part == "yes" and not code: 

50 code = _EDGE_LABEL_COLORS["yes"] 

51 elif part in ("next", "_") and not code: 

52 code = _EDGE_LABEL_COLORS["next"] 

53 return colorize(label, code) if code else label 

54 

55 

56def _edge_line_color(label: str) -> str: 

57 """Return the ANSI SGR code to use for connector characters of an edge. 

58 

59 Applies the same priority cascade as ``_colorize_label`` so that line 

60 characters (│, ─, ▼, ▶, corners) match the semantic color of their label. 

61 Returns an empty string when no color applies (callers treat this as "no-op"). 

62 """ 

63 parts = label.split("/") 

64 code = "" 

65 for part in parts: 

66 if part in ( 

67 "no", 

68 "error", 

69 "blocked", 

70 "retry_exhausted", 

71 "rate_limit_exhausted", 

72 "throttle_hard", 

73 ): 

74 return _EDGE_LABEL_COLORS.get(part, "31") 

75 if part == "partial" and not code: 

76 code = _EDGE_LABEL_COLORS["partial"] 

77 elif part == "yes" and not code: 

78 code = _EDGE_LABEL_COLORS["yes"] 

79 elif part in ("next", "_") and not code: 

80 code = _EDGE_LABEL_COLORS["next"] 

81 return code 

82 

83 

84def _colorize_diagram_labels(diagram: str, colors: dict[str, str] | None = None) -> str: 

85 """Apply ANSI color to known edge labels in a rendered diagram string. 

86 

87 Labels are colorized only when bounded by box-drawing or whitespace chars 

88 to avoid coloring partial matches inside state names. 

89 

90 Args: 

91 colors: Optional label→SGR-code mapping; falls back to _EDGE_LABEL_COLORS if None. 

92 """ 

93 label_colors = colors if colors is not None else _EDGE_LABEL_COLORS 

94 for label, code in label_colors.items(): 

95 colored = colorize(label, code) 

96 diagram = re.sub( 

97 r"(?<=[─ │▶\n])" + re.escape(label) + r"(?=[─ │▶\n])", 

98 colored, 

99 diagram, 

100 ) 

101 return diagram 

102 

103 

104# --------------------------------------------------------------------------- 

105# State box badge definitions 

106# --------------------------------------------------------------------------- 

107 

108_ACTION_TYPE_BADGES: dict[str, str] = { 

109 "prompt": "\u2726", # ✦ 

110 "slash_command": "/\u2501\u25ba", # /━► 

111 "shell": "\u276f_", # ❯_ 

112 "mcp_tool": "\u26a1", # ⚡ 

113} 

114 

115_SUB_LOOP_BADGE = "\u21b3\u27f3" # ↳⟳ 

116_ROUTE_BADGE = "\u2443" # ⑃ 

117 

118 

119def _badge_display_width(badge: str) -> int: 

120 """Compute terminal display width of a badge string using wcwidth.""" 

121 w = _wcswidth(badge) 

122 return w if w >= 0 else len(badge) 

123 

124 

125def _display_width(s: str) -> int: 

126 """Terminal display width of a string (wcwidth), with a safe len() fallback. 

127 

128 Box layout reserves *display columns*, not characters, so widths must be 

129 measured this way; using ``len()`` undercounts wide glyphs (CJK, some 

130 symbols) and overflows the box border. 

131 """ 

132 w = _wcswidth(s) 

133 return w if w >= 0 else len(s) 

134 

135 

136def _truncate_to_width(text: str, width: int) -> str: 

137 """Truncate ``text`` so its display width is ≤ ``width``. 

138 

139 When truncation occurs the last column is replaced with ``…`` so the result 

140 still fits ``width`` display columns. Wide glyphs are kept whole. 

141 """ 

142 if width <= 0: 

143 return "" 

144 if _display_width(text) <= width: 

145 return text 

146 # Reserve one column for the ellipsis. 

147 budget = width - 1 

148 out: list[str] = [] 

149 used = 0 

150 for ch in text: 

151 cw = _wcwidth(ch) 

152 if cw < 0: 

153 cw = 1 

154 if used + cw > budget: 

155 break 

156 out.append(ch) 

157 used += cw 

158 return "".join(out) + "…" 

159 

160 

161def _wrap_to_width(text: str, width: int) -> list[str]: 

162 """Hard-wrap ``text`` into chunks each ≤ ``width`` display columns. 

163 

164 Splits on display width (not character count) so wide glyphs are kept whole 

165 and no chunk overflows the box border. 

166 """ 

167 if width <= 0: 

168 return [text] if text else [] 

169 chunks: list[str] = [] 

170 cur: list[str] = [] 

171 used = 0 

172 for ch in text: 

173 cw = _wcwidth(ch) 

174 if cw < 0: 

175 cw = 1 

176 if used + cw > width and cur: 

177 chunks.append("".join(cur)) 

178 cur = [] 

179 used = 0 

180 cur.append(ch) 

181 used += cw 

182 if cur: 

183 chunks.append("".join(cur)) 

184 return chunks 

185 

186 

187def _get_state_badge(state: StateConfig | None, badges: dict[str, str] | None = None) -> str: 

188 """Return the unicode badge string for a state, or '' if none.""" 

189 if state is None: 

190 return "" 

191 effective = {**_ACTION_TYPE_BADGES, **(badges or {})} 

192 sub_loop_badge = (badges or {}).get("sub_loop", _SUB_LOOP_BADGE) 

193 route_badge = (badges or {}).get("route", _ROUTE_BADGE) 

194 if state.loop is not None: 

195 return sub_loop_badge 

196 if state.action_type: 

197 return effective.get(state.action_type, f"[{state.action_type}]") 

198 if state.action: 

199 return effective["shell"] 

200 if state.route is not None: 

201 return route_badge 

202 return "" 

203 

204 

205# --------------------------------------------------------------------------- 

206# Box content helpers for multi-row diagram boxes 

207# --------------------------------------------------------------------------- 

208 

209 

210def _box_inner_lines( 

211 state: StateConfig | None, 

212 display_label: str, 

213 verbose: bool, 

214 inner_width: int, 

215 title_only: bool = False, 

216) -> list[str]: 

217 """Return interior lines for a state box (between top and bottom borders). 

218 

219 The first line is always ``display_label`` + type badge (if any). 

220 Subsequent lines are action content lines. All lines fit within 

221 ``inner_width`` characters (content is truncated or wrapped accordingly). 

222 

223 When ``title_only`` is True, only the name row is returned (used by 

224 ``--show-diagrams=mini`` for skeleton rendering). 

225 """ 

226 # Badge is now rendered in the top-right corner by _draw_box; name row is label only. 

227 # Truncation is by display width so wide glyphs don't overflow the box border. 

228 name_line = _truncate_to_width(display_label, inner_width) 

229 

230 lines: list[str] = [name_line] 

231 

232 if title_only: 

233 return lines 

234 

235 # Action lines 

236 if state and state.action: 

237 action_src = [ln.rstrip() for ln in state.action.strip().splitlines()] 

238 if verbose: 

239 for src in action_src: 

240 if not src: 

241 continue 

242 # Wrap long lines to inner_width (measured in display columns) 

243 lines.extend(_wrap_to_width(src, inner_width)) 

244 else: 

245 # Show first non-empty line, truncated to display width 

246 first = next((ln for ln in action_src if ln), "") 

247 first = _truncate_to_width(first, inner_width) 

248 if first: 

249 lines.append(first) 

250 

251 return lines 

252 

253 

254# --------------------------------------------------------------------------- 

255# Topology detection 

256# --------------------------------------------------------------------------- 

257 

258 

259def _collect_edges(fsm: FSMLoop) -> list[tuple[str, str, str]]: 

260 """Collect all (source, target, label) edges from an FSM.""" 

261 edges: list[tuple[str, str, str]] = [] 

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

263 if state.on_yes: 

264 edges.append((name, state.on_yes, "yes")) 

265 if state.on_no: 

266 edges.append((name, state.on_no, "no")) 

267 if state.on_error: 

268 edges.append((name, state.on_error, "error")) 

269 if state.on_partial: 

270 edges.append((name, state.on_partial, "partial")) 

271 if state.on_blocked: 

272 edges.append((name, state.on_blocked, "blocked")) 

273 if state.on_retry_exhausted: 

274 edges.append((name, state.on_retry_exhausted, "retry_exhausted")) 

275 if state.on_rate_limit_exhausted: 

276 edges.append((name, state.on_rate_limit_exhausted, "rate_limit_exhausted")) 

277 if state.on_throttle_hard: 

278 edges.append((name, state.on_throttle_hard, "throttle_hard")) 

279 if state.next: 

280 edges.append((name, state.next, "next")) 

281 if state.route: 

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

283 edges.append((name, target, verdict)) 

284 if state.route.default: 

285 edges.append((name, state.route.default, "_")) 

286 for verdict, target in state.extra_routes.items(): 

287 edges.append((name, target, verdict)) 

288 return edges 

289 

290 

291def _bfs_order(initial: str, edges: list[tuple[str, str, str]]) -> tuple[list[str], dict[str, int]]: 

292 """BFS from initial state. Returns (order, depth_map).""" 

293 order: list[str] = [] 

294 depth: dict[str, int] = {initial: 0} 

295 queue: deque[str] = deque([initial]) 

296 while queue: 

297 node = queue.popleft() 

298 order.append(node) 

299 for src, dst, _ in edges: 

300 if src == node and dst not in depth: 

301 depth[dst] = depth[node] + 1 

302 queue.append(dst) 

303 return order, depth 

304 

305 

306def _trace_main_path( 

307 fsm: FSMLoop, edges: list[tuple[str, str, str]] 

308) -> tuple[list[str], set[tuple[str, str]]]: 

309 """Trace the main (happy) path through the FSM.""" 

310 visited: set[str] = set() 

311 main_path: list[str] = [] 

312 main_edge_set: set[tuple[str, str]] = set() 

313 current = fsm.initial 

314 while current and current not in visited: 

315 visited.add(current) 

316 main_path.append(current) 

317 st = fsm.states.get(current) 

318 if not st or st.terminal: 

319 break 

320 nxt: str = st.on_yes or st.next or "" 

321 if not nxt and st.route: 

322 nxt = next(iter(st.route.routes.values()), "") or st.route.default or "" 

323 if nxt: 

324 main_edge_set.add((current, nxt)) 

325 current = nxt 

326 else: 

327 break 

328 return main_path, main_edge_set 

329 

330 

331def _classify_edges( 

332 edges: list[tuple[str, str, str]], 

333 main_edge_set: set[tuple[str, str]], 

334 bfs_order: list[str], 

335) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]: 

336 """Split non-main edges into branches and back_edges.""" 

337 main_consumed: set[int] = set() 

338 for src, dst in main_edge_set: 

339 for i, (s, d, _) in enumerate(edges): 

340 if s == src and d == dst and i not in main_consumed: 

341 main_consumed.add(i) 

342 break 

343 

344 branches: list[tuple[str, str, str]] = [] 

345 back_edges: list[tuple[str, str, str]] = [] 

346 for i, (src, dst, label) in enumerate(edges): 

347 if i in main_consumed: 

348 continue 

349 src_pos = bfs_order.index(src) if src in bfs_order else len(bfs_order) 

350 dst_pos = bfs_order.index(dst) if dst in bfs_order else len(bfs_order) 

351 if dst == src or dst_pos < src_pos: 

352 back_edges.append((src, dst, label)) 

353 else: 

354 branches.append((src, dst, label)) 

355 return branches, back_edges 

356 

357 

358class TopologyDetector: 

359 """Classify FSM graph topology for layout strategy selection.""" 

360 

361 def __init__( 

362 self, 

363 edges: list[tuple[str, str, str]], 

364 main_path: list[str], 

365 branches: list[tuple[str, str, str]], 

366 back_edges: list[tuple[str, str, str]], 

367 ) -> None: 

368 self._edges = edges 

369 self._main_path = main_path 

370 self._branches = branches 

371 self._back_edges = back_edges 

372 

373 def classify(self) -> str: 

374 """Return 'linear', 'tree', or 'general'. 

375 

376 Linear: main path only, no non-self branches, only self-loop back-edges. 

377 Tree: branches exist but no fan-in (every non-initial state has ≤1 incoming). 

378 General: everything else (full Sugiyama needed). 

379 """ 

380 non_self_branches = [b for b in self._branches if b[0] != b[1]] 

381 non_self_back = [b for b in self._back_edges if b[0] != b[1]] 

382 

383 if not non_self_branches and not non_self_back: 

384 return "linear" 

385 

386 # Check for fan-in: any state with >1 incoming forward edge 

387 in_count: dict[str, int] = {} 

388 for _, dst, _ in self._edges: 

389 # Only count forward edges (not back-edges) 

390 in_count[dst] = in_count.get(dst, 0) + 1 

391 

392 if not non_self_back and all(v <= 1 for v in in_count.values()): 

393 return "tree" 

394 

395 return "general" 

396 

397 

398# --------------------------------------------------------------------------- 

399# Sugiyama layout pipeline 

400# --------------------------------------------------------------------------- 

401 

402 

403class LayerAssigner: 

404 """Assign nodes to layers using longest-path + width constraint.""" 

405 

406 def __init__( 

407 self, 

408 all_states: list[str], 

409 edges: list[tuple[str, str, str]], 

410 back_edge_set: set[tuple[str, str]], 

411 initial: str, 

412 max_width: int = 4, 

413 ) -> None: 

414 self._all_states = all_states 

415 self._edges = edges 

416 self._back_edge_set = back_edge_set 

417 self._initial = initial 

418 self._max_width = max_width 

419 

420 def assign(self) -> list[list[str]]: 

421 """Return list of layers, each a list of state names (top to bottom).""" 

422 # Build adjacency (forward edges only) 

423 forward: dict[str, list[str]] = {s: [] for s in self._all_states} 

424 reverse: dict[str, list[str]] = {s: [] for s in self._all_states} 

425 seen_edges: set[tuple[str, str]] = set() 

426 for src, dst, _ in self._edges: 

427 if (src, dst) in self._back_edge_set or src == dst: 

428 continue 

429 if src in forward and dst in forward and (src, dst) not in seen_edges: 

430 forward[src].append(dst) 

431 reverse[dst].append(src) 

432 seen_edges.add((src, dst)) 

433 

434 # Longest-path layer assignment (topological order) 

435 layer_of: dict[str, int] = {} 

436 

437 # Kahn's algorithm for topological order 

438 in_degree = {s: len(reverse[s]) for s in self._all_states} 

439 queue: deque[str] = deque() 

440 for s in self._all_states: 

441 if in_degree[s] == 0: 

442 queue.append(s) 

443 

444 topo_order: list[str] = [] 

445 while queue: 

446 node = queue.popleft() 

447 topo_order.append(node) 

448 for dst in forward[node]: 

449 in_degree[dst] -= 1 

450 if in_degree[dst] == 0: 

451 queue.append(dst) 

452 

453 # Handle nodes not reached by topo sort (cycles in forward graph) 

454 for s in self._all_states: 

455 if s not in set(topo_order): 

456 topo_order.append(s) 

457 

458 # Assign layers: longest path from root 

459 for node in topo_order: 

460 if not reverse[node]: 

461 layer_of[node] = 0 

462 else: 

463 layer_of[node] = max( 

464 (layer_of.get(p, 0) + 1 for p in reverse[node]), 

465 default=0, 

466 ) 

467 

468 # Ensure initial state is at layer 0 

469 if self._initial in layer_of and layer_of[self._initial] != 0: 

470 offset = layer_of[self._initial] 

471 for s in layer_of: 

472 layer_of[s] -= offset 

473 

474 # Build layers list 

475 max_layer = max(layer_of.values(), default=0) 

476 layers: list[list[str]] = [[] for _ in range(max_layer + 1)] 

477 for s in self._all_states: 

478 layer = layer_of.get(s, 0) 

479 layers[layer].append(s) 

480 

481 # Width constraint: if any layer exceeds max_width, split 

482 if self._max_width > 0: 

483 new_layers: list[list[str]] = [] 

484 for grp in layers: 

485 remaining = list(grp) 

486 while len(remaining) > self._max_width: 

487 new_layers.append(remaining[: self._max_width]) 

488 remaining = remaining[self._max_width :] 

489 if remaining: 

490 new_layers.append(remaining) 

491 layers = new_layers 

492 

493 return layers 

494 

495 

496class CrossingMinimizer: 

497 """Minimize edge crossings using barycenter heuristic.""" 

498 

499 def __init__( 

500 self, 

501 layers: list[list[str]], 

502 edges: list[tuple[str, str, str]], 

503 back_edge_set: set[tuple[str, str]], 

504 ) -> None: 

505 self._layers = layers 

506 self._edges = edges 

507 self._back_edge_set = back_edge_set 

508 

509 def minimize(self) -> list[list[str]]: 

510 """Return reordered layers with reduced crossings.""" 

511 # Build position lookup 

512 layer_of: dict[str, int] = {} 

513 for i, layer in enumerate(self._layers): 

514 for s in layer: 

515 layer_of[s] = i 

516 

517 # Forward adjacency (non-back, non-self) 

518 adj_down: dict[str, list[str]] = {} 

519 adj_up: dict[str, list[str]] = {} 

520 for src, dst, _ in self._edges: 

521 if (src, dst) in self._back_edge_set or src == dst: 

522 continue 

523 if src in layer_of and dst in layer_of: 

524 adj_down.setdefault(src, []).append(dst) 

525 adj_up.setdefault(dst, []).append(src) 

526 

527 layers = [list(layer) for layer in self._layers] 

528 

529 # 3 sweeps: down, up, down 

530 for sweep in range(3): 

531 if sweep % 2 == 0: 

532 # Top-down sweep 

533 for i in range(1, len(layers)): 

534 pos_above = {s: j for j, s in enumerate(layers[i - 1])} 

535 bary: dict[str, float] = {} 

536 for s in layers[i]: 

537 parents = [p for p in adj_up.get(s, []) if p in pos_above] 

538 if parents: 

539 bary[s] = sum(pos_above[p] for p in parents) / len(parents) 

540 else: 

541 bary[s] = float(layers[i].index(s)) 

542 layers[i].sort(key=lambda s: bary.get(s, 0)) 

543 else: 

544 # Bottom-up sweep 

545 for i in range(len(layers) - 2, -1, -1): 

546 pos_below = {s: j for j, s in enumerate(layers[i + 1])} 

547 bary_up: dict[str, float] = {} 

548 for s in layers[i]: 

549 children = [c for c in adj_down.get(s, []) if c in pos_below] 

550 if children: 

551 bary_up[s] = sum(pos_below[c] for c in children) / len(children) 

552 else: 

553 bary_up[s] = float(layers[i].index(s)) 

554 layers[i].sort(key=lambda s: bary_up.get(s, 0)) 

555 

556 return layers 

557 

558 

559# --------------------------------------------------------------------------- 

560# Rendering helpers 

561# --------------------------------------------------------------------------- 

562 

563 

564def _compute_display_labels( 

565 all_states: list[str], 

566 initial: str, 

567 terminal_states: set[str], 

568) -> dict[str, str]: 

569 """Compute display labels with → prefix and ◉ suffix.""" 

570 display_label: dict[str, str] = {} 

571 for s in all_states: 

572 label = s 

573 if s == initial: 

574 label = "\u2192 " + label 

575 if s in terminal_states: 

576 label = label + " \u25c9" 

577 display_label[s] = label 

578 return display_label 

579 

580 

581def _compute_box_sizes( 

582 all_states: list[str], 

583 display_label: dict[str, str], 

584 fsm_states: dict[str, StateConfig] | None, 

585 verbose: bool, 

586 max_box_inner: int, 

587 badges: dict[str, str] | None = None, 

588 title_only: bool = False, 

589) -> tuple[dict[str, list[str]], dict[str, int], dict[str, int], dict[str, str]]: 

590 """Compute box content, widths, and heights for all states. 

591 

592 Returns (box_inner, box_width, box_height, box_badge). 

593 

594 When ``title_only`` is True, action body lines are suppressed (used by 

595 ``--show-diagrams=mini`` for skeleton rendering); box widths are computed 

596 from the name label / badge only. 

597 """ 

598 box_inner: dict[str, list[str]] = {} 

599 box_width: dict[str, int] = {} 

600 box_badge: dict[str, str] = {} 

601 

602 for s in all_states: 

603 state_obj = fsm_states.get(s) if fsm_states else None 

604 

605 badge = _get_state_badge(state_obj, badges) 

606 badge_w = _badge_display_width(badge) if badge else 0 

607 box_badge[s] = badge 

608 

609 # Width must fit: name label on content row, badge on top border (with one space 

610 # of padding on each side: " badge "). All widths are display columns. 

611 base_w = max(_display_width(display_label[s]), badge_w + 2 if badge_w else 0) 

612 

613 inner_w = base_w 

614 if not title_only and state_obj and state_obj.action and max_box_inner > 0: 

615 action_lines = state_obj.action.strip().splitlines() 

616 if verbose: 

617 max_action_w = max( 

618 (_display_width(ln.rstrip()) for ln in action_lines if ln.rstrip()), default=0 

619 ) 

620 inner_w = max(base_w, min(max_action_w, max_box_inner)) 

621 else: 

622 first_action = next((ln.rstrip() for ln in action_lines if ln.rstrip()), "") 

623 inner_w = max(base_w, min(_display_width(first_action), max_box_inner)) 

624 

625 content = _box_inner_lines( 

626 state_obj, display_label[s], verbose, inner_w, title_only=title_only 

627 ) 

628 actual_w = max(_display_width(ln) for ln in content) 

629 inner_w = max(inner_w, actual_w) 

630 box_inner[s] = content 

631 box_width[s] = inner_w + 4 # "│ " + content + " │" 

632 

633 box_height: dict[str, int] = {s: len(box_inner[s]) + 2 for s in all_states} 

634 return box_inner, box_width, box_height, box_badge 

635 

636 

637def _draw_box( 

638 grid: list[list[str]], 

639 row: int, 

640 col: int, 

641 width: int, 

642 height: int, 

643 content: list[str], 

644 is_highlighted: bool, 

645 highlight_color: str, 

646 badge: str = "", 

647) -> None: 

648 """Draw a state box onto a character grid at (row, col). 

649 

650 If *badge* is provided it is placed right-aligned in the top border row with 

651 one space of padding on each side (``─ badge ┐``), colorized via ``_bc()``. 

652 """ 

653 total_width = len(grid[0]) if grid else 0 

654 try: 

655 bg_code: str | None = str(int(highlight_color) + 10) 

656 except (ValueError, TypeError): 

657 bg_code = None 

658 

659 # Pre-compute the combined border SGR code for highlighted boxes so 

660 # entire border strings can be batched into a single colorize() call. 

661 border_code: str | None = None 

662 if is_highlighted: 

663 border_code = f"{highlight_color};{bg_code}" if bg_code else highlight_color 

664 

665 def _bc(ch: str) -> str: 

666 """Colorize a single character for non-batched cell writes (side borders).""" 

667 if not is_highlighted: 

668 return ch 

669 if bg_code: 

670 return colorize(ch, f"{highlight_color};{bg_code}") 

671 return colorize(ch, highlight_color) 

672 

673 # ── Top border ────────────────────────────────────────────────────── 

674 # Batched into a single colorize() call when highlighted. When a badge 

675 # is present the full border string is built in one shot — badge display 

676 # width drives dash-count arithmetic so wide chars are handled correctly. 

677 if is_highlighted and border_code: 

678 if not badge: 

679 grid[row][col] = colorize("\u250c" + "\u2500" * (width - 2) + "\u2510", border_code) 

680 else: 

681 badge_w = _badge_display_width(badge) 

682 dash_count = width - badge_w - 4 

683 grid[row][col] = colorize( 

684 "\u250c" + "\u2500" * dash_count + " " + badge + " " + "\u2510", 

685 border_code, 

686 ) 

687 # Clear cells consumed by the batched border string 

688 for j in range(1, width): 

689 if col + j < total_width: 

690 grid[row][col + j] = "" 

691 else: 

692 if col < total_width: 

693 grid[row][col] = "\u250c" 

694 for j in range(1, width - 1): 

695 if col + j < total_width: 

696 grid[row][col + j] = "\u2500" 

697 if col + width - 1 < total_width: 

698 grid[row][col + width - 1] = "\u2510" 

699 

700 # Overlay badge in top-right corner (non-highlighted path only; 

701 # highlighted path builds the badge into the batched string above). 

702 if badge: 

703 badge_w = _badge_display_width(badge) 

704 trail_pos = col + width - 2 

705 if col + 1 <= trail_pos < col + width - 1 and trail_pos < total_width: 

706 grid[row][trail_pos] = " " 

707 lead_pos = col + width - badge_w - 3 

708 if col + 1 <= lead_pos < col + width - 1 and lead_pos < total_width: 

709 grid[row][lead_pos] = " " 

710 pos = col + width - 1 - badge_w - 1 

711 for ch in badge: 

712 ch_w = _wcwidth(ch) 

713 if ch_w < 1: 

714 ch_w = 1 

715 if col + 1 <= pos < col + width - 1 and pos < total_width: 

716 grid[row][pos] = ch 

717 if ch_w == 2 and pos + 1 < col + width - 1 and pos + 1 < total_width: 

718 grid[row][pos + 1] = "" 

719 pos += ch_w 

720 

721 # ── Interior fill ─────────────────────────────────────────────────── 

722 # Batched per row — a single colorize(" " * N, bg_code) replaces the 

723 # per-cell loop that previously generated (width-2) SGR pairs per row. 

724 if is_highlighted and bg_code: 

725 fill_str = colorize(" " * (width - 2), bg_code) 

726 for ri in range(row + 1, row + height - 1): 

727 if ri >= len(grid): 

728 break 

729 if col + 1 < total_width: 

730 grid[ri][col + 1] = fill_str 

731 

732 # ── Content rows ──────────────────────────────────────────────────── 

733 def _place_content_row( 

734 r: int, 

735 line: str, 

736 lead_code: str | None, 

737 text_code: str | None, 

738 fill_code: str | None, 

739 ) -> None: 

740 """Lay one interior line into the grid: leading space, the line itself, 

741 then trailing fill up to the right border. 

742 

743 The line (and the trailing-fill run) are each written into a *single* 

744 grid cell as a multi-character string for SGR batching, so the grid 

745 cells those strings visually cover MUST be cleared — otherwise their 

746 original single-space contents survive ``"".join(row)`` and push the 

747 right border out (the action-row overflow bug). Widths are display 

748 columns so wide glyphs are accounted for. 

749 """ 

750 dw = _display_width(line) 

751 if col + 1 < total_width: 

752 grid[r][col + 1] = colorize(" ", lead_code) if lead_code else " " 

753 if col + 2 < total_width: 

754 grid[r][col + 2] = colorize(line, text_code) if text_code else line 

755 # Clear the cells the content string visually covers. 

756 for j in range(1, dw): 

757 cc = col + 2 + j 

758 if cc < col + width - 1 and cc < total_width: 

759 grid[r][cc] = "" 

760 # Trailing fill between content and the right border. 

761 trail_pad = width - 3 - dw 

762 fill_start = col + 2 + dw 

763 if trail_pad > 0 and fill_start < total_width: 

764 grid[r][fill_start] = ( 

765 colorize(" " * trail_pad, fill_code) if fill_code else " " * trail_pad 

766 ) 

767 for j in range(1, trail_pad): 

768 cc = fill_start + j 

769 if cc < col + width - 1 and cc < total_width: 

770 grid[r][cc] = "" 

771 

772 for i, line in enumerate(content): 

773 r = row + i + 1 

774 if r >= len(grid): 

775 break 

776 # Clear the batched fill cell for this row so content placement 

777 # rebuilds the interior with proper leading-space + content + 

778 # trailing-fill layout (the batched fill at col+1 would otherwise 

779 # appear before the content instead of behind it). 

780 if is_highlighted and bg_code: 

781 grid[r][col + 1] = "" 

782 if col < total_width: 

783 grid[r][col] = _bc("\u2502") 

784 if col + width - 1 < total_width: 

785 grid[r][col + width - 1] = _bc("\u2502") 

786 if i == 0: 

787 # Name row (bold; brightened on the highlighted box). 

788 if is_highlighted: 

789 lead_code: str | None = bg_code or highlight_color 

790 text_code: str | None = f"97;{bg_code};1" if bg_code else f"{highlight_color};1" 

791 fill_code: str | None = bg_code or highlight_color 

792 else: 

793 lead_code = None 

794 text_code = "1" 

795 fill_code = None 

796 else: 

797 # Action body rows. 

798 if is_highlighted and bg_code: 

799 lead_code = bg_code 

800 text_code = f"97;{bg_code}" 

801 fill_code = bg_code 

802 else: 

803 lead_code = None 

804 text_code = None 

805 fill_code = None 

806 _place_content_row(r, line, lead_code, text_code, fill_code) 

807 

808 # ── Padding rows ──────────────────────────────────────────────────── 

809 for i in range(len(content) + 1, height - 1): 

810 r = row + i 

811 if r >= len(grid): 

812 break 

813 if col < total_width: 

814 grid[r][col] = _bc("\u2502") 

815 if col + width - 1 < total_width: 

816 grid[r][col + width - 1] = _bc("\u2502") 

817 

818 # ── Bottom border ─────────────────────────────────────────────────── 

819 # Fully batched — same pattern as top border (no badge on bottom). 

820 brow = row + height - 1 

821 if brow < len(grid): 

822 if is_highlighted and border_code: 

823 if col < total_width: 

824 grid[brow][col] = colorize( 

825 "\u2514" + "\u2500" * (width - 2) + "\u2518", border_code 

826 ) 

827 for j in range(1, width): 

828 if col + j < total_width: 

829 grid[brow][col + j] = "" 

830 else: 

831 if col < total_width: 

832 grid[brow][col] = "\u2514" 

833 for j in range(1, width - 1): 

834 if col + j < total_width: 

835 grid[brow][col + j] = "\u2500" 

836 if col + width - 1 < total_width: 

837 grid[brow][col + width - 1] = "\u2518" 

838 

839 

840# --------------------------------------------------------------------------- 

841# Layered (vertical) renderer 

842# --------------------------------------------------------------------------- 

843 

844 

845def _render_layered_diagram( 

846 layers: list[list[str]], 

847 edges: list[tuple[str, str, str]], 

848 main_edge_set: set[tuple[str, str]], 

849 branches: list[tuple[str, str, str]], 

850 back_edges: list[tuple[str, str, str]], 

851 initial: str, 

852 terminal_states: set[str] | None, 

853 fsm_states: dict[str, StateConfig] | None, 

854 verbose: bool, 

855 highlight_state: str | None, 

856 highlight_color: str, 

857 edge_label_colors: dict[str, str] | None = None, 

858 badges: dict[str, str] | None = None, 

859 title_only: bool = False, 

860 suppress_labels: bool = False, 

861) -> str: 

862 """Render FSM using layered (Sugiyama-style) vertical layout. 

863 

864 When ``title_only`` is True, per-state body lines are suppressed. 

865 When ``suppress_labels`` is True, inter-state edges render without labels. 

866 """ 

867 terminal_states = terminal_states or set() 

868 fsm_states = fsm_states or {} 

869 tw = terminal_width() 

870 

871 # Flatten layers to get all states 

872 all_states = [s for layer in layers for s in layer] 

873 if not all_states: 

874 return "" 

875 

876 display_label = _compute_display_labels(all_states, initial, terminal_states) 

877 

878 # Compute max_box_inner based on widest layer 

879 max_layer_size = max(len(layer) for layer in layers) 

880 if verbose: 

881 max_box_inner = max(20, min(60, (tw - 4) // max(1, max_layer_size) - 6)) 

882 else: 

883 max_box_inner = max(20, min(40, (tw - 4) // max(1, max_layer_size) - 6)) 

884 

885 box_inner, box_width, box_height, box_badge = _compute_box_sizes( 

886 all_states, 

887 display_label, 

888 fsm_states, 

889 verbose, 

890 max_box_inner, 

891 badges, 

892 title_only=title_only, 

893 ) 

894 

895 # Post-hoc layer merge: re-merge adjacent single-state layers that were 

896 # over-split by the conservative max_width_per_layer estimate. Only merge 

897 # when both layers receive an edge from the same source state (indicating 

898 # they were originally one layer split by width constraint). 

899 available_w = tw - 20 # conservative content-area estimate 

900 gap_between = 6 

901 # Build edge target sets: for each state, which earlier states point to it 

902 _incoming: dict[str, set[str]] = {s: set() for layer in layers for s in layer} 

903 for src, dst, _ in edges: 

904 if src != dst and dst in _incoming: 

905 _incoming[dst].add(src) 

906 merged = True 

907 while merged: 

908 merged = False 

909 i = 0 

910 while i < len(layers) - 1: 

911 la, lb = layers[i], layers[i + 1] 

912 # Only merge single-state layers that share an incoming source 

913 if len(la) == 1 and len(lb) == 1: 

914 sources_a = _incoming.get(la[0], set()) 

915 sources_b = _incoming.get(lb[0], set()) 

916 shared_source = sources_a & sources_b 

917 else: 

918 shared_source = set() 

919 combined_w = ( 

920 sum(box_width[s] for s in la) 

921 + gap_between * (len(la) - 1) 

922 + gap_between 

923 + sum(box_width[s] for s in lb) 

924 + gap_between * (len(lb) - 1) 

925 ) 

926 if shared_source and combined_w <= available_w and len(la) + len(lb) <= 4: 

927 layers[i] = la + lb 

928 layers.pop(i + 1) 

929 merged = True 

930 else: 

931 i += 1 

932 

933 # Collect ALL non-self-loop forward edge labels (main + branches + same-depth back-edges) 

934 # Multiple edges between the same pair are combined as "label1/label2" 

935 forward_edge_labels: dict[tuple[str, str], str] = {} 

936 for src, dst, lbl in edges: 

937 if src == dst: 

938 continue 

939 if (src, dst) in main_edge_set or (src, dst, lbl) in branches: 

940 if (src, dst) in forward_edge_labels: 

941 forward_edge_labels[(src, dst)] += "/" + lbl 

942 else: 

943 forward_edge_labels[(src, dst)] = lbl 

944 

945 # True back-edges: only those going to an earlier layer (computed after layer assignment) 

946 # Will be finalized below after col positions are computed 

947 # Combine same-pair back-edges into single entries with merged labels (e.g. "error/fail") 

948 back_edge_labels_initial: dict[tuple[str, str], str] = {} 

949 for s, d, lbl in back_edges: 

950 if s != d: 

951 if (s, d) in back_edge_labels_initial: 

952 back_edge_labels_initial[(s, d)] += "/" + lbl 

953 else: 

954 back_edge_labels_initial[(s, d)] = lbl 

955 

956 # Pre-compute layer positions to detect main-path cycle edges early. 

957 # This ensures back_edge_margin accounts for ALL backward-pointing edges 

958 # (including main-path cycles like commit → initial_state) before column 

959 # positions are computed. 

960 prelim_layer_of: dict[str, int] = {} 

961 for li, layer in enumerate(layers): 

962 for s in layer: 

963 prelim_layer_of[s] = li 

964 

965 # Include main-path/branch edges that point backward in margin estimate 

966 all_back_labels: dict[tuple[str, str], str] = dict(back_edge_labels_initial) 

967 for (src, dst), lbl in forward_edge_labels.items(): 

968 src_layer = prelim_layer_of.get(src, -1) 

969 dst_layer = prelim_layer_of.get(dst, -1) 

970 if dst_layer < src_layer: 

971 if (src, dst) in all_back_labels: 

972 all_back_labels[(src, dst)] += "/" + lbl 

973 else: 

974 all_back_labels[(src, dst)] = lbl 

975 

976 non_self_back_initial = [(s, d, lbl) for (s, d), lbl in all_back_labels.items()] 

977 back_edge_margin = 0 

978 if non_self_back_initial: 

979 max_label_len = max(len(lbl) for _, _, lbl in non_self_back_initial) 

980 n_back_initial = len(non_self_back_initial) 

981 back_edge_margin = max_label_len + max(6, 2 * n_back_initial + 2) 

982 

983 content_left = 2 + back_edge_margin 

984 

985 # Self-loops per state 

986 self_loops: dict[str, list[str]] = {} 

987 for src, dst, lbl in back_edges: 

988 if src == dst: 

989 self_loops.setdefault(src, []).append(lbl) 

990 

991 # --- Compute a common center column for alignment --- 

992 # For layers with single boxes, we want vertical alignment through a 

993 # shared center column. Use the widest single-state layer's center. 

994 max_single_w = max((box_width[layer[0]] for layer in layers if len(layer) == 1), default=0) 

995 # The common center is at content_left + max_single_w // 2 

996 common_center = content_left + max_single_w // 2 

997 

998 # Compute column positions per layer 

999 col_start: dict[str, int] = {} 

1000 col_center: dict[str, int] = {} 

1001 layer_of: dict[str, int] = {} 

1002 

1003 for li, layer in enumerate(layers): 

1004 if len(layer) == 1: 

1005 # Single-state layer: center-align to common center 

1006 sname = layer[0] 

1007 col_start[sname] = common_center - box_width[sname] // 2 

1008 col_center[sname] = common_center 

1009 layer_of[sname] = li 

1010 else: 

1011 # Multi-state layer: place side-by-side, centered around common_center 

1012 gap_between = 6 

1013 total_layer_w = sum(box_width[s] for s in layer) + gap_between * (len(layer) - 1) 

1014 x = common_center - total_layer_w // 2 

1015 x = max(content_left, x) 

1016 for i, sname in enumerate(layer): 

1017 col_start[sname] = x 

1018 col_center[sname] = x + box_width[sname] // 2 

1019 layer_of[sname] = li 

1020 if i < len(layer) - 1: 

1021 next_s = layer[i + 1] 

1022 # Check for edge labels in both directions between adjacent states 

1023 label_fwd = forward_edge_labels.get((sname, next_s), "") 

1024 label_rev = forward_edge_labels.get((next_s, sname), "") 

1025 max_label = max(len(label_fwd), len(label_rev)) 

1026 gap = max(gap_between, max_label + 6) if max_label > 0 else gap_between 

1027 x += box_width[sname] + gap 

1028 else: 

1029 x += box_width[sname] 

1030 

1031 # Reclassify back-edges based on actual layer positions 

1032 # Only edges going to an earlier layer are true margin back-edges 

1033 # Combine same-pair edges into merged labels (e.g. "error/fail") 

1034 back_edge_labels_reclass: dict[tuple[str, str], str] = {} 

1035 same_layer_edges: list[tuple[str, str, str]] = [] 

1036 for src, dst, lbl in back_edges: 

1037 if src == dst: 

1038 continue 

1039 src_layer = layer_of.get(src, -1) 

1040 dst_layer = layer_of.get(dst, -1) 

1041 if dst_layer < src_layer: 

1042 if (src, dst) in back_edge_labels_reclass: 

1043 back_edge_labels_reclass[(src, dst)] += "/" + lbl 

1044 else: 

1045 back_edge_labels_reclass[(src, dst)] = lbl 

1046 elif dst_layer == src_layer: 

1047 same_layer_edges.append((src, dst, lbl)) 

1048 else: # dst_layer > src_layer: actually forward edge 

1049 if (src, dst) in forward_edge_labels: 

1050 forward_edge_labels[(src, dst)] += "/" + lbl 

1051 else: 

1052 forward_edge_labels[(src, dst)] = lbl 

1053 

1054 # Also reclassify main/branch edges in forward_edge_labels that point backward 

1055 # after layer assignment (e.g. main-path cycle edges like commit → initial_state) 

1056 backward_in_fwd: list[tuple[str, str]] = [] 

1057 for (src, dst), lbl in forward_edge_labels.items(): 

1058 src_layer = layer_of.get(src, -1) 

1059 dst_layer = layer_of.get(dst, -1) 

1060 if dst_layer < src_layer: 

1061 backward_in_fwd.append((src, dst)) 

1062 if (src, dst) in back_edge_labels_reclass: 

1063 back_edge_labels_reclass[(src, dst)] += "/" + lbl 

1064 else: 

1065 back_edge_labels_reclass[(src, dst)] = lbl 

1066 elif dst_layer == src_layer and src != dst: 

1067 backward_in_fwd.append((src, dst)) 

1068 same_layer_edges.append((src, dst, lbl)) 

1069 for key in backward_in_fwd: 

1070 del forward_edge_labels[key] 

1071 

1072 # Add same-layer back-edges to forward_edge_labels so gap calculation accounts for them 

1073 for src, dst, lbl in same_layer_edges: 

1074 if (src, dst) in forward_edge_labels: 

1075 forward_edge_labels[(src, dst)] += "/" + lbl 

1076 else: 

1077 forward_edge_labels[(src, dst)] = lbl 

1078 

1079 # Recalculate inter-box gaps for layers with newly discovered same-layer edges 

1080 affected_layers: set[int] = set() 

1081 for src, dst, _lbl in same_layer_edges: 

1082 sl = layer_of.get(src, -1) 

1083 dl = layer_of.get(dst, -1) 

1084 if sl >= 0: 

1085 affected_layers.add(sl) 

1086 if dl >= 0: 

1087 affected_layers.add(dl) 

1088 for li in affected_layers: 

1089 layer = layers[li] 

1090 if len(layer) < 2: 

1091 continue 

1092 gap_between = 6 

1093 total_layer_w = sum(box_width[s] for s in layer) 

1094 # For non-adjacent same-layer edges the label lands in the gap immediately 

1095 # adjacent to the source box (left of src for right-to-left, right of src 

1096 # for left-to-right). Collect those requirements so the gap is wide enough. 

1097 extra_gap_req: dict[tuple[str, str], int] = {} 

1098 for src, dst, lbl in same_layer_edges: 

1099 if layer_of.get(src) != li or layer_of.get(dst) != li: 

1100 continue 

1101 try: 

1102 si, di = layer.index(src), layer.index(dst) 

1103 except ValueError: 

1104 continue 

1105 if abs(si - di) <= 1: 

1106 continue # adjacent — already handled by forward_edge_labels 

1107 if si > di: 

1108 key = (layer[si - 1], src) # gap to the left of src 

1109 else: 

1110 key = (src, layer[si + 1]) # gap to the right of src 

1111 extra_gap_req[key] = max(extra_gap_req.get(key, 0), len(lbl)) 

1112 # Recalculate gaps with label-aware spacing 

1113 gaps: list[int] = [] 

1114 for i in range(len(layer) - 1): 

1115 sname, next_s = layer[i], layer[i + 1] 

1116 label_fwd = forward_edge_labels.get((sname, next_s), "") 

1117 label_rev = forward_edge_labels.get((next_s, sname), "") 

1118 max_label = max(len(label_fwd), len(label_rev), extra_gap_req.get((sname, next_s), 0)) 

1119 gap = max(gap_between, max_label + 6) if max_label > 0 else gap_between 

1120 gaps.append(gap) 

1121 total_layer_w += sum(gaps) 

1122 x = common_center - total_layer_w // 2 

1123 x = max(content_left, x) 

1124 for i, sname in enumerate(layer): 

1125 col_start[sname] = x 

1126 col_center[sname] = x + box_width[sname] // 2 

1127 if i < len(layer) - 1: 

1128 x += box_width[sname] + gaps[i] 

1129 else: 

1130 x += box_width[sname] 

1131 

1132 non_self_back = [(s, d, lbl) for (s, d), lbl in back_edge_labels_reclass.items()] 

1133 

1134 # Recalculate back-edge margin if it changed 

1135 if non_self_back: 

1136 max_label_len = max(len(lbl) for _, _, lbl in non_self_back) 

1137 n_back = len(non_self_back) 

1138 actual_margin = max_label_len + max(6, 2 * n_back + 2) 

1139 if actual_margin != back_edge_margin: 

1140 # Need to recalculate positions (rare case - usually matches) 

1141 back_edge_margin = actual_margin 

1142 content_left = 2 + back_edge_margin 

1143 

1144 # Identify forward skip-layer edges (span > 1 layer, not handled by consecutive renderer) 

1145 skip_forward_edges: list[tuple[str, str, str]] = [] 

1146 for (src, dst), lbl in forward_edge_labels.items(): 

1147 src_layer = layer_of.get(src, -1) 

1148 dst_layer = layer_of.get(dst, -1) 

1149 if dst_layer > src_layer + 1: 

1150 skip_forward_edges.append((src, dst, lbl)) 

1151 

1152 # Pre-compute right margin width for forward skip-layer edges 

1153 right_edge_margin = 0 

1154 if skip_forward_edges: 

1155 max_fwd_label_len = max(len(lbl) for _, _, lbl in skip_forward_edges) 

1156 right_edge_margin = max_fwd_label_len + 6 

1157 

1158 # Compute total width needed 

1159 total_content_w = 0 

1160 for s in all_states: 

1161 right = col_start[s] + box_width[s] 

1162 total_content_w = max(total_content_w, right) 

1163 total_width = max(total_content_w + right_edge_margin + 4, tw) 

1164 

1165 # Compute vertical positions with space for self-loops and inter-layer arrows 

1166 row_start: dict[str, int] = {} 

1167 y = 0 

1168 for li, layer in enumerate(layers): 

1169 layer_h = max(box_height[s] for s in layer) 

1170 for sname in layer: 

1171 row_start[sname] = y 

1172 y += layer_h 

1173 

1174 # Add self-loop row if any state in this layer has self-loops 

1175 has_self_loop = any(s in self_loops for s in layer) 

1176 if has_self_loop: 

1177 y += 1 # self-loop marker row 

1178 

1179 if li < len(layers) - 1: 

1180 y += 2 if suppress_labels else 3 # arrow gap: suppress_labels skips label row 

1181 

1182 total_height = y 

1183 

1184 # Build character grid 

1185 grid: list[list[str]] = [[" "] * total_width for _ in range(total_height)] 

1186 

1187 # Draw boxes 

1188 for sname in all_states: 

1189 is_highlighted = highlight_state is not None and sname == highlight_state 

1190 _draw_box( 

1191 grid, 

1192 row_start[sname], 

1193 col_start[sname], 

1194 box_width[sname], 

1195 box_height[sname], 

1196 box_inner[sname], 

1197 is_highlighted, 

1198 highlight_color, 

1199 badge=box_badge[sname], 

1200 ) 

1201 

1202 # Precompute box-occupied (row, col) pairs so connector lines can avoid overwriting box cells 

1203 _box_occ: dict[int, set[int]] = {} 

1204 for _s in all_states: 

1205 for _r in range(row_start[_s], row_start[_s] + box_height[_s]): 

1206 _row_set = _box_occ.setdefault(_r, set()) 

1207 for _c in range(col_start[_s], col_start[_s] + box_width[_s]): 

1208 _row_set.add(_c) 

1209 

1210 # Draw self-loop markers immediately below their boxes 

1211 for sname, labels in self_loops.items(): 

1212 marker = "\u21ba" if suppress_labels else "\u21ba " + ", ".join(labels) 

1213 r = row_start[sname] + box_height[sname] 

1214 if r < total_height: 

1215 cx = col_center[sname] 

1216 pos = max(0, cx - len(marker) // 2) 

1217 for j, ch in enumerate(marker): 

1218 if pos + j < total_width: 

1219 grid[r][pos + j] = ch 

1220 

1221 # Shared row tracker: prevents two labels (back-edge, skip-forward, or adjacent) 

1222 # landing on the same grid row, which would clobber the first label written there. 

1223 used_label_rows: set[int] = set() 

1224 

1225 # Draw forward edges between layers (vertical arrows with labels) 

1226 for li in range(len(layers) - 1): 

1227 layer_h = max(box_height[s] for s in layers[li]) 

1228 has_self_loop = any(s in self_loops for s in layers[li]) 

1229 self_loop_offset = 1 if has_self_loop else 0 

1230 

1231 # Arrow area starts after box bottom + self-loop row 

1232 arrow_start_row = row_start[layers[li][0]] + layer_h + self_loop_offset 

1233 arrow_end_row = row_start[layers[li + 1][0]] - 1 

1234 

1235 # Collect all inter-layer edges from this layer to the next 

1236 inter_edges: list[tuple[str, str, str]] = [] 

1237 for src in layers[li]: 

1238 for dst in layers[li + 1]: 

1239 label = forward_edge_labels.get((src, dst)) 

1240 if label is not None: 

1241 inter_edges.append((src, dst, label)) 

1242 

1243 # Draw each edge with its own vertical pipe to the target's center 

1244 for src, dst, label in inter_edges: 

1245 dst_cc = col_center[dst] 

1246 src_left = col_start[src] 

1247 src_right = src_left + box_width[src] 

1248 ec = _edge_line_color(label) # ANSI code for this edge's connector chars 

1249 

1250 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306 

1251 return colorize(ch, _ec) if _ec else ch 

1252 

1253 # Horizontal connector when pipe is outside source box range 

1254 if dst_cc >= src_right or dst_cc < src_left: 

1255 conn_row = arrow_start_row 

1256 if 0 <= conn_row < total_height: 

1257 if dst_cc >= src_right: 

1258 # Pipe right of source: └───┐ 

1259 src_cc = col_center[src] 

1260 if 0 <= src_cc < total_width and grid[conn_row][src_cc] == " ": 

1261 grid[conn_row][src_cc] = _lc("\u2514") # └ 

1262 start_c = src_cc + 1 

1263 else: 

1264 start_c = src_right 

1265 for c in range(start_c, dst_cc): 

1266 if 0 <= c < total_width: 

1267 grid[conn_row][c] = _lc("\u2500") 

1268 if 0 <= dst_cc < total_width: 

1269 grid[conn_row][dst_cc] = _lc("\u2510") # ┐ 

1270 else: 

1271 # Pipe left of source: ┌───┘ 

1272 src_cc = col_center[src] 

1273 if 0 <= src_cc < total_width and grid[conn_row][src_cc] == " ": 

1274 end_c = src_cc 

1275 grid[conn_row][src_cc] = _lc("\u2518") # ┘ 

1276 else: 

1277 end_c = src_left 

1278 for c in range(dst_cc + 1, end_c): 

1279 if 0 <= c < total_width: 

1280 grid[conn_row][c] = _lc("\u2500") 

1281 if 0 <= dst_cc < total_width: 

1282 grid[conn_row][dst_cc] = _lc("\u250c") # ┌ 

1283 pipe_start = arrow_start_row + 1 

1284 else: 

1285 pipe_start = arrow_start_row 

1286 

1287 # Draw vertical pipe at destination's center column 

1288 for r in range(pipe_start, arrow_end_row): 

1289 if 0 <= dst_cc < total_width and r < total_height: 

1290 grid[r][dst_cc] = _lc("\u2502") 

1291 

1292 # Arrow tip at destination center 

1293 if arrow_end_row < total_height and 0 <= dst_cc < total_width: 

1294 grid[arrow_end_row][dst_cc] = _lc("\u25bc") 

1295 

1296 # Label to the right of the pipe. When skip-layer forward edges 

1297 # exist their vertical pipes occupy columns starting at 

1298 # total_content_w+2, so clamp to total_content_w to avoid 

1299 # overwriting them (BUG-1500). Without skip-layer edges the right 

1300 # margin is empty and the full total_width is available. 

1301 label_row = arrow_start_row 

1302 # Nudge if this row already has a label from another inter-layer edge 

1303 # (e.g., two edges from the same source go to states in the same layer). 

1304 # Try pipe rows (pipe_start..arrow_end_row) before giving up (BUG-1501). 

1305 if label_row in used_label_rows: 

1306 for _cand in range(pipe_start, arrow_end_row + 1): 

1307 if _cand not in used_label_rows: 

1308 label_row = _cand 

1309 break 

1310 if label_row < total_height and not suppress_labels: 

1311 used_label_rows.add(label_row) 

1312 label_start = dst_cc + 2 

1313 max_col = total_content_w if skip_forward_edges else total_width 

1314 max_label = max_col - label_start 

1315 if 0 < max_label < len(label): 

1316 label = label[: max_label - 1] + "…" 

1317 for j, ch in enumerate(label): 

1318 if label_start + j < max_col: 

1319 grid[label_row][label_start + j] = ch 

1320 

1321 # Post-pass: connect horizontal gaps for multi-branch sources 

1322 if len(inter_edges) >= 2 and 0 <= arrow_start_row < total_height: 

1323 src_targets: dict[str, list[int]] = {} 

1324 for src, dst, _ in inter_edges: 

1325 if dst in col_center: 

1326 src_targets.setdefault(src, []).append(col_center[dst]) 

1327 for _src, centers in src_targets.items(): 

1328 if len(centers) < 2: 

1329 continue 

1330 left = min(centers) 

1331 right = max(centers) 

1332 for c in range(left + 1, right): 

1333 if 0 <= c < total_width: 

1334 cell = grid[arrow_start_row][c] 

1335 if cell == " ": 

1336 grid[arrow_start_row][c] = "\u2500" # ─ 

1337 elif cell == "\u2502": # │ → ┼ 

1338 grid[arrow_start_row][c] = "\u253c" 

1339 elif cell == "\u2518": # ┘ → ┴ 

1340 grid[arrow_start_row][c] = "\u2534" 

1341 elif cell == "\u2514": # └ → ┴ 

1342 grid[arrow_start_row][c] = "\u2534" 

1343 elif cell == "\u2510": # ┐ → ┬ 

1344 grid[arrow_start_row][c] = "\u252c" 

1345 elif cell == "\u250c": # ┌ → ┬ 

1346 grid[arrow_start_row][c] = "\u252c" 

1347 # Update boundary junction chars where the horizontal bar meets a pipe 

1348 if 0 <= left < total_width and grid[arrow_start_row][left] == "\u2502": # │ → ├ 

1349 grid[arrow_start_row][left] = "\u251c" 

1350 if 0 <= right < total_width and grid[arrow_start_row][right] == "\u2502": # │ → ┤ 

1351 grid[arrow_start_row][right] = "\u2524" 

1352 

1353 # Draw same-layer edges (horizontal arrows between states on same layer) 

1354 # Includes both branches and reclassified back-edges within same layer 

1355 all_same_layer: list[tuple[str, str, str]] = list(same_layer_edges) 

1356 for _li, layer in enumerate(layers): 

1357 for i, src in enumerate(layer): 

1358 for j, dst in enumerate(layer): 

1359 if i == j: 

1360 continue 

1361 label = forward_edge_labels.get((src, dst)) 

1362 if label is not None and (src, dst, label) not in all_same_layer: 

1363 all_same_layer.append((src, dst, label)) 

1364 

1365 for src, dst, label in all_same_layer: 

1366 if src not in col_start or dst not in col_start: 

1367 continue 

1368 if suppress_labels: 

1369 label = "" 

1370 name_row = row_start[src] + 1 

1371 src_right = col_start[src] + box_width[src] 

1372 dst_right = col_start[dst] + box_width[dst] 

1373 dst_left = col_start[dst] 

1374 src_left = col_start[src] 

1375 _row_boxes = _box_occ.get(name_row, set()) 

1376 ec = _edge_line_color(label) 

1377 

1378 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306 

1379 return colorize(ch, _ec) if _ec else ch 

1380 

1381 if dst_left >= src_right: 

1382 # Left to right horizontal arrow: src ──label──▶ dst 

1383 start = src_right 

1384 end = dst_left 

1385 edge_text = "\u2500" + label + "\u2500\u2500\u25b6" 

1386 available = end - start 

1387 if available < len(edge_text): 

1388 edge_text = edge_text[: max(1, available)] 

1389 left_dashes = max(0, available - len(edge_text)) 

1390 for k in range(left_dashes): 

1391 pos = start + k 

1392 if pos < total_width and name_row < total_height and pos not in _row_boxes: 

1393 grid[name_row][pos] = _lc("\u2500") 

1394 for k, ch in enumerate(edge_text): 

1395 pos = start + left_dashes + k 

1396 if ( 

1397 0 <= pos < end 

1398 and pos < total_width 

1399 and name_row < total_height 

1400 and pos not in _row_boxes 

1401 ): 

1402 grid[name_row][pos] = _lc(ch) 

1403 elif dst_right <= src_left: 

1404 # Right to left: dst is left of src: src → dst drawn as dst ◀──label── src 

1405 start = dst_right 

1406 end = src_left 

1407 edge_text = "\u25c4\u2500\u2500" + label + "\u2500" 

1408 available = end - start 

1409 if available < len(edge_text): 

1410 edge_text = edge_text[: max(1, available)] 

1411 for k, ch in enumerate(edge_text): 

1412 pos = start + k 

1413 if ( 

1414 0 <= pos < end 

1415 and pos < total_width 

1416 and name_row < total_height 

1417 and pos not in _row_boxes 

1418 ): 

1419 grid[name_row][pos] = _lc(ch) 

1420 for k in range(start + len(edge_text), end): 

1421 if k < total_width and name_row < total_height and k not in _row_boxes: 

1422 grid[name_row][k] = _lc("\u2500") 

1423 

1424 # Back-edges: left-margin vertical arrows with labels 

1425 if non_self_back: 

1426 sorted_back = sorted( 

1427 non_self_back, 

1428 key=lambda e: abs(row_start.get(e[0], 0) - row_start.get(e[1], 0)), 

1429 reverse=True, 

1430 ) 

1431 used_cols: list[int] = [] 

1432 # Compute rightmost pipe column so labels go right of ALL pipes 

1433 rightmost_pipe_col = 1 + (len(sorted_back) - 1) * 2 

1434 

1435 for src, dst, label in sorted_back: 

1436 # Source: name row of source box; target: name row of target box 

1437 src_row = row_start.get(src, 0) + 1 # name row, not bottom border 

1438 dst_row = row_start.get(dst, 0) + 1 # name row 

1439 

1440 # Find a free column in the margin 

1441 col = 1 

1442 for uc in sorted(used_cols): 

1443 if uc == col: 

1444 col += 2 

1445 used_cols.append(col) 

1446 

1447 if col >= content_left - 1: 

1448 continue 

1449 

1450 top_row = min(src_row, dst_row) 

1451 bot_row = max(src_row, dst_row) 

1452 ec = _edge_line_color(label) 

1453 

1454 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306 

1455 return colorize(ch, _ec) if _ec else ch 

1456 

1457 # Draw vertical line in margin (exclude corner rows handled below) 

1458 for r in range(top_row + 1, bot_row): 

1459 if 0 <= r < total_height and col < total_width: 

1460 cell = grid[r][col] 

1461 if cell == "\u2500": # ─ → ┼ (junction, leave uncolored) 

1462 grid[r][col] = "\u253c" 

1463 elif cell == " ": 

1464 grid[r][col] = _lc("\u2502") 

1465 

1466 # Horizontal connector from source box to margin 

1467 # Draw right-to-left, crossing existing pipes with junction chars 

1468 if 0 <= src_row < total_height: 

1469 src_left = col_start.get(src, col + 1) 

1470 _src_row_boxes = _box_occ.get(src_row, set()) 

1471 for c in range(col + 1, src_left): 

1472 if c < total_width and c not in _src_row_boxes: 

1473 cell = grid[src_row][c] 

1474 if cell == " ": 

1475 grid[src_row][c] = _lc("\u2500") # ─ 

1476 elif cell == "\u2502": # │ → ┼ (junction) 

1477 grid[src_row][c] = "\u253c" 

1478 elif cell == "\u2514": # └ → ┴ (junction) 

1479 grid[src_row][c] = "\u2534" 

1480 elif cell == "\u250c": # ┌ → ┬ (junction) 

1481 grid[src_row][c] = "\u252c" 

1482 elif cell == "\u251c": # ├ → ┼ (junction) 

1483 grid[src_row][c] = "\u253c" 

1484 # Leave ─, ▶, box chars unchanged 

1485 

1486 # Horizontal connector from margin to target box 

1487 # Draw right-to-left, crossing existing pipes with junction chars 

1488 dst_left = col_start.get(dst, col + 1) 

1489 if 0 <= dst_row < total_height: 

1490 _dst_row_boxes = _box_occ.get(dst_row, set()) 

1491 for c in range(col + 1, dst_left): 

1492 if c < total_width and c not in _dst_row_boxes: 

1493 cell = grid[dst_row][c] 

1494 if cell == " ": 

1495 grid[dst_row][c] = _lc("\u2500") # ─ 

1496 elif cell == "\u2502": # │ → ┼ (junction) 

1497 grid[dst_row][c] = "\u253c" 

1498 elif cell == "\u2514": # └ → ┴ (junction) 

1499 grid[dst_row][c] = "\u2534" 

1500 elif cell == "\u250c": # ┌ → ┬ (junction) 

1501 grid[dst_row][c] = "\u252c" 

1502 elif cell == "\u251c": # ├ → ┼ (junction) 

1503 grid[dst_row][c] = "\u253c" 

1504 

1505 # Corner characters at pipe-to-horizontal turn points 

1506 for row in (src_row, dst_row): 

1507 if 0 <= row < total_height and col < total_width: 

1508 existing = grid[row][col] 

1509 if row == bot_row: 

1510 # Pipe ends, turns right: └; if horizontal already crosses here: ┴ 

1511 grid[row][col] = "\u2534" if existing == "\u2500" else _lc("\u2514") 

1512 else: # row == top_row 

1513 # Pipe starts going down, turns right: ┌; if horizontal already crosses here: ┬ 

1514 grid[row][col] = "\u252c" if existing == "\u2500" else _lc("\u250c") 

1515 

1516 # Arrow tip at target: place ▶ at end of horizontal connector (entering box from left) 

1517 if 0 <= dst_row < total_height and dst_left - 1 > col and dst_left - 1 < total_width: 

1518 grid[dst_row][dst_left - 1] = _lc("\u25b6") 

1519 

1520 # Label on the margin line (right of ALL pipes, not just this one) 

1521 label_row_pos = (top_row + bot_row) // 2 

1522 # Nudge away from already-used rows to prevent clobbering earlier labels 

1523 if label_row_pos in used_label_rows and top_row + 1 < bot_row: 

1524 midpoint = label_row_pos 

1525 found = False 

1526 for _off in range(1, bot_row - top_row): 

1527 for _cand in (midpoint - _off, midpoint + _off): 

1528 if top_row < _cand < bot_row and _cand not in used_label_rows: 

1529 label_row_pos = _cand 

1530 found = True 

1531 break 

1532 if found: 

1533 break 

1534 if not found: 

1535 label_row_pos = top_row + 1 

1536 used_label_rows.add(label_row_pos) 

1537 if 0 <= label_row_pos < total_height and not title_only: 

1538 label_start = rightmost_pipe_col + 2 

1539 for j, ch in enumerate(label): 

1540 if label_start + j < content_left - 1 and label_start + j < total_width: 

1541 grid[label_row_pos][label_start + j] = _lc(ch) 

1542 

1543 # Forward skip-layer edges: right-margin vertical arrows with labels 

1544 # Symmetric to the left-margin back-edge renderer above 

1545 if skip_forward_edges: 

1546 sorted_fwd_skip = sorted( 

1547 skip_forward_edges, 

1548 key=lambda e: abs(row_start.get(e[0], 0) - row_start.get(e[1], 0)), 

1549 reverse=True, 

1550 ) 

1551 used_fwd_cols: list[int] = [] 

1552 # Rightmost pipe column (furthest from content) for label placement 

1553 rightmost_fwd_pipe_col = total_content_w + 2 + (len(sorted_fwd_skip) - 1) * 2 

1554 

1555 for src, dst, label in sorted_fwd_skip: 

1556 src_row = row_start.get(src, 0) + 1 # name row 

1557 dst_row = row_start.get(dst, 0) + 1 # name row 

1558 

1559 # Allocate column in right margin (starting from content edge, going right) 

1560 col = total_content_w + 2 

1561 for uc in sorted(used_fwd_cols): 

1562 if uc == col: 

1563 col += 2 

1564 used_fwd_cols.append(col) 

1565 

1566 if col >= total_width: 

1567 continue 

1568 

1569 top_row = min(src_row, dst_row) 

1570 bot_row = max(src_row, dst_row) 

1571 ec = _edge_line_color(label) 

1572 

1573 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306 

1574 return colorize(ch, _ec) if _ec else ch 

1575 

1576 # Draw vertical line in right margin (exclude corner rows handled below) 

1577 for r in range(top_row + 1, bot_row): 

1578 if 0 <= r < total_height and col < total_width: 

1579 cell = grid[r][col] 

1580 if cell == "\u2500": # ─ → ┼ (junction) 

1581 grid[r][col] = "\u253c" 

1582 elif cell == " ": 

1583 grid[r][col] = _lc("\u2502") 

1584 

1585 # Horizontal connector from source box right side to margin 

1586 # Draw left-to-right, crossing existing pipes with junction chars 

1587 src_right = col_start.get(src, 0) + box_width.get(src, 0) 

1588 _src_row_boxes = _box_occ.get(src_row, set()) 

1589 if 0 <= src_row < total_height: 

1590 for c in range(src_right, col): 

1591 if 0 <= c < total_width and c not in _src_row_boxes: 

1592 cell = grid[src_row][c] 

1593 if cell == " ": 

1594 grid[src_row][c] = _lc("\u2500") # ─ 

1595 elif cell == "\u2502": # │ → ┼ (junction) 

1596 grid[src_row][c] = "\u253c" 

1597 elif cell == "\u2518": # ┘ → ┴ (junction) 

1598 grid[src_row][c] = "\u2534" 

1599 elif cell == "\u2510": # ┐ → ┬ (junction) 

1600 grid[src_row][c] = "\u252c" 

1601 elif cell == "\u2524": # ┤ → ┼ (junction) 

1602 grid[src_row][c] = "\u253c" 

1603 # Leave ─, ◀, box chars unchanged 

1604 

1605 # Horizontal connector from margin to destination box right side 

1606 dst_right = col_start.get(dst, 0) + box_width.get(dst, 0) 

1607 _dst_row_boxes = _box_occ.get(dst_row, set()) 

1608 if 0 <= dst_row < total_height: 

1609 for c in range(dst_right, col): 

1610 if 0 <= c < total_width and c not in _dst_row_boxes: 

1611 cell = grid[dst_row][c] 

1612 if cell == " ": 

1613 grid[dst_row][c] = _lc("\u2500") # ─ 

1614 elif cell == "\u2502": # │ → ┼ (junction) 

1615 grid[dst_row][c] = "\u253c" 

1616 elif cell == "\u2518": # ┘ → ┴ (junction) 

1617 grid[dst_row][c] = "\u2534" 

1618 elif cell == "\u2510": # ┐ → ┬ (junction) 

1619 grid[dst_row][c] = "\u252c" 

1620 elif cell == "\u2524": # ┤ → ┼ (junction) 

1621 grid[dst_row][c] = "\u253c" 

1622 

1623 # Corner characters at pipe-to-horizontal turn points 

1624 for row in (src_row, dst_row): 

1625 if 0 <= row < total_height and col < total_width: 

1626 existing = grid[row][col] 

1627 if row == bot_row: 

1628 # Pipe ends, turns left: ┘; if horizontal crosses: ┤ 

1629 grid[row][col] = "\u2524" if existing == "\u2500" else _lc("\u2518") 

1630 else: # row == top_row 

1631 # Pipe starts going down, turns left: ┐; if horizontal crosses: ┤ 

1632 grid[row][col] = "\u2524" if existing == "\u2500" else _lc("\u2510") 

1633 

1634 # Arrow tip at target: ◀ entering box from right side 

1635 if 0 <= dst_row < total_height and dst_right < col and dst_right < total_width: 

1636 grid[dst_row][dst_right] = _lc("\u25c0") 

1637 

1638 # Label on the margin line (right of ALL pipes, mirroring left-margin 

1639 # approach). Truncate with … when label would exceed total_width 

1640 # to prevent extending diagram lines far past the content area (BUG-1500). 

1641 label_row_pos = (top_row + bot_row) // 2 

1642 # Nudge to avoid row collision with a previously-placed label (back- or fwd-edge) 

1643 if label_row_pos in used_label_rows and top_row + 1 < bot_row: 

1644 midpoint = label_row_pos 

1645 found = False 

1646 for _off in range(1, bot_row - top_row): 

1647 for _cand in (midpoint - _off, midpoint + _off): 

1648 if top_row < _cand < bot_row and _cand not in used_label_rows: 

1649 label_row_pos = _cand 

1650 found = True 

1651 break 

1652 if found: 

1653 break 

1654 if not found: 

1655 label_row_pos = top_row + 1 

1656 used_label_rows.add(label_row_pos) 

1657 if 0 <= label_row_pos < total_height and not title_only: 

1658 label_start = rightmost_fwd_pipe_col + 2 

1659 max_label = total_width - label_start 

1660 if 0 < max_label < len(label): 

1661 label = label[: max_label - 1] + "…" 

1662 for j, ch in enumerate(label): 

1663 if label_start + j < total_width: 

1664 grid[label_row_pos][label_start + j] = _lc(ch) 

1665 

1666 # Convert grid to string 

1667 lines = ["".join(row).rstrip() for row in grid] 

1668 

1669 # Remove trailing empty lines 

1670 while lines and not lines[-1].strip(): 

1671 lines.pop() 

1672 

1673 # Center diagram 

1674 max_line_len = max((_display_width(strip_ansi(ln)) for ln in lines), default=0) 

1675 diagram_indent = max(0, (tw - max_line_len) // 2) 

1676 if diagram_indent > 0: 

1677 lines = [" " * diagram_indent + ln if ln.strip() else ln for ln in lines] 

1678 

1679 return _colorize_diagram_labels("\n".join(lines), edge_label_colors) 

1680 

1681 

1682# --------------------------------------------------------------------------- 

1683# FSM diagram renderer (main entry point) 

1684# --------------------------------------------------------------------------- 

1685 

1686 

1687_MAIN_PATH_EDGE_LABELS: frozenset[str] = frozenset({"yes", "no", "next", "_"}) 

1688 

1689 

1690def _filter_main_path_graph( 

1691 fsm: FSMLoop, edges: list[tuple[str, str, str]] 

1692) -> tuple[list[tuple[str, str, str]], set[str]]: 

1693 """Filter edges to the main happy-path subgraph and the states reachable through it. 

1694 

1695 Drops off-happy-path labels (anything not in ``_MAIN_PATH_EDGE_LABELS`` and not 

1696 a ``route``-verdict label) plus any state unreachable from ``fsm.initial`` once 

1697 those edges are gone. Route-verdict labels (everything emitted by ``state.route`` 

1698 other than the default ``_``) are kept since routes encode normal branching. 

1699 """ 

1700 route_verdicts: set[str] = set() 

1701 for _name, state in fsm.states.items(): 

1702 if state.route is not None: 

1703 route_verdicts.update(state.route.routes.keys()) 

1704 route_verdicts.update(state.extra_routes.keys()) 

1705 

1706 def _is_main_label(label: str) -> bool: 

1707 return label in _MAIN_PATH_EDGE_LABELS or label in route_verdicts 

1708 

1709 filtered_edges = [(s, t, lbl) for (s, t, lbl) in edges if _is_main_label(lbl)] 

1710 _bfs_visited_order, depth_map = _bfs_order(fsm.initial, filtered_edges) 

1711 reachable: set[str] = set(depth_map.keys()) 

1712 filtered_edges = [ 

1713 (s, t, lbl) for (s, t, lbl) in filtered_edges if s in reachable and t in reachable 

1714 ] 

1715 return filtered_edges, reachable 

1716 

1717 

1718def _render_fsm_diagram( 

1719 fsm: FSMLoop, 

1720 verbose: bool = False, 

1721 highlight_state: str | None = None, 

1722 highlight_color: str = "32", 

1723 edge_label_colors: dict[str, str] | None = None, 

1724 badges: dict[str, str] | None = None, 

1725 mode: str = "full", 

1726 *, 

1727 suppress_labels: bool = False, 

1728 title_only: bool = False, 

1729) -> str: 

1730 """Render an adaptive text diagram of the FSM graph. 

1731 

1732 Detects FSM topology and selects appropriate layout: 

1733 - Linear chains: vertical top-to-bottom 

1734 - Branching/cyclic: layered Sugiyama-style 

1735 

1736 Args: 

1737 fsm: The FSM loop to render. 

1738 verbose: If True, show expanded action content in boxes. 

1739 highlight_state: If provided, render this state's box with the highlight color. 

1740 highlight_color: ANSI SGR code for the highlighted state (default: green). 

1741 edge_label_colors: Optional label→SGR-code mapping for transition labels. 

1742 Falls back to hardcoded defaults when None. 

1743 badges: Optional glyph-key→string mapping for state type badges. 

1744 Falls back to hardcoded defaults when None. 

1745 mode: Controls edge scope: "main" (default when filtering active) hides 

1746 off-happy-path edges. "full" renders every edge and state. "mini" is 

1747 an alias for main-scope; use ``suppress_labels=True, title_only=True`` 

1748 instead. Callers that need full-detail dumps (e.g. ``ll-loop info``) 

1749 keep the default "full". 

1750 suppress_labels: If True, edge labels are omitted from all rendered edges. 

1751 title_only: If True, state boxes show only the state name (no action body). 

1752 """ 

1753 edges = _collect_edges(fsm) 

1754 if mode in ("main", "mini"): 

1755 edges, _reachable = _filter_main_path_graph(fsm, edges) 

1756 bfs_order_list, bfs_depth = _bfs_order(fsm.initial, edges) 

1757 main_path, main_edge_set = _trace_main_path(fsm, edges) 

1758 branches, back_edges = _classify_edges(edges, main_edge_set, bfs_order_list) 

1759 

1760 terminal_states = {name for name, state in fsm.states.items() if state.terminal} 

1761 

1762 # Collect all states 

1763 all_states = list(main_path) 

1764 for src, dst, _ in branches: 

1765 for s in (src, dst): 

1766 if s not in all_states: 

1767 all_states.append(s) 

1768 

1769 # Topology detection 

1770 detector = TopologyDetector(edges, main_path, branches, back_edges) 

1771 topology = detector.classify() 

1772 

1773 # Build back-edge set for layout pipeline 

1774 back_edge_set: set[tuple[str, str]] = set() 

1775 for src, dst, _ in back_edges: 

1776 if src != dst: 

1777 back_edge_set.add((src, dst)) 

1778 

1779 tw = terminal_width() 

1780 

1781 if topology == "linear" and len(all_states) <= 1: 

1782 # Single state or empty — use simple horizontal 

1783 return _render_horizontal_simple( 

1784 main_path, 

1785 edges, 

1786 main_edge_set, 

1787 branches, 

1788 back_edges, 

1789 bfs_order_list, 

1790 fsm.initial, 

1791 terminal_states, 

1792 fsm.states, 

1793 verbose, 

1794 highlight_state, 

1795 highlight_color, 

1796 edge_label_colors, 

1797 badges, 

1798 title_only=title_only or (mode == "mini"), 

1799 suppress_labels=suppress_labels or (mode == "mini"), 

1800 ) 

1801 

1802 # Compute max node width to determine width constraint 

1803 # Quick estimate: widest state name or badge + padding 

1804 max_node_w = 30 # reasonable default 

1805 for s in all_states: 

1806 st = fsm.states.get(s) 

1807 badge = _get_state_badge(st, badges) 

1808 badge_w = _badge_display_width(badge) if badge else 0 

1809 label = s 

1810 if s == fsm.initial: 

1811 label = "\u2192 " + label 

1812 if s in terminal_states: 

1813 label = label + " \u25c9" 

1814 w = max(_display_width(label), badge_w) 

1815 max_node_w = max(max_node_w, w + 4 + 4) # inner + borders + padding 

1816 

1817 max_width_per_layer = max(1, (tw - 10) // (max_node_w + 4)) 

1818 

1819 # Layer assignment 

1820 assigner = LayerAssigner(all_states, edges, back_edge_set, fsm.initial, max_width_per_layer) 

1821 layers = assigner.assign() 

1822 

1823 # Crossing minimization 

1824 minimizer = CrossingMinimizer(layers, edges, back_edge_set) 

1825 layers = minimizer.minimize() 

1826 

1827 return _render_layered_diagram( 

1828 layers, 

1829 edges, 

1830 main_edge_set, 

1831 branches, 

1832 back_edges, 

1833 fsm.initial, 

1834 terminal_states, 

1835 fsm.states, 

1836 verbose, 

1837 highlight_state, 

1838 highlight_color, 

1839 edge_label_colors, 

1840 badges, 

1841 title_only=title_only or (mode == "mini"), 

1842 suppress_labels=suppress_labels or (mode == "mini"), 

1843 ) 

1844 

1845 

1846_PREV_STATE_COLOR = "33" # ANSI orange/yellow border for the just-prior FSM state. 

1847 

1848 

1849def _render_neighborhood_diagram( 

1850 fsm: FSMLoop, 

1851 active_state: str, 

1852 *, 

1853 edge_label_colors: dict[str, str] | None = None, 

1854 badges: dict[str, str] | None = None, 

1855 highlight_color: str = "32", 

1856 mode: str = "full", 

1857 prev_state: str | None = None, 

1858) -> str: 

1859 """Render a compact 1-hop neighborhood: predecessors → [active] → successors. 

1860 

1861 Suitable as a fallback when the full FSM diagram does not fit the viewport. 

1862 Bounded: ``max(len(preds), len(succs), 1) * 3`` rows (each state box is 3 

1863 lines tall). Returns the empty string when ``active_state`` is not in 

1864 ``fsm.states``. 

1865 

1866 Self-loops are collapsed: a state that only points to itself contributes 

1867 neither predecessors nor successors here. 

1868 

1869 Args: 

1870 mode: ``"full"`` (default) includes every edge. ``"main"`` filters edges 

1871 through ``_filter_main_path_graph`` so off-happy-path predecessors 

1872 (e.g. those connected only via ``on_error``) are hidden. Falls back 

1873 to ``"full"`` if the active state would be filtered out. 

1874 prev_state: Name of the predecessor the FSM most recently transitioned 

1875 from. When that name appears in the rendered pred stack, its box is 

1876 drawn with the orange ``_PREV_STATE_COLOR`` border. Silently 

1877 skipped if the name is missing from the pred stack. 

1878 """ 

1879 if active_state not in fsm.states: 

1880 return "" 

1881 

1882 edges = _collect_edges(fsm) 

1883 if mode == "main": 

1884 filtered_edges, reachable = _filter_main_path_graph(fsm, edges) 

1885 if active_state in reachable: 

1886 edges = filtered_edges 

1887 preds = sorted({s for (s, t, _lbl) in edges if t == active_state and s != active_state}) 

1888 succs = sorted({t for (s, t, _lbl) in edges if s == active_state and t != active_state}) 

1889 

1890 terminal_states = {n for n, st in fsm.states.items() if st.terminal} 

1891 

1892 def _label(name: str) -> str: 

1893 label = name 

1894 if name == fsm.initial: 

1895 label = "→ " + label 

1896 if name in terminal_states: 

1897 label = label + " ◉" 

1898 return label 

1899 

1900 pred_labels = [_label(p) for p in preds] 

1901 active_label = _label(active_state) 

1902 succ_labels = [_label(s) for s in succs] 

1903 

1904 inner_pred = max((_display_width(lbl) for lbl in pred_labels), default=0) 

1905 inner_active = _display_width(active_label) 

1906 inner_succ = max((_display_width(lbl) for lbl in succ_labels), default=0) 

1907 

1908 box_w_pred = inner_pred + 4 if pred_labels else 0 

1909 box_w_active = inner_active + 4 

1910 box_w_succ = inner_succ + 4 if succ_labels else 0 

1911 

1912 n_rows = max(len(pred_labels), len(succ_labels), 1) 

1913 try: 

1914 nd_bg_code: str | None = str(int(highlight_color) + 10) 

1915 except (ValueError, TypeError): 

1916 nd_bg_code = None 

1917 

1918 def _make_box( 

1919 label: str, 

1920 inner_w: int, 

1921 highlighted: bool, 

1922 *, 

1923 border_color: str | None = None, 

1924 ) -> list[str]: 

1925 top = "┌" + "─" * (inner_w + 2) + "┐" 

1926 bot = "└" + "─" * (inner_w + 2) + "┘" 

1927 # Pad by display width (not char count) so wide glyphs keep the box square. 

1928 padded = label + " " * max(0, inner_w - _display_width(label)) 

1929 if highlighted: 

1930 border_code = f"{highlight_color};{nd_bg_code}" if nd_bg_code else highlight_color 

1931 top = colorize(top, border_code) 

1932 bot = colorize(bot, border_code) 

1933 if nd_bg_code: 

1934 mid = ( 

1935 colorize("│", border_code) 

1936 + colorize(" ", nd_bg_code) 

1937 + colorize(padded, f"97;{nd_bg_code};1") 

1938 + colorize(" ", nd_bg_code) 

1939 + colorize("│", border_code) 

1940 ) 

1941 else: 

1942 mid = ( 

1943 colorize("│", border_code) 

1944 + " " 

1945 + colorize(padded, f"{highlight_color};1") 

1946 + " " 

1947 + colorize("│", border_code) 

1948 ) 

1949 elif border_color is not None: 

1950 top = colorize(top, border_color) 

1951 bot = colorize(bot, border_color) 

1952 mid = ( 

1953 colorize("│", border_color) 

1954 + " " 

1955 + colorize(padded, "1") 

1956 + " " 

1957 + colorize("│", border_color) 

1958 ) 

1959 else: 

1960 mid = "│ " + colorize(padded, "1") + " │" 

1961 return [top, mid, bot] 

1962 

1963 center_idx = (n_rows - 1) // 2 

1964 

1965 def _build_stack( 

1966 labels: list[str], 

1967 box_w: int, 

1968 *, 

1969 color_for: dict[str, str] | None = None, 

1970 ) -> list[str]: 

1971 rows: list[str] = [] 

1972 # Align the stack to the arrow row (the active state's slot). Without 

1973 # this, a shorter stack always sits at row 0 and the single ``──▶`` 

1974 # arrow drawn at ``active_line_offset`` points into empty space. 

1975 # ``min(center_idx, n_rows - len(labels))`` caps the start so longer 

1976 # but still-smaller stacks (e.g. 4 succs vs. 5 preds) don't overflow. 

1977 start = max(0, min(center_idx, n_rows - len(labels))) 

1978 color_map = color_for or {} 

1979 for i in range(n_rows): 

1980 j = i - start 

1981 if 0 <= j < len(labels): 

1982 border = color_map.get(labels[j]) 

1983 rows.extend(_make_box(labels[j], box_w - 4, False, border_color=border)) 

1984 else: 

1985 rows.extend([" " * box_w] * 3) 

1986 return rows 

1987 

1988 pred_color_for: dict[str, str] = {} 

1989 if prev_state is not None and prev_state in preds: 

1990 pred_color_for[_label(prev_state)] = _PREV_STATE_COLOR 

1991 

1992 pred_col = ( 

1993 _build_stack(pred_labels, box_w_pred, color_for=pred_color_for) if pred_labels else None 

1994 ) 

1995 succ_col = _build_stack(succ_labels, box_w_succ) if succ_labels else None 

1996 active_rows: list[str] = [] 

1997 for i in range(n_rows): 

1998 if i == center_idx: 

1999 active_rows.extend(_make_box(active_label, inner_active, True)) 

2000 else: 

2001 active_rows.extend([" " * box_w_active] * 3) 

2002 

2003 arrow = " ──▶ " 

2004 arrow_blank = " " * len(arrow) 

2005 active_line_offset = center_idx * 3 + 1 

2006 

2007 total_lines = n_rows * 3 

2008 out_lines: list[str] = [] 

2009 for i in range(total_lines): 

2010 parts: list[str] = [] 

2011 if pred_col is not None: 

2012 parts.append(pred_col[i]) 

2013 parts.append(arrow if i == active_line_offset else arrow_blank) 

2014 parts.append(active_rows[i]) 

2015 if succ_col is not None: 

2016 parts.append(arrow if i == active_line_offset else arrow_blank) 

2017 parts.append(succ_col[i]) 

2018 out_lines.append("".join(parts).rstrip()) 

2019 

2020 return "\n".join(out_lines) 

2021 

2022 

2023def _render_horizontal_simple( 

2024 main_path: list[str], 

2025 edges: list[tuple[str, str, str]], 

2026 main_edge_set: set[tuple[str, str]], 

2027 branches: list[tuple[str, str, str]], 

2028 back_edges: list[tuple[str, str, str]], 

2029 bfs_order: list[str], 

2030 initial: str, 

2031 terminal_states: set[str], 

2032 fsm_states: dict[str, StateConfig], 

2033 verbose: bool, 

2034 highlight_state: str | None, 

2035 highlight_color: str, 

2036 edge_label_colors: dict[str, str] | None = None, 

2037 badges: dict[str, str] | None = None, 

2038 title_only: bool = False, 

2039 suppress_labels: bool = False, 

2040) -> str: 

2041 """Simple horizontal rendering for single-state or very simple FSMs. 

2042 

2043 When ``title_only`` is True, per-state body lines and self-loop labels are suppressed. 

2044 When ``suppress_labels`` is True, self-loop markers omit label text. 

2045 """ 

2046 if not main_path: 

2047 return "" 

2048 all_states = list(main_path) 

2049 display_label = _compute_display_labels(all_states, initial, terminal_states) 

2050 

2051 tw = terminal_width() 

2052 num_main = max(1, len(main_path)) 

2053 if verbose and fsm_states and main_path: 

2054 max_box_inner = max(20, min(60, (tw - 4) // num_main - 6)) 

2055 else: 

2056 max_box_inner = max(20, min(40, (tw - 4) // num_main - 6)) 

2057 

2058 box_inner, box_width, box_height, box_badge = _compute_box_sizes( 

2059 all_states, 

2060 display_label, 

2061 fsm_states, 

2062 verbose, 

2063 max_box_inner, 

2064 badges, 

2065 title_only=title_only, 

2066 ) 

2067 

2068 main_height = max((box_height[s] for s in main_path), default=3) 

2069 total_width = tw 

2070 

2071 # Column positions 

2072 col_start: dict[str, int] = {} 

2073 col_center: dict[str, int] = {} 

2074 x = 2 

2075 for i, sname in enumerate(main_path): 

2076 col_start[sname] = x 

2077 col_center[sname] = x + box_width[sname] // 2 

2078 x += box_width[sname] 

2079 if i < len(main_path) - 1: 

2080 x += 4 

2081 

2082 rows: list[list[str]] = [[" "] * total_width for _ in range(main_height)] 

2083 

2084 for sname in main_path: 

2085 is_highlighted = highlight_state is not None and sname == highlight_state 

2086 _draw_box( 

2087 rows, 

2088 0, 

2089 col_start[sname], 

2090 box_width[sname], 

2091 main_height, 

2092 box_inner[sname], 

2093 is_highlighted, 

2094 highlight_color, 

2095 badge=box_badge[sname], 

2096 ) 

2097 

2098 # Self-loops 

2099 self_loops_list = [(s, d, lbl) for s, d, lbl in back_edges if s == d] 

2100 lines = ["".join(row).rstrip() for row in rows] 

2101 if self_loops_list: 

2102 self_labels: dict[str, list[str]] = {} 

2103 for src, _, label in self_loops_list: 

2104 self_labels.setdefault(src, []).append(label) 

2105 for sname, labels in self_labels.items(): 

2106 marker = "\u21ba" if suppress_labels else "\u21ba " + ", ".join(labels) 

2107 self_row = [" "] * total_width 

2108 cx = col_center.get(sname, 0) 

2109 pos = max(0, cx - len(marker) // 2) 

2110 for j, ch in enumerate(marker): 

2111 if pos + j < total_width: 

2112 self_row[pos + j] = ch 

2113 lines.append("".join(self_row).rstrip()) 

2114 

2115 diagram_indent = max(0, (tw - (x + 4)) // 2) 

2116 if diagram_indent > 0: 

2117 lines = [" " * diagram_indent + ln if ln.strip() else ln for ln in lines] 

2118 

2119 return _colorize_diagram_labels("\n".join(lines), edge_label_colors)