termrender 0.8.0__tar.gz → 1.0.1__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 (50) hide show
  1. termrender-1.0.1/.git +1 -0
  2. {termrender-0.8.0 → termrender-1.0.1}/CHANGELOG.md +102 -29
  3. termrender-1.0.1/CLAUDE.md +25 -0
  4. {termrender-0.8.0 → termrender-1.0.1}/PKG-INFO +5 -5
  5. {termrender-0.8.0 → termrender-1.0.1}/README.md +4 -4
  6. termrender-1.0.1/src/termrender/CLAUDE.md +15 -0
  7. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/__main__.py +15 -5
  8. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/emit.py +13 -1
  9. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/layout.py +8 -2
  10. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/parser.py +22 -37
  11. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/text.py +1 -1
  12. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/timeline.py +7 -9
  13. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/style.py +9 -2
  14. {termrender-0.8.0 → termrender-1.0.1}/tests/test_column_alignment.py +8 -8
  15. termrender-1.0.1/tests/test_linebreak.py +79 -0
  16. {termrender-0.8.0 → termrender-1.0.1}/tests/test_myst_gaps.py +25 -38
  17. termrender-0.8.0/CLAUDE.md +0 -64
  18. termrender-0.8.0/src/termrender/CLAUDE.md +0 -75
  19. {termrender-0.8.0 → termrender-1.0.1}/.github/workflows/publish.yml +0 -0
  20. {termrender-0.8.0 → termrender-1.0.1}/.gitignore +0 -0
  21. {termrender-0.8.0 → termrender-1.0.1}/LICENSE +0 -0
  22. {termrender-0.8.0 → termrender-1.0.1}/design.json +0 -0
  23. {termrender-0.8.0 → termrender-1.0.1}/pyproject.toml +0 -0
  24. {termrender-0.8.0 → termrender-1.0.1}/requirements.json +0 -0
  25. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/__init__.py +0 -0
  26. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/blocks.py +0 -0
  27. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/py.typed +0 -0
  28. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/CLAUDE.md +0 -0
  29. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/__init__.py +0 -0
  30. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/borders.py +0 -0
  31. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/charts.py +0 -0
  32. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/code.py +0 -0
  33. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/columns.py +0 -0
  34. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/diff.py +0 -0
  35. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/divider.py +0 -0
  36. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/mermaid.py +0 -0
  37. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/panel.py +0 -0
  38. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/quote.py +0 -0
  39. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/stat.py +0 -0
  40. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/table.py +0 -0
  41. {termrender-0.8.0 → termrender-1.0.1}/src/termrender/renderers/tree.py +0 -0
  42. {termrender-0.8.0 → termrender-1.0.1}/tests/__init__.py +0 -0
  43. {termrender-0.8.0 → termrender-1.0.1}/tests/test_charts.py +0 -0
  44. {termrender-0.8.0 → termrender-1.0.1}/tests/test_diff.py +0 -0
  45. {termrender-0.8.0 → termrender-1.0.1}/tests/test_inline_badge.py +0 -0
  46. {termrender-0.8.0 → termrender-1.0.1}/tests/test_mermaid_compat.py +0 -0
  47. {termrender-0.8.0 → termrender-1.0.1}/tests/test_stat.py +0 -0
  48. {termrender-0.8.0 → termrender-1.0.1}/tests/test_tasklist.py +0 -0
  49. {termrender-0.8.0 → termrender-1.0.1}/tests/test_timeline.py +0 -0
  50. {termrender-0.8.0 → termrender-1.0.1}/tests/test_variable_colons.py +0 -0
