Coverage for src/ai_jury/theater.py: 100%

533 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-30 06:29 +0000

1"""Animated "deliberation" scene for a live jury run (opt-in ``--theater`` mode). 

2 

3A presentation-only consumer of the orchestrator's ``on_event`` stream: it draws 

4a top-down **deliberation room** where the models sit around a table, take turns 

5speaking as the run moves through its phases (review -> debate -> verify -> 

6decision), and reach a decision together — by panel vote, or recorded by the 

7chair (the synthesizer). There is no judge; the jurors deliberate with each 

8other. Pure stdlib (ANSI escapes; no curses, no deps). 

9 

10It reflects the REAL run — seats come from the configured panel, and every 

11speech bubble / finding / decision is the actual structured output of that phase 

12(``--mock`` drives the deterministic mock panel for a demo). It is a side 

13channel only: it never touches the structured outcome, the report, or the CI 

14gate, and it degrades to the plain ``--live`` transcript on a non-TTY. 

15 

16The design follows ``docs/theater-design.md``. 

17""" 

18 

19from __future__ import annotations 

20 

21import shutil 

22import sys 

23import threading 

24import time 

25 

26from .adapters import AgentResult 

27from .config import normalise_vendor 

28from .findings import SEVERITY_ORDER, flatten_inline, parse_findings, parse_verdicts 

29 

30# ---- styling --------------------------------------------------------------- 

31_RESET = "\033[0m" 

32# Each vendor's own product brand colour (24-bit truecolor SGR). Matches the 

33# website's vendor tokens: Anthropic coral, OpenAI teal, Google blue, local 

34# violet. Terminals without truecolor degrade to the nearest available colour. 

35_VENDOR_SGR = { 

36 "anthropic": "38;2;217;119;87", # Claude #d97757 

37 "anthropic-api": "38;2;217;119;87", # Claude API 

38 "openai": "38;2;16;163;127", # Codex #10a37f 

39 "openai-api": "38;2;16;163;127", # OpenAI API 

40 "google": "38;2;66;133;244", # Antigravity #4285f4 

41 "google-api": "38;2;66;133;244", # Gemini API 

42 "xai": "38;2;120;120;130", # Grok / xAI #787882 

43 "xai-api": "38;2;120;120;130", # xAI API 

44 "local": "38;2;168;85;247", # local / open-weight #a855f7 

45 "openai-compatible": "38;2;139;92;246", # OpenRouter/DeepSeek/Groq #8b5cf6 

46 "cli": "38;2;236;72;153", # arbitrary CLI agent #ec4899 

47} 

48 

49 

50def _vendor_sgr(vendor, default: str = "1") -> str: 

51 """The brand colour for *vendor*, keyed on its normalised spelling (pure). 

52 

53 A replayed outcome file is an external document: it carries whatever vendor 

54 string the run recorded, so this looks it up the way every other rule looks 

55 up a vendor (issue #701, round 3). Cosmetic — an unmatched vendor just gets 

56 the default attribute — but "which vendor is this" has one answer here too. 

57 """ 

58 return _VENDOR_SGR.get(normalise_vendor(vendor), default) 

59 

60 

61# Same brand palette as RGB tuples, for the pixel-art style (half-block render). 

62_VENDOR_RGB = { 

63 "anthropic": (217, 119, 87), 

64 "anthropic-api": (217, 119, 87), 

65 "openai": (16, 163, 127), 

66 "openai-api": (16, 163, 127), 

67 "google": (66, 133, 244), 

68 "google-api": (66, 133, 244), 

69 "xai": (120, 120, 130), 

70 "xai-api": (120, 120, 130), 

71 "local": (168, 85, 247), 

72 "openai-compatible": (139, 92, 246), 

73 "cli": (236, 72, 153), 

74} 

75# Pixel-art scene palette (RGB). The room is drawn into a pixel buffer and folded 

76# to the terminal two rows at a time via the upper-half-block ▀ (fg = top pixel, 

77# bg = bottom pixel), so it needs a truecolor + unicode terminal. 

78_HALF = "▀" # ▀ 

79_PIX = { 

80 "bg": (16, 16, 20), 

81 "floor_a": (208, 170, 120), 

82 "floor_b": (196, 156, 108), 

83 "rug": (34, 36, 54), 

84 "table": (176, 110, 38), 

85 "table_edge": (150, 92, 28), 

86 "glow": (210, 210, 210), 

87 "skin": (226, 208, 182), 

88 "spk": (255, 255, 255), 

89} 

90_PHASES = ( 

91 ("review", "REVIEW"), 

92 ("debate", "DEBATE"), 

93 ("verify", "VERIFY"), 

94 ("synthesis", "DECISION"), 

95) 

96 

97_GLYPHS = { 

98 "caret": "▲", 

99 "ok": "✓", 

100 "no": "✗", 

101 "dispute": "⚖", 

102 "play": "⏵", 

103 "idle": "•", 

104 "speak": "●", 

105 "table": "═", 

106} 

107_ASCII_GLYPHS = { 

108 "caret": "^", 

109 "ok": "v", 

110 "no": "x", 

111 "dispute": "?", 

112 "play": ">", 

113 "idle": ".", 

114 "speak": "*", 

115 "table": "=", 

116} 

117# Positive / negative / neutral verdict vocab for code AND issue modes. 

118_VERDICT_POS = ("APPROVE", "READY") 

119_VERDICT_NEG = ("REQUEST", "BLOCK", "NEEDS-INFO", "NEEDS INFO", "CHANGES") 

120 

121 

122def _banner_sgr(verdict: str) -> str: 

123 up = verdict.upper() 

124 if any(k in up for k in _VERDICT_NEG): 

125 return "97;41;1" # white on red 

126 if any(k in up for k in _VERDICT_POS): 

127 return "30;42;1" # black on green 

128 return "30;43;1" # black on yellow (COMMENT / UNCLEAR / neutral) 

129 

130 

131def supports_scene(stream) -> bool: 

132 """True when ``stream`` is a TTY wide enough for the deliberation scene.""" 

133 try: 

134 if not stream.isatty(): 

135 return False 

136 except Exception: # noqa: BLE001 

137 return False 

138 return shutil.get_terminal_size((80, 24)).columns >= 60 

139 

140 

141def _unsafe_cell_char(ch: str) -> bool: 

142 """True for characters that must never appear in agent-influenced terminal 

143 cell content: C0 controls (incl. ESC), DEL, C1 controls, and the Unicode 

144 bidi/zero-width format characters used for text-spoofing (Trojan Source, 

145 CVE-2021-42574). Styling escapes arrive separately via the trusted ``sgr`` 

146 argument, so any of these in the *content* is an injection/spoof attempt.""" 

147 o = ord(ch) 

148 return ( 

149 o < 0x20 

150 or o == 0x7F 

151 or 0x80 <= o <= 0x9F # C0 / DEL / C1 

152 or 0x200B <= o <= 0x200F 

153 or 0x202A <= o <= 0x202E # zero-width / bidi 

154 or 0x2066 <= o <= 0x2069 

155 or o == 0xFEFF # bidi isolates / BOM 

156 ) 

157 

158 

159class Screen: 

160 """A fixed grid of (char, sgr) cells rendered to ANSI or plain text.""" 

161 

162 def __init__(self, cols: int, rows: int): 

163 self.cols, self.rows = cols, rows 

164 self.clear() 

165 

166 def clear(self) -> None: 

167 self._g = [[(" ", "")] * self.cols for _ in range(self.rows)] 

168 

169 def put(self, r: int, c: int, text: str, sgr: str = "") -> None: 

170 if not (0 <= r < self.rows): 

171 return 

172 row = self._g[r] 

173 for i, ch in enumerate(text): 

174 # Scrub control / bidi / zero-width characters from agent-influenced 

175 # cell content (finding claims, the verdict line). Styling arrives 

176 # separately via ``sgr`` (trusted), so any of these in the content is 

177 # a terminal-injection (cursor/clear/title) or text-spoof (bidi 

178 # override) attempt. Replace with a space to keep the layout width. 

179 if _unsafe_cell_char(ch): 

180 ch = " " 

181 x = c + i 

182 if 0 <= x < self.cols: 

183 row[x] = (ch, sgr) 

184 

185 def center(self, r: int, text: str, sgr: str = "") -> None: 

