abstractcode 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abstractcode/__init__.py +1 -1
- abstractcode/cli.py +911 -9
- abstractcode/file_mentions.py +276 -0
- abstractcode/flow_cli.py +1413 -0
- abstractcode/fullscreen_ui.py +2473 -158
- abstractcode/gateway_cli.py +715 -0
- abstractcode/py.typed +1 -0
- abstractcode/react_shell.py +8140 -546
- abstractcode/recall.py +384 -0
- abstractcode/remember.py +184 -0
- abstractcode/terminal_markdown.py +557 -0
- abstractcode/theme.py +244 -0
- abstractcode/workflow_agent.py +1412 -0
- abstractcode/workflow_cli.py +229 -0
- abstractcode-0.3.1.dist-info/METADATA +158 -0
- abstractcode-0.3.1.dist-info/RECORD +21 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/WHEEL +1 -1
- abstractcode-0.2.0.dist-info/METADATA +0 -160
- abstractcode-0.2.0.dist-info/RECORD +0 -11
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Minimal, terminal-friendly Markdown renderer.
|
|
2
|
+
|
|
3
|
+
Goal: improve readability in the TUI without attempting full CommonMark compliance.
|
|
4
|
+
We deliberately keep this conservative:
|
|
5
|
+
- Only style headings, code fences, and a few inline constructs.
|
|
6
|
+
- Never mutate the underlying content used for copy-to-clipboard.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
from .theme import Theme, theme_from_env
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AnsiPalette:
|
|
20
|
+
reset: str = "\033[0m"
|
|
21
|
+
dim: str = "\033[2m"
|
|
22
|
+
bold: str = "\033[1m"
|
|
23
|
+
italic: str = "\033[3m"
|
|
24
|
+
underline: str = "\033[4m"
|
|
25
|
+
cyan: str = "\033[36m"
|
|
26
|
+
green: str = "\033[32m"
|
|
27
|
+
blue: str = "\033[38;5;39m"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ansi_fg(hex_color: str) -> str:
|
|
31
|
+
s = str(hex_color or "").strip()
|
|
32
|
+
if not s:
|
|
33
|
+
return ""
|
|
34
|
+
if not s.startswith("#"):
|
|
35
|
+
s = "#" + s
|
|
36
|
+
if len(s) != 7:
|
|
37
|
+
return ""
|
|
38
|
+
try:
|
|
39
|
+
r = int(s[1:3], 16)
|
|
40
|
+
g = int(s[3:5], 16)
|
|
41
|
+
b = int(s[5:7], 16)
|
|
42
|
+
except Exception:
|
|
43
|
+
return ""
|
|
44
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _palette_from_theme(theme: Theme) -> AnsiPalette:
|
|
48
|
+
t = theme.normalized()
|
|
49
|
+
primary = _ansi_fg(t.primary) or AnsiPalette.cyan
|
|
50
|
+
secondary = _ansi_fg(t.secondary) or AnsiPalette.blue
|
|
51
|
+
return AnsiPalette(cyan=primary, green=primary, blue=secondary)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TerminalMarkdownRenderer:
|
|
55
|
+
"""Render a subset of Markdown to ANSI-styled plain text."""
|
|
56
|
+
|
|
57
|
+
_re_heading = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>.+?)\s*$")
|
|
58
|
+
_re_hr = re.compile(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$")
|
|
59
|
+
_re_inline_code = re.compile(r"`(?P<code>[^`]+)`")
|
|
60
|
+
_re_md_link = re.compile(r"\[(?P<label>[^\]]+)\]\((?P<url>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
|
61
|
+
_re_autolink = re.compile(r"<(?P<url>https?://[^>]+)>")
|
|
62
|
+
_re_bare_url = re.compile(r"(?P<url>https?://[^\s<>()\]]+)")
|
|
63
|
+
_re_bold = re.compile(r"(\*\*|__)(?P<txt>.+?)\1")
|
|
64
|
+
_re_strike = re.compile(r"~~(?P<txt>[^~]+)~~")
|
|
65
|
+
_re_italic_star = re.compile(r"(?<!\*)\*(?P<txt>[^*]+?)\*(?!\*)")
|
|
66
|
+
_re_italic_us = re.compile(r"(?<!_)_(?P<txt>[^_]+?)_(?!_)")
|
|
67
|
+
_re_blockquote = re.compile(r"^\s*>\s?(?P<body>.*)$")
|
|
68
|
+
_re_task = re.compile(r"^(?P<indent>\s*)[-*+]\s+\[(?P<state>[ xX])\]\s+(?P<body>.*)$")
|
|
69
|
+
_re_bullet = re.compile(r"^(?P<indent>\s*)[-*+]\s+(?P<body>.*)$")
|
|
70
|
+
_re_ordered = re.compile(r"^(?P<indent>\s*)(?P<num>\d+)[.)]\s+(?P<body>.*)$")
|
|
71
|
+
_re_table_sep_cell = re.compile(r"^:?-{3,}:?$")
|
|
72
|
+
_re_ansi = re.compile(r"\x1b\[[0-9;]*m")
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
color: bool = True,
|
|
78
|
+
theme: Theme | None = None,
|
|
79
|
+
palette: AnsiPalette | None = None,
|
|
80
|
+
width: int | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._color = bool(color)
|
|
83
|
+
if palette is not None:
|
|
84
|
+
self._p = palette
|
|
85
|
+
else:
|
|
86
|
+
self._p = _palette_from_theme(theme or theme_from_env()) if self._color else AnsiPalette()
|
|
87
|
+
try:
|
|
88
|
+
w = int(width) if width is not None else None
|
|
89
|
+
except Exception:
|
|
90
|
+
w = None
|
|
91
|
+
self._width = max(40, w) if isinstance(w, int) else 120
|
|
92
|
+
|
|
93
|
+
def _style(self, text: str, *codes: str) -> str:
|
|
94
|
+
if not self._color or not codes:
|
|
95
|
+
return text
|
|
96
|
+
return "".join(codes) + text + self._p.reset
|
|
97
|
+
|
|
98
|
+
def _strip_ansi(self, s: str) -> str:
|
|
99
|
+
return self._re_ansi.sub("", str(s or ""))
|
|
100
|
+
|
|
101
|
+
def _split_inline_code(self, s: str) -> List[tuple[str, str]]:
|
|
102
|
+
"""Split into [('text'|'code', chunk), ...] preserving order."""
|
|
103
|
+
out: List[tuple[str, str]] = []
|
|
104
|
+
pos = 0
|
|
105
|
+
for m in self._re_inline_code.finditer(s):
|
|
106
|
+
if m.start() > pos:
|
|
107
|
+
out.append(("text", s[pos : m.start()]))
|
|
108
|
+
out.append(("code", m.group("code")))
|
|
109
|
+
pos = m.end()
|
|
110
|
+
if pos < len(s):
|
|
111
|
+
out.append(("text", s[pos:]))
|
|
112
|
+
if not out:
|
|
113
|
+
out.append(("text", s))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
def _split_url_trailing_punct(self, url: str) -> tuple[str, str]:
|
|
117
|
+
u = str(url or "")
|
|
118
|
+
trailing = ""
|
|
119
|
+
while u and u[-1] in ".,;:)]}":
|
|
120
|
+
trailing = u[-1] + trailing
|
|
121
|
+
u = u[:-1]
|
|
122
|
+
return u, trailing
|
|
123
|
+
|
|
124
|
+
def _style_url(self, url: str) -> str:
|
|
125
|
+
clean, trailing = self._split_url_trailing_punct(url)
|
|
126
|
+
if not clean:
|
|
127
|
+
return url
|
|
128
|
+
return self._style(clean, self._p.blue, self._p.underline) + trailing
|
|
129
|
+
|
|
130
|
+
def _render_inline_text(self, s: str) -> str:
|
|
131
|
+
"""Render inline Markdown for non-code text segments."""
|
|
132
|
+
|
|
133
|
+
def _md_link(m: re.Match) -> str:
|
|
134
|
+
label = str(m.group("label") or "")
|
|
135
|
+
url = str(m.group("url") or "")
|
|
136
|
+
if not url:
|
|
137
|
+
return label
|
|
138
|
+
label_s = self._style(label, self._p.blue, self._p.underline) if label else self._style_url(url)
|
|
139
|
+
return f"{label_s} {self._style('↗', self._p.dim)} {self._style_url(url)}"
|
|
140
|
+
|
|
141
|
+
def _auto(m: re.Match) -> str:
|
|
142
|
+
return self._style_url(str(m.group("url") or ""))
|
|
143
|
+
|
|
144
|
+
# Links first (to avoid emphasis rules eating brackets/parentheses).
|
|
145
|
+
out = self._re_md_link.sub(_md_link, s)
|
|
146
|
+
out = self._re_autolink.sub(_auto, out)
|
|
147
|
+
|
|
148
|
+
# Bare URLs.
|
|
149
|
+
def _bare(m: re.Match) -> str:
|
|
150
|
+
return self._style_url(str(m.group("url") or ""))
|
|
151
|
+
|
|
152
|
+
out = self._re_bare_url.sub(_bare, out)
|
|
153
|
+
|
|
154
|
+
# Bold / strike / italic.
|
|
155
|
+
out = self._re_bold.sub(lambda m: self._style(m.group("txt"), self._p.bold), out)
|
|
156
|
+
out = self._re_strike.sub(lambda m: self._style(m.group("txt"), self._p.dim), out)
|
|
157
|
+
out = self._re_italic_star.sub(lambda m: self._style(m.group("txt"), self._p.italic), out)
|
|
158
|
+
out = self._re_italic_us.sub(lambda m: self._style(m.group("txt"), self._p.italic), out)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
def _render_inline(self, line: str) -> str:
|
|
162
|
+
"""Render a line with inline Markdown (code + emphasis + links)."""
|
|
163
|
+
out: List[str] = []
|
|
164
|
+
for kind, chunk in self._split_inline_code(str(line or "")):
|
|
165
|
+
if not chunk:
|
|
166
|
+
continue
|
|
167
|
+
if kind == "code":
|
|
168
|
+
out.append(self._style(chunk, self._p.blue))
|
|
169
|
+
else:
|
|
170
|
+
out.append(self._render_inline_text(chunk))
|
|
171
|
+
return "".join(out)
|
|
172
|
+
|
|
173
|
+
def _is_table_separator(self, line: str) -> bool:
|
|
174
|
+
s = str(line or "").strip()
|
|
175
|
+
if not s or "|" not in s:
|
|
176
|
+
return False
|
|
177
|
+
s = s.strip("|").replace(" ", "")
|
|
178
|
+
if not s:
|
|
179
|
+
return False
|
|
180
|
+
parts = s.split("|")
|
|
181
|
+
if not parts or any(not p for p in parts):
|
|
182
|
+
return False
|
|
183
|
+
return all(self._re_table_sep_cell.match(p or "") for p in parts)
|
|
184
|
+
|
|
185
|
+
def _split_table_row(self, line: str) -> List[str]:
|
|
186
|
+
s = str(line or "").strip()
|
|
187
|
+
if s.startswith("|"):
|
|
188
|
+
s = s[1:]
|
|
189
|
+
if s.endswith("|"):
|
|
190
|
+
s = s[:-1]
|
|
191
|
+
parts = [p.strip() for p in s.split("|")]
|
|
192
|
+
return parts
|
|
193
|
+
|
|
194
|
+
def _table_alignments(self, sep_line: str, ncols: int) -> List[str]:
|
|
195
|
+
parts = self._split_table_row(sep_line)
|
|
196
|
+
aligns: List[str] = []
|
|
197
|
+
for p in parts[:ncols]:
|
|
198
|
+
cell = str(p or "").strip()
|
|
199
|
+
left = cell.startswith(":")
|
|
200
|
+
right = cell.endswith(":")
|
|
201
|
+
if left and right:
|
|
202
|
+
aligns.append("center")
|
|
203
|
+
elif right:
|
|
204
|
+
aligns.append("right")
|
|
205
|
+
else:
|
|
206
|
+
aligns.append("left")
|
|
207
|
+
while len(aligns) < ncols:
|
|
208
|
+
aligns.append("left")
|
|
209
|
+
return aligns
|
|
210
|
+
|
|
211
|
+
def _truncate_plain(self, s: str, max_len: int) -> str:
|
|
212
|
+
txt = str(s or "").replace("\t", " ").replace("\r", "")
|
|
213
|
+
txt = txt.replace("\n", " ")
|
|
214
|
+
if max_len <= 0:
|
|
215
|
+
return ""
|
|
216
|
+
if len(txt) <= max_len:
|
|
217
|
+
return txt
|
|
218
|
+
if max_len == 1:
|
|
219
|
+
return "…"
|
|
220
|
+
return txt[: max_len - 1] + "…"
|
|
221
|
+
|
|
222
|
+
def _render_table(self, header: List[str], sep_line: str, rows: List[List[str]]) -> List[str]:
|
|
223
|
+
ncols = max(1, len(header))
|
|
224
|
+
aligns = self._table_alignments(sep_line, ncols=ncols)
|
|
225
|
+
|
|
226
|
+
norm_rows: List[List[str]] = []
|
|
227
|
+
for r in rows:
|
|
228
|
+
rr = list(r or [])
|
|
229
|
+
if len(rr) < ncols:
|
|
230
|
+
rr.extend([""] * (ncols - len(rr)))
|
|
231
|
+
norm_rows.append(rr[:ncols])
|
|
232
|
+
|
|
233
|
+
# Compute widths from plain (un-styled) content.
|
|
234
|
+
widths = [0] * ncols
|
|
235
|
+
for c in range(ncols):
|
|
236
|
+
widths[c] = max(widths[c], len(self._strip_ansi(str(header[c] if c < len(header) else ""))))
|
|
237
|
+
for r in norm_rows:
|
|
238
|
+
for c in range(ncols):
|
|
239
|
+
widths[c] = max(widths[c], len(self._strip_ansi(str(r[c] or ""))))
|
|
240
|
+
|
|
241
|
+
# Fit into available width (best-effort; avoid wrapping table borders).
|
|
242
|
+
max_total = max(40, int(self._width or 120))
|
|
243
|
+
# Borders: 1 + (ncols-1) + 1 = ncols+1, plus padding " " around cells (2*ncols).
|
|
244
|
+
border_cost = (ncols + 1) + (2 * ncols)
|
|
245
|
+
avail_cells = max(1, max_total - border_cost)
|
|
246
|
+
total_cells = sum(widths)
|
|
247
|
+
if total_cells > avail_cells:
|
|
248
|
+
# Shrink proportionally, but keep a minimum for readability.
|
|
249
|
+
min_w = 6
|
|
250
|
+
widths = [max(min_w, w) for w in widths]
|
|
251
|
+
total_cells = sum(widths)
|
|
252
|
+
if total_cells > avail_cells:
|
|
253
|
+
# Still too wide: hard-cap each column.
|
|
254
|
+
cap = max(min_w, avail_cells // ncols)
|
|
255
|
+
widths = [min(w, cap) for w in widths]
|
|
256
|
+
|
|
257
|
+
def hline(left: str, mid: str, right: str) -> str:
|
|
258
|
+
segs = ["─" * (w + 2) for w in widths]
|
|
259
|
+
return self._style(left + mid.join(segs) + right, self._p.dim)
|
|
260
|
+
|
|
261
|
+
def render_row(cells: List[str], *, header_row: bool) -> str:
|
|
262
|
+
parts: List[str] = []
|
|
263
|
+
for i, w in enumerate(widths):
|
|
264
|
+
raw = str(cells[i] if i < len(cells) else "")
|
|
265
|
+
plain = self._truncate_plain(raw, w)
|
|
266
|
+
if aligns[i] == "right":
|
|
267
|
+
plain = plain.rjust(w)
|
|
268
|
+
elif aligns[i] == "center":
|
|
269
|
+
plain = plain.center(w)
|
|
270
|
+
else:
|
|
271
|
+
plain = plain.ljust(w)
|
|
272
|
+
styled = self._render_inline(plain)
|
|
273
|
+
if header_row:
|
|
274
|
+
styled = self._style(styled, self._p.bold, self._p.cyan)
|
|
275
|
+
parts.append(f" {styled} ")
|
|
276
|
+
inner = "│".join(parts)
|
|
277
|
+
return "│" + inner + "│"
|
|
278
|
+
|
|
279
|
+
out: List[str] = []
|
|
280
|
+
out.append(hline("┌", "┬", "┐"))
|
|
281
|
+
out.append(render_row(header, header_row=True))
|
|
282
|
+
out.append(hline("├", "┼", "┤"))
|
|
283
|
+
for r in norm_rows:
|
|
284
|
+
out.append(render_row(r, header_row=False))
|
|
285
|
+
out.append(hline("└", "┴", "┘"))
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
def _render_mermaid(self, code_lines: List[str]) -> List[str]:
|
|
289
|
+
"""Best-effort text rendering for common Mermaid diagrams."""
|
|
290
|
+
lines = [str(l or "").rstrip() for l in (code_lines or [])]
|
|
291
|
+
non_empty = [ln for ln in lines if ln.strip()]
|
|
292
|
+
if not non_empty:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
head = non_empty[0].strip()
|
|
296
|
+
items: List[str] = []
|
|
297
|
+
|
|
298
|
+
def add(line: str) -> None:
|
|
299
|
+
if line:
|
|
300
|
+
items.append(line)
|
|
301
|
+
|
|
302
|
+
if head.startswith(("graph", "flowchart")):
|
|
303
|
+
arrows = ["-->", "==>", "-.->", "---", "--", "->>"]
|
|
304
|
+
for ln in non_empty[1:]:
|
|
305
|
+
raw = ln.strip()
|
|
306
|
+
if not raw or raw.startswith("%%"):
|
|
307
|
+
continue
|
|
308
|
+
# Strip inline labels like -->|label|
|
|
309
|
+
raw = re.sub(r"\|[^|]*\|", "", raw)
|
|
310
|
+
found = None
|
|
311
|
+
for a in arrows:
|
|
312
|
+
if a in raw:
|
|
313
|
+
found = a
|
|
314
|
+
break
|
|
315
|
+
if not found:
|
|
316
|
+
continue
|
|
317
|
+
left, right = raw.split(found, 1)
|
|
318
|
+
left = left.strip()
|
|
319
|
+
right = right.strip()
|
|
320
|
+
for sep in ("[", "(", "{", "<"):
|
|
321
|
+
if sep in left:
|
|
322
|
+
left = left.split(sep, 1)[0].strip()
|
|
323
|
+
if sep in right:
|
|
324
|
+
right = right.split(sep, 1)[0].strip()
|
|
325
|
+
if not left or not right:
|
|
326
|
+
continue
|
|
327
|
+
add(f"• {left} → {right}")
|
|
328
|
+
elif head.startswith("sequenceDiagram"):
|
|
329
|
+
msg_re = re.compile(r"^(?P<a>[^-]+?)-+>>?(?P<b>[^:]+?):\\s*(?P<msg>.+)$")
|
|
330
|
+
for ln in non_empty[1:]:
|
|
331
|
+
raw = ln.strip()
|
|
332
|
+
if not raw or raw.startswith("%%"):
|
|
333
|
+
continue
|
|
334
|
+
m = msg_re.match(raw)
|
|
335
|
+
if not m:
|
|
336
|
+
continue
|
|
337
|
+
a = m.group("a").strip()
|
|
338
|
+
b = m.group("b").strip()
|
|
339
|
+
msg = m.group("msg").strip()
|
|
340
|
+
add(f"• {a} ⇢ {b}: {msg}")
|
|
341
|
+
else:
|
|
342
|
+
# Unknown kind: provide a lightweight summary.
|
|
343
|
+
add(f"• (unrecognized mermaid; showing source)")
|
|
344
|
+
|
|
345
|
+
if not items:
|
|
346
|
+
add("• (no edges/messages parsed; showing source)")
|
|
347
|
+
|
|
348
|
+
# Always show the source too (still inside the fenced block).
|
|
349
|
+
out: List[str] = []
|
|
350
|
+
for it in items[:40]:
|
|
351
|
+
out.append(self._style("│ ", self._p.dim) + self._render_inline(it))
|
|
352
|
+
if len(items) > 40:
|
|
353
|
+
out.append(self._style("│ … (more)", self._p.dim))
|
|
354
|
+
out.append(self._style("│", self._p.dim))
|
|
355
|
+
out.append(self._style("│ source:", self._p.dim))
|
|
356
|
+
for ln in lines[:60]:
|
|
357
|
+
out.append(self._style("│ ", self._p.dim) + ln)
|
|
358
|
+
if len(lines) > 60:
|
|
359
|
+
out.append(self._style("│ … (source truncated)", self._p.dim))
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
def _unescape_newlines_if_needed(self, s: str) -> str:
|
|
363
|
+
"""Convert literal "\\n" / "\\r" / "\\r\\n" sequences into real newlines.
|
|
364
|
+
|
|
365
|
+
Some upstream layers accidentally pass serialized strings (repr/json) where newlines are
|
|
366
|
+
encoded as the two characters backslash+n. We only unescape when the input has *no* real
|
|
367
|
+
newlines to avoid corrupting valid code like `print("a\\nb")`.
|
|
368
|
+
"""
|
|
369
|
+
if "\n" in s or "\r" in s:
|
|
370
|
+
return s
|
|
371
|
+
if "\\n" not in s and "\\r" not in s:
|
|
372
|
+
return s
|
|
373
|
+
|
|
374
|
+
out: List[str] = []
|
|
375
|
+
i = 0
|
|
376
|
+
n = len(s)
|
|
377
|
+
while i < n:
|
|
378
|
+
ch = s[i]
|
|
379
|
+
if ch != "\\":
|
|
380
|
+
out.append(ch)
|
|
381
|
+
i += 1
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# Count consecutive backslashes.
|
|
385
|
+
j = i
|
|
386
|
+
while j < n and s[j] == "\\":
|
|
387
|
+
j += 1
|
|
388
|
+
run_len = j - i
|
|
389
|
+
|
|
390
|
+
if j >= n:
|
|
391
|
+
out.append("\\" * run_len)
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
nxt = s[j]
|
|
395
|
+
|
|
396
|
+
# Only treat "\n"/"\r" as escapes when the escape backslash is not itself escaped.
|
|
397
|
+
if nxt in ("n", "r") and (run_len % 2 == 1):
|
|
398
|
+
# Preserve all but the escape backslash.
|
|
399
|
+
if run_len > 1:
|
|
400
|
+
out.append("\\" * (run_len - 1))
|
|
401
|
+
out.append("\n")
|
|
402
|
+
i = j + 1
|
|
403
|
+
|
|
404
|
+
# Collapse \r\n into a single newline (Windows-style payloads).
|
|
405
|
+
if nxt == "r" and i < n and s[i] == "\\":
|
|
406
|
+
k = i
|
|
407
|
+
while k < n and s[k] == "\\":
|
|
408
|
+
k += 1
|
|
409
|
+
run2_len = k - i
|
|
410
|
+
if k < n and s[k] == "n" and (run2_len % 2 == 1):
|
|
411
|
+
if run2_len > 1:
|
|
412
|
+
out.append("\\" * (run2_len - 1))
|
|
413
|
+
i = k + 1
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
# Not an escape we handle; emit literally.
|
|
417
|
+
out.append("\\" * run_len)
|
|
418
|
+
out.append(nxt)
|
|
419
|
+
i = j + 1
|
|
420
|
+
|
|
421
|
+
return "".join(out)
|
|
422
|
+
|
|
423
|
+
def render(self, text: str) -> str:
|
|
424
|
+
s = "" if text is None else str(text)
|
|
425
|
+
s = self._unescape_newlines_if_needed(s)
|
|
426
|
+
lines = s.splitlines()
|
|
427
|
+
out: List[str] = []
|
|
428
|
+
|
|
429
|
+
i = 0
|
|
430
|
+
while i < len(lines):
|
|
431
|
+
raw = lines[i]
|
|
432
|
+
line = str(raw or "").rstrip("\n")
|
|
433
|
+
|
|
434
|
+
# Code fences (block-level).
|
|
435
|
+
stripped = line.strip()
|
|
436
|
+
if stripped.startswith("```"):
|
|
437
|
+
fence_lang = stripped[3:].strip().lower()
|
|
438
|
+
code_lines: List[str] = []
|
|
439
|
+
i += 1
|
|
440
|
+
while i < len(lines):
|
|
441
|
+
candidate = str(lines[i] or "").rstrip("\n")
|
|
442
|
+
if candidate.strip().startswith("```"):
|
|
443
|
+
break
|
|
444
|
+
code_lines.append(candidate)
|
|
445
|
+
i += 1
|
|
446
|
+
|
|
447
|
+
label = "code" + (f" ({fence_lang})" if fence_lang else "")
|
|
448
|
+
if fence_lang == "mermaid":
|
|
449
|
+
label = "diagram (mermaid)"
|
|
450
|
+
out.append(self._style(f"┌─ {label}", self._p.dim))
|
|
451
|
+
if fence_lang == "mermaid":
|
|
452
|
+
rendered = self._render_mermaid(code_lines)
|
|
453
|
+
if rendered:
|
|
454
|
+
out.extend(rendered)
|
|
455
|
+
else:
|
|
456
|
+
for ln in code_lines:
|
|
457
|
+
out.append(self._style("│ ", self._p.dim) + ln)
|
|
458
|
+
else:
|
|
459
|
+
for ln in code_lines:
|
|
460
|
+
out.append(self._style("│ ", self._p.dim) + ln)
|
|
461
|
+
out.append(self._style("└─", self._p.dim))
|
|
462
|
+
# Skip the closing fence if present.
|
|
463
|
+
if i < len(lines) and str(lines[i] or "").strip().startswith("```"):
|
|
464
|
+
i += 1
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
# Tables (header + separator line).
|
|
468
|
+
if i + 1 < len(lines) and "|" in line and self._is_table_separator(lines[i + 1]):
|
|
469
|
+
header = self._split_table_row(line)
|
|
470
|
+
sep = str(lines[i + 1] or "")
|
|
471
|
+
body: List[List[str]] = []
|
|
472
|
+
i += 2
|
|
473
|
+
while i < len(lines):
|
|
474
|
+
row_line = str(lines[i] or "").rstrip("\n")
|
|
475
|
+
if not row_line.strip() or "|" not in row_line:
|
|
476
|
+
break
|
|
477
|
+
body.append(self._split_table_row(row_line))
|
|
478
|
+
i += 1
|
|
479
|
+
out.extend(self._render_table(header, sep, body))
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
# Horizontal rules
|
|
483
|
+
if self._re_hr.match(line):
|
|
484
|
+
out.append(self._style("─" * 60, self._p.dim))
|
|
485
|
+
i += 1
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
# Headings
|
|
489
|
+
m = self._re_heading.match(line)
|
|
490
|
+
if m:
|
|
491
|
+
title = m.group("title").strip()
|
|
492
|
+
level = len(m.group("hashes"))
|
|
493
|
+
rendered_title = self._render_inline(title)
|
|
494
|
+
plain_title = self._strip_ansi(rendered_title)
|
|
495
|
+
if level <= 2:
|
|
496
|
+
if self._color:
|
|
497
|
+
base = self._p.bold + self._p.cyan
|
|
498
|
+
# Keep heading styling applied after inline segments reset.
|
|
499
|
+
rendered_title = rendered_title.replace(self._p.reset, self._p.reset + base)
|
|
500
|
+
out.append(base + rendered_title + self._p.reset)
|
|
501
|
+
else:
|
|
502
|
+
out.append(rendered_title)
|
|
503
|
+
underline = "─" * max(8, min(60, len(plain_title)))
|
|
504
|
+
out.append(self._style(underline, self._p.dim))
|
|
505
|
+
else:
|
|
506
|
+
if self._color:
|
|
507
|
+
base = self._p.bold
|
|
508
|
+
rendered_title = rendered_title.replace(self._p.reset, self._p.reset + base)
|
|
509
|
+
out.append(base + rendered_title + self._p.reset)
|
|
510
|
+
else:
|
|
511
|
+
out.append(rendered_title)
|
|
512
|
+
i += 1
|
|
513
|
+
continue
|
|
514
|
+
|
|
515
|
+
# Blockquotes
|
|
516
|
+
m = self._re_blockquote.match(line)
|
|
517
|
+
if m:
|
|
518
|
+
body = str(m.group("body") or "")
|
|
519
|
+
out.append(self._style("│ ", self._p.dim) + self._render_inline(body))
|
|
520
|
+
i += 1
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
# Task lists
|
|
524
|
+
m = self._re_task.match(line)
|
|
525
|
+
if m:
|
|
526
|
+
indent = str(m.group("indent") or "")
|
|
527
|
+
state = str(m.group("state") or " ").lower()
|
|
528
|
+
body = str(m.group("body") or "")
|
|
529
|
+
box = "☑" if state == "x" else "☐"
|
|
530
|
+
prefix = self._style(box, self._p.cyan, self._p.bold)
|
|
531
|
+
out.append(f"{indent}{prefix} {self._render_inline(body)}")
|
|
532
|
+
i += 1
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
# Bullets / ordered lists
|
|
536
|
+
m = self._re_bullet.match(line)
|
|
537
|
+
if m:
|
|
538
|
+
indent = str(m.group("indent") or "")
|
|
539
|
+
body = str(m.group("body") or "")
|
|
540
|
+
bullet = self._style("•", self._p.cyan, self._p.bold)
|
|
541
|
+
out.append(f"{indent}{bullet} {self._render_inline(body)}")
|
|
542
|
+
i += 1
|
|
543
|
+
continue
|
|
544
|
+
m = self._re_ordered.match(line)
|
|
545
|
+
if m:
|
|
546
|
+
indent = str(m.group("indent") or "")
|
|
547
|
+
num = str(m.group("num") or "").strip()
|
|
548
|
+
body = str(m.group("body") or "")
|
|
549
|
+
num_s = self._style(f"{num}.", self._p.cyan, self._p.bold)
|
|
550
|
+
out.append(f"{indent}{num_s} {self._render_inline(body)}")
|
|
551
|
+
i += 1
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
out.append(self._render_inline(line))
|
|
555
|
+
i += 1
|
|
556
|
+
|
|
557
|
+
return "\n".join(out)
|