termrender-1.0.1/.git ADDED
@@ -0,0 +1 @@
1
+ gitdir: /Users/silasrhyneer/Code/cli/termrender/.git/worktrees/tr-v101
@@ -1,12 +1,85 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v1.0.1 (2026-04-28)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **parser**: Extract bullet text from paragraph children in loose lists
9
+ ([`95f389b`](https://github.com/crouton-labs/termrender/commit/95f389b808d89195a8b9dd957d7949328a8859e8))
10
+
11
+ Mistune wraps list_item content in block_text for tight lists and in paragraph for loose lists. Only
12
+ block_text was being extracted into item spans, so loose-list bullets rendered with empty text —
13
+ the renderer's `if not block.text` short-circuit dropped the paragraph child entirely.
14
+
15
+
16
+ ## v1.0.0 (2026-04-27)
17
+
18
+ ### Documentation
19
+
20
+ - **claude-md**: Tighten root and src CLAUDE.md
21
+ ([`7fe01ec`](https://github.com/crouton-labs/termrender/commit/7fe01ec2da0c7a0c1aa0f4eccf9b958496562be8))
22
+
23
+ ### Features
24
+
25
+ - **mermaid**: Switch to :::mermaid directive, drop backtick fence forms
26
+ ([`083f590`](https://github.com/crouton-labs/termrender/commit/083f5900b4b516f9df599dd08129afee34310e3d))
27
+
28
+ BREAKING CHANGE: ```mermaid fenced code blocks are no longer rendered as mermaid diagrams — they now
29
+ render as plain code blocks. Mermaid diagrams must use the new :::mermaid directive. The
30
+ MyST-style ```{name} backtick directive form is also removed; backtick fences now always produce a
31
+ code block, regardless of the language tag. Every directive uses ::: exclusively.
32
+
33
+ ### Breaking Changes
34
+
35
+ - **mermaid**: ```mermaid fenced code blocks are no longer rendered as mermaid diagrams — they now
36
+ render as plain code blocks. Mermaid diagrams must use the new :::mermaid directive. The
37
+ MyST-style ```{name} backtick directive form is also removed; backtick fences now always produce a
38
+ code block, regardless of the language tag. Every directive uses ::: exclusively.
39
+
40
+
41
+ ## v0.9.1 (2026-04-25)
42
+
43
+ ### Bug Fixes
44
+
45
+ - **timeline**: Wrap event text instead of truncating with ellipsis
46
+ ([`5af0e04`](https://github.com/crouton-labs/termrender/commit/5af0e04c3256075a2016e2cf8ae3d44e9e78c8fc))
47
+
48
+ Long event entries previously got clipped with `…` when they exceeded event_w. Now they wrap across
49
+ multiple lines, with continuation lines indented under the bullet and prefixed by the accent bar.
50
+ Layout height sums per-entry wrapped line counts so the block reserves the right space.
51
+
52
+
53
+ ## v0.9.0 (2026-04-21)
54
+
55
+ ### Bug Fixes
56
+
57
+ - **wrap**: Honor hard line breaks in wrap_text
58
+ ([`a383f4d`](https://github.com/crouton-labs/termrender/commit/a383f4de66b3d8370bda500dd4c3771a591563aa))
59
+
60
+ Markdown hard breaks were parsed as \n spans but wrap_text only split on spaces, leaking raw \n into
61
+ wrapped output. Inside panels and columns this broke border alignment because visual_ljust padded
62
+ the string once, not per visual line.
63
+
64
+ wrap_text now recursively wraps each \n-separated segment; the text-renderer offset heuristic skips
65
+ \n as well as space between lines. Layout height calcs pick up the extra lines automatically.
66
+
67
+ ### Features
68
+
69
+ - **spacing**: Add blank lines between hard breaks and top-level blocks
70
+ ([`7610189`](https://github.com/crouton-labs/termrender/commit/761018928504cf9626678fe46b5ee66d5e899d5d))
71
+
72
+ Hard line breaks now render a blank line between the two sides (parser emits \n\n so wrap_text
73
+ naturally produces the gap), and DOCUMENT-level siblings are separated by a blank padded line so
74
+ paragraphs, headings, and blocks no longer visually run together.
75
+
76
+
4
77
  ## v0.8.0 (2026-04-18)
5
78
 
6
79
  ### Features
7
80
 
8
81
  - **mermaid**: Preprocess sequence diagrams for mermaid-ascii compatibility
9
- ([`a642576`](https://github.com/CaptainCrouton89/termrender/commit/a642576d41d5dbde372d7de2ab47745296a78e32))
82
+ ([`a642576`](https://github.com/crouton-labs/termrender/commit/a642576d41d5dbde372d7de2ab47745296a78e32))
10
83
 
11
84
  mermaid-ascii only parses ->> / -->> arrows, participants, and self-loops; every other common
12
85
  sequence-diagram construct made it fail and fall back to raw source. Rewrite Note lines into
@@ -20,13 +93,13 @@ mermaid-ascii only parses ->> / -->> arrows, participants, and self-loops; every
20
93
  ### Bug Fixes
21
94
 
22
95
  - **code**: Wrap long code lines to fit layout width
23
- ([`31c6e59`](https://github.com/CaptainCrouton89/termrender/commit/31c6e595a438c4ced8c61fff679b59d4ae55f938))
96
+ ([`31c6e59`](https://github.com/crouton-labs/termrender/commit/31c6e595a438c4ced8c61fff679b59d4ae55f938))
24
97
 
25
98
  Code blocks previously used raw line count for height and let render_box grow beyond the layout
26
99
  allocation. Now wraps source lines to the available content width in both layout and renderer.
27
100
 
28
101
  - **parser**: Add directive trace and file-absolute line numbers to error messages
29
- ([`0f99ea0`](https://github.com/CaptainCrouton89/termrender/commit/0f99ea0310116f8fa06e933cd26126246d7a3b43))
102
+ ([`0f99ea0`](https://github.com/crouton-labs/termrender/commit/0f99ea0310116f8fa06e933cd26126246d7a3b43))
30
103
 
31
104
  Stray-closer and unclosed-directive errors now print the full open/close trace and, when nested
32
105
  directives share a colon count, name the specific cause and suggest the fix. Recursive body
@@ -39,7 +112,7 @@ Stray-closer and unclosed-directive errors now print the full open/close trace a
39
112
  ### Bug Fixes
40
113
 
41
114
  - **cli**: Default --tmux pane to 1/3 window width
42
- ([`d9c1bcc`](https://github.com/CaptainCrouton89/termrender/commit/d9c1bccbe95a4e5cf1f975b82cbafde6d9d3807a))
115
+ ([`d9c1bcc`](https://github.com/crouton-labs/termrender/commit/d9c1bccbe95a4e5cf1f975b82cbafde6d9d3807a))
43
116
 
44
117
  Instead of preview-rendering at 80 cols to measure content width, default to (window_width - 2) // 3
45
118
  for a consistent 1/3 split.
@@ -50,7 +123,7 @@ Instead of preview-rendering at 80 cols to measure content width, default to (wi
50
123
  ### Bug Fixes
51
124
 
52
125
  - **cli**: Give --pane error paths actionable recovery guidance
53
- ([`f857c32`](https://github.com/CaptainCrouton89/termrender/commit/f857c32c89afe32a3a668f03a3d570b0f14dae97))
126
+ ([`f857c32`](https://github.com/crouton-labs/termrender/commit/f857c32c89afe32a3a668f03a3d570b0f14dae97))
54
127
 
55
128
  The two --pane error paths now tell the agent how to recover instead of restating the problem.
56
129
  "Check that the pane id is valid" is a dead end for an agent — it needs either a command to list
@@ -62,7 +135,7 @@ The two --pane error paths now tell the agent how to recover instead of restatin
62
135
  ### Features
63
136
 
64
137
  - **cli**: Add --pane for in-place tmux pane updates
65
- ([`4ab1d77`](https://github.com/CaptainCrouton89/termrender/commit/4ab1d77b996aa356926407dcc11c1b408e68e0ee))
138
+ ([`4ab1d77`](https://github.com/crouton-labs/termrender/commit/4ab1d77b996aa356926407dcc11c1b408e68e0ee))
66
139
 
67
140
  --tmux now prints the newly-created pane id to stdout (via split-window -P -F) so callers can
68
141
  capture it for subsequent updates. --pane <ID> targets an existing pane via tmux respawn-pane -k
@@ -82,7 +155,7 @@ Also in this commit: - Expand -h epilog to cover the 8 visualization directives
82
155
  ### Bug Fixes
83
156
 
84
157
  - **borders**: Grow render_box to fit overflowing content and titles
85
- ([`dc108c8`](https://github.com/CaptainCrouton89/termrender/commit/dc108c8242763828245569f719abce64b26ddf5b))
158
+ ([`dc108c8`](https://github.com/crouton-labs/termrender/commit/dc108c8242763828245569f719abce64b26ddf5b))
86
159
 
87
160
  mermaid-ascii's --maxWidth is non-strict, so a child mermaid block can return lines wider than the
88
161
  panel's allocated content area. Previously the side walls floated outward to accommodate the
@@ -99,7 +172,7 @@ render_box now measures the widest content line (and the title) and grows its ef
99
172
  ### Features
100
173
 
101
174
  - Add diff, charts, stat, timeline, tasklist, and inline badges
102
- ([`e14f615`](https://github.com/CaptainCrouton89/termrender/commit/e14f615ae8d0723405db61c79b0f858d7bf0f863))
175
+ ([`e14f615`](https://github.com/crouton-labs/termrender/commit/e14f615ae8d0723405db61c79b0f858d7bf0f863))
103
176
 
104
177
  New block-level directives: - :::diff — colored unified diff with +/- gutters - :::bar — multi-bar
105
178
  chart with sub-cell precision via eighth blocks - :::progress — single-line progress bar (auto
@@ -122,7 +195,7 @@ Cross-cutting changes: - InlineSpan gained fg/bg fields; render_spans and span-s
122
195
  63 new tests across six test files. All 94 tests pass.
123
196
 
124
197
  - **cli**: Add --watch mode for live re-rendering
125
- ([`4223ad8`](https://github.com/CaptainCrouton89/termrender/commit/4223ad86805b0b3ad45450bd7ca4441a668f0e23))
198
+ ([`4223ad8`](https://github.com/crouton-labs/termrender/commit/4223ad86805b0b3ad45450bd7ca4441a668f0e23))
126
199
 
127
200
  Re-renders the file whenever its mtime changes, with terminal-resize detection and inline error
128
201
  display so the watcher survives malformed input. Uses the alternate screen buffer so Ctrl+C
@@ -134,7 +207,7 @@ Composes with --tmux: --tmux --watch points the spawned pane at the real file pa
134
207
  ### Refactoring
135
208
 
136
209
  - **parser**: Require strictly more colons on outer fences
137
- ([`4a501d9`](https://github.com/CaptainCrouton89/termrender/commit/4a501d917db191f758874bb6c3d922c879a763be))
210
+ ([`4a501d9`](https://github.com/crouton-labs/termrender/commit/4a501d917db191f758874bb6c3d922c879a763be))
138
211
 
139
212
  Drops the depth-counter that allowed `:::outer ... :::inner ... ::: ... :::` nesting with same colon
140
213
  counts. Termrender now matches the standard followed by MyST, Pandoc fenced divs,
@@ -154,7 +227,7 @@ Fixtures in test_column_alignment.py rewritten to ascending colon counts (7/6/5/
154
227
  ### Features
155
228
 
156
229
  - **table**: Render horizontal separator lines between data rows
157
- ([`3e4c74a`](https://github.com/CaptainCrouton89/termrender/commit/3e4c74a10d63470f2eb2ec096bb47cf41f0b7f70))
230
+ ([`3e4c74a`](https://github.com/crouton-labs/termrender/commit/3e4c74a10d63470f2eb2ec096bb47cf41f0b7f70))
158
231
 
159
232
 
160
233
  ## v0.4.0 (2026-04-05)
@@ -162,7 +235,7 @@ Fixtures in test_column_alignment.py rewritten to ascending colon counts (7/6/5/
162
235
  ### Features
163
236
 
164
237
  - **parser**: Variable colon counts, backtick fence directives, and gloam-inspired theming
165
- ([`47fac7f`](https://github.com/CaptainCrouton89/termrender/commit/47fac7fcf13d33e5d9986d3f9ca42ddaf5e7207d))
238
+ ([`47fac7f`](https://github.com/crouton-labs/termrender/commit/47fac7fcf13d33e5d9986d3f9ca42ddaf5e7207d))
166
239
 
167
240
  Parser changes: - Support 3+ colon openers/closers with stack-based matching - Backtick fence
168
241
  directive syntax (```{name}) via mistune AST interception - Option line stripping (:key: value)
@@ -184,18 +257,18 @@ Theming (gloam-inspired defaults): - Headings: depth-based colored fg + dim tint
184
257
  ### Documentation
185
258
 
186
259
  - Update CLAUDE.md notes for mermaid, tmux, and layout
187
- ([`9e104d5`](https://github.com/CaptainCrouton89/termrender/commit/9e104d5ee7bad9a57902e79586c02b0e8d80c589))
260
+ ([`9e104d5`](https://github.com/crouton-labs/termrender/commit/9e104d5ee7bad9a57902e79586c02b0e8d80c589))
188
261
 
189
262
  ### Features
190
263
 
191
264
  - **cli**: Auto-size tmux pane to fit rendered content
192
- ([`91f0414`](https://github.com/CaptainCrouton89/termrender/commit/91f0414d0bf8bfbe4d7167159b928ed9c736db74))
265
+ ([`91f0414`](https://github.com/crouton-labs/termrender/commit/91f0414d0bf8bfbe4d7167159b928ed9c736db74))
193
266
 
194
267
  - **mermaid**: Pass width and vertical padding to mermaid-ascii
195
- ([`96145c2`](https://github.com/CaptainCrouton89/termrender/commit/96145c2789a52a4d94e9bc5f4adf7f3a88d8501f))
268
+ ([`96145c2`](https://github.com/crouton-labs/termrender/commit/96145c2789a52a4d94e9bc5f4adf7f3a88d8501f))
196
269
 
197
270
  - **table**: Auto-wrap cell content when columns overflow
198
- ([`0fae56f`](https://github.com/CaptainCrouton89/termrender/commit/0fae56f8f00260c3263671df9a63a5bea17820bb))
271
+ ([`0fae56f`](https://github.com/crouton-labs/termrender/commit/0fae56f8f00260c3263671df9a63a5bea17820bb))
199
272
 
200
273
  When a table exceeds available width, cells now wrap text within their proportionally-shrunk column
201
274
  widths instead of overflowing. Layout height calculation updated to account for multi-line cells.
@@ -206,7 +279,7 @@ When a table exceeds available width, cells now wrap text within their proportio
206
279
  ### Bug Fixes
207
280
 
208
281
  - **mermaid**: Undo double-encoded UTF-8 from mermaid-ascii output
209
- ([`9e0560c`](https://github.com/CaptainCrouton89/termrender/commit/9e0560ce46b6dc3f90d2d716a97780713e5e5e53))
282
+ ([`9e0560c`](https://github.com/crouton-labs/termrender/commit/9e0560ce46b6dc3f90d2d716a97780713e5e5e53))
210
283
 
211
284
  mermaid-ascii misinterprets UTF-8 bytes as Latin-1 and re-encodes, corrupting multi-byte characters
212
285
  (e.g. → renders as â<U+0086><U+0092>). Apply latin-1 round-trip to recover original UTF-8 in both
@@ -215,7 +288,7 @@ mermaid-ascii misinterprets UTF-8 bytes as Latin-1 and re-encodes, corrupting mu
215
288
  ### Documentation
216
289
 
217
290
  - Add tmux pane lifecycle and --check interaction notes to CLAUDE.md
218
- ([`9400092`](https://github.com/CaptainCrouton89/termrender/commit/9400092e507d470acb97ac5a17b66fcf0e9aa2f6))
291
+ ([`9400092`](https://github.com/crouton-labs/termrender/commit/9400092e507d470acb97ac5a17b66fcf0e9aa2f6))
219
292
 
220
293
 
221
294
  ## v0.2.0 (2026-04-05)
@@ -223,27 +296,27 @@ mermaid-ascii misinterprets UTF-8 bytes as Latin-1 and re-encodes, corrupting mu
223
296
  ### Bug Fixes
224
297
 
225
298
  - Handle zero-width and emoji presentation chars in visual width calculation
226
- ([`d0bb8dc`](https://github.com/CaptainCrouton89/termrender/commit/d0bb8dcfa5ca0d2c16d78a1d7f81825231b9cb59))
299
+ ([`d0bb8dc`](https://github.com/crouton-labs/termrender/commit/d0bb8dcfa5ca0d2c16d78a1d7f81825231b9cb59))
227
300
 
228
301
  _char_width now returns 0 for combining marks and format characters (ZWJ, variation selectors).
229
302
  visual_len handles VS16 emoji presentation sequences by promoting the preceding character to width
230
303
  2. Fixes panel border misalignment when content contains emoji or special Unicode.
231
304
 
232
305
  - **docs**: Update README output examples to match actual rendered output
233
- ([`de6d0cc`](https://github.com/CaptainCrouton89/termrender/commit/de6d0ccfd8a60aca20f2b2659a313f8d8c87d853))
306
+ ([`de6d0cc`](https://github.com/crouton-labs/termrender/commit/de6d0ccfd8a60aca20f2b2659a313f8d8c87d853))
234
307
 
235
308
  ### Chores
236
309
 
237
310
  - Add README, design specs, and project CLAUDE.md files
238
- ([`93ac358`](https://github.com/CaptainCrouton89/termrender/commit/93ac35857981c549797a9359573cacea1478b3ad))
311
+ ([`93ac358`](https://github.com/crouton-labs/termrender/commit/93ac35857981c549797a9359573cacea1478b3ad))
239
312
 
240
313
  - Derive version from git tags via hatch-vcs
241
- ([`33595a0`](https://github.com/CaptainCrouton89/termrender/commit/33595a0b64363e445b90c9df135a50a4652e2bae))
314
+ ([`33595a0`](https://github.com/crouton-labs/termrender/commit/33595a0b64363e445b90c9df135a50a4652e2bae))
242
315
 
243
316
  ### Continuous Integration
244
317
 
245
318
  - Auto-release and publish via conventional commits
246
- ([`80a456b`](https://github.com/CaptainCrouton89/termrender/commit/80a456b7301c57f2fd2b0cd30622b78f2d4b931e))
319
+ ([`80a456b`](https://github.com/crouton-labs/termrender/commit/80a456b7301c57f2fd2b0cd30622b78f2d4b931e))
247
320
 
248
321
  Replace manual GitHub release trigger with python-semantic-release. On push to main, conventional
249
322
  commits are analyzed to determine version bumps (feat→minor, fix→patch) and publish to PyPI
@@ -252,12 +325,12 @@ Replace manual GitHub release trigger with python-semantic-release. On push to m
252
325
  ### Documentation
253
326
 
254
327
  - Update README token count and expand CLAUDE.md implementation notes
255
- ([`1f70a53`](https://github.com/CaptainCrouton89/termrender/commit/1f70a5352cbced30219012dbede7040c6ac97457))
328
+ ([`1f70a53`](https://github.com/crouton-labs/termrender/commit/1f70a5352cbced30219012dbede7040c6ac97457))
256
329
 
257
330
  ### Features
258
331
 
259
332
  - Add CJK ambiguous-width support, strict directive parsing, and rendering fixes
260
- ([`c000883`](https://github.com/CaptainCrouton89/termrender/commit/c0008835d66b721b0a09c7a34dde11d08b3d3d94))
333
+ ([`c000883`](https://github.com/crouton-labs/termrender/commit/c0008835d66b721b0a09c7a34dde11d08b3d3d94))
261
334
 
262
335
  - Add emoji presentation and East Asian ambiguous-width character handling with --cjk flag and
263
336
  TERMRENDER_CJK env var - All renderers (borders, divider, quote, tree) now compute box-drawing
@@ -266,14 +339,14 @@ Replace manual GitHub release trigger with python-semantic-release. On push to m
266
339
  for inter-column gaps - Support 'author' as alias for 'by' attribute on quote blocks
267
340
 
268
341
  - Add GFM table rendering with box-drawing borders
269
- ([`c3b61cd`](https://github.com/CaptainCrouton89/termrender/commit/c3b61cdd659fdb782089cbca2fd3f74b18486605))
342
+ ([`c3b61cd`](https://github.com/crouton-labs/termrender/commit/c3b61cdd659fdb782089cbca2fd3f74b18486605))
270
343
 
271
344
  Enable mistune table plugin, parse table AST into TABLE blocks, and render with box-drawing
272
345
  characters. Supports left/center/right column alignment, bold headers, auto-sized columns, and
273
346
  proportional overflow distribution.
274
347
 
275
348
  - **cli**: Add --tmux pane output, --check validation, and structured error handling
276
- ([`36b52ee`](https://github.com/CaptainCrouton89/termrender/commit/36b52eed9701cd0acd363db2d0fa3d277244c8b0))
349
+ ([`36b52ee`](https://github.com/crouton-labs/termrender/commit/36b52eed9701cd0acd363db2d0fa3d277244c8b0))
277
350
 
278
351
  - --tmux renders in a new tmux side pane via split-window, piped through less -R - --check validates
279
352
  directive syntax without rendering (exit 0/2) - Structured _error() helper with fix/hint guidance
@@ -283,12 +356,12 @@ Enable mistune table plugin, parse table AST into TABLE blocks, and render with
283
356
  depth note
284
357
 
285
358
  - **cli**: Improve help output with examples, version flag, and tty detection
286
- ([`cb3e7e2`](https://github.com/CaptainCrouton89/termrender/commit/cb3e7e2752ae860e5c3cbd4c4f1627e925a9c431))
359
+ ([`cb3e7e2`](https://github.com/crouton-labs/termrender/commit/cb3e7e2752ae860e5c3cbd4c4f1627e925a9c431))
287
360
 
288
361
  ### Testing
289
362
 
290
363
  - Add column alignment and visual width tests
291
- ([`f8b6099`](https://github.com/CaptainCrouton89/termrender/commit/f8b60998625977b10dd4697f8e772d80125cb9ce))
364
+ ([`f8b6099`](https://github.com/crouton-labs/termrender/commit/f8b60998625977b10dd4697f8e772d80125cb9ce))
292
365
 
293
366
  Covers showpiece rendering, column line width consistency, status marker visual widths (text vs
294
367
  emoji presentation), and panel border alignment.
@@ -0,0 +1,25 @@
1
+ # termrender
2
+
3
+ ## Commands
4
+ ```bash
5
+ pip install -e .
6
+ pytest tests/
7
+ python -m termrender <file.md>
8
+ python -m build
9
+ ```
10
+
11
+ No linter or formatter is configured.
12
+
13
+ ## Constraints
14
+ - **Layout pass order is load-bearing**: `resolve_width()` top-down must complete before `resolve_height()` bottom-up — height calls `wrap_text(text, width)`, which requires width already set.
15
+ - **`borders.py` `render_box` width**: takes **total** width including borders, not content width. Passing content width silently overflows.
16
+ - **`wrap_text()` CJK bug**: uses `len()` internally, not `visual_len()` — silently overflows for CJK content.
17
+ - **`_ambiguous_width` is global mutable state** with no reset path — `set_ambiguous_width()` or `TERMRENDER_CJK` env var changes persist for the process lifetime.
18
+ - **Version**: derived from git tags via hatch-vcs — no version in `pyproject.toml`. Adding one will conflict.
19
+ - **Commits**: conventional commits. `feat` → minor, `fix`/`perf` → patch. Auto-released via python-semantic-release on main.
20
+
21
+ ## Supplementary CLAUDE.md files
22
+ - `src/termrender/CLAUDE.md` — parser, layout, mermaid, nesting, and `--check`/`--tmux` implementation gotchas
23
+ - `src/termrender/renderers/CLAUDE.md` — renderer contracts, `render_box` width semantics, EAW edge cases
24
+
25
+ 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.8.0
3
+ Version: 1.0.1
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
@@ -393,17 +393,17 @@ Horizontal rules with optional centered labels.
393
393
 
394
394
  ### Mermaid diagrams
395
395
 
396
- Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Use standard mermaid fenced code blocks.
396
+ Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Mermaid uses the `:::mermaid` directive — backtick fences (`` ```mermaid ``) are treated as plain code blocks.
397
397
 
398
398
  **Input:**
399
- ````markdown
400
- ```mermaid
399
+ ```markdown
400
+ :::mermaid
401
401
  graph LR
402
402
  A[Request] --> B{Auth?}
403
403
  B -->|Yes| C[Handler]
404
404
  B -->|No| D[401]
405
+ :::
405
406
  ```
406
- ````
407
407
 
408
408
  The diagram gets rendered as ASCII art inline in the terminal output. Requires `mermaid-ascii` to be installed (it's a dependency, so it should be).
409
409
 
@@ -365,17 +365,17 @@ Horizontal rules with optional centered labels.
365
365
 
366
366
  ### Mermaid diagrams
367
367
 
368
- Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Use standard mermaid fenced code blocks.
368
+ Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Mermaid uses the `:::mermaid` directive — backtick fences (`` ```mermaid ``) are treated as plain code blocks.
369
369
 
370
370
  **Input:**
371
- ````markdown
372
- ```mermaid
371
+ ```markdown
372
+ :::mermaid
373
373
  graph LR
374
374
  A[Request] --> B{Auth?}
375
375
  B -->|Yes| C[Handler]
376
376
  B -->|No| D[401]
377
+ :::
377
378
  ```
378
- ````
379
379
 
380
380
  The diagram gets rendered as ASCII art inline in the terminal output. Requires `mermaid-ascii` to be installed (it's a dependency, so it should be).
381
381
 
@@ -0,0 +1,15 @@
1
+ - `layout.py` imports `fix_mermaid_encoding` and `preprocess_mermaid_for_ascii` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must preserve these imports. The two mermaid subprocess call sites differ: layout uses `check=True` (non-zero exit raises → caught → raw-source fallback); the renderer omits `check` (non-zero exit silently reads `stdout`, which may be empty or partial).
2
+
3
+ - `--check` calls `parse()` 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.
4
+
5
+ - Column widths: explicit percent/absolute allocations are subtracted 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.
6
+
7
+ - **QUOTE** height gets `+1` only when `author` or `by` attr is set. Using any other key (`attribution`, `source`) silently omits the extra line — the renderer's attribution line is clipped.
8
+
9
+ - **LIST_ITEM** layout 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.
10
+
11
+ - `text.py:32–33`: after each wrapped line, the offset skips one character if the next plain-text character is a space (the space `wrap_text` consumed). If wrapping preserves trailing spaces, this skip shifts subsequent span styling by one character.
12
+
13
+ - `--tmux` exits `EXIT_OK` after the tmux pane command succeeds — the `--check` branch comes later and is never reached. `--check` is silently dropped when combined with `--tmux`.
14
+
15
+ - `_EMOJI_WIDE_RANGES` in `style.py`: `_char_width()` exits early when `cp < lo`, assuming ranges are in ascending codepoint order. Adding a range out of order silently misclassifies all codepoints whose `lo` is higher than the inserted range's `lo`.
@@ -53,7 +53,8 @@ directives (close each with a matching colon count):
53
53
  :::tasklist Checkbox list — [x] checked, [ ] unchecked, [!] in-progress
54
54
  Plain lists with at least one marker auto-promote; use the directive
55
55
  to force unchecked styling on items without explicit markers.
56
- ```mermaid ... ``` Mermaid diagram (via mermaid-ascii)
56
+ :::mermaid Mermaid diagram (via mermaid-ascii)
57
+ body: standard mermaid source (graph TD, flowchart, sequenceDiagram, ...)
57
58
 
58
59
  Inline:
59
60
  :badge[text]{color=c} Inline pill badge
@@ -335,18 +336,27 @@ def main() -> None:
335
336
  if args.width:
336
337
  pane_width = args.width
337
338
  else:
338
- # Default: 1/3 of window width (minus separator)
339
+ # Default sizing aims for a readable side pane (~80 cols) and
340
+ # only falls back to 1/3-of-window when the window is too narrow
341
+ # to give that much without crushing the source pane.
339
342
  try:
340
343
  result = subprocess.run(
341
344
  ["tmux", "display-message", "-p", "#{window_width}"],
342
345
  capture_output=True, text=True, check=True,
343
346
  )
344
347
  window_width = int(result.stdout.strip())
345
- pane_width = (window_width - 2) // 3
348
+ # Prefer 80 cols if the source pane keeps at least 60.
349
+ # Otherwise split evenly. Floor of 40 keeps text legible.
350
+ if window_width >= 140:
351
+ pane_width = 80
352
+ elif window_width >= 100:
353
+ pane_width = window_width // 2
354
+ else:
355
+ pane_width = max(window_width - 2 - 50, 40)
346
356
  except Exception:
347
- pane_width = 60
357
+ pane_width = 80
348
358
 
349
- pane_width = max(pane_width, 20) # absolute minimum
359
+ pane_width = max(pane_width, 40) # absolute minimum for readability
350
360
 
351
361
  # Watch mode points the new pane at the user's real file so edits
352
362
  # propagate; non-watch mode snapshots source into a tempfile.
@@ -7,12 +7,24 @@ from termrender.renderers import (
7
7
  panel, columns, tree, code, text, divider, quote, mermaid, table,
8
8
  diff, charts, stat, timeline,
9
9
  )
10
+ from termrender.style import visual_ljust
10
11
 
11
12
 
12
13
  def emit_block(block: Block, color: bool) -> list[str]:
13
14
  """Render a single block and its children, returning output lines."""
14
15
  match block.type:
15
- case BlockType.DOCUMENT | BlockType.COL:
16
+ case BlockType.DOCUMENT:
17
+ # Insert a blank padded line between top-level siblings so
18
+ # paragraphs, headings, and blocks don't visually run together.
19
+ lines: list[str] = []
20
+ sep = visual_ljust("", block.width or 0)
21
+ for i, child in enumerate(block.children):
22
+ if i > 0:
23
+ lines.append(sep)
24
+ lines.extend(emit_block(child, color))
25
+ return lines
26
+
27
+ case BlockType.COL:
16
28
  lines: list[str] = []
17
29
  for child in block.children:
18
30
  lines.extend(emit_block(child, color))
@@ -192,9 +192,15 @@ def resolve_height(block: Block) -> None:
192
192
  elif bt == BlockType.TIMELINE:
193
193
  entries = block.attrs.get("entries", [])
194
194
  title_h = 1 if block.attrs.get("title") else 0
195
- # Each entry takes 1 line + 1 connector line between entries (none after last)
196
195
  if entries:
197
- block.height = title_h + len(entries) * 2 - 1
196
+ date_w = max(visual_len(e["date"]) for e in entries)
197
+ event_w = max((block.width or 60) - date_w - 4, 5)
198
+ total = 0
199
+ for entry in entries:
200
+ wrapped = wrap_text(entry["event"], event_w) or [""]
201
+ total += len(wrapped)
202
+ total += len(entries) - 1 # connector between entries
203
+ block.height = title_h + total
198
204
  else:
199
205
  block.height = max(title_h, 1)
200
206
 
@@ -69,15 +69,13 @@ _DIRECTIVE_TO_BLOCK: dict[str, BlockType] = {
69
69
  "gauge": BlockType.GAUGE,
70
70
  "stat": BlockType.STAT,
71
71
  "timeline": BlockType.TIMELINE,
72
+ "mermaid": BlockType.MERMAID,
72
73
  "tasklist": BlockType.LIST, # alias: forces tasklist styling on the inner list
73
74
  }
74
75
 
75
76
  _SELF_CLOSING_DIRECTIVES = frozenset({"divider", "progress", "gauge"})
76
77
 
77
- # MyST backtick fence directive: ```{name} optional-argument
78
- _BACKTICK_DIRECTIVE_RE = re.compile(r"^\{(\w[\w-]*)\}(.*)")
79
-
80
- # MyST option line: :key: value — intentionally requires a value after the key
78
+ # Option line: :key: value — intentionally requires a value after the key
81
79
  # (the \s+(.+) part). Flag-style options like :nosandbox: (no value) won't match
82
80
  # and will be treated as body content.
83
81
  _OPTION_LINE_RE = re.compile(r"^:(\w[\w-]*):\s+(.+)$")
@@ -134,7 +132,10 @@ def _convert_inline(nodes: list[dict]) -> list[InlineSpan]:
134
132
  elif ntype == "softbreak":
135
133
  spans.append(InlineSpan(text=" "))
136
134
  elif ntype == "linebreak":
137
- spans.append(InlineSpan(text="\n"))
135
+ # Two \n so wrap_text emits a blank line between the two sides
136
+ # of the hard break, giving more vertical breathing room than a
137
+ # soft wrap. See tests/test_linebreak.py.
138
+ spans.append(InlineSpan(text="\n\n"))
138
139
  else:
139
140
  # Fallback: try raw text
140
141
  if "raw" in node:
@@ -245,7 +246,7 @@ def _expand_inline_roles(spans: list[InlineSpan]) -> list[InlineSpan]:
245
246
 
246
247
 
247
248
  def _strip_options(body: str) -> tuple[dict[str, str], str]:
248
- """Strip MyST option lines from the start of a directive body.
249
+ """Strip option lines from the start of a directive body.
249
250
 
250
251
  Option lines have the form `:key: value` and appear at the start of the body.
251
252
  Blank lines between option lines are allowed. Scanning stops at the first
@@ -302,33 +303,10 @@ def _convert_ast(nodes: list[dict], _depth: int = 0) -> list[Block]:
302
303
  elif ntype == "block_code":
303
304
  raw = node.get("raw", "")
304
305
  info = node.get("attrs", {}).get("info", "")
305
- # MyST backtick fence directive: ```{name} optional-arg
306
- m_directive = _BACKTICK_DIRECTIVE_RE.match(info) if info else None
307
- if m_directive:
308
- dir_name = m_directive.group(1)
309
- arg_text = m_directive.group(2).strip()
310
- if dir_name == "mermaid":
311
- options, body = _strip_options(raw)
312
- attrs = dict(options)
313
- if arg_text:
314
- attrs["argument"] = arg_text
315
- attrs["source"] = body
316
- blocks.append(Block(type=BlockType.MERMAID, attrs=attrs))
317
- else:
318
- attrs: dict[str, Any] = {}
319
- if arg_text:
320
- attrs["argument"] = arg_text
321
- blocks.append(_directive_to_block(dir_name, attrs, raw, _depth=_depth))
322
- elif info == "mermaid":
323
- blocks.append(Block(
324
- type=BlockType.MERMAID,
325
- attrs={"source": raw},
326
- ))
327
- else:
328
- blocks.append(Block(
329
- type=BlockType.CODE,
330
- attrs={"lang": info, "source": raw},
331
- ))
306
+ blocks.append(Block(
307
+ type=BlockType.CODE,
308
+ attrs={"lang": info, "source": raw},
309
+ ))
332
310
 
333
311
  elif ntype == "list":
334
312
  ordered = node.get("attrs", {}).get("ordered", False)
@@ -336,12 +314,19 @@ def _convert_ast(nodes: list[dict], _depth: int = 0) -> list[Block]:
336
314
  for item_node in node.get("children", []):
337
315
  if item_node["type"] == "list_item":
338
316
  item_children = item_node.get("children", [])
339
- # list_item contains block_text nodes
317
+ # Tight lists wrap item content in block_text; loose lists
318
+ # (blank line between items) wrap it in paragraph. Either way,
319
+ # the first text-bearing child becomes the bullet's inline text.
340
320
  item_spans: list[InlineSpan] = []
341
321
  sub_blocks: list[Block] = []
322
+ bullet_text_taken = False
342
323
  for child in item_children:
343
- if child["type"] == "block_text":
324
+ ctype = child["type"]
325
+ if ctype == "block_text":
326
+ item_spans.extend(_convert_inline(child.get("children", [])))
327
+ elif ctype == "paragraph" and not bullet_text_taken:
344
328
  item_spans.extend(_convert_inline(child.get("children", [])))
329
+ bullet_text_taken = True
345
330
  else:
346
331
  sub_blocks.extend(_convert_ast([child], _depth=_depth))
347
332
  items.append(Block(
@@ -610,8 +595,8 @@ def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int
610
595
 
611
596
  block_type = _DIRECTIVE_TO_BLOCK.get(name, BlockType.PANEL)
612
597
 
613
- # Tree, Code, Diff: store raw body, don't parse as markdown
614
- if block_type in (BlockType.TREE, BlockType.CODE, BlockType.DIFF):
598
+ # Tree, Code, Diff, Mermaid: store raw body, don't parse as markdown
599
+ if block_type in (BlockType.TREE, BlockType.CODE, BlockType.DIFF, BlockType.MERMAID):
615
600
  attrs["source"] = body
616
601
  return Block(type=block_type, attrs=attrs)
617
602
 
@@ -29,7 +29,7 @@ def _render_wrapped_spans(
29
29
  prefix = first_prefix if i == 0 else cont_prefix
30
30
  lines.append(visual_ljust(prefix + styled, total_width))
31
31
  char_offset += line_len
32
- if char_offset < len(plain) and plain[char_offset] == " ":
32
+ if char_offset < len(plain) and plain[char_offset] in (" ", "\n"):
33
33
  char_offset += 1
34
34
 
35
35
  return lines
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from termrender.blocks import Block
6
- from termrender.style import style, visual_len, visual_ljust
6
+ from termrender.style import style, visual_len, visual_ljust, wrap_text
7
7
 
8
8
 
9
9
  def render(block: Block, color: bool, render_child=None) -> list[str]:
@@ -28,18 +28,16 @@ def render(block: Block, color: bool, render_child=None) -> list[str]:
28
28
  bar = style("│", color=accent, dim=True, enabled=color)
29
29
 
30
30
  event_w = max(w - date_w - 4, 5) # date + space + bullet + space + event
31
+ cont_indent = " " * (date_w + 1)
31
32
 
32
33
  for i, entry in enumerate(entries):
33
34
  date_text = entry["date"].rjust(date_w)
34
35
  date_styled = style(date_text, dim=True, enabled=color)
35
- event_text = entry["event"]
36
- if visual_len(event_text) > event_w:
37
- event_text = event_text[: max(event_w - 1, 0)] + "…"
38
- line = f"{date_styled} {bullet} {event_text}"
39
- lines.append(visual_ljust(line, w))
36
+ wrapped = wrap_text(entry["event"], event_w) or [""]
37
+ lines.append(visual_ljust(f"{date_styled} {bullet} {wrapped[0]}", w))
38
+ for cont in wrapped[1:]:
39
+ lines.append(visual_ljust(f"{cont_indent}{bar} {cont}", w))
40
40
  if i < len(entries) - 1:
41
- connector_indent = " " * (date_w + 1)
42
- connector = f"{connector_indent}{bar}"
43
- lines.append(visual_ljust(connector, w))
41
+ lines.append(visual_ljust(f"{cont_indent}{bar}", w))
44
42
 
45
43
  return lines
@@ -219,10 +219,17 @@ def visual_center(s: str, width: int, fillchar: str = ' ') -> str:
219
219
 
220
220
 
221
221
  def wrap_text(text: str, width: int) -> list[str]:
222
- if not text or text.isspace():
222
+ if not text:
223
+ return ['']
224
+ if "\n" in text:
225
+ result: list[str] = []
226
+ for seg in text.split("\n"):
227
+ result.extend(wrap_text(seg, width))
228
+ return result
229
+ if text.isspace():
223
230
  return ['']
224
231
  if width <= 0:
225
- return [text] if text else ['']
232
+ return [text]
226
233
  words = text.split(' ')
227
234
  lines: list[str] = []
228
235
  current = ''
@@ -85,24 +85,24 @@ c/
85
85
  # the column allocation. The inner panel's side walls and corner glyphs must
86
86
  # stay aligned even when content forces the panel to grow past its allotment.
87
87
  NESTED_PANEL_OVERFLOW_INPUT = """\
88
- ::::::panel{title="Outer" color="cyan"}
89
- :::::columns
90
- ::::col{width="58%"}
91
- :::panel{title="Request Flow" color="blue"}
92
- ```mermaid
88
+ :::::::panel{title="Outer" color="cyan"}
89
+ ::::::columns
90
+ :::::col{width="58%"}
91
+ ::::panel{title="Request Flow" color="blue"}
92
+ :::mermaid
93
93
  graph TD
94
94
  A[Edge gateway<br/>accepts request] --> B{Token valid?}
95
95
  B -->|yes| C[Route via LB<br/>to backend pool]
96
96
  B -->|no| D[Reject 401,<br/>write audit log]
97
97
  C --> E[Service responds,<br/>metrics emitted]
98
- ```
99
98
  :::
100
99
  ::::
101
- ::::col{width="42%"}
100
+ :::::
101
+ :::::col{width="42%"}
102
102
  right
103
- ::::
104
103
  :::::
105
104
  ::::::
105
+ :::::::
106
106
  """
107
107
 
108
108
 
@@ -0,0 +1,79 @@
1
+ import unittest
2
+
3
+ from termrender import render
4
+ from termrender.style import visual_len, wrap_text
5
+
6
+
7
+ class TestWrapTextLinebreak(unittest.TestCase):
8
+ def test_single_newline_splits(self):
9
+ self.assertEqual(wrap_text("a\nb", 10), ["a", "b"])
10
+
11
+ def test_newline_with_wrap(self):
12
+ # Each segment wraps independently.
13
+ self.assertEqual(wrap_text("aa bb\ncc dd", 3), ["aa", "bb", "cc", "dd"])
14
+
15
+ def test_leading_newline(self):
16
+ self.assertEqual(wrap_text("\nfoo", 10), ["", "foo"])
17
+
18
+ def test_trailing_newline(self):
19
+ self.assertEqual(wrap_text("foo\n", 10), ["foo", ""])
20
+
21
+ def test_consecutive_newlines(self):
22
+ self.assertEqual(wrap_text("a\n\nb", 10), ["a", "", "b"])
23
+
24
+ def test_plain_wrap_unchanged(self):
25
+ self.assertEqual(wrap_text("hello world foo bar", 10),
26
+ ["hello", "world foo", "bar"])
27
+
28
+
29
+ class TestLinebreakRendering(unittest.TestCase):
30
+ def test_hard_break_in_paragraph_pads_both_lines(self):
31
+ # Two trailing spaces = markdown hard line break.
32
+ # Hard breaks now emit a blank line between the two sides for extra
33
+ # vertical breathing room, so expect 3 padded lines.
34
+ output = render("line one \nline two", width=40, color=False)
35
+ lines = output.split("\n")
36
+ if lines and lines[-1] == "":
37
+ lines = lines[:-1]
38
+ self.assertEqual(len(lines), 3)
39
+ self.assertEqual(lines[1].strip(), "")
40
+ for line in lines:
41
+ self.assertEqual(visual_len(line), 40)
42
+
43
+ def test_hard_break_in_panel_aligns_borders(self):
44
+ src = ':::panel{title="t"}\nline one \nline two\n:::'
45
+ output = render(src, width=30, color=False)
46
+ lines = [ln for ln in output.split("\n") if ln]
47
+ # Panel: top border, line 1, blank, line 2, bottom border = 5 lines.
48
+ self.assertEqual(len(lines), 5)
49
+ widths = {visual_len(ln) for ln in lines}
50
+ self.assertEqual(widths, {30})
51
+ # Interior lines begin and end with the side border glyph.
52
+ for interior in lines[1:-1]:
53
+ self.assertTrue(interior.startswith("│"))
54
+ self.assertTrue(interior.rstrip().endswith("│"))
55
+
56
+ def test_hard_break_in_columns_preserves_row_width(self):
57
+ src = (
58
+ "::::columns\n:::col\nleft one \nleft two\n:::\n"
59
+ ":::col\nright\n:::\n::::"
60
+ )
61
+ output = render(src, width=40, color=False)
62
+ lines = [ln for ln in output.split("\n") if ln]
63
+ # Left column: "left one", blank, "left two" = 3 rows.
64
+ self.assertEqual(len(lines), 3)
65
+ for line in lines:
66
+ self.assertEqual(visual_len(line), 40)
67
+
68
+ def test_hard_break_preserves_inline_style(self):
69
+ # Bold span followed by hard break and plain text — bold must not
70
+ # bleed onto line two (or the blank between).
71
+ output = render("**bold** \nplain", width=30, color=True)
72
+ lines = output.rstrip("\n").split("\n")
73
+ self.assertEqual(len(lines), 3)
74
+ self.assertIn("\x1b[1m", lines[0])
75
+ self.assertNotIn("\x1b[1m", lines[2])
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()
@@ -1,4 +1,4 @@
1
- """Tests for MyST Markdown syntax support in the parser."""
1
+ """Tests for directive option lines and the colon-fence mermaid directive."""
2
2
 
3
3
  import unittest
4
4
 
@@ -6,56 +6,43 @@ from termrender.blocks import BlockType
6
6
  from termrender.parser import parse, _strip_options
7
7
 
8
8
 
9
- class TestBacktickFenceDirective(unittest.TestCase):
10
- """Feature 1: Backtick fence directive syntax."""
9
+ class TestMermaidDirective(unittest.TestCase):
10
+ """:::mermaid is the only fence form for mermaid diagrams."""
11
11
 
12
- def test_basic_backtick_fence_directive(self):
13
- """```{panel}\ncontent\n``` → BlockType.PANEL"""
14
- doc = parse("```{panel}\ncontent\n```")
12
+ def test_basic_mermaid_directive(self):
13
+ """:::mermaid\ngraph LR\nA-->B\n::: → BlockType.MERMAID"""
14
+ doc = parse(":::mermaid\ngraph LR\nA-->B\n:::")
15
15
  self.assertEqual(len(doc.children), 1)
16
- self.assertEqual(doc.children[0].type, BlockType.PANEL)
17
-
18
- def test_backtick_fence_with_option_lines(self):
19
- """```{panel}\n:title: Hello\ncontent\n``` → panel with title attr"""
20
- doc = parse("```{panel}\n:title: Hello\ncontent\n```")
21
- panel = doc.children[0]
22
- self.assertEqual(panel.type, BlockType.PANEL)
23
- self.assertEqual(panel.attrs["title"], "Hello")
16
+ block = doc.children[0]
17
+ self.assertEqual(block.type, BlockType.MERMAID)
18
+ self.assertIn("graph LR", block.attrs["source"])
24
19
 
25
- def test_backtick_mermaid_directive(self):
26
- """```{mermaid}\ngraph LR\nA-->B\n``` BlockType.MERMAID"""
27
- doc = parse("```{mermaid}\ngraph LR\nA-->B\n```")
28
- self.assertEqual(len(doc.children), 1)
20
+ def test_mermaid_with_option_lines(self):
21
+ """Option lines at the top of a mermaid body set attrs, not source."""
22
+ doc = parse(":::mermaid\n:title: Flow\ngraph LR\nA-->B\n:::")
29
23
  block = doc.children[0]
30
24
  self.assertEqual(block.type, BlockType.MERMAID)
25
+ self.assertEqual(block.attrs.get("title"), "Flow")
31
26
  self.assertIn("graph LR", block.attrs["source"])
27
+ self.assertNotIn(":title:", block.attrs["source"])
32
28
 
33
- def test_bare_mermaid_still_works(self):
34
- """```mermaid\ngraph LR\n``` BlockType.MERMAID (backward compat)"""
29
+ def test_backtick_mermaid_is_plain_code_block(self):
30
+ """```mermaid is no longer a mermaid block — it renders as a code block."""
35
31
  doc = parse("```mermaid\ngraph LR\nA-->B\n```")
36
- self.assertEqual(doc.children[0].type, BlockType.MERMAID)
37
-
38
- def test_empty_body_backtick_directive(self):
39
- """```{panel}\n``` → panel with no children"""
40
- doc = parse("```{panel}\n```")
41
- panel = doc.children[0]
42
- self.assertEqual(panel.type, BlockType.PANEL)
43
-
44
- def test_backtick_fence_with_argument(self):
45
- """```{code-block} python\nprint("hi")\n``` → attrs["argument"] = "python" """
46
- doc = parse('```{code-block} python\nprint("hi")\n```')
47
32
  block = doc.children[0]
48
- self.assertEqual(block.attrs.get("argument"), "python")
33
+ self.assertEqual(block.type, BlockType.CODE)
34
+ self.assertEqual(block.attrs.get("lang"), "mermaid")
49
35
 
50
- def test_four_backtick_fence(self):
51
- """````{panel}\ncontent\n```` works via mistune"""
52
- doc = parse("````{panel}\ncontent\n````")
53
- self.assertEqual(len(doc.children), 1)
54
- self.assertEqual(doc.children[0].type, BlockType.PANEL)
36
+ def test_backtick_directive_is_plain_code_block(self):
37
+ """```{panel} is no longer a directive — it renders as a code block."""
38
+ doc = parse("```{panel}\ncontent\n```")
39
+ block = doc.children[0]
40
+ self.assertEqual(block.type, BlockType.CODE)
41
+ self.assertEqual(block.attrs.get("lang"), "{panel}")
55
42
 
56
43
 
57
44
  class TestDirectiveOptionLines(unittest.TestCase):
58
- """Feature 2: Directive option lines."""
45
+ """Directive option lines (`:key: value` at top of body)."""
59
46
 
60
47
  def test_colon_directive_with_options(self):
61
48
  """:::panel\n:title: Hi\n:color: blue\ncontent\n::: → attrs have title and color"""
@@ -1,64 +0,0 @@
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,75 +0,0 @@
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` and `preprocess_mermaid_for_ascii` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must account for these imports. 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
- `preprocess_mermaid_for_ascii` rewrites sequence diagrams into the subset `mermaid-ascii` parses (it only supports `->>` / `-->>` arrows, `participant`, and self-loops). `Note over|left of|right of X[,Y]: msg` becomes a self-loop `X->>X: 📝 msg`; `->`, `-x`, `--x`, `-)`, `--)`, and bare `-->` are mapped to `->>`/`-->>`; block keywords (`loop`/`alt`/`opt`/`par`/`critical`/`break`/`rect`/`activate`/`deactivate`/`autonumber`/`else`/`and`/`end`) are dropped so the inner arrow lines still render; `<br/>` is flattened to ` / `. Non-sequence diagrams (`flowchart`, `graph`, etc.) pass through unchanged. Semantics are lossy by design — `-x` (fail arrow) renders as a plain arrow, and block scoping is lost — but the flow diagram renders instead of silently degrading to raw source.
14
-
15
- ## Directive nesting: outer must have more colons than inner
16
-
17
- 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.
18
-
19
- For colon directives, closers are paired strictly by colon count. A closer whose colon count differs from the open directive's colon count is treated as body content; the recursive `parse()` call inside `_directive_to_block` re-parses it as an inner directive. This matches the standard rule used by MyST, Pandoc fenced divs, markdown-it-container, and CommonMark fenced code blocks: outer fences must use strictly more colons than the inner fences they wrap, making opener/closer pairing unambiguous.
20
-
21
- 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`).
22
-
23
- ## `--check` validates parse only, not layout
24
-
25
- `__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.
26
-
27
- ## `_ambiguous_width` is global mutable state; `TERMRENDER_CJK` makes it permanent
28
-
29
- `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.
30
-
31
- ## Column width: explicit widths exceeding available space truncate auto-columns to 1
32
-
33
- `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.
34
-
35
- ## Height calculations with hidden assumptions
36
-
37
- - **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.
38
- - **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`.
39
- - **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.
40
-
41
- ## Character offset tracking across wrapped lines
42
-
43
- `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.
44
-
45
- ## `wrap_text` measures in characters, not visual columns
46
-
47
- `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`.
48
-
49
- ## Unknown directive names silently become PANEL
50
-
51
- `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).
52
-
53
- ## `--tmux`: exit code reflects pane creation, auto-sizing runs a full render, tempfiles leak
54
-
55
- `__main__.py:357`: 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 357 precedes the `--check` branch at line 368), even though `--tmux` now runs its own `parse()` call (lines 253–266) for fail-fast syntax validation before spawning the pane. The `--check` "ok" message and exit-code contract are not honoured.
56
-
57
- Tempfile cleanup is embedded as `... | less -R; rm -f <tmpfile>` (line 339); `less` killed abnormally (SIGKILL, closed session) leaks `/tmp/termrender-*.md`. `--tmux --watch` skips tempfile creation entirely — the pane is pointed at the real file path, so no leak.
58
-
59
- When `--width` is omitted, `--tmux` calls `render(source, width=80, color=False)` (lines 283–290) 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.
60
-
61
- ## `--watch`: re-renders on resize as well as file change; errors are inline, not fatal
62
-
63
- `_watch_loop` (lines 99–169) polls `os.path.getmtime` every 0.2 s and also re-renders when `shutil.get_terminal_size()` changes — so a terminal resize triggers a re-render even with no file edit. `width=None` is passed to `render()` each cycle, so auto-detection always uses current pane width.
64
-
65
- The loop catches bare `Exception` (line 142) to keep the watcher alive across render errors; errors appear as a one-line message in the pane, not as an exit. `DirectiveError`, `TerminalError`, and `ValueError` (nesting depth) are each caught separately with distinct prefixes for that same reason — the watcher never exits on render failure, only on `KeyboardInterrupt`.
66
-
67
- The alternate screen buffer (`\033[?1049h` / `\033[?1049l`) is entered on start and restored in a `finally` block, so Ctrl+C cleanly returns to the prior terminal state. `--watch` requires a FILE argument; stdin cannot be watched (polled by path, not fd).
68
-
69
- ## `TERMRENDER_COLOR=1` forces color when stdout is not a tty
70
-
71
- `use_color` (lines 361–363 and 388–390) is `True` when stdout is a tty OR `TERMRENDER_COLOR == "1"`. The tmux pane command is prefixed with `TERMRENDER_COLOR=1` (lines 334, 337) so color survives the `| less -R` pipe. Setting this env var in scripts achieves the same effect — it overrides the tty check entirely.
72
-
73
- ## `_EMOJI_WIDE_RANGES` must stay sorted by codepoint
74
-
75
- `_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.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes