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.
579 lines
23 KiB
Python
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)
|