186 self.put(r, max(0, (self.cols - len(text)) // 2), text, sgr) 

187 

188 def _row_ansi(self, row) -> str: 

189 out, cur = [], None 

190 for ch, sgr in row: 

191 if sgr != cur: 

192 out.append(_RESET if not sgr else f"\033[{sgr}m") 

193 cur = sgr 

194 out.append(ch) 

195 if cur: 

196 out.append(_RESET) 

197 return "".join(out).rstrip() 

198 

199 def to_ansi(self) -> str: 

200 return "\n".join(self._row_ansi(r) for r in self._g) 

201 

202 def to_plain(self) -> str: 

203 return "\n".join("".join(ch for ch, _ in r).rstrip() for r in self._g) 

204 

205 

206# Table geometry (rows on the fixed grid). 

207_TABLE_TOP = 8 

208_TABLE_BOT = 14 

209_SEAT_SLOT = 14 # min horizontal room per seat before we fall back to a roster 

210 

211# Pixel-art band geometry (terminal rows occupied by the half-block scene). 

212_PIX_TOP = 5 

213_PIX_BOT = 17 

214 

215 

216class Courtroom: 

217 """Draws and animates the deliberation from the on_event stream. 

218 

219 (Class name kept for back-compat; the scene is a round-table deliberation.) 

220 """ 

221 

222 def __init__( 

223 self, 

224 agents, 

225 chair: str, 

226 *, 

227 case: str = "", 

228 stream=None, 

229 animate: bool = True, 

230 cols: int | None = None, 

231 rows: int = 30, 

232 capture=None, 

233 mode: str = "code", 

234 decision: str = "chair", 

235 unicode: bool = True, 

236 style: str = "flat", 

237 ): 

238 # agents: list of (name, vendor); chair: the synthesizer (records the 

239 # decision in chair mode). mode: "code"/"issue". decision: "chair" or 

240 # "vote" (the panel tallies ballots) — different decision beat. style: 

241 # "flat" (ANSI line scene) or "pixel" (half-block pixel-art room). 

242 self.agents = list(agents) 

243 self.chair = chair 

244 self.case = case 

245 self.mode = mode 

246 self.decision = decision 

247 self.out = stream if stream is not None else sys.stdout 

248 self.animate = animate 

249 self.cols = cols or min(98, max(70, shutil.get_terminal_size((90, 30)).columns)) 

250 self.rows = rows 

251 self._capture = capture 

252 self.unicode = unicode 

253 # Pixel-art needs the half-block glyph + truecolor; with unicode off it 

254 # transparently falls back to the flat line scene. 

255 self.pixel = style == "pixel" and unicode 

256 self.g = _GLYPHS if unicode else _ASCII_GLYPHS 

257 self.hr = "─" if unicode else "-" 

258 self.dot = "·" if unicode else "." 

259 self.screen = Screen(self.cols, self.rows) 

260 self.phase = None 

261 self.done_phases: set[str] = set() 

262 self.state: dict[str, str] = {a[0]: "idle" for a in self.agents} 

263 self.log: list[str] = [] 

264 self.bubble: tuple[str, str] = ("", "") 

265 self.verifies: list = [] 

266 self.verdict: str | None = None 

267 self.vote = None 

268 self.ballots: dict[str, str] = {} 

269 self.debate_seen = False 

270 self.max_round = 0 

271 self.disputes = 0 

272 self.start = time.monotonic() 

273 # Background ticker: repaint on an interval so the clock stays live and 

274 # the scene doesn't freeze between on_event callbacks (agents can run for 

275 # tens of seconds). Guarded by a lock shared with event-driven repaints. 

276 self.tick_interval = 1.0 

277 self._lock = threading.RLock() 

278 self._tick_stop: threading.Event | None = None 

279 self._tick_thread: threading.Thread | None = None 

280 

281 # -- geometry -------------------------------------------------------- 

282 def _split_seats(self): 

283 """Split jurors into the top edge and bottom edge of the table.""" 

284 n = len(self.agents) 

285 top = self.agents[: (n + 1) // 2] 

286 bottom = self.agents[(n + 1) // 2 :] 

287 return top, bottom 

288 

289 def _slots(self, count: int): 

290 """Evenly spaced seat centre-x across the table's width.""" 

291 if count <= 0: 

292 return [] 

293 x0, x1 = 4, self.cols - 4 

294 span = x1 - x0 

295 return [x0 + span * (2 * i + 1) // (2 * count) for i in range(count)] 

296 

297 def _seats_fit(self) -> bool: 

298 top, bottom = self._split_seats() 

299 widest = max(len(top), len(bottom), 1) 

300 return (self.cols - 8) // widest >= _SEAT_SLOT 

301 

302 # -- painting -------------------------------------------------------- 

303 def _paint(self) -> None: 

304 self.screen.clear() 

305 self._title() 

306 self._strip() 

307 if self.pixel and self._seats_fit(): 

308 self._pixel_room() 

309 elif self._seats_fit(): 

310 self._table_and_seats() 

311 else: 

312 self._roster() 

313 self._speaking_area() 

314 self._transcript() 

315 self._status() 

316 

317 def _title(self) -> None: 

318 s = self.screen 

319 s.put(0, 1, f"{self.g['speak']} ai-jury - deliberation", "1") 

320 decided = "panel vote" if self.decision == "vote" else f"chair: {self.chair[:10]}" 

321 meta = f"{len(self.agents)} jurors {self.dot} {decided}" 

322 if self.case: 

323 meta = f"case: {self.case} {self.dot} " + meta 

324 s.put(0, 34, meta, "2") 

325 s.put(1, 0, self.hr, "2") 

326 

327 def _strip(self) -> None: 

328 s = self.screen 

329 x = 2 

330 for kind, label in _PHASES: 

331 text = label 

332 if kind == "debate" and self.max_round > 1 and kind in (self.phase, *self.done_phases): 

333 text = f"{label}{self.dot}r{self.max_round}" 

334 if kind in self.done_phases: 

335 mark, sgr = self.g["ok"], "32" 

336 elif kind == self.phase: 

337 mark, sgr = ">", "96;1" 

338 else: 

339 mark, sgr = self.dot, "2" 

340 cell = f"{mark} {text}" 

341 s.put(2, x, cell, sgr) 

342 x += len(cell) + 1 

343 if kind != _PHASES[-1][0]: 

344 s.put(2, x, "--", "2") 

345 x += 3 

346 elapsed = int(time.monotonic() - self.start) 

347 s.put(2, self.cols - 8, f"{elapsed // 60:02d}:{elapsed % 60:02d}", "96") 

348 s.put(3, 0, self.hr, "2") 

349 

350 def _seat(self, x: int, name: str, vendor: str, *, facing: str) -> None: 

351 """Draw one juror seated at the table (facing 'down' = top edge, 'up' = 

352 bottom edge), with vendor-coloured nameplate + a chair/figure glyph.""" 

353 s = self.screen 

354 st = self.state.get(name, "idle") 

355 hi = st in ("speaking", "arguing") 

356 vsgr = _vendor_sgr(vendor) + (";1" if hi else "") 

357 figure = self.g["speak"] if hi else self.g["idle"] 

358 if st == "done": 

359 figure = self.g["ok"] 

360 elif st == "error": 

361 figure = "!" 

362 plate = f"{name[:10]}" 

363 if self.chair == name and self.decision != "vote": 

364 plate += "*" # chair/moderator marker 

365 ballot = self.ballots.get(name) 

366 plate_x = x - len(plate) // 2 

367 fig = f"({figure})" 

368 fx = x - 1 

369 if facing == "down": # top edge: nameplate above, figure toward table 

370 s.put(_TABLE_TOP - 3, plate_x, plate, vsgr) 

371 s.put(_TABLE_TOP - 2, fx, fig, "96;1" if hi else "2") 

372 if ballot: 

373 s.put( 

374 _TABLE_TOP - 4, x - len(ballot) // 2 - 1, f"[{ballot[:8]}]", _banner_sgr(ballot) 

375 ) 

376 if hi: 

377 s.put(_TABLE_TOP - 1, x, self.g["caret"], "96") 

378 else: # bottom edge: figure toward table, nameplate below 

379 if hi: 

380 s.put(_TABLE_BOT, x, self.g["caret"], "96") 

381 s.put(_TABLE_BOT + 1, fx, fig, "96;1" if hi else "2") 

382 s.put(_TABLE_BOT + 2, plate_x, plate, vsgr) 

383 if ballot: 

384 s.put( 

385 _TABLE_BOT + 3, x - len(ballot) // 2 - 1, f"[{ballot[:8]}]", _banner_sgr(ballot) 

386 ) 

387 

388 def _table_and_seats(self) -> None: 

389 s = self.screen 

390 tx0, tx1 = 6, self.cols - 7 

391 # table border 

392 s.put(_TABLE_TOP, tx0, "." + self.hr * (tx1 - tx0 - 1) + ".", "33") 

393 for r in range(_TABLE_TOP + 1, _TABLE_BOT): 

394 s.put(r, tx0, "|", "33") 

395 s.put(r, tx1, "|", "33") 

396 s.put(_TABLE_BOT, tx0, "'" + self.hr * (tx1 - tx0 - 1) + "'", "33") 

397 self._table_interior() 

398 top, bottom = self._split_seats() 

399 for x, (name, vendor) in zip(self._slots(len(top)), top, strict=True): 

400 self._seat(x, name, vendor, facing="down") 

401 for x, (name, vendor) in zip(self._slots(len(bottom)), bottom, strict=True): 

402 self._seat(x, name, vendor, facing="up") 

403 

404 def _table_interior(self) -> None: 

405 """What's 'on the table': the decision, the verify checklist, or a hint.""" 

406 s = self.screen 

407 mid = (_TABLE_TOP + _TABLE_BOT) // 2 

408 if self.verdict: 

409 extra = "" 

410 if self.decision == "vote" and self.vote is not None: 

411 extra = ( 

412 " (" 

413 + " · ".join(f"{n} {lbl.lower()}" for lbl, n in self.vote.tally.items()) 

414 + ")" 

415 ) 

416 label = "DECISION by panel vote" if self.decision == "vote" else "DECISION (chair)" 

417 s.center(_TABLE_TOP + 1, label, "2") 

418 sgr = _banner_sgr(self.verdict) 

419 for j, ln in enumerate(self._wrap_banner(self.verdict + extra, self.cols - 16, 3)): 

420 s.center(_TABLE_TOP + 3 + j, f" {ln} ", sgr) 

421 return 

422 if self.phase == "verify" and self.verifies: 

423 s.center(_TABLE_TOP + 1, "- verifying findings -", "1") 

424 for j, v in enumerate(self.verifies[:4]): 

425 mk, msg, sgr = self._verify_row(v) 

426 s.put(_TABLE_TOP + 2 + j, 9, f"{mk} {msg}", sgr) 

427 return 

428 s.center(mid, f"case: {self.case}" if self.case else "deliberating…", "2") 

429 

430 def _roster(self) -> None: 

431 # Compact fallback (many jurors / narrow terminal): a wrapped row of 

432 # juror chips with state marks, no clipping. 

433 s = self.screen 

434 marks = { 

435 "speaking": self.g["caret"], 

436 "arguing": self.g["caret"], 

437 "done": self.g["ok"], 

438 "error": "!", 

439 } 

440 s.put(_TABLE_TOP, 2, "JURY:", "2") 

441 row, x = _TABLE_TOP + 1, 4 

442 for name, vendor in self.agents: 

443 st = self.state.get(name, "idle") 

444 label = f"{name[:10]}{marks.get(st, self.dot)}" 

445 if x + len(label) + 2 > self.cols - 2: 

446 row += 1 

447 x = 4 

448 if row > _TABLE_BOT: 

449 break 

450 s.put( 

451 row, 

452 x, 

453 label, 

454 _vendor_sgr(vendor) + (";1" if st in ("speaking", "arguing") else ""), 

455 ) 

456 x += len(label) + 2 

457 self._table_interior() 

458 

459 def _fit(self, text: str, width: int) -> str: 

460 """Truncate ``text`` to ``width`` columns with an ellipsis so a long 

461 verdict line never overflows the table / screen edge.""" 

462 if width <= 0: 

463 return "" 

464 if len(text) <= width: 

465 return text 

466 ell = "…" if self.unicode else "..." 

467 return text[: max(0, width - len(ell))].rstrip() + ell 

468 

469 def _verdict_label(self, verdict: str) -> str: 

470 """Short verdict keyword for the transcript log (the full rationale is on 

471 the banner), e.g. 'NEEDS-INFO — long reason…' -> 'NEEDS-INFO'. Splits on 

472 the em-dash / spaced-hyphen separator, never the keyword's own hyphen.""" 

473 head = verdict.split("—")[0].split(" - ")[0].strip() 

474 return head or verdict 

475 

476 def _wrap_banner(self, text: str, width: int, max_lines: int) -> list[str]: 

477 """Wrap ``text`` to ``width`` over at most ``max_lines`` rows so the 

478 verdict is readable; if it still overflows, the last line gets an 

479 ellipsis (better than truncating the whole verdict to one line).""" 

480 lines = _wrap(text, width) 

481 if len(lines) > max_lines: 

482 lines = lines[:max_lines] 

483 ell = "…" if self.unicode else "..." 

484 # plain slice (not _fit, which would add its own ellipsis → "x… …") 

485 lines[-1] = lines[-1][: max(0, width - len(ell) - 1)].rstrip() + " " + ell 

486 return lines 

487 

488 def _verify_row(self, v): 

489 msg = f"{v.status:<18} {flatten_inline(v.claim)[:40]}" 

490 if v.status == "verified": 

491 return self.g["ok"], msg, "32;1" 

492 if v.status == "unsupported": 

493 return self.g["no"], msg, "2;31" 

494 return self.g["dispute"], msg, "33" 

495 

496 # -- pixel-art scene (--theater-style pixel) ------------------------- 

497 def _pix_slots(self, count: int, width: int): 

498 """Evenly spaced seat centre-x in pixel columns across the room width.""" 

499 if count <= 0: 

500 return [] 

501 x0, x1 = 9, width - 9 

502 span = x1 - x0 

503 return [x0 + span * (2 * i + 1) // (2 * count) for i in range(count)] 

504 

505 def _pixel_room(self) -> None: 

506 """Draw the top-down room as pixel-art (half-block) and overlay labels.""" 

507 pw, ph = self.cols, (_PIX_BOT - _PIX_TOP + 1) * 2 

508 px = [[_PIX["bg"]] * pw for _ in range(ph)] 

509 

510 def rect(x0, y0, x1, y1, c): 

511 for y in range(max(0, y0), min(ph, y1 + 1)): 

512 rowp = px[y] 

513 for x in range(max(0, x0), min(pw, x1 + 1)): 

514 rowp[x] = c 

515 

516 for y in range(ph): # warm checkerboard floor 

517 for x in range(pw): 

518 px[y][x] = _PIX["floor_a"] if (x // 2 + y // 2) % 2 else _PIX["floor_b"] 

519 mx = 4 

520 rect(mx, 2, pw - 1 - mx, ph - 3, _PIX["rug"]) 

521 tx0, ty0, tx1, ty1 = mx + 6, 8, pw - 1 - mx - 6, 17 

522 rect(tx0, ty0, tx1, ty1, _PIX["table"]) 

523 rect(tx0, ty0, tx1, ty0 + 1, _PIX["table_edge"]) 

524 cx, cy = (tx0 + tx1) // 2, (ty0 + ty1) // 2 

525 rect(cx - 6, cy - 1, cx + 6, cy + 1, _PIX["glow"]) 

526 

527 eye = (40, 40, 54) 

528 

529 def figure(axc, heady, vendor, name): 

530 # Same reason as `_vendor_sgr`: keyed on the normalised spelling so 

531 # a recorded " XAI " still gets its own vendor tint (#701, round 3). 

532 body = _VENDOR_RGB.get(normalise_vendor(vendor), (180, 180, 190)) 

533 hair = tuple(int(c * 0.55) for c in body) # darker vendor tint 

534 hi = self.state.get(name) in ("speaking", "arguing") 

535 rect(axc - 2, heady, axc + 2, heady + 2, _PIX["skin"]) # head (5×3) 

536 rect(axc - 2, heady, axc + 2, heady, hair) # hair on top 

537 rect(axc - 1, heady + 1, axc - 1, heady + 1, eye) # left eye 

538 rect(axc + 1, heady + 1, axc + 1, heady + 1, eye) # right eye 

539 rect(axc - 2, heady + 3, axc + 2, heady + 5, body) # torso (5×3) 

540 rect(axc - 3, heady + 3, axc - 3, heady + 4, body) # left arm 

541 rect(axc + 3, heady + 3, axc + 3, heady + 4, body) # right arm 

542 if hi: # speaking halo 

543 rect(axc - 3, heady - 1, axc + 3, heady - 1, _PIX["spk"]) 

544 

545 top, bottom = self._split_seats() 

546 txs, bxs = self._pix_slots(len(top), pw), self._pix_slots(len(bottom), pw) 

547 for axc, (name, vendor) in zip(txs, top, strict=True): 

548 figure(axc, 2, vendor, name) 

549 for axc, (name, vendor) in zip(bxs, bottom, strict=True): 

550 figure(axc, 18, vendor, name) 

551 

552 self._blit_band(px) 

553 self._pixel_overlays(txs, bxs, top, bottom) 

554 

555 def _blit_band(self, px) -> None: 

556 """Fold the pixel buffer into the screen, two rows per cell via ▀.""" 

557 s = self.screen 

558 for i in range(_PIX_BOT - _PIX_TOP + 1): 

559 top_row, bot_row = px[2 * i], px[2 * i + 1] 

560 for c in range(self.cols): 

561 tr, tg, tb = top_row[c] 

562 br, bg, bb = bot_row[c] 

563 s.put(_PIX_TOP + i, c, _HALF, f"38;2;{tr};{tg};{tb};48;2;{br};{bg};{bb}") 

564 

565 def _pixel_overlays(self, txs, bxs, top, bottom) -> None: 

566 """Names, ballots and the decision/verify text laid over the pixel band.""" 

567 s = self.screen 

568 # name labels: top edge along the top of the band, bottom edge below it. 

569 # Top ballots sit in the gap above the band; the bottom edge has no spare 

570 # row (the speech band follows), so the panel tally on the table banner is 

571 # the per-juror vote record there. 

572 for x, (name, vendor) in zip(txs, top, strict=True): 

573 self._pix_label(x, name, vendor, _PIX_TOP, ballot_row=_PIX_TOP - 1) 

574 for x, (name, vendor) in zip(bxs, bottom, strict=True): 

575 self._pix_label(x, name, vendor, _PIX_BOT, ballot_row=None) 

576 

577 mid = (_PIX_TOP + _PIX_BOT) // 2 

578 if self.verdict: 

579 extra = "" 

580 if self.decision == "vote" and self.vote is not None: 

581 extra = ( 

582 " (" 

583 + " · ".join(f"{n} {lbl.lower()}" for lbl, n in self.vote.tally.items()) 

584 + ")" 

585 ) 

586 label = "DECISION by panel vote" if self.decision == "vote" else "DECISION (chair)" 

587 s.center(mid - 1, f" {label} ", "97;1") 

588 sgr = _banner_sgr(self.verdict) 

589 for j, ln in enumerate(self._wrap_banner(self.verdict + extra, self.cols - 8, 3)): 

590 s.center(mid + j, f" {ln} ", sgr) 

591 elif self.phase == "verify" and self.verifies: 

592 s.center(mid - 1, " verifying findings ", "97;1") 

593 for j, v in enumerate(self.verifies[:3]): 

594 mk, msg, sgr = self._verify_row(v) 

595 s.center(mid + j, f"{mk} {msg}", sgr) 

596 elif self.case: 

597 s.center(mid, f" case: {self.case} ", "97;1") 

598 

599 def _pix_label(self, x, name, vendor, row, *, ballot_row) -> None: 

600 st = self.state.get(name, "idle") 

601 plate = name[:10] 

602 if self.chair == name and self.decision != "vote": 

603 plate += "*" 

604 plate = f" {plate} " # padding for the dark pill 

605 # Vendor-coloured, bold, on a dark pill so the name reads over the floor. 

606 speaking = st in ("speaking", "arguing") 

607 sgr = _vendor_sgr(vendor, "37") + ";48;2;22;22;32;1" 

608 if speaking: 

609 sgr = "30;47;1" # invert (black on white) while speaking 

610 self.screen.put(row, max(0, x - len(plate) // 2), plate, sgr) 

611 ballot = self.ballots.get(name) 

612 if ballot and ballot_row is not None: 

613 chip = f"[{ballot[:8]}]" 

614 self.screen.put(ballot_row, max(0, x - len(chip) // 2), chip, _banner_sgr(ballot)) 

615 

616 def _speaking_area(self) -> None: 

617 s = self.screen 

618 r0 = 18 

619 s.put(r0, 0, self.hr, "2") 

620 speaker, text = self.bubble 

621 if speaker and not self.verdict: 

622 s.put(r0, 2, f" {speaker} is speaking ", "96;1") 

623 wrapped = _wrap(text, self.cols - 12)[:3] 

624 width = max((len(w) for w in wrapped), default=0) 

625 s.put(r0 + 1, 6, "." + "-" * (width + 2) + ".", "2") 

626 for j, w in enumerate(wrapped): 

627 s.put(r0 + 2 + j, 6, f"( {w:<{width}} )", "39") 

628 s.put(r0 + 2 + len(wrapped), 6, "'" + "-" * (width + 2) + "'", "2") 

629 elif self.verdict: 

630 # The full verdict is shown wrapped on the table banner now, so this 

631 # is just the closing note. 

632 s.put(r0, 2, " the panel has decided ", "1") 

633 

634 def _transcript(self) -> None: 

635 s = self.screen 

636 r0 = 23 

637 s.put(r0, 0, self.hr, "2") 

638 s.put(r0, 2, " TRANSCRIPT ", "2") 

639 for j, line in enumerate(self.log[-4:]): 

640 s.put(r0 + 1 + j, 2, self._fit(flatten_inline(line), self.cols - 4), "") 

641 

642 def _status(self) -> None: 

643 s = self.screen 

644 msg = "deliberation closed" if self.verdict else (f"{self.phase or 'opening'}...") 

645 s.put(self.rows - 1, 2, f"{self.g['play']} {msg}", "96") 

646 

647 # -- emit / animate -------------------------------------------------- 

648 def _flush(self) -> None: 

649 frame = self.screen.to_ansi() 

650 if self._capture is not None: 

651 self._capture.append(frame) 

652 if self.animate: 

653 self.out.write("\033[H\033[J" + frame) 

654 self.out.flush() 

655 

656 def _render(self) -> None: 

657 # Paint + flush as one critical section so an event-driven repaint and a 

658 # background tick never interleave writes (torn frames) on the terminal. 

659 with self._lock: 

660 self._paint() 

661 self._flush() 

662 

663 def _beat(self, secs: float) -> None: 

664 if self.animate: 

665 time.sleep(secs) 

666 

667 def _frame(self, beat: float = 0.0) -> None: 

668 self._render() 

669 self._beat(beat) 

670 

671 def _tick_loop(self) -> None: 

672 # Repaint every ``tick_interval`` until stopped — keeps the clock live and 

673 # the scene from freezing while the orchestrator waits on the agents. 

674 assert self._tick_stop is not None 

675 while not self._tick_stop.wait(self.tick_interval): 

676 self._render() 

677 

678 def _start_ticker(self) -> None: 

679 if not self.animate or self._tick_thread is not None: 

680 return 

681 self._tick_stop = threading.Event() 

682 self._tick_thread = threading.Thread(target=self._tick_loop, daemon=True) 

683 self._tick_thread.start() 

684 

685 def _stop_ticker(self) -> None: 

686 if self._tick_stop is not None: 

687 self._tick_stop.set() 

688 if self._tick_thread is not None: 

689 self._tick_thread.join(timeout=1.0) 

690 self._tick_thread = None 

691 

692 # -- public API ------------------------------------------------------ 

693 def open(self) -> None: 

694 if self.animate: 

695 self.out.write("\033[2J\033[?25l") 

696 self.phase = "review" 

697 self.log.append("the jury convenes") 

698 self._frame(0.4) 

699 self._start_ticker() 

700 

701 def step(self, kind: str, result: AgentResult, round_no: int | None = None) -> None: 

702 skipped_debate = ( 

703 kind in ("verify", "synthesis") 

704 and not self.debate_seen 

705 and "debate" not in self.done_phases 

706 ) 

707 if kind != self.phase and self.phase is not None: 

708 self.done_phases.update( 

709 k for k, _ in _PHASES if k != kind and self._phase_before(k, kind) 

710 ) 

711 if skipped_debate: 

712 self.done_phases.add("debate") 

713 self.log.append("no debate - the jurors agreed") 

714 self.phase = kind 

715 if kind == "debate": 

716 self.debate_seen = True 

717 self.max_round = max(self.max_round, round_no or 1) 

718 if kind in ("review", "debate"): 

719 self._speak(kind, result, round_no) 

720 elif kind == "verify": 

721 self._verify(result) 

722 else: # synthesis (the four phases are exhaustive) 

723 self._synthesize(result) 

724 

725 @staticmethod 

726 def _phase_before(a: str, b: str) -> bool: 

727 order = [k for k, _ in _PHASES] 

728 return order.index(a) < order.index(b) 

729 

730 def _speak(self, kind, result, _round_no=None): 

731 name = result.agent 

732 for k in self.state: 

733 if self.state[k] != "done": 

734 self.state[k] = "idle" 

735 self.state[name] = "arguing" if kind == "debate" else "speaking" 

736 if not result.ok: 

737 self.state[name] = "error" 

738 self.bubble = (name, flatten_inline(result.error or "no output")) 

739 self.log.append(f"{name}: failed") 

740 self._frame(0.5) 

741 return 

742 findings, _ = parse_findings(result.output or "", name) 

743 if findings: 

744 top = sorted(findings, key=lambda f: SEVERITY_ORDER.get(f.severity, 9))[0] 

745 text = f"[{top.severity}] {flatten_inline(top.claim)}" 

746 verb = "raises" if kind == "review" else "argues" 

747 self.log.append(f"{name} {verb} {top.severity}: {flatten_inline(top.claim)[:48]}") 

748 else: 

749 text = _gist(result.output or "") 

750 self.log.append(f"{name}: {text[:54]}") 

751 self.bubble = (name, text) 

752 self._frame(0.6) 

753 self.state[name] = "idle" 

754 

755 def _verify(self, result): 

756 for k in self.state: 

757 self.state[k] = "done" if self.state[k] != "error" else "error" 

758 verdicts, _ = parse_verdicts(result.output or "", result.agent) 

759 self.verifies = verdicts 

760 

761 ok = 0 

762 self.disputes = 0 

763 for v in verdicts: 

764 if v.status == "verified": 

765 ok += 1 

766 elif v.status == "needs_human_decision": 

767 self.disputes += 1 

768 

769 note = f"the jury verifies: {ok}/{len(verdicts) or 0} upheld" 

770 if self.disputes: 

771 note += f" {self.dot} {self.disputes} disputed" 

772 self.log.append(note) 

773 self.bubble = ("", "") 

774 self._frame(0.6) 

775 

776 def _synthesize(self, result): 

777 self.done_phases.update({"review", "debate", "verify"}) 

778 if self.decision != "vote": 

779 self.verdict = _verdict_headline(result.output or "") if result.ok else "NO DECISION" 

780 self.log.append(f"DECISION -> {self._verdict_label(self.verdict)}") 

781 self._frame(0.4) 

782 

783 def set_vote(self, vote) -> None: 

784 """Provide the panel-vote result (decided after the run) for the finale.""" 

785 self.vote = vote 

786 self.verdict = getattr(vote, "verdict", None) 

787 for b in getattr(vote, "ballots", []): 

788 self.ballots[b.reviewer] = b.vote 

789 

790 def close(self) -> None: 

791 self._stop_ticker() 

792 self.done_phases.update(k for k, _ in _PHASES) 

793 if self.decision == "vote" and self.vote is not None: 

794 self.log.append(f"the panel votes -> {self._verdict_label(self.verdict)}") 

795 self._frame(0.6) 

796 self._frame() 

797 if self.animate: 

798 self.out.write("\033[?25h\n") 

799 self.out.flush() 

800 

801 

802# ---- helpers --------------------------------------------------------------- 

803def _wrap(text: str, width: int) -> list[str]: 

804 words, lines, cur = text.split(), [], "" 

805 for w in words: 

806 if len(cur) + len(w) + 1 > width and cur: 

807 lines.append(cur) 

808 cur = w 

809 else: 

810 cur = f"{cur} {w}".strip() 

811 if cur: 

812 lines.append(cur) 

813 return lines or [""] 

814 

815 

816def _gist(text: str) -> str: 

817 for line in text.splitlines(): 

818 if line.strip(): 

819 return flatten_inline(line)[:100] 

820 return "(no output)" 

821 

822 

823def _verdict_headline(text: str) -> str: 

824 lines = text.splitlines() 

825 for i, line in enumerate(lines): 

826 if line.strip().lower().startswith("## verdict"): 

827 for nxt in lines[i + 1 :]: 

828 if nxt.strip(): 

829 return flatten_inline(nxt) 

830 return _gist(text)