termrender 0.2.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (37) hide show
  1. {termrender-0.2.1 → termrender-0.4.0}/.gitignore +3 -0
  2. {termrender-0.2.1 → termrender-0.4.0}/CHANGELOG.md +44 -0
  3. termrender-0.4.0/CLAUDE.md +64 -0
  4. {termrender-0.2.1 → termrender-0.4.0}/PKG-INFO +1 -1
  5. termrender-0.4.0/src/termrender/CLAUDE.md +59 -0
  6. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/__main__.py +55 -7
  7. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/layout.py +24 -2
  8. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/parser.py +97 -23
  9. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/CLAUDE.md +14 -0
  10. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/borders.py +12 -2
  11. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/mermaid.py +1 -1
  12. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/panel.py +15 -1
  13. termrender-0.4.0/src/termrender/renderers/table.py +158 -0
  14. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/text.py +17 -6
  15. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/style.py +27 -1
  16. termrender-0.4.0/tests/test_myst_gaps.py +114 -0
  17. termrender-0.4.0/tests/test_variable_colons.py +162 -0
  18. termrender-0.2.1/src/termrender/CLAUDE.md +0 -59
  19. termrender-0.2.1/src/termrender/renderers/table.py +0 -83
  20. {termrender-0.2.1 → termrender-0.4.0}/.github/workflows/publish.yml +0 -0
  21. {termrender-0.2.1 → termrender-0.4.0}/LICENSE +0 -0
  22. {termrender-0.2.1 → termrender-0.4.0}/README.md +0 -0
  23. {termrender-0.2.1 → termrender-0.4.0}/design.json +0 -0
  24. {termrender-0.2.1 → termrender-0.4.0}/pyproject.toml +0 -0
  25. {termrender-0.2.1 → termrender-0.4.0}/requirements.json +0 -0
  26. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/__init__.py +0 -0
  27. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/blocks.py +0 -0
  28. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/emit.py +0 -0
  29. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/py.typed +0 -0
  30. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/__init__.py +0 -0
  31. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/code.py +0 -0
  32. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/columns.py +0 -0
  33. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/divider.py +0 -0
  34. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/quote.py +0 -0
  35. {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/tree.py +0 -0
  36. {termrender-0.2.1 → termrender-0.4.0}/tests/__init__.py +0 -0
  37. {termrender-0.2.1 → termrender-0.4.0}/tests/test_column_alignment.py +0 -0
@@ -7,3 +7,6 @@ build/
7
7
  .eggs/
8
8
  *.egg
9
9
  .sisyphus/
10
+
11
+ # Sisyphus
12
+ .sisyphus
@@ -1,6 +1,50 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.4.0 (2026-04-05)
5
+
6
+ ### Features
7
+
8
+ - **parser**: Variable colon counts, backtick fence directives, and gloam-inspired theming
9
+ ([`47fac7f`](https://github.com/CaptainCrouton89/termrender/commit/47fac7fcf13d33e5d9986d3f9ca42ddaf5e7207d))
10
+
11
+ Parser changes: - Support 3+ colon openers/closers with stack-based matching - Backtick fence
12
+ directive syntax (```{name}) via mistune AST interception - Option line stripping (:key: value)
13
+ into directive attrs
14
+
15
+ CLI changes: - Syntax validation before tmux pane creation (no orphan panes on bad input) - TTY
16
+ auto-detect for color (disabled when piping, forced in tmux subprocess)
17
+
18
+ Theming (gloam-inspired defaults): - Headings: depth-based colored fg + dim tinted bg
19
+ (yellow→green→cyan→blue→magenta) - Inline code: cyan (aqua) - Panel borders: dim gray with yellow
20
+ bold titles - Table borders: blue dim, headers: yellow bold on dim-blue bg - Background color
21
+ support added to style()
22
+
23
+ 24 new tests across two test files.
24
+
25
+
26
+ ## v0.3.0 (2026-04-05)
27
+
28
+ ### Documentation
29
+
30
+ - Update CLAUDE.md notes for mermaid, tmux, and layout
31
+ ([`9e104d5`](https://github.com/CaptainCrouton89/termrender/commit/9e104d5ee7bad9a57902e79586c02b0e8d80c589))
32
+
33
+ ### Features
34
+
35
+ - **cli**: Auto-size tmux pane to fit rendered content
36
+ ([`91f0414`](https://github.com/CaptainCrouton89/termrender/commit/91f0414d0bf8bfbe4d7167159b928ed9c736db74))
37
+
38
+ - **mermaid**: Pass width and vertical padding to mermaid-ascii
39
+ ([`96145c2`](https://github.com/CaptainCrouton89/termrender/commit/96145c2789a52a4d94e9bc5f4adf7f3a88d8501f))
40
+
41
+ - **table**: Auto-wrap cell content when columns overflow
42
+ ([`0fae56f`](https://github.com/CaptainCrouton89/termrender/commit/0fae56f8f00260c3263671df9a63a5bea17820bb))
43
+
44
+ When a table exceeds available width, cells now wrap text within their proportionally-shrunk column
45
+ widths instead of overflowing. Layout height calculation updated to account for multi-line cells.
46
+
47
+
4
48
  ## v0.2.1 (2026-04-05)
5
49
 
6
50
  ### Bug Fixes
@@ -0,0 +1,64 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ termrender renders directive-flavored markdown to ANSI terminal output. LLM agents describe layout with `:::directives` (panels, columns, trees, callouts, etc.) and termrender produces styled terminal output. Public API: `from termrender import render`.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ # Install in dev mode
13
+ pip install -e .
14
+
15
+ # Run tests
16
+ pytest tests/
17
+ pytest tests/test_column_alignment.py::TestColumnAlignment::test_showpiece_renders_without_error
18
+
19
+ # Run the CLI
20
+ python -m termrender <file.md>
21
+ echo ':::panel{title="Hi"}\nHello\n:::' | python -m termrender
22
+
23
+ # Build
24
+ python -m build
25
+ ```
26
+
27
+ No linter or formatter is configured.
28
+
29
+ ## Architecture
30
+
31
+ Three-stage pipeline: **parse → layout → emit**.
32
+
33
+ 1. **Parse** (`parser.py`) — Two-pass: regex extracts `:::directives` first, then mistune v3 processes markdown segments. Produces a tree of `Block` dataclasses (`blocks.py`).
34
+ 2. **Layout** (`layout.py`) — Two-pass, order is load-bearing: `resolve_width()` top-down, then `resolve_height()` bottom-up. Width must resolve first because height calls `wrap_text(text, width)`.
35
+ 3. **Emit** (`emit.py`) — Walks the block tree and dispatches to renderer functions in `renderers/`.
36
+
37
+ Entry points:
38
+ - **Library**: `__init__.py:render()` — parse → layout → emit
39
+ - **CLI**: `__main__.py:main()` — argparse with `--width`, `--no-color`, `--check`, `--cjk`, `--tmux`. Exit codes: 0=ok, 1=input, 2=syntax, 3=terminal.
40
+
41
+ ### Renderers (`src/termrender/renderers/`)
42
+
43
+ Two signatures (not type-enforced):
44
+ - **Leaf**: `render(block, color) -> list[str]` — divider, tree
45
+ - **Container**: `render(block, color, render_child) -> list[str]` — panel, quote, code, columns, callout, table, text, mermaid
46
+
47
+ `borders.py` is a shared utility, not a renderer. Its `render_box(content_lines, width, ...)` takes **total** width (including borders), not content width.
48
+
49
+ ### Style (`style.py`)
50
+
51
+ `visual_len()` measures display width accounting for ANSI escapes, emoji, CJK, and combining marks. `wrap_text()` uses `len()` internally (known bug: CJK overflow). `_ambiguous_width` is global mutable state with no reset path — set via `set_ambiguous_width()` or `TERMRENDER_CJK` env var.
52
+
53
+ ## Conventions
54
+
55
+ - **Commits**: conventional commits (`feat:`, `fix:`, `chore:`, etc.). `feat` → minor, `fix`/`perf` → patch. Auto-released via python-semantic-release on main.
56
+ - **Version**: derived from git tags via hatch-vcs (no version in pyproject.toml).
57
+ - **Python**: 3.10+.
58
+
59
+ ## Supplementary CLAUDE.md files
60
+
61
+ - `src/termrender/CLAUDE.md` — parser, layout, mermaid, nesting, and `--check`/`--tmux` implementation gotchas
62
+ - `src/termrender/renderers/CLAUDE.md` — renderer contracts, `render_box` width semantics, EAW edge cases
63
+
64
+ Read these before modifying layout, parsing, or renderer code.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Rich terminal rendering of directive-flavored markdown
5
5
  Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
6
6
  Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
@@ -0,0 +1,59 @@
1
+ # termrender/src/termrender
2
+
3
+ ## Layout: two-pass order is mandatory
4
+
5
+ `layout.py` runs `resolve_width()` then `resolve_height()` — this order is load-bearing. Height resolution calls `wrap_text(text, width)`, so every block must have `.width` set first. Reversing the order causes `block.width = None`, which silently falls back to `width = 1` (layout.py:77), drastically underestimating all heights.
6
+
7
+ ## Mermaid: subprocess runs in layout, not in the renderer
8
+
9
+ `layout.py:119–134` runs `mermaid-ascii` and caches the result in `block.attrs["_rendered"]`. The `mermaid` renderer (renderers/mermaid.py) reads this cache key; if it's absent, it runs the subprocess again. A failed layout subprocess (tool missing, timeout) silently stores the raw source diagram in `_rendered`, so the renderer falls back to printing source — no error is raised. Both sites have a 30s timeout.
10
+
11
+ `layout.py` imports `fix_mermaid_encoding` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must account for this import. The two subprocess call sites differ: layout uses `check=True` (non-zero exit raises `CalledProcessError` → caught → raw source fallback); the renderer omits `check` (non-zero exit silently reads `stdout`, which may be empty or partial). See `renderers/CLAUDE.md` for encoding-fix details.
12
+
13
+ ## Directive nesting: depth counter, not stack entries
14
+
15
+ The parser handles two directive syntaxes through different passes: colon directives (`:::name`) are extracted in pass 1 via regex in `_split_directives`, while backtick fence directives (`` ```{name} ``) are resolved in pass 2 via mistune's AST walk in `_convert_ast`. Both paths funnel through `_directive_to_block` for block construction.
16
+
17
+ For colon directives, the parser tracks nesting with a `depth` integer inside the top stack entry, not separate stack entries (parser.py:241–306). A closer `:::` only pops the stack when `depth == 1`; otherwise it decrements depth and treats the closer as body content. Consequence: innermost closers appear verbatim in the body of the parent and are re-parsed on the recursive `parse()` call at line 351.
18
+
19
+ Max recursion depth is 50; exceeding it raises `ValueError`, not `DirectiveError`. Both the render path and `--check` path in `__main__.py` catch `ValueError` and map it to exit code 2 (`EXIT_SYNTAX`).
20
+
21
+ ## `--check` validates parse only, not layout
22
+
23
+ `__main__.py` `--check` calls `parse()` directly and exits — it never runs `layout.py`. Layout-time failures (mermaid subprocess missing, column percent overflow, `resolve_width`/`resolve_height` exceptions) pass `--check` cleanly but crash at render time. Use `--check` to catch directive syntax errors, not to guarantee a successful render.
24
+
25
+ ## `_ambiguous_width` is global mutable state; `TERMRENDER_CJK` makes it permanent
26
+
27
+ `style.set_ambiguous_width(n)` (style.py:21–23) changes East Asian ambiguous-width measurement for the entire process with no reset function. The CLI `--cjk` flag sets `os.environ["TERMRENDER_CJK"]` rather than calling the function directly, so it persists for the entire process. `__init__.py:30–31` calls `set_ambiguous_width(2)` on every `render()` call when the env var is set — but since there's no reset, a single call in any render context permanently widens ambiguous-width for all subsequent renders in the same process. Affects `visual_len()` and therefore all wrapping and column math.
28
+
29
+ ## Column width: explicit widths exceeding available space truncate auto-columns to 1
30
+
31
+ `layout.py:30–63`: explicit column widths (percent or absolute) are allocated first; remaining space is split among auto-width columns with `max(remaining, 0)`. Two columns each claiming 80% of a 100px terminal leaves auto-width columns with width 1 — no error, no proportional scaling.
32
+
33
+ ## Height calculations with hidden assumptions
34
+
35
+ - **QUOTE** (layout.py:138): height gets `+1` only when the `author` or `by` attr is set. Using any other key (`attribution`, `source`) silently omits the extra line — the renderer's attribution line is clipped.
36
+ - **TABLE** (layout.py:117): `height = len(rows) + 4` where `rows` includes the header row. The code comment mis-describes this as 5 structural parts; `rows[0]` is the header. Adding a footer or subtitle row needs `+5`, not `+4+1`.
37
+ - **LIST_ITEM** (layout.py:107): text wraps at `max(width - 2, 1)`, hardcoding a 2-column indent. If the renderer changes the indent width, layout height and actual render height diverge silently.
38
+
39
+ ## Character offset tracking across wrapped lines
40
+
41
+ `text.py:32–33`: after rendering each wrapped line, the offset advances by the line's length and then skips one character if the next character in the original plain text is a space (the space `wrap_text` consumed). If wrapping preserves trailing spaces, this skip is wrong and subsequent span styling shifts by one character.
42
+
43
+ ## `wrap_text` measures in characters, not visual columns
44
+
45
+ `style.py:195–241`: all line-length comparisons inside `wrap_text` use `len()` (character count), not `visual_len()`. A 2-column-wide CJK character is counted as 1 column-unit, so wrapped lines silently overflow their allocated width by one cell per wide character. This affects every block type that calls `wrap_text`.
46
+
47
+ ## Unknown directive names silently become PANEL
48
+
49
+ `parser.py:339`: `_DIRECTIVE_TO_BLOCK.get(name, BlockType.PANEL)` — any unrecognized `:::name` becomes a bordered PANEL block with no error or warning. Typos in directive names (e.g. `:::callOut`) produce visible output that looks correct but lacks the expected behavior (callout type, icon, color).
50
+
51
+ ## `--tmux`: exit code reflects pane creation, auto-sizing runs a full render, tempfiles leak
52
+
53
+ `__main__.py:229`: after `tmux split-window` succeeds the parent exits `EXIT_OK` immediately — the pane renders asynchronously. Render errors surface only inside the pane. `--check` is silently dropped with `--tmux` (exit at line 229 precedes the `--check` branch at line 231). Tempfile cleanup is embedded as `... | less -R; rm -f <tmpfile>` (line 214); `less` killed abnormally (SIGKILL, closed session) leaks `/tmp/termrender-*.md`.
54
+
55
+ When `--width` is omitted, `--tmux` calls `render(source, width=80, color=False)` (lines 177–185) to measure content width — mermaid subprocesses fire and `TERMRENDER_CJK` mutations apply here. Any exception silently falls back to `pane_width=80`. The pane is capped to `tmux #{pane_width} - 10`; if the tmux query fails, no cap is applied. Minimum enforced pane width is 20.
56
+
57
+ ## `_EMOJI_WIDE_RANGES` must stay sorted by codepoint
58
+
59
+ `_char_width()` (style.py:144) exits the range scan early on `cp < lo`, assuming all ranges are in ascending codepoint order. Adding a new range out of order causes the early exit to skip ranges with higher `lo` values, silently misclassifying those codepoints as 1-wide.
@@ -158,8 +158,25 @@ def main() -> None:
158
158
  if args.cjk:
159
159
  os.environ["TERMRENDER_CJK"] = "1"
160
160
 
161
- # --tmux: render in a new tmux side pane
161
+ # --tmux: render in a new tmux side pane, sized to fit
162
162
  if args.tmux:
163
+ # Validate syntax before creating pane — fail fast to caller's terminal
164
+ try:
165
+ from termrender.parser import parse as _parse
166
+ _parse(source)
167
+ except DirectiveError as e:
168
+ _error(
169
+ f"syntax error: {e}",
170
+ fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
171
+ code=EXIT_SYNTAX,
172
+ )
173
+ except ValueError as e:
174
+ _error(
175
+ f"nesting error: {e}",
176
+ fix="reduce directive nesting depth (max 50 levels)",
177
+ code=EXIT_SYNTAX,
178
+ )
179
+
163
180
  import shlex
164
181
  import subprocess
165
182
  import tempfile
@@ -168,7 +185,35 @@ def main() -> None:
168
185
  _error("not inside a tmux session",
169
186
  fix="run inside tmux or omit --tmux")
170
187
 
171
- # Save source so the new pane can render it with its own width
188
+ # Determine desired pane width
189
+ if args.width:
190
+ pane_width = args.width
191
+ else:
192
+ # Preview render to measure content width
193
+ from termrender.style import visual_len
194
+ try:
195
+ preview = render(source, width=80, color=False)
196
+ max_w = max(
197
+ (visual_len(line) for line in preview.split('\n') if line),
198
+ default=40,
199
+ )
200
+ pane_width = max(max_w, 40)
201
+ except Exception:
202
+ pane_width = 80
203
+
204
+ # Cap to available tmux space (leave room for the source pane)
205
+ try:
206
+ result = subprocess.run(
207
+ ["tmux", "display-message", "-p", "#{pane_width}"],
208
+ capture_output=True, text=True, check=True,
209
+ )
210
+ available = int(result.stdout.strip())
211
+ pane_width = min(pane_width, available - 10)
212
+ except Exception:
213
+ pass
214
+ pane_width = max(pane_width, 20) # absolute minimum
215
+
216
+ # Save source so the new pane can render it
172
217
  with tempfile.NamedTemporaryFile(
173
218
  mode="w", suffix=".md", prefix="termrender-", delete=False,
174
219
  ) as f:
@@ -181,14 +226,14 @@ def main() -> None:
181
226
  cmd_parts.append("--no-color")
182
227
  if args.cjk:
183
228
  cmd_parts.append("--cjk")
184
- if args.width:
185
- cmd_parts.extend(["-w", str(args.width)])
229
+ cmd_parts.extend(["-w", str(pane_width)])
186
230
 
187
- pane_cmd = " ".join(cmd_parts) + " | less -R; rm -f " + shlex.quote(tmpfile)
231
+ # TERMRENDER_COLOR=1 forces color on despite stdout piping to less
232
+ pane_cmd = "TERMRENDER_COLOR=1 " + " ".join(cmd_parts) + " | less -R; rm -f " + shlex.quote(tmpfile)
188
233
 
189
234
  try:
190
235
  subprocess.run(
191
- ["tmux", "split-window", "-h", pane_cmd],
236
+ ["tmux", "split-window", "-h", "-f", "-l", str(pane_width), pane_cmd],
192
237
  check=True,
193
238
  )
194
239
  except FileNotFoundError:
@@ -222,7 +267,10 @@ def main() -> None:
222
267
  sys.exit(EXIT_OK)
223
268
 
224
269
  try:
225
- output = render(source, width=args.width, color=not args.no_color)
270
+ use_color = not args.no_color and (
271
+ sys.stdout.isatty() or os.environ.get("TERMRENDER_COLOR") == "1"
272
+ )
273
+ output = render(source, width=args.width, color=use_color)
226
274
  except TerminalError as e:
227
275
  _error(
228
276
  f"terminal error: {e}",
@@ -113,15 +113,37 @@ def resolve_height(block: Block) -> None:
113
113
  block.height = len(source.split("\n")) if source else 1
114
114
 
115
115
  elif bt == BlockType.TABLE:
116
+ headers = block.attrs.get("headers", [])
116
117
  rows = block.attrs.get("rows", [])
117
- block.height = len(rows) + 4 # top border + header + separator + data rows + bottom border
118
+ n_cols = max(len(headers), max((len(r) for r in rows), default=0))
119
+ if n_cols == 0:
120
+ block.height = 0
121
+ else:
122
+ rh = [_plain_text(headers[i]) if i < len(headers) else "" for i in range(n_cols)]
123
+ rr = [[_plain_text(row[i]) if i < len(row) else "" for i in range(n_cols)] for row in rows]
124
+ col_widths = [
125
+ max(3, visual_len(rh[i]), *(visual_len(r[i]) for r in rr))
126
+ for i in range(n_cols)
127
+ ]
128
+ total = sum(col_widths) + n_cols * 2 + (n_cols + 1)
129
+ if total > width:
130
+ avail = max(width - n_cols * 2 - (n_cols + 1), n_cols * 3)
131
+ total_natural = sum(col_widths)
132
+ if total_natural > 0:
133
+ col_widths = [max(3, round(cw / total_natural * avail)) for cw in col_widths]
134
+ header_h = max(len(wrap_text(rh[i], col_widths[i])) for i in range(n_cols))
135
+ data_h = sum(
136
+ max(len(wrap_text(r[i], col_widths[i])) for i in range(n_cols))
137
+ for r in rr
138
+ ) if rr else 0
139
+ block.height = header_h + data_h + 3 # top border + header sep + bottom border
118
140
 
119
141
  elif bt == BlockType.MERMAID:
120
142
  source = block.attrs.get("source", "") or _plain_text(block.text)
121
143
  rendered = source # fallback
122
144
  try:
123
145
  result = subprocess.run(
124
- ["mermaid-ascii", "-f", "-"],
146
+ ["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
125
147
  input=source,
126
148
  capture_output=True,
127
149
  text=True,
@@ -36,12 +36,12 @@ def _sanitize_text(text: str) -> str:
36
36
  """Strip non-SGR ANSI escape sequences from text."""
37
37
  return _UNSAFE_ANSI_RE.sub('', text)
38
38
 
39
- # Directive opener: :::name or :::name{attrs}
39
+ # Directive opener: :::name or ::::name etc. (3+ colons)
40
40
  _DIRECTIVE_OPEN = re.compile(
41
- r"^:::(\w+)(?:\{([^}]*)\})?\s*$"
41
+ r"^(:{3,})(\w+)(?:\{([^}]*)\})?\s*$"
42
42
  )
43
- # Directive closer: exactly ::: on its own line
44
- _DIRECTIVE_CLOSE = re.compile(r"^:::\s*$")
43
+ # Directive closer: 3+ colons on its own line
44
+ _DIRECTIVE_CLOSE = re.compile(r"^(:{3,})\s*$")
45
45
 
46
46
  # Attribute parser: key=value or key="quoted value"
47
47
  _ATTR_PAIR = re.compile(
@@ -61,6 +61,14 @@ _DIRECTIVE_TO_BLOCK: dict[str, BlockType] = {
61
61
 
62
62
  _SELF_CLOSING_DIRECTIVES = frozenset({"divider"})
63
63
 
64
+ # MyST backtick fence directive: ```{name} optional-argument
65
+ _BACKTICK_DIRECTIVE_RE = re.compile(r"^\{(\w[\w-]*)\}(.*)")
66
+
67
+ # MyST option line: :key: value — intentionally requires a value after the key
68
+ # (the \s+(.+) part). Flag-style options like :nosandbox: (no value) won't match
69
+ # and will be treated as body content.
70
+ _OPTION_LINE_RE = re.compile(r"^:(\w[\w-]*):\s+(.+)$")
71
+
64
72
  _mistune_md = mistune.create_markdown(renderer="ast", plugins=["table"])
65
73
 
66
74
 
@@ -71,7 +79,7 @@ def _any_self_closing_before(lines: list[str], close_idx: int) -> bool:
71
79
  if not line:
72
80
  continue
73
81
  m = _DIRECTIVE_OPEN.match(lines[j])
74
- if m and m.group(1) in _SELF_CLOSING_DIRECTIVES:
82
+ if m and m.group(2) in _SELF_CLOSING_DIRECTIVES:
75
83
  return True
76
84
  return False
77
85
  return False
@@ -117,7 +125,40 @@ def _convert_inline(nodes: list[dict]) -> list[InlineSpan]:
117
125
  return spans
118
126
 
119
127
 
120
- def _convert_ast(nodes: list[dict]) -> list[Block]:
128
+ def _strip_options(body: str) -> tuple[dict[str, str], str]:
129
+ """Strip MyST option lines from the start of a directive body.
130
+
131
+ Option lines have the form `:key: value` and appear at the start of the body.
132
+ Blank lines between option lines are allowed. Scanning stops at the first
133
+ non-option, non-blank line.
134
+
135
+ Returns (options_dict, remaining_body).
136
+ """
137
+ if not body or not body.lstrip("\n").startswith(":"):
138
+ return {}, body
139
+ lines = body.split("\n")
140
+ options: dict[str, str] = {}
141
+ last_option_idx = -1
142
+ for i, line in enumerate(lines):
143
+ stripped = line.strip()
144
+ if not stripped:
145
+ # blank lines are OK between options
146
+ continue
147
+ m = _OPTION_LINE_RE.match(stripped)
148
+ if m:
149
+ options[m.group(1)] = m.group(2)
150
+ last_option_idx = i
151
+ else:
152
+ break
153
+ if last_option_idx == -1:
154
+ return {}, body
155
+ remaining = "\n".join(lines[last_option_idx + 1:])
156
+ # Strip leading blank lines from remaining body
157
+ remaining = remaining.lstrip("\n")
158
+ return options, remaining
159
+
160
+
161
+ def _convert_ast(nodes: list[dict], _depth: int = 0) -> list[Block]:
121
162
  """Convert mistune AST nodes into Block tree."""
122
163
  blocks: list[Block] = []
123
164
  for node in nodes:
@@ -142,7 +183,24 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
142
183
  elif ntype == "block_code":
143
184
  raw = node.get("raw", "")
144
185
  info = node.get("attrs", {}).get("info", "")
145
- if info == "mermaid":
186
+ # MyST backtick fence directive: ```{name} optional-arg
187
+ m_directive = _BACKTICK_DIRECTIVE_RE.match(info) if info else None
188
+ if m_directive:
189
+ dir_name = m_directive.group(1)
190
+ arg_text = m_directive.group(2).strip()
191
+ if dir_name == "mermaid":
192
+ options, body = _strip_options(raw)
193
+ attrs = dict(options)
194
+ if arg_text:
195
+ attrs["argument"] = arg_text
196
+ attrs["source"] = body
197
+ blocks.append(Block(type=BlockType.MERMAID, attrs=attrs))
198
+ else:
199
+ attrs: dict[str, Any] = {}
200
+ if arg_text:
201
+ attrs["argument"] = arg_text
202
+ blocks.append(_directive_to_block(dir_name, attrs, raw, _depth=_depth))
203
+ elif info == "mermaid":
146
204
  blocks.append(Block(
147
205
  type=BlockType.MERMAID,
148
206
  attrs={"source": raw},
@@ -166,7 +224,7 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
166
224
  if child["type"] == "block_text":
167
225
  item_spans.extend(_convert_inline(child.get("children", [])))
168
226
  else:
169
- sub_blocks.extend(_convert_ast([child]))
227
+ sub_blocks.extend(_convert_ast([child], _depth=_depth))
170
228
  items.append(Block(
171
229
  type=BlockType.LIST_ITEM,
172
230
  text=item_spans,
@@ -209,23 +267,23 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
209
267
  blocks.append(Block(type=BlockType.DIVIDER))
210
268
 
211
269
  elif ntype == "block_quote":
212
- children = _convert_ast(node.get("children", []))
270
+ children = _convert_ast(node.get("children", []), _depth=_depth)
213
271
  blocks.append(Block(type=BlockType.QUOTE, children=children))
214
272
 
215
273
  else:
216
274
  # Unknown block type - try to extract any content
217
275
  if "children" in node:
218
- blocks.extend(_convert_ast(node["children"]))
276
+ blocks.extend(_convert_ast(node["children"], _depth=_depth))
219
277
 
220
278
  return blocks
221
279
 
222
280
 
223
- def _parse_markdown(source: str) -> list[Block]:
281
+ def _parse_markdown(source: str, _depth: int = 0) -> list[Block]:
224
282
  """Parse a markdown string via mistune and convert to Block list."""
225
283
  if not source.strip():
226
284
  return []
227
285
  ast_nodes = _mistune_md(source)
228
- return _convert_ast(ast_nodes)
286
+ return _convert_ast(ast_nodes, _depth=_depth)
229
287
 
230
288
 
231
289
  def _split_directives(source: str) -> list[dict]:
@@ -247,6 +305,9 @@ def _split_directives(source: str) -> list[dict]:
247
305
  # Check for directive opener
248
306
  m_open = _DIRECTIVE_OPEN.match(line)
249
307
  if m_open:
308
+ colons = m_open.group(1)
309
+ name = m_open.group(2)
310
+ attrs_raw = m_open.group(3)
250
311
  if not stack:
251
312
  # Top-level directive opening — flush accumulated markdown
252
313
  if current_md_lines:
@@ -256,13 +317,14 @@ def _split_directives(source: str) -> list[dict]:
256
317
  })
257
318
  current_md_lines = []
258
319
  entry = {
259
- "name": m_open.group(1),
260
- "attrs_raw": m_open.group(2),
320
+ "name": name,
321
+ "attrs_raw": attrs_raw,
261
322
  "body_lines": [],
262
323
  "depth": 1,
324
+ "colon_count": len(colons),
263
325
  }
264
326
  # Self-closing directives (no body content expected)
265
- if entry["name"] in ("divider",):
327
+ if entry["name"] in _SELF_CLOSING_DIRECTIVES:
266
328
  segments.append({
267
329
  "type": "directive",
268
330
  "name": entry["name"],
@@ -272,8 +334,9 @@ def _split_directives(source: str) -> list[dict]:
272
334
  else:
273
335
  stack.append(entry)
274
336
  else:
275
- # Nested directive — track depth and include line in body
276
- stack[-1]["depth"] += 1
337
+ # Nested directive — track depth only if colon count matches
338
+ if len(colons) == stack[-1]["colon_count"]:
339
+ stack[-1]["depth"] += 1
277
340
  stack[-1]["body_lines"].append(line)
278
341
  i += 1
279
342
  continue
@@ -282,15 +345,20 @@ def _split_directives(source: str) -> list[dict]:
282
345
  m_close = _DIRECTIVE_CLOSE.match(line)
283
346
  if m_close and not stack:
284
347
  if not _any_self_closing_before(lines, i):
348
+ close_colons = m_close.group(1)
285
349
  raise DirectiveError(
286
- f"line {i + 1}: stray ':::' closer with no open directive"
350
+ f"line {i + 1}: stray '{close_colons}' closer with no open directive"
287
351
  )
288
352
  # Stray closer after a self-closing directive like divider — skip
289
353
  i += 1
290
354
  continue
291
355
  if m_close and stack:
292
- if stack[-1]["depth"] > 1:
293
- # Closing a nested directive
356
+ close_colon_count = len(m_close.group(1))
357
+ if close_colon_count != stack[-1]["colon_count"]:
358
+ # Different colon count — treat as body content
359
+ stack[-1]["body_lines"].append(line)
360
+ elif stack[-1]["depth"] > 1:
361
+ # Closing a nested directive with same colon count
294
362
  stack[-1]["depth"] -= 1
295
363
  stack[-1]["body_lines"].append(line)
296
364
  else:
@@ -322,10 +390,10 @@ def _split_directives(source: str) -> list[dict]:
322
390
  # If stack is not empty, the source has unclosed directives
323
391
  if stack:
324
392
  unclosed = stack[-1]
325
- # Find the line number where this directive was opened
393
+ colons = ":" * unclosed["colon_count"]
326
394
  name = unclosed["name"]
327
395
  raise DirectiveError(
328
- f"unclosed directive ':::{name}' — missing closing ':::'"
396
+ f"unclosed directive '{colons}{name}' — missing closing '{colons}'"
329
397
  )
330
398
 
331
399
  return segments
@@ -336,6 +404,12 @@ _MAX_PARSE_DEPTH = 50
336
404
 
337
405
  def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0) -> Block:
338
406
  """Convert a parsed directive into a Block."""
407
+ # Strip option lines from body; inline attrs take precedence over options
408
+ options, body = _strip_options(body)
409
+ for key, value in options.items():
410
+ if key not in attrs:
411
+ attrs[key] = value
412
+
339
413
  block_type = _DIRECTIVE_TO_BLOCK.get(name, BlockType.PANEL)
340
414
 
341
415
  # Tree and Code directives: store raw body, don't parse as markdown
@@ -368,7 +442,7 @@ def parse(source: str, _depth: int = 0) -> Block:
368
442
 
369
443
  for seg in segments:
370
444
  if seg["type"] == "markdown":
371
- children.extend(_parse_markdown(seg["content"]))
445
+ children.extend(_parse_markdown(seg["content"], _depth=_depth))
372
446
  else:
373
447
  children.append(_directive_to_block(
374
448
  seg["name"], seg["attrs"], seg["body"], _depth=_depth,
@@ -38,3 +38,17 @@ Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by
38
38
 
39
39
  ## `panel.py` — callouts delegate through a proxy Block
40
40
  `render_callout` patches `title`, `color`, and `type=BlockType.PANEL` into a new `Block` instance and calls `render()` on it. The original block is not mutated. The callout type string (`"info"`, `"warning"`, `"error"`, `"success"`) is only used to look up `_CALLOUT_MAP`; an unknown type falls back to blue `ℹ`.
41
+
42
+ ## `mermaid.py` — leaf signature (not container), encoding fix, no returncode check, no truncation
43
+
44
+ **Leaf signature, not container**: `render(block, color)` — no `render_child`. The parent `CLAUDE.md` incorrectly lists mermaid as a Container renderer.
45
+
46
+ **`fix_mermaid_encoding`**: `mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters (e.g. `→` becomes `â\x86\x92`). The fix is `text.encode("latin-1").decode("utf-8")`; on failure it silently returns the corrupted string — callers cannot distinguish fixed vs. corrupt output.
47
+
48
+ **`except Exception` swallows everything**: timeout, missing tool, `MemoryError` — all fall back to `rendered = source` (the raw mermaid source text) silently. Returncode is also never checked; a non-zero exit still reads `result.stdout`, producing a blank-line block if stdout was empty. If both `_rendered` and `source` are absent, empty string is sent to `mermaid-ascii`.
49
+
50
+ **`visual_ljust` pads but never truncates**: every output line is padded to `block.width`. Lines wider than `block.width` overflow without clipping. If `mermaid-ascii` emits a diagram wider than the allocated block, it silently exceeds the layout boundary.
51
+
52
+ **Trailing blank line**: `rendered.split("\n")` on output ending with `\n` (typical subprocess output) produces a trailing empty string, which becomes a `block.width`-wide blank line appended to every mermaid block.
53
+
54
+ Layout pre-renders into `block.attrs["_rendered"]` (see `src/termrender/CLAUDE.md`); this renderer re-runs the subprocess only when that key is absent.
@@ -11,6 +11,7 @@ def render_box(
11
11
  color: bool,
12
12
  title: str | None = None,
13
13
  border_color: str | None = None,
14
+ title_color: str | None = None,
14
15
  dim: bool = False,
15
16
  ) -> list[str]:
16
17
  """Render content lines inside a box-drawing border.
@@ -21,6 +22,7 @@ def render_box(
21
22
  color: Whether ANSI styling is enabled.
22
23
  title: Optional title to display in the top border.
23
24
  border_color: Color name for the border (used by panels).
25
+ title_color: Color for the title text (defaults to border_color).
24
26
  dim: Whether to dim the border (used by code blocks).
25
27
  """
26
28
  # Calculate border character widths dynamically
@@ -46,11 +48,19 @@ def render_box(
46
48
  title_visual = visual_len(title_part)
47
49
  remaining = inner_w - title_visual
48
50
  fill_count = max(0, remaining // dash_v)
49
- top_raw = "┌" + title_part + "─" * fill_count + "┐"
51
+ if title_color and color:
52
+ # Style title text separately from border chrome
53
+ styled_title = style(title, color=title_color, bold=True)
54
+ border_prefix = style("┌─ ", **style_kw)
55
+ border_suffix = style(" " + "─" * fill_count + "┐", **style_kw)
56
+ top = border_prefix + styled_title + border_suffix
57
+ else:
58
+ top_raw = "┌" + title_part + "─" * fill_count + "┐"
59
+ top = style(top_raw, **style_kw)
50
60
  else:
51
61
  fill_count = max(0, inner_w // dash_v)
52
62
  top_raw = "┌" + "─" * fill_count + "┐"
53
- top = style(top_raw, **style_kw)
63
+ top = style(top_raw, **style_kw)
54
64
  top = visual_ljust(top, width)
55
65
 
56
66
  # Bottom border
@@ -31,7 +31,7 @@ def render(block: Block, color: bool) -> list[str]:
31
31
  source = block.attrs.get("source", "")
32
32
  try:
33
33
  result = subprocess.run(
34
- ["mermaid-ascii", "-f", "-"],
34
+ ["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
35
35
  input=source,
36
36
  capture_output=True,
37
37
  text=True,