Files
tai/tai_pkg/render.py
T
retoor 8df5cf7a45 Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged.

Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals.

Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
2026-10-10 19:25:02 +02:00

579 lines
23 KiB
Python

"""Terminal output: color, markdown, diffs, spinners, live views."""
import collections
import difflib
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
from tai_pkg.consts import Consts
from tai_pkg.text import Text
__all__ = ["Ansi", "Spinner", "LiveStream", "Render"]
class Ansi:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
UNDERLINE = "\033[4m"
STRIKE = "\033[9m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
GRAY = "\033[90m"
class Spinner:
FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
def __init__(self, label):
self.label = label
self.stop_flag = threading.Event()
self.worker = None
def start(self):
if not sys.stderr.isatty():
return
self.worker = threading.Thread(target=self.spin, daemon=True)
self.worker.start()
def spin(self):
pos = 0
while not self.stop_flag.is_set():
frame = self.FRAMES[pos % len(self.FRAMES)]
sys.stderr.write("\r%s %s" % (frame, self.label))
sys.stderr.flush()
pos += 1
time.sleep(0.08)
sys.stderr.write("\r%s\r" % (" " * (len(self.label) + 2)))
sys.stderr.flush()
def stop(self):
self.stop_flag.set()
if self.worker is not None:
self.worker.join(timeout=1)
class LiveStream:
def __init__(self, height=Consts.STREAM_HEIGHT, width=0, file=None, enabled=True):
self.height = max(1, height)
self.width = width or shutil.get_terminal_size().columns
self.file = file if file is not None else sys.stdout
self.enabled = enabled
self.lines = collections.deque(maxlen=self.height)
self.shown = 0
def feed(self, line):
if not self.enabled:
return
cleaned = Render.truncate_ansi(line.split("\r")[-1].rstrip("\n").expandtabs(8), max(8, self.width - 2))
self.lines.append(cleaned)
if self.shown < self.height:
self.file.write("│ %s\n" % cleaned)
self.shown += 1
else:
self.file.write("\033[%dA" % self.height)
for kept in self.lines:
self.file.write("\r\033[K│ %s\n" % kept)
self.file.flush()
def close(self, status):
if not self.enabled:
return
if self.shown == 0:
self.file.write("│ (no output)\n")
self.file.write("│ %s\n" % Render.truncate_ansi(status, max(8, self.width - 2)))
self.file.flush()
class Render:
"""Render."""
@staticmethod
def paint(text, *codes):
if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
return text
return "".join(codes) + text + Ansi.RESET
@staticmethod
def stream_status(code, output, elapsed, timed_out):
detail = "%d lines · %s" % (len(output.splitlines()), Text.human_size(len(output.encode("utf-8", "replace"))))
if timed_out:
return "timed out after %ds (killed) · %s" % (Consts.SHELL_TIMEOUT, detail)
return "exit %d · %.1fs · %s" % (code, elapsed, detail)
@staticmethod
def truncate_ansi(text, width):
plain = Consts.ANSI_RE.sub("", text)
if len(plain) <= width:
return text
out = []
count = 0
pos = 0
for match in Consts.ANSI_RE.finditer(text):
chunk = text[pos:match.start()]
take = min(len(chunk), width - count)
out.append(chunk[:take])
count += take
if count >= width:
pos = match.start()
break
out.append(match.group(0))
pos = match.end()
if count < width:
out.append(Consts.ANSI_RE.sub("", text[pos:])[:width - count])
return "".join(out) + Ansi.RESET
@staticmethod
def run_live(argv, timeout=Consts.SHELL_TIMEOUT, shell=False, cwd=None, env=None, stream=None):
started = time.time()
proc = subprocess.Popen(argv, shell=shell, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, errors="replace", bufsize=1)
chunks = []
pending = queue.Queue()
def pump():
try:
for line in proc.stdout:
chunks.append(line)
pending.put(line)
finally:
pending.put(None)
reader = threading.Thread(target=pump, daemon=True)
reader.start()
timed_out = False
try:
deadline = started + timeout
while True:
remaining = deadline - time.time()
if remaining <= 0:
timed_out = True
break
try:
item = pending.get(timeout=min(0.05, remaining))
except queue.Empty:
continue
if item is None:
break
if stream is not None:
stream.feed(item)
except BaseException:
proc.kill()
proc.wait()
raise
finally:
if timed_out:
proc.kill()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
reader.join(timeout=10)
if not timed_out:
while not pending.empty():
item = pending.get_nowait()
if item is None:
break
if stream is not None:
stream.feed(item)
return proc.returncode, "".join(chunks), time.time() - started, timed_out
@staticmethod
def color_enabled():
return not os.environ.get("NO_COLOR") and sys.stdout.isatty()
@staticmethod
def highlight_python(line):
parts = []
pos = 0
for match in Consts.PY_TOKEN_RE.finditer(line):
parts.append(line[pos:match.start()])
kind = match.lastgroup
text = match.group(0)
if kind == "string":
parts.append(Ansi.YELLOW + text + Ansi.RESET)
elif kind == "comment":
parts.append(Ansi.GRAY + text + Ansi.RESET)
elif kind == "number":
parts.append(Consts.FG_ORANGE + text + Ansi.RESET)
elif kind == "keyword":
parts.append(Ansi.MAGENTA + text + Ansi.RESET)
else:
parts.append(Ansi.CYAN + text + Ansi.RESET)
pos = match.end()
parts.append(line[pos:])
return "".join(parts)
@staticmethod
def split_diff_lines(text):
if not text:
return []
lines = text.split("\n")
if lines and lines[-1] == "":
lines.pop()
return lines
@staticmethod
def render_diff(path, old, new, width=0, colors=True):
if new is not None and (old or "") == new:
return ""
old_lines = Render.split_diff_lines(old or "")
new_lines = Render.split_diff_lines(new) if new is not None else []
if not width:
try:
width = shutil.get_terminal_size().columns
except OSError:
width = 80
matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
rows = []
for group in matcher.get_grouped_opcodes(Consts.DIFF_CONTEXT):
if rows:
rows.append(("gap", 0, 0, ""))
for tag, i1, i2, j1, j2 in group:
if tag == "equal":
for offset in range(i2 - i1):
rows.append(("ctx", i1 + offset + 1, j1 + offset + 1, old_lines[i1 + offset]))
elif tag == "delete":
for offset in range(i2 - i1):
rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset]))
elif tag == "insert":
for offset in range(j2 - j1):
rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset]))
else:
for offset in range(i2 - i1):
rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset]))
for offset in range(j2 - j1):
rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset]))
added = sum(1 for kind, _a, _b, _t in rows if kind == "add")
removed = sum(1 for kind, _a, _b, _t in rows if kind == "del")
if new is None:
head = "── %s (deleted, %d lines)" % (path, len(old_lines))
elif not old_lines:
head = "── %s (new file, %d lines)" % (path, len(new_lines))
else:
head = "── %s (+%d -%d)" % (path, added, removed)
python = str(path).lower().endswith(".py")
num_width = max(4, len(str(max(len(old_lines), len(new_lines), 1))))
code_width = max(20, width - num_width - 4)
fence = None
out = []
out.append(Ansi.DIM + "│ " + head + Ansi.RESET if colors else "│ " + head)
for kind, old_no, new_no, text in rows[:Consts.DIFF_MAX_LINES]:
if kind == "gap":
out.append(Ansi.DIM + "···" + Ansi.RESET if colors else "···")
continue
code = Render.strip_ansi(text).expandtabs(8)
if fence is not None:
if python and colors:
code = Ansi.YELLOW + code + Ansi.RESET
if fence in text:
fence = None
elif python and colors:
for mark in ('"""', "'''"):
if text.count(mark) % 2 == 1:
fence = mark
code = Ansi.YELLOW + code + Ansi.RESET
break
else:
code = Render.highlight_python(code)
number = old_no if kind in ("ctx", "del") else new_no
gutter = str(number).rjust(num_width)
if not colors:
sign = {"ctx": " ", "del": "-", "add": "+"}[kind]
out.append("%s %s %s" % (gutter, sign, code[:code_width]))
continue
dimmed = Ansi.DIM + gutter + Ansi.RESET
if kind == "ctx":
out.append("%s %s" % (dimmed, Render.truncate_ansi(code, code_width)))
elif kind == "del":
row = "%s %s %s" % (dimmed, Ansi.RED + Ansi.BOLD + "-" + Ansi.RESET, Render.truncate_ansi(code, code_width))
out.append(Render.pad_tinted(Render.style_span(row, Consts.BG_DEL), width))
else:
row = "%s %s %s" % (dimmed, Ansi.GREEN + Ansi.BOLD + "+" + Ansi.RESET, Render.truncate_ansi(code, code_width))
out.append(Render.pad_tinted(Render.style_span(row, Consts.BG_ADD), width))
if len(rows) > Consts.DIFF_MAX_LINES:
note = "··· %d more lines hidden" % (len(rows) - Consts.DIFF_MAX_LINES)
out.append(Ansi.DIM + note + Ansi.RESET if colors else note)
return "\n".join(out)
@staticmethod
def pad_tinted(line, width):
plain = len(Consts.ANSI_RE.sub("", line))
if plain < width:
fill = " " * (width - plain)
if line.endswith(Ansi.RESET):
return line[:-len(Ansi.RESET)] + fill + Ansi.RESET
return line + fill
return line
@staticmethod
def strip_ansi(text):
return Consts.ANSI_RE.sub("", text)
@staticmethod
def style_text(text, *codes):
return "".join(codes) + text + Ansi.RESET
@staticmethod
def style_span(text, *codes):
prefix = "".join(codes)
return prefix + text.replace(Ansi.RESET, Ansi.RESET + prefix) + Ansi.RESET
@staticmethod
def wrap_ansi(text, width, first_prefix="", next_prefix=""):
parts = re.findall(r"\033\[[0-9;]*m|[^\s\033]+|\s+", text)
lines = []
current = first_prefix
length = len(Render.strip_ansi(first_prefix))
base = length
active = ""
for part in parts:
if not part:
continue
if part.startswith("\033"):
current += part
if part == Ansi.RESET:
active = ""
else:
active += part
continue
if part.isspace():
if length < width and length > base:
current += " "
length += 1
continue
if length + len(part) > width and length > base:
if active:
current += Ansi.RESET
lines.append(current.rstrip())
current = next_prefix + active
length = len(Render.strip_ansi(next_prefix))
base = length
current += part
length += len(part)
if active:
current += Ansi.RESET
if current.strip():
lines.append(current.rstrip())
return lines
@staticmethod
def md_inline(text):
stashed = []
def stash(value):
stashed.append(value)
return "\x00%d\x00" % (len(stashed) - 1)
text = re.sub(r"`([^`\n]+)`", lambda match: stash(Render.style_text(match.group(1), Ansi.CYAN)), text)
text = re.sub(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])", lambda match: stash(match.group(1)), text)
text = re.sub(r"!\[([^\]\n]*)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: stash("[image: %s]" % (match.group(1) or match.group(2))), text)
text = re.sub(r"\[([^\]\n]+)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: Render.style_span(Render.md_inline(match.group(1)), Ansi.BLUE, Ansi.UNDERLINE) + Render.style_text(" (%s)" % match.group(2), Ansi.DIM), text)
text = re.sub(r"\*\*([^*\n]+)\*\*", lambda match: Render.style_text(match.group(1), Ansi.BOLD), text)
text = re.sub(r"(?<!\w)__([^_\n]+)__(?!\w)", lambda match: Render.style_text(match.group(1), Ansi.BOLD), text)
text = re.sub(r"~~([^~\n]+)~~", lambda match: Render.style_text(match.group(1), Ansi.STRIKE), text)
text = re.sub(r"\*([^*\n]+)\*", lambda match: Render.style_text(match.group(1), Ansi.DIM), text)
text = re.sub(r"(?<!\w)_([^_\n]+)_(?!\w)", lambda match: Render.style_text(match.group(1), Ansi.DIM), text)
for pos, value in enumerate(stashed):
text = text.replace("\x00%d\x00" % pos, value)
return text
@staticmethod
def md_split_row(line):
line = line.strip()
if line.startswith("|"):
line = line[1:]
if line.endswith("|") and not line.endswith("\\|"):
line = line[:-1]
return [cell.replace("\\|", "|").strip() for cell in re.split(r"(?<!\\)\|", line)]
@staticmethod
def md_is_separator(line):
cells = Render.md_split_row(line)
return bool(cells) and all(re.fullmatch(r":?-{1,}:?", cell) for cell in cells)
@staticmethod
def md_align_cell(cell, width, align):
pad = max(0, width - len(Render.strip_ansi(cell)))
if align == "right":
return " " * pad + cell
if align == "center":
left = pad // 2
return " " * left + cell + " " * (pad - left)
return cell + " " * pad
@staticmethod
def md_table_block(header, aligns, rows):
span = max([len(header)] + [len(row) for row in rows])
header = (header + [""] * span)[:span]
rows = [(row + [""] * span)[:span] for row in rows]
head = [Render.md_inline(cell) for cell in header]
body = [[Render.md_inline(cell) for cell in row] for row in rows]
widths = [0] * span
for pos in range(span):
widths[pos] = max([len(Render.strip_ansi(head[pos]))] + [len(Render.strip_ansi(row[pos])) for row in body])
bar = Render.style_text("│", Ansi.DIM)
lines = [Render.style_text("┌" + "┬".join("─" * (width + 2) for width in widths) + "┐", Ansi.DIM)]
lines.append(bar + bar.join(" " + Render.style_span(Render.md_align_cell(cell, widths[pos], aligns[pos]), Ansi.BOLD) + " " for pos, cell in enumerate(head)) + bar)
lines.append(Render.style_text("├" + "┼".join("─" * (width + 2) for width in widths) + "┤", Ansi.DIM))
for row in body:
lines.append(bar + bar.join(" " + Render.md_align_cell(cell, widths[pos], aligns[pos]) + " " for pos, cell in enumerate(row)) + bar)
lines.append(Render.style_text("└" + "┴".join("─" * (width + 2) for width in widths) + "┘", Ansi.DIM))
return lines
@staticmethod
def render_markdown(text, width=None, color=None):
if color is None:
color = not os.environ.get("NO_COLOR") and sys.stdout.isatty()
if not color:
return text
if width is None:
try:
width = shutil.get_terminal_size().columns
except OSError:
width = 80
text = (text or "").replace("\r\n", "\n").expandtabs(8)
text = re.sub(r"<br\s*/?>", " \n", text, flags=re.IGNORECASE)
raw = text.split("\n")
out = []
pos = 0
total = len(raw)
while pos < total:
line = raw[pos]
stripped = line.strip()
if not stripped:
pos += 1
continue
fence = re.match(r"^\s*(`{3,}|~{3,})\s*\S*\s*$", line)
if fence:
char = fence.group(1)[0]
pos += 1
while pos < total:
closer = raw[pos].strip()
if closer and set(closer) == {char} and len(closer) >= 3:
pos += 1
break
out.append(" " + Render.style_text(raw[pos], Ansi.DIM) if raw[pos].strip() else "")
pos += 1
out.append("")
continue
if "|" in line and pos + 1 < total and Render.md_is_separator(raw[pos + 1]):
header = Render.md_split_row(line)
aligns = []
for cell in Render.md_split_row(raw[pos + 1]):
if cell.startswith(":") and cell.endswith(":") and len(cell) > 2:
aligns.append("center")
elif cell.endswith(":"):
aligns.append("right")
else:
aligns.append("left")
pos += 2
rows = []
while pos < total and "|" in raw[pos] and raw[pos].strip():
rows.append(Render.md_split_row(raw[pos]))
pos += 1
while len(aligns) < max([len(header)] + [len(row) for row in rows]):
aligns.append("left")
out.extend(Render.md_table_block(header, aligns, rows))
out.append("")
continue
heading = re.match(r"^(#{1,6})\s+(.*\S)\s*$", stripped)
if heading:
level = len(heading.group(1))
body = Render.md_inline(heading.group(2))
if level == 1:
out.append(Render.style_span(body, Ansi.BOLD, Ansi.CYAN))
elif level == 2:
out.append(Render.style_span(body, Ansi.BOLD))
else:
out.append(Render.style_span(body, Ansi.BOLD, Ansi.DIM))
out.append("")
pos += 1
continue
if re.fullmatch(r"\*{3,}|-{3,}|_{3,}", stripped.replace(" ", "")):
out.append(Render.style_text("─" * width, Ansi.DIM))
out.append("")
pos += 1
continue
if stripped.startswith(">"):
paras = []
current = []
while pos < total and raw[pos].strip().startswith(">"):
inner = re.sub(r"^\s*(?:>\s?)+", "", raw[pos])
if inner.strip():
current.append(inner.strip())
elif current:
paras.append(current)
current = []
pos += 1
if current:
paras.append(current)
for index, para in enumerate(paras):
if index:
out.append(Render.style_text("│", Ansi.DIM))
prefix = Render.style_text("│ ", Ansi.DIM)
for wrapped in Render.wrap_ansi(Render.md_inline(" ".join(para)), width, prefix, prefix):
out.append(wrapped)
out.append("")
continue
bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", line)
numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", line)
if bullet or numbered:
counters = {}
while pos < total:
again_bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", raw[pos])
again_numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", raw[pos])
if not again_bullet and not again_numbered:
break
if again_bullet:
level = len(again_bullet.group(1)) // 2
marker = Consts.MD_BULLETS[level % len(Consts.MD_BULLETS)]
body = again_bullet.group(2)
check = re.match(r"^\[([ xX])\]\s+(.*\S)\s*$", body)
if check:
marker = "☑" if check.group(1).lower() == "x" else "☐"
body = check.group(2)
else:
level = len(again_numbered.group(1)) // 2
counters[level] = counters.get(level, 0) + 1
for deeper in [key for key in counters if key > level]:
del counters[deeper]
marker = "%d." % counters[level]
body = again_numbered.group(2)
pad = " " * level
for wrapped in Render.wrap_ansi(Render.md_inline(body), width, pad + marker + " ", pad + " " * (len(marker) + 1)):
out.append(wrapped)
pos += 1
out.append("")
continue
para = []
while pos < total and raw[pos].strip():
para.append(raw[pos])
pos += 1
segments = []
current = []
for chunk in para:
current.append(chunk.strip())
if chunk.endswith(" "):
segments.append(" ".join(current))
current = []
if current:
segments.append(" ".join(current))
for segment in segments:
for wrapped in Render.wrap_ansi(Render.md_inline(segment), width):
out.append(wrapped)
out.append("")
while out and not out[-1]:
out.pop()
return "\n".join(out)