"""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"(?", " \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)