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
« 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).
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).
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.
16The design follows ``docs/theater-design.md``.
17"""
19from __future__ import annotations
21import shutil
22import sys
23import threading
24import time
26from .adapters import AgentResult
27from .config import normalise_vendor
28from .findings import SEVERITY_ORDER, flatten_inline, parse_findings, parse_verdicts
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}
50def _vendor_sgr(vendor, default: str = "1") -> str:
51 """The brand colour for *vendor*, keyed on its normalised spelling (pure).
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)
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)
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")
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)
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
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 )
159class Screen:
160 """A fixed grid of (char, sgr) cells rendered to ANSI or plain text."""
162 def __init__(self, cols: int, rows: int):
163 self.cols, self.rows = cols, rows
164 self.clear()
166 def clear(self) -> None:
167 self._g = [[(" ", "")] * self.cols for _ in range(self.rows)]
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)
185 def center(self, r: int, text: str, sgr: str = "") -> None:
186 self.put(r, max(0, (self.cols - len(text)) // 2), text, sgr)
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()
199 def to_ansi(self) -> str:
200 return "\n".join(self._row_ansi(r) for r in self._g)
202 def to_plain(self) -> str:
203 return "\n".join("".join(ch for ch, _ in r).rstrip() for r in self._g)
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
211# Pixel-art band geometry (terminal rows occupied by the half-block scene).
212_PIX_TOP = 5
213_PIX_BOT = 17
216class Courtroom:
217 """Draws and animates the deliberation from the on_event stream.
219 (Class name kept for back-compat; the scene is a round-table deliberation.)
220 """
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
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
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)]
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
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()
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")
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")
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 )
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")
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")
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()
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
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
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
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"
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)]
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)]
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
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"])
527 eye = (40, 40, 54)
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"])
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)
552 self._blit_band(px)
553 self._pixel_overlays(txs, bxs, top, bottom)
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}")
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)
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")
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))
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")
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), "")
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")
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()
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()
663 def _beat(self, secs: float) -> None:
664 if self.animate:
665 time.sleep(secs)
667 def _frame(self, beat: float = 0.0) -> None:
668 self._render()
669 self._beat(beat)
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()
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()
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
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()
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)
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)
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"
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
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
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)
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)
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
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()
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 [""]
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)"
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)