abstractcode 0.3.0__py3-none-any.whl → 0.3.2__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 +682 -3
- abstractcode/file_mentions.py +276 -0
- abstractcode/fullscreen_ui.py +1592 -74
- abstractcode/gateway_cli.py +715 -0
- abstractcode/react_shell.py +2474 -116
- abstractcode/terminal_markdown.py +426 -37
- abstractcode/theme.py +244 -0
- abstractcode/workflow_agent.py +630 -112
- abstractcode/workflow_cli.py +229 -0
- abstractcode-0.3.2.dist-info/METADATA +158 -0
- abstractcode-0.3.2.dist-info/RECORD +21 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/WHEEL +1 -1
- abstractcode-0.3.0.dist-info/METADATA +0 -270
- abstractcode-0.3.0.dist-info/RECORD +0 -17
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -12,45 +12,351 @@ import re
|
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
from typing import List
|
|
14
14
|
|
|
15
|
+
from .theme import Theme, theme_from_env
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
@dataclass(frozen=True)
|
|
17
19
|
class AnsiPalette:
|
|
18
20
|
reset: str = "\033[0m"
|
|
19
21
|
dim: str = "\033[2m"
|
|
20
22
|
bold: str = "\033[1m"
|
|
23
|
+
italic: str = "\033[3m"
|
|
24
|
+
underline: str = "\033[4m"
|
|
21
25
|
cyan: str = "\033[36m"
|
|
22
26
|
green: str = "\033[32m"
|
|
23
27
|
blue: str = "\033[38;5;39m"
|
|
24
28
|
|
|
25
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
|
+
|
|
26
54
|
class TerminalMarkdownRenderer:
|
|
27
55
|
"""Render a subset of Markdown to ANSI-styled plain text."""
|
|
28
56
|
|
|
29
57
|
_re_heading = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>.+?)\s*$")
|
|
30
58
|
_re_hr = re.compile(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$")
|
|
31
|
-
_re_bold = re.compile(r"\*\*(?P<txt>[^*]+)\*\*")
|
|
32
59
|
_re_inline_code = re.compile(r"`(?P<code>[^`]+)`")
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
35
82
|
self._color = bool(color)
|
|
36
|
-
|
|
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
|
|
37
92
|
|
|
38
93
|
def _style(self, text: str, *codes: str) -> str:
|
|
39
94
|
if not self._color or not codes:
|
|
40
95
|
return text
|
|
41
96
|
return "".join(codes) + text + self._p.reset
|
|
42
97
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
47
115
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
160
|
|
|
52
|
-
|
|
53
|
-
|
|
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))
|
|
54
360
|
return out
|
|
55
361
|
|
|
56
362
|
def _unescape_newlines_if_needed(self, s: str) -> str:
|
|
@@ -120,49 +426,132 @@ class TerminalMarkdownRenderer:
|
|
|
120
426
|
lines = s.splitlines()
|
|
121
427
|
out: List[str] = []
|
|
122
428
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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)
|
|
136
458
|
else:
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
139
465
|
continue
|
|
140
466
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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))
|
|
144
480
|
continue
|
|
145
481
|
|
|
146
482
|
# Horizontal rules
|
|
147
483
|
if self._re_hr.match(line):
|
|
148
484
|
out.append(self._style("─" * 60, self._p.dim))
|
|
485
|
+
i += 1
|
|
149
486
|
continue
|
|
150
487
|
|
|
151
488
|
# Headings
|
|
152
489
|
m = self._re_heading.match(line)
|
|
153
490
|
if m:
|
|
154
|
-
hashes = m.group("hashes")
|
|
155
491
|
title = m.group("title").strip()
|
|
156
|
-
level = len(hashes)
|
|
492
|
+
level = len(m.group("hashes"))
|
|
493
|
+
rendered_title = self._render_inline(title)
|
|
494
|
+
plain_title = self._strip_ansi(rendered_title)
|
|
157
495
|
if level <= 2:
|
|
158
|
-
|
|
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))
|
|
159
505
|
else:
|
|
160
|
-
|
|
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
|
|
161
513
|
continue
|
|
162
514
|
|
|
163
|
-
|
|
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
|
|
164
522
|
|
|
165
|
-
|
|
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
|
|
166
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
|
|
167
553
|
|
|
554
|
+
out.append(self._render_inline(line))
|
|
555
|
+
i += 1
|
|
168
556
|
|
|
557
|
+
return "\n".join(out)
|