Vectrum
Sign In

tui: render what the model says as the Markdown it is

The transcript has always word-wrapped assistant text and left it at
that, so a table arrived as a wall of pipes and a heading as a line
beginning with a hash. Everything the model writes is Markdown; the one
part of the screen that had to know that was the part that did not.

Assistant text now goes through glamour, with a stylesheet built from
the theme in force rather than glamour's own palette, so headings take
the accent, code blocks are chroma-highlighted in the same colours the
rest of the UI uses, and /setup repaints Markdown along with everything
else. Headings drop their hashes: having obeyed the instruction there is
no reason to print it too, and the levels are told apart by colour and
weight, the way this UI tells everything else apart. It renders as it
streams, so a half-typed fence is a code block in progress.

Only what the model says goes through it. Tool results and the output of
your own ! and $ commands are program output and are shown as they came,
and -no-tui is untouched so -p stays pipeable.

Four things the library needed working around. Styles.Text is applied as
an override on every text run, so a colour there flattens headings and
links back to one colour — Document carries the base instead. glamour
registers its chroma palette under one global name and skips the work if
taken, which would freeze code blocks in whichever theme drew first, so
we register our own per theme. Its default formatter quantises to 256
colours, a shade off everything beside it. And it re-emits the same
colour per word and one escape per cell of padding — an order of
magnitude more escape than text, held for the session — so each line is
compacted to the escapes it actually needs.

A word too long to wrap is now broken by force rather than left hanging
over the edge for the viewport to wrap again at a column this layout
knows nothing about. Laying out every entry on every streamed token cost
32ms on a fifty-message session, so an entry keeps its rendered
Markdown; the theme generation counter that drops it also fixes the
scrollback keeping its old colours after a theme change, which it did
before any of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 4f35549commite9e6141

Showing 9 changed files with 1021 additions and 13 deletions.

+30−0README.mdView File
@@ -87,6 +87,36 @@ is indented under that dot, so a screen of tool output still reads as a
8787 sequence of things that happened. Diffs are shown in full and in colour with a
8888 count of what changed; other output is elided after six lines.
8989
90+ What the model says is Markdown, and it is rendered as Markdown — headings,
91+ weight, lists, block quotes, rules, and tables laid out in columns:
92+
93+ ```
94+ ⏺ Findings
95+
96+ The refresh path drops the rotated token. Three things are going on:
97+
98+ 1. refresh() returns the new pair but never writes it back
99+ 2. the old refresh token is already dead by then
100+
101+ File │ Line │ Change
102+ ────────────────────────┼───────────────────────┼───────────────────────
103+ refresh.go │ 84 │ write back
104+
105+ func refresh(tok string) (Creds, error) {
106+ // TODO: persist the rotation
107+ }
108+ ```
109+
110+ Fenced code is syntax-highlighted by [chroma](https://github.com/alecthomas/chroma),
111+ and every colour — prose, headings, keywords, strings — comes from the theme in
112+ force, so `/setup` repaints Markdown along with everything else. Headings drop
113+ their hashes: the levels are told apart by colour and weight instead. It
114+ renders as it streams, so a table fills in row by row.
115+
116+ Only what the model says goes through this. Tool results and the output of your
117+ own `!` and `$` commands are program output and are shown as they came, and
118+ `-no-tui` prints raw text so `-p` stays pipeable.
119+
90120 The status bar reports the context gauge — the last turn's input size against
91121 the window, which drops when the conversation is compacted — and separates
92122 fresh input tokens from cache reads, because the gap between the two is what
+14−2go.modView File
@@ -3,10 +3,12 @@ module harness
33 go 1.26.8
44
55 require (
6+ github.com/alecthomas/chroma/v2 v2.20.0
67 github.com/bmatcuk/doublestar/v4 v4.10.0
78 github.com/charmbracelet/bubbles v1.0.0
89 github.com/charmbracelet/bubbletea v1.3.10
9- github.com/charmbracelet/lipgloss v1.1.0
10+ github.com/charmbracelet/glamour v1.0.0
11+ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1012 github.com/charmbracelet/x/ansi v0.11.6
1113 github.com/invopop/jsonschema v0.14.0
1214 github.com/muesli/termenv v0.16.0
@@ -17,25 +19,35 @@ require (
1719 require (
1820 github.com/atotto/clipboard v0.1.4 // indirect
1921 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
22+ github.com/aymerick/douceur v0.2.0 // indirect
2023 github.com/bahlo/generic-list-go v0.2.0 // indirect
2124 github.com/buger/jsonparser v1.1.2 // indirect
2225 github.com/charmbracelet/colorprofile v0.4.1 // indirect
2326 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
27+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
2428 github.com/charmbracelet/x/term v0.2.2 // indirect
2529 github.com/clipperhouse/displaywidth v0.9.0 // indirect
2630 github.com/clipperhouse/stringish v0.1.1 // indirect
2731 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
32+ github.com/dlclark/regexp2 v1.11.5 // indirect
2833 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
34+ github.com/gorilla/css v1.0.1 // indirect
2935 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3036 github.com/mattn/go-isatty v0.0.20 // indirect
3137 github.com/mattn/go-localereader v0.0.1 // indirect
3238 github.com/mattn/go-runewidth v0.0.19 // indirect
39+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3340 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3441 github.com/muesli/cancelreader v0.2.2 // indirect
42+ github.com/muesli/reflow v0.3.0 // indirect
3543 github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
3644 github.com/rivo/uniseg v0.4.7 // indirect
3745 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
46+ github.com/yuin/goldmark v1.7.13 // indirect
47+ github.com/yuin/goldmark-emoji v1.0.6 // indirect
3848 go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
49+ golang.org/x/net v0.38.0 // indirect
3950 golang.org/x/sys v0.47.0 // indirect
40- golang.org/x/text v0.3.8 // indirect
51+ golang.org/x/term v0.45.0 // indirect
52+ golang.org/x/text v0.30.0 // indirect
4153 )
+39−4go.sumView File
@@ -1,11 +1,19 @@
11 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
22 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3+ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
4+ github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
5+ github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
6+ github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
7+ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
8+ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
39 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
410 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
511 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
612 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
713 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
814 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
15+ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
16+ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
917 github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
1018 github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
1119 github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
@@ -18,12 +26,18 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
1826 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
1927 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
2028 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
21- github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
22- github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
29+ github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
30+ github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
31+ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
32+ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
2333 github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
2434 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
2535 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
2636 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
37+ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
38+ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
39+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
40+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
2741 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
2842 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
2943 github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
@@ -34,12 +48,18 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
3448 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
3549 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3650 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
51+ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
52+ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
3753 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3854 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3955 github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
4056 github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
4157 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4258 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
59+ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
60+ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
61+ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
62+ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
4363 github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
4464 github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
4565 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -52,18 +72,25 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
5272 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
5373 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
5474 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
75+ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
5576 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
5677 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
78+ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
79+ github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
5780 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
5881 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
5982 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
6083 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
84+ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
85+ github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
6186 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
6287 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
6388 github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
6489 github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
6590 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6691 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
92+ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
93+ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
6794 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
6895 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
6996 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
@@ -72,16 +99,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
7299 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
73100 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
74101 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
102+ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
103+ github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
104+ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
105+ github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
75106 go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
76107 go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
77108 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
78109 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
110+ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
111+ golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
79112 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
80113 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81114 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
82115 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
83- golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
84- golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
116+ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
117+ golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
118+ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
119+ golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
85120 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
86121 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
87122 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+449−0internal/tui/markdown.goaddedView File
@@ -0,0 +1,449 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+
6+ "github.com/alecthomas/chroma/v2"
7+ "github.com/alecthomas/chroma/v2/styles"
8+ "github.com/charmbracelet/glamour"
9+ glamourstyle "github.com/charmbracelet/glamour/ansi"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/charmbracelet/x/ansi"
12+ "github.com/muesli/termenv"
13+ )
14+
15+ // Assistant text is Markdown, so the transcript renders it as Markdown:
16+ // headings become headings, ** becomes weight, a table becomes a table. The
17+ // work is done by glamour, which is goldmark's CommonMark parser behind a
18+ // terminal renderer; what this file owns is the stylesheet, which is derived
19+ // from the theme in force so Markdown is in the same palette as everything
20+ // else on the screen rather than in glamour's own.
21+ //
22+ // Only assistant text goes through here. Tool results, local command output
23+ // and notices are program output, not prose, and are shown as they came.
24+
25+ // minMarkdownWidth is the point below which layout stops being worth
26+ // attempting: a table or an indented code block in fifteen columns is noise,
27+ // and plain wrapped text is the more useful thing to show.
28+ const minMarkdownWidth = 20
29+
30+ // mdRenderers holds one renderer per width and colour depth: constructing one
31+ // builds a goldmark pipeline and bakes the stylesheet in, and the transcript
32+ // asks for a render on every streamed token.
33+ var mdRenderers map[mdKey]*glamour.TermRenderer
34+
35+ // mdKey is everything a built renderer has baked in. The colour profile is
36+ // part of it because a test that forces colour on, or a terminal that reports
37+ // its depth late, must not be served a renderer built for the old answer.
38+ type mdKey struct {
39+ width int
40+ profile termenv.Profile
41+ }
42+
43+ // renderMarkdown lays out assistant prose to fit width. It never fails in a
44+ // way the reader can see: anything glamour cannot handle falls back to the
45+ // plain wrap the transcript used before, because a message that renders badly
46+ // is still better than a message that does not render.
47+ func renderMarkdown(src string, width int) string {
48+ src = strings.TrimRight(src, "\n")
49+ if strings.TrimSpace(src) == "" {
50+ return ""
51+ }
52+ if width < minMarkdownWidth {
53+ return plainWrap(src, width)
54+ }
55+ r := markdownRenderer(width)
56+ if r == nil {
57+ return plainWrap(src, width)
58+ }
59+ out, err := r.Render(src)
60+ if err != nil {
61+ return plainWrap(src, width)
62+ }
63+ // glamour frames a document in blank lines. The transcript already
64+ // separates entries, so they would read as a gap that means nothing.
65+ return fit(strings.Trim(out, "\n"), width)
66+ }
67+
68+ // fit squares glamour's output up with the columns we asked for.
69+ //
70+ // Two things need doing per line. The padding glamour lays down out to the
71+ // wrap width comes off: it is invisible until someone selects the text, and
72+ // then every line has a ragged tail of highlighted nothing. And a word too
73+ // long to wrap — a URL, an import path, a hash — gets broken by force, because
74+ // word wrapping leaves it hanging over the edge and the viewport then wraps
75+ // it again at a column this layout knows nothing about.
76+ func fit(s string, width int) string {
77+ var out []string
78+ for _, line := range strings.Split(s, "\n") {
79+ bare := strings.TrimRight(ansi.Strip(line), " \t")
80+ line = ansi.Truncate(line, ansi.StringWidth(bare), "")
81+ if ansi.StringWidth(line) <= width {
82+ out = append(out, compact(line))
83+ continue
84+ }
85+ for _, w := range strings.Split(ansi.Hardwrap(line, width, false), "\n") {
86+ out = append(out, compact(w))
87+ }
88+ }
89+ return strings.Join(out, "\n")
90+ }
91+
92+ // compact drops the escape sequences in a line that paint nothing.
93+ //
94+ // glamour styles each word and each cell of padding separately, so one line of
95+ // prose arrives as the same colour set and cleared thirty times over, followed
96+ // by an escape per trimmed padding space. Stripping to the visible width
97+ // leaves all of that behind — for a screenful of answer it is an order of
98+ // magnitude more bytes of escape than of text, held for the whole session and
99+ // re-scanned by the viewport on every frame.
100+ //
101+ // What survives is every escape that precedes text it actually changes, in
102+ // order, and one reset to close the line.
103+ func compact(line string) string {
104+ var (
105+ b strings.Builder
106+ pending []string // escapes seen since the last visible text
107+ active []string // what is in force on screen
108+ reset bool // a reset was seen and has not been emitted yet
109+ )
110+ for _, tok := range sgrTokens(line) {
111+ switch {
112+ case tok == "\x1b[0m" || tok == "\x1b[m":
113+ pending, reset = nil, len(active) > 0
114+ case strings.HasPrefix(tok, "\x1b[") && strings.HasSuffix(tok, "m"):
115+ pending = append(pending, tok)
116+ default:
117+ if reset {
118+ b.WriteString("\x1b[0m")
119+ active, reset = nil, false
120+ }
121+ for _, esc := range pending {
122+ b.WriteString(esc)
123+ }
124+ active = append(active, pending...)
125+ pending = nil
126+ b.WriteString(tok)
127+ }
128+ }
129+ if len(active) > 0 {
130+ b.WriteString("\x1b[0m")
131+ }
132+ return b.String()
133+ }
134+
135+ // sgrTokens splits a line into SGR sequences and the text between them.
136+ // Anything else escaped — a hyperlink, a cursor move — comes back as text, so
137+ // it is passed through untouched rather than reasoned about.
138+ func sgrTokens(s string) []string {
139+ var out []string
140+ text := 0
141+ for i := 0; i < len(s); {
142+ if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
143+ i++
144+ continue
145+ }
146+ j := i + 2
147+ for j < len(s) && (s[j] == ';' || (s[j] >= '0' && s[j] <= '9')) {
148+ j++
149+ }
150+ if j >= len(s) || s[j] != 'm' {
151+ i++
152+ continue
153+ }
154+ if text < i {
155+ out = append(out, s[text:i])
156+ }
157+ out = append(out, s[i:j+1])
158+ i, text = j+1, j+1
159+ }
160+ if text < len(s) {
161+ out = append(out, s[text:])
162+ }
163+ return out
164+ }
165+
166+ // plainWrap is what the transcript did before Markdown, kept as the fallback.
167+ func plainWrap(src string, width int) string {
168+ return styText.Width(maxInt(1, width)).Render(src)
169+ }
170+
171+ func markdownRenderer(width int) *glamour.TermRenderer {
172+ // lipgloss's profile rather than termenv's own: glamour assumes truecolor,
173+ // and the rest of this UI paints with whatever lipgloss decided the
174+ // terminal can show. Two answers to that question on one screen is how
175+ // half a transcript ends up in colour and half of it not.
176+ key := mdKey{width: width, profile: lipgloss.ColorProfile()}
177+ if r, ok := mdRenderers[key]; ok {
178+ return r
179+ }
180+ r, err := glamour.NewTermRenderer(
181+ glamour.WithStyles(markdownStyle(currentTheme, key.profile, width)),
182+ glamour.WithWordWrap(width),
183+ glamour.WithColorProfile(key.profile),
184+ glamour.WithChromaFormatter(chromaFormatter(key.profile)),
185+ )
186+ if err != nil {
187+ return nil
188+ }
189+ if mdRenderers == nil {
190+ mdRenderers = map[mdKey]*glamour.TermRenderer{}
191+ }
192+ mdRenderers[key] = r
193+ return r
194+ }
195+
196+ // resetMarkdown drops the renderers built against the previous palette, so the
197+ // next draw builds them from the new one. applyTheme calls it.
198+ func resetMarkdown() { mdRenderers = nil }
199+
200+ // chromaFormatter names the chroma writer that matches what the terminal can
201+ // show. glamour defaults to terminal256, which would quantise this theme's
202+ // colours to the nearest xterm slot and leave highlighted code a shade off
203+ // every other thing on the screen.
204+ func chromaFormatter(p termenv.Profile) string {
205+ switch p {
206+ case termenv.TrueColor:
207+ return "terminal16m"
208+ case termenv.ANSI:
209+ return "terminal"
210+ default:
211+ return "terminal256"
212+ }
213+ }
214+
215+ // chromaThemes records which palettes have been handed to chroma's global
216+ // registry, which is keyed by name and shared by every user of the package.
217+ var chromaThemes = map[string]bool{}
218+
219+ // chromaTheme registers this theme's syntax colours with chroma and returns
220+ // the name to ask for. It returns "" on a terminal that cannot show colour,
221+ // which is how a code block is told to render plain: glamour only guards its
222+ // own stylesheet against that case, not a theme named directly.
223+ //
224+ // The name is per-theme because chroma's registry is global and write-once in
225+ // practice — glamour registers its own under the single name "charm" and skips
226+ // the work if that name is taken, so a shared name would freeze code blocks in
227+ // whichever palette happened to draw first.
228+ func chromaTheme(t theme, p termenv.Profile) string {
229+ if p == termenv.Ascii {
230+ return ""
231+ }
232+ name := "harness-" + t.name
233+ if chromaThemes[name] {
234+ return name
235+ }
236+ c := markdownChroma(t)
237+ styles.Register(chroma.MustNewStyle(name, chroma.StyleEntries{
238+ chroma.Text: chromaEntry(c.Text),
239+ chroma.Error: chromaEntry(c.Error),
240+ chroma.Comment: chromaEntry(c.Comment),
241+ chroma.CommentPreproc: chromaEntry(c.CommentPreproc),
242+ chroma.Keyword: chromaEntry(c.Keyword),
243+ chroma.KeywordReserved: chromaEntry(c.KeywordReserved),
244+ chroma.KeywordNamespace: chromaEntry(c.KeywordNamespace),
245+ chroma.KeywordType: chromaEntry(c.KeywordType),
246+ chroma.Operator: chromaEntry(c.Operator),
247+ chroma.Punctuation: chromaEntry(c.Punctuation),
248+ chroma.Name: chromaEntry(c.Name),
249+ chroma.NameBuiltin: chromaEntry(c.NameBuiltin),
250+ chroma.NameTag: chromaEntry(c.NameTag),
251+ chroma.NameAttribute: chromaEntry(c.NameAttribute),
252+ chroma.NameClass: chromaEntry(c.NameClass),
253+ chroma.NameConstant: chromaEntry(c.NameConstant),
254+ chroma.NameDecorator: chromaEntry(c.NameDecorator),
255+ chroma.NameException: chromaEntry(c.NameException),
256+ chroma.NameFunction: chromaEntry(c.NameFunction),
257+ chroma.NameOther: chromaEntry(c.NameOther),
258+ chroma.Literal: chromaEntry(c.Literal),
259+ chroma.LiteralNumber: chromaEntry(c.LiteralNumber),
260+ chroma.LiteralDate: chromaEntry(c.LiteralDate),
261+ chroma.LiteralString: chromaEntry(c.LiteralString),
262+ chroma.LiteralStringEscape: chromaEntry(c.LiteralStringEscape),
263+ chroma.GenericDeleted: chromaEntry(c.GenericDeleted),
264+ chroma.GenericInserted: chromaEntry(c.GenericInserted),
265+ chroma.GenericEmph: chromaEntry(c.GenericEmph),
266+ chroma.GenericStrong: chromaEntry(c.GenericStrong),
267+ chroma.GenericSubheading: chromaEntry(c.GenericSubheading),
268+ }))
269+ chromaThemes[name] = true
270+ return name
271+ }
272+
273+ // markdownChroma maps chroma's token classes onto the same theme roles the
274+ // rest of the UI uses, so highlighted code reads as part of this theme and not
275+ // as a photograph of somebody else's editor.
276+ func markdownChroma(t theme) glamourstyle.Chroma {
277+ var (
278+ text = colorOf(t.text)
279+ dim = colorOf(t.dim)
280+ accent = colorOf(t.accent)
281+ info = colorOf(t.info)
282+ ok = colorOf(t.ok)
283+ warn = colorOf(t.warn)
284+ bad = colorOf(t.err)
285+ )
286+ yes := ptr(true)
287+ return glamourstyle.Chroma{
288+ Text: glamourstyle.StylePrimitive{Color: text},
289+ Error: glamourstyle.StylePrimitive{Color: bad},
290+ Comment: glamourstyle.StylePrimitive{Color: dim, Italic: yes},
291+ CommentPreproc: glamourstyle.StylePrimitive{Color: warn},
292+ Keyword: glamourstyle.StylePrimitive{Color: accent},
293+ KeywordReserved: glamourstyle.StylePrimitive{Color: accent},
294+ KeywordNamespace: glamourstyle.StylePrimitive{Color: accent},
295+ KeywordType: glamourstyle.StylePrimitive{Color: info},
296+ Operator: glamourstyle.StylePrimitive{Color: dim},
297+ Punctuation: glamourstyle.StylePrimitive{Color: dim},
298+ Name: glamourstyle.StylePrimitive{Color: text},
299+ NameBuiltin: glamourstyle.StylePrimitive{Color: info},
300+ NameTag: glamourstyle.StylePrimitive{Color: accent},
301+ NameAttribute: glamourstyle.StylePrimitive{Color: info},
302+ NameClass: glamourstyle.StylePrimitive{Color: info, Bold: yes},
303+ NameConstant: glamourstyle.StylePrimitive{Color: warn},
304+ NameDecorator: glamourstyle.StylePrimitive{Color: warn},
305+ NameException: glamourstyle.StylePrimitive{Color: bad},
306+ NameFunction: glamourstyle.StylePrimitive{Color: info},
307+ NameOther: glamourstyle.StylePrimitive{Color: text},
308+ Literal: glamourstyle.StylePrimitive{Color: ok},
309+ LiteralNumber: glamourstyle.StylePrimitive{Color: warn},
310+ LiteralDate: glamourstyle.StylePrimitive{Color: warn},
311+ LiteralString: glamourstyle.StylePrimitive{Color: ok},
312+ LiteralStringEscape: glamourstyle.StylePrimitive{Color: warn},
313+ GenericDeleted: glamourstyle.StylePrimitive{Color: bad},
314+ GenericInserted: glamourstyle.StylePrimitive{Color: ok},
315+ GenericEmph: glamourstyle.StylePrimitive{Italic: yes},
316+ GenericStrong: glamourstyle.StylePrimitive{Bold: yes},
317+ GenericSubheading: glamourstyle.StylePrimitive{Color: dim},
318+ }
319+ }
320+
321+ // chromaEntry writes one token style in the "#rrggbb italic bold" grammar
322+ // chroma parses its stylesheets from.
323+ func chromaEntry(s glamourstyle.StylePrimitive) string {
324+ var parts []string
325+ if s.Color != nil {
326+ parts = append(parts, *s.Color)
327+ }
328+ if s.Italic != nil && *s.Italic {
329+ parts = append(parts, "italic")
330+ }
331+ if s.Bold != nil && *s.Bold {
332+ parts = append(parts, "bold")
333+ }
334+ return strings.Join(parts, " ")
335+ }
336+
337+ // markdownStyle maps Markdown onto the ten theme roles.
338+ //
339+ // Headings carry no hashes. A hash is an instruction to make the line a
340+ // heading, and having obeyed it there is no reason to print it too; the levels
341+ // are told apart by colour and weight, the way the rest of this UI tells
342+ // things apart.
343+ func markdownStyle(t theme, profile termenv.Profile, width int) glamourstyle.StyleConfig {
344+ var (
345+ text = colorOf(t.text)
346+ dim = colorOf(t.dim)
347+ faint = colorOf(t.faint)
348+ accent = colorOf(t.accent)
349+ info = colorOf(t.info)
350+ )
351+ no, yes := ptr(false), ptr(true)
352+
353+ return glamourstyle.StyleConfig{
354+ Document: glamourstyle.StyleBlock{
355+ StylePrimitive: glamourstyle.StylePrimitive{Color: text},
356+ // The transcript owns the left margin: everything hangs off the
357+ // dot in the gutter, and a second indent from glamour would put
358+ // prose out of line with every other kind of entry.
359+ Margin: ptr(uint(0)),
360+ },
361+ // Paragraph and Text are deliberately left bare. glamour applies
362+ // Styles.Text to every text run as an override, so a colour here would
363+ // win over the heading, the link and the blockquote alike and flatten
364+ // the whole document back to one colour. Document above is the base;
365+ // everything else is a departure from it.
366+ Paragraph: glamourstyle.StyleBlock{},
367+
368+ Heading: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{
369+ Color: accent, Bold: yes, BlockSuffix: "\n",
370+ }},
371+ H1: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: accent, Bold: yes}},
372+ H2: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: accent, Bold: yes}},
373+ H3: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: text, Bold: yes}},
374+ H4: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: text, Bold: yes}},
375+ H5: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: dim, Bold: yes}},
376+ H6: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: dim, Bold: no}},
377+
378+ Strong: glamourstyle.StylePrimitive{Bold: yes},
379+ Emph: glamourstyle.StylePrimitive{Italic: yes},
380+ Strikethrough: glamourstyle.StylePrimitive{CrossedOut: yes, Color: dim},
381+
382+ BlockQuote: glamourstyle.StyleBlock{
383+ StylePrimitive: glamourstyle.StylePrimitive{Color: dim, Italic: yes},
384+ Indent: ptr(uint(1)),
385+ IndentToken: ptr("│ "),
386+ },
387+
388+ List: glamourstyle.StyleList{LevelIndent: 2},
389+ Item: glamourstyle.StylePrimitive{BlockPrefix: "• "},
390+ Enumeration: glamourstyle.StylePrimitive{BlockPrefix: ". "},
391+ Task: glamourstyle.StyleTask{Ticked: "[✓] ", Unticked: "[ ] "},
392+
393+ Link: glamourstyle.StylePrimitive{Color: info, Underline: yes},
394+ LinkText: glamourstyle.StylePrimitive{Color: text},
395+ Image: glamourstyle.StylePrimitive{Color: info, Underline: yes},
396+ ImageText: glamourstyle.StylePrimitive{
397+ Color: dim, Format: "Image: {{.text}} →",
398+ },
399+
400+ // A rule divides the page, so it is drawn as wide as the page. The
401+ // stylesheet can say that because it is built per width, the same as
402+ // the renderer that holds it.
403+ HorizontalRule: glamourstyle.StylePrimitive{
404+ Color: faint, Format: "\n" + strings.Repeat("─", maxInt(1, width)) + "\n",
405+ },
406+
407+ // Inline code takes the accent so a literal token stands out of the
408+ // sentence around it. A background colour would be the obvious other
409+ // choice, and is what glamour ships, but it tears across a wrap.
410+ Code: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: accent}},
411+
412+ CodeBlock: glamourstyle.StyleCodeBlock{
413+ StyleBlock: glamourstyle.StyleBlock{
414+ StylePrimitive: glamourstyle.StylePrimitive{Color: text},
415+ Margin: ptr(uint(2)),
416+ },
417+ // Named rather than handed over inline: glamour would register an
418+ // inline Chroma block under one global name shared with every
419+ // other program using it. See chromaTheme.
420+ Theme: chromaTheme(t, profile),
421+ },
422+
423+ // The rules take the dim grey the rest of the UI draws structure in,
424+ // leaving the cells at the document's own colour.
425+ Table: glamourstyle.StyleTable{
426+ StyleBlock: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: dim}},
427+ CenterSeparator: ptr("┼"),
428+ ColumnSeparator: ptr("│"),
429+ RowSeparator: ptr("─"),
430+ },
431+
432+ DefinitionTerm: glamourstyle.StylePrimitive{Bold: yes},
433+ DefinitionDescription: glamourstyle.StylePrimitive{BlockPrefix: "\n "},
434+ }
435+ }
436+
437+ // colorOf resolves an adaptive colour to the one hex value glamour can take.
438+ // glamour's stylesheet is strings, so the light/dark choice has to be made
439+ // here rather than at draw time; it is remade whenever the theme is applied,
440+ // which is the only moment the answer can change.
441+ func colorOf(c lipgloss.AdaptiveColor) *string {
442+ s := c.Light
443+ if lipgloss.HasDarkBackground() {
444+ s = c.Dark
445+ }
446+ return &s
447+ }
448+
449+ func ptr[T any](v T) *T { return &v }
+18−0internal/tui/markdown_bench_test.goaddedView File
@@ -0,0 +1,18 @@
1+ package tui
2+
3+ import "testing"
4+
5+ // A streamed token redraws the whole scrollback, so the cost of one render of
6+ // a long session is the cost of every token of the next answer.
7+ func BenchmarkTranscriptRender(b *testing.B) {
8+ tr := newTranscript(100)
9+ for range 50 {
10+ tr.appendDelta(sampleMarkdown)
11+ tr.closeOpen()
12+ }
13+ b.ResetTimer()
14+ for b.Loop() {
15+ tr.dirty = true
16+ _ = tr.render()
17+ }
18+ }
+336−0internal/tui/markdown_test.goaddedView File
@@ -0,0 +1,336 @@
1+ package tui
2+
3+ import (
4+ "regexp"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/charmbracelet/x/ansi"
10+ "github.com/muesli/termenv"
11+ )
12+
13+ // plain is what the reader sees with the colour taken off, which is what most
14+ // of these assertions are about: the shape of the text, not its palette.
15+ func plain(s string) string { return ansi.Strip(s) }
16+
17+ // A heading is markup, not content: the hashes are an instruction to make the
18+ // line a heading, and printing them back means the instruction was ignored.
19+ func TestMarkdownHeadingLosesItsHashes(t *testing.T) {
20+ got := plain(renderMarkdown("# Overview", 60))
21+ if !strings.Contains(got, "Overview") {
22+ t.Errorf("heading text missing from %q", got)
23+ }
24+ if strings.Contains(got, "#") {
25+ t.Errorf("heading still carries its hashes: %q", got)
26+ }
27+ }
28+
29+ // fgSeq is the SGR parameters lipgloss emits for a foreground colour — the
30+ // "38;2;r;g;b" alone, without the brackets, because glamour packs bold into
31+ // the same escape and an assertion on the whole sequence would be an
32+ // assertion about that packing rather than about the colour.
33+ func fgSeq(c lipgloss.AdaptiveColor) string {
34+ open, _, _ := strings.Cut(lipgloss.NewStyle().Foreground(c).Render("x"), "x")
35+ return strings.TrimSuffix(strings.TrimPrefix(open, "\x1b["), "m")
36+ }
37+
38+ // Markdown is styled from the theme in force, so switching theme repaints it
39+ // with everything else rather than leaving glamour's own colours on screen.
40+ func TestMarkdownFollowsTheTheme(t *testing.T) {
41+ forceColour(t)
42+ t.Cleanup(func() { applyTheme(defaultTheme) })
43+
44+ for _, name := range []string{"ember", "mint", "iris"} {
45+ applyTheme(name)
46+ got := renderMarkdown("# Overview", 60)
47+ if want := fgSeq(lookupTheme(name).accent); !strings.Contains(got, want) {
48+ t.Errorf("%s: heading is not in the theme's accent\n got %q\nwant %q inside", name, got, want)
49+ }
50+ }
51+ }
52+
53+ // sampleMarkdown is the shape of an ordinary assistant answer: a heading, a
54+ // sentence with weight in it, a list, a table and a fenced block.
55+ const sampleMarkdown = "# Findings\n\n" +
56+ "The **refresh** path drops the token when `rotate` is set, which is why\n" +
57+ "the login dies on the second day.\n\n" +
58+ "## What to change\n\n" +
59+ "- Write the replacement back to disk\n" +
60+ "- Keep the old one until the write lands\n" +
61+ " - and fsync before returning\n\n" +
62+ "| File | Change |\n| --- | --- |\n| refresh.go | write-back |\n| config.go | none |\n\n" +
63+ "```go\nfunc refresh(tok string) error {\n\treturn nil // TODO\n}\n```\n\n" +
64+ "> Worth a second pair of eyes.\n"
65+
66+ // Trailing spaces are invisible until you select the text, and then they are
67+ // a ragged right edge on every line. glamour pads to the wrap width; the
68+ // transcript should not carry that padding into the scrollback.
69+ func TestMarkdownLeavesNoTrailingSpaces(t *testing.T) {
70+ forceColour(t)
71+ for _, line := range strings.Split(renderMarkdown(sampleMarkdown, 60), "\n") {
72+ if bare := plain(line); bare != strings.TrimRight(bare, " \t") {
73+ t.Errorf("line padded with trailing whitespace: %q", bare)
74+ }
75+ }
76+ }
77+
78+ // The transcript hands Markdown the columns it has. Anything wider wraps into
79+ // the viewport's own wrapping, which turns one over-long table row into two
80+ // rows of rubble.
81+ func TestMarkdownFitsTheWidth(t *testing.T) {
82+ forceColour(t)
83+ for _, width := range []int{20, 34, 60, 100} {
84+ for _, line := range strings.Split(renderMarkdown(sampleMarkdown, width), "\n") {
85+ if w := lipgloss.Width(line); w > width {
86+ t.Errorf("width %d: line is %d cells: %q", width, w, plain(line))
87+ }
88+ }
89+ }
90+ }
91+
92+ // A URL is one word as far as word wrapping is concerned, so it runs off the
93+ // end of a narrow terminal and the viewport wraps it again at a column the
94+ // layout knows nothing about. Assistant answers are full of URLs and paths.
95+ func TestMarkdownBreaksAnUnbreakableWord(t *testing.T) {
96+ forceColour(t)
97+ src := "See https://example.com/a/very/long/url/that/cannot/be/broken/anywhere for more.\n"
98+ for _, width := range []int{20, 30, 40} {
99+ for _, line := range strings.Split(renderMarkdown(src, width), "\n") {
100+ if w := lipgloss.Width(line); w > width {
101+ t.Errorf("width %d: line is %d cells: %q", width, w, plain(line))
102+ }
103+ }
104+ }
105+ }
106+
107+ // A fenced block with a language on it is highlighted, in this theme's
108+ // colours rather than chroma's own, so code in the transcript looks like it
109+ // belongs to the same program as the rest of the screen.
110+ func TestMarkdownHighlightsFencedCode(t *testing.T) {
111+ forceColour(t)
112+ got := renderMarkdown("```go\nfunc main() { return }\n```\n", 60)
113+
114+ if !strings.Contains(got, fgSeq(currentTheme.accent)) {
115+ t.Errorf("keywords are not in the theme's accent:\n%q", got)
116+ }
117+ if !strings.Contains(plain(got), "func main()") {
118+ t.Errorf("code text missing:\n%q", plain(got))
119+ }
120+ }
121+
122+ // An unlabelled fence has no language to highlight, and guessing one is how a
123+ // block of log output ends up half-coloured. It stays plain.
124+ func TestMarkdownLeavesUnlabelledFencePlain(t *testing.T) {
125+ forceColour(t)
126+ got := renderMarkdown("```\nplain block\n```\n", 60)
127+ if !strings.Contains(plain(got), "plain block") {
128+ t.Errorf("code text missing:\n%q", plain(got))
129+ }
130+ if strings.Contains(got, fgSeq(currentTheme.accent)) {
131+ t.Errorf("an unlabelled block was highlighted anyway:\n%q", got)
132+ }
133+ }
134+
135+ // Code blocks are painted by chroma, which keeps one global registry of
136+ // palettes keyed by name. Sharing a name across themes is how a /setup theme
137+ // change repaints everything on screen except the code.
138+ func TestMarkdownRepaintsCodeOnAThemeChange(t *testing.T) {
139+ forceColour(t)
140+ t.Cleanup(func() { applyTheme(defaultTheme) })
141+
142+ const src = "```go\nfunc main() { return }\n```\n"
143+ applyTheme("ember")
144+ ember := renderMarkdown(src, 60)
145+ applyTheme("iris")
146+ iris := renderMarkdown(src, 60)
147+
148+ if ember == iris {
149+ t.Error("code block kept the old theme's syntax colours")
150+ }
151+ if !strings.Contains(iris, fgSeq(lookupTheme("iris").accent)) {
152+ t.Errorf("keywords are not in iris's accent:\n%q", iris)
153+ }
154+ }
155+
156+ // A terminal that cannot show colour must not be sent colour. glamour guards
157+ // its own stylesheet against this; a chroma theme named directly is not
158+ // guarded, and would paint code blocks under NO_COLOR regardless.
159+ //
160+ // Weight and italics do survive, which is what lipgloss does for every other
161+ // style in this UI at the same profile: NO_COLOR is about colour.
162+ func TestMarkdownEmitsNoColourOnAColourlessTerminal(t *testing.T) {
163+ prev := lipgloss.ColorProfile()
164+ lipgloss.SetColorProfile(termenv.Ascii)
165+ t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
166+
167+ got := renderMarkdown(sampleMarkdown, 60)
168+ for _, seq := range []string{"38;", "48;"} {
169+ if strings.Contains(got, seq) {
170+ t.Errorf("colour %q emitted on a colourless terminal:\n%q", seq, got)
171+ }
172+ }
173+ if !strings.Contains(got, "func refresh") {
174+ t.Errorf("code block content missing:\n%s", plain(got))
175+ }
176+ }
177+
178+ // Half a fence is what every code block looks like while it streams. goldmark
179+ // reads the unterminated one as a block running to the end, which is the right
180+ // guess, and nothing here may drop the text on the floor.
181+ func TestMarkdownHandlesPartialInput(t *testing.T) {
182+ forceColour(t)
183+ cases := []struct {
184+ name, src string
185+ want []string
186+ }{
187+ {"unterminated fence", "Here:\n\n```go\nfunc main() {", []string{"Here:", "func main() {"}},
188+ {"half an emphasis", "The **refresh", []string{"The", "refresh"}},
189+ {"a table mid-row", "| File | Change |\n| --- | --- |\n| refresh.go |", []string{"File", "Change", "refresh.go"}},
190+ // A lone hash is an empty heading, and a lone fence an empty block:
191+ // both render to nothing until their content arrives a token later.
192+ // What matters is that neither takes the renderer down.
193+ {"a bare hash", "#", nil},
194+ {"a fence with no body yet", "```", nil},
195+ }
196+ for _, c := range cases {
197+ t.Run(c.name, func(t *testing.T) {
198+ got := plain(renderMarkdown(c.src, 60))
199+ for _, want := range c.want {
200+ if !strings.Contains(got, want) {
201+ t.Errorf("lost %q from the stream:\n%s", want, got)
202+ }
203+ }
204+ })
205+ }
206+ }
207+
208+ // Below a certain width there is no layout worth attempting, and a table or an
209+ // indented code block becomes noise. Plain wrapped text is the useful answer.
210+ func TestMarkdownFallsBackOnANarrowTerminal(t *testing.T) {
211+ forceColour(t)
212+ got := plain(renderMarkdown("# Findings\n\nThe refresh path drops the token.", 8))
213+ if !strings.Contains(got, "refresh") {
214+ t.Errorf("text missing at a narrow width:\n%s", got)
215+ }
216+ for _, line := range strings.Split(got, "\n") {
217+ if w := lipgloss.Width(line); w > 8 {
218+ t.Errorf("line is %d cells on an 8-column terminal: %q", w, line)
219+ }
220+ }
221+ }
222+
223+ // Empty text is what an assistant entry holds for the instant between the turn
224+ // starting and the first token landing.
225+ func TestMarkdownRendersNothingForEmptyText(t *testing.T) {
226+ for _, src := range []string{"", "\n\n", " "} {
227+ if got := renderMarkdown(src, 60); got != "" {
228+ t.Errorf("renderMarkdown(%q) = %q, want empty", src, got)
229+ }
230+ }
231+ }
232+
233+ // Every theme has to produce a complete stylesheet. A missing role would
234+ // render as an unstyled patch rather than as an obviously wrong colour, which
235+ // is the kind of bug that ships.
236+ func TestEveryThemeStylesMarkdown(t *testing.T) {
237+ forceColour(t)
238+ t.Cleanup(func() { applyTheme(defaultTheme) })
239+
240+ for _, name := range themeNames() {
241+ applyTheme(name)
242+ got := renderMarkdown(sampleMarkdown, 60)
243+ if strings.TrimSpace(plain(got)) == "" {
244+ t.Errorf("%s: rendered nothing", name)
245+ }
246+ if got == plain(got) {
247+ t.Errorf("%s: rendered without any colour at all", name)
248+ }
249+ }
250+ }
251+
252+ // Trimming the padding off a line means cutting a string that ends in an
253+ // escape. A line left with its colour still open bleeds that colour across
254+ // everything the viewport draws after it.
255+ func TestMarkdownClosesEveryStyledLine(t *testing.T) {
256+ forceColour(t)
257+ src := sampleMarkdown + "\nA line with a very long unbreakable https://example.com/x/y/z/aaaaaaaaaaaaaaaaaaaaaaaaaaaa tail.\n"
258+ for _, line := range strings.Split(renderMarkdown(src, 40), "\n") {
259+ if strings.Contains(line, "\x1b[") && !strings.HasSuffix(line, "\x1b[0m") {
260+ t.Errorf("styled line left open: %q", line)
261+ }
262+ }
263+ }
264+
265+ // glamour emits one escape sequence per cell of the padding it lays down, so
266+ // trimming that padding by width alone leaves a tail of dozens of escapes
267+ // painting nothing. They cost the scrollback more bytes than the text does.
268+ func TestMarkdownLeavesNoDanglingEscapes(t *testing.T) {
269+ forceColour(t)
270+ dangling := regexp.MustCompile(`\x1b\[[0-9;]*m\x1b\[[0-9;]*m$`)
271+
272+ rendered := renderMarkdown(sampleMarkdown, 60)
273+ for _, line := range strings.Split(rendered, "\n") {
274+ if dangling.MatchString(line) {
275+ t.Errorf("line ends in escapes that paint nothing: %q", line)
276+ }
277+ }
278+ if visible := len(plain(rendered)); len(rendered) > 8*visible {
279+ t.Errorf("%d bytes of escapes for %d bytes of text", len(rendered)-visible, visible)
280+ }
281+ }
282+
283+ // A rule divides the page, so it is as wide as the page. A fixed-length stub
284+ // reads as a stray dash rather than as a division.
285+ func TestMarkdownRuleSpansTheWidth(t *testing.T) {
286+ forceColour(t)
287+ for _, width := range []int{30, 72} {
288+ got := plain(renderMarkdown("above\n\n---\n\nbelow", width))
289+ rule := ""
290+ for _, line := range strings.Split(got, "\n") {
291+ if strings.Contains(line, "─") {
292+ rule = line
293+ }
294+ }
295+ if lipgloss.Width(rule) != width {
296+ t.Errorf("width %d: rule is %d cells: %q", width, lipgloss.Width(rule), rule)
297+ }
298+ }
299+ }
300+
301+ // Most of what the model says is ordinary prose with no markup in it at all,
302+ // and that must come out as the words it went in as — wrapped, and nothing
303+ // else. This is the case the feature is least allowed to get wrong.
304+ func TestMarkdownLeavesPlainProseAlone(t *testing.T) {
305+ forceColour(t)
306+ const prose = "The test fails because the window is zero, so compaction never runs " +
307+ "and the conversation grows until the API refuses it. Two lines, one paragraph."
308+
309+ got := plain(renderMarkdown(prose, 50))
310+ if strings.Join(strings.Fields(got), " ") != prose {
311+ t.Errorf("prose came back changed:\n got %q\nwant %q", got, prose)
312+ }
313+ if len(strings.Split(got, "\n")) < 2 {
314+ t.Errorf("prose was not wrapped to 50 columns:\n%s", got)
315+ }
316+ }
317+
318+ // Nesting is the structure of a list, so a sub-item has to sit inside its
319+ // parent rather than beside it.
320+ func TestMarkdownIndentsNestedLists(t *testing.T) {
321+ forceColour(t)
322+ got := plain(renderMarkdown("- outer\n - inner\n- second\n", 60))
323+
324+ var outer, inner int
325+ for _, line := range strings.Split(got, "\n") {
326+ switch {
327+ case strings.Contains(line, "outer"):
328+ outer = len(line) - len(strings.TrimLeft(line, " "))
329+ case strings.Contains(line, "inner"):
330+ inner = len(line) - len(strings.TrimLeft(line, " "))
331+ }
332+ }
333+ if inner <= outer {
334+ t.Errorf("nested item indented %d, parent %d:\n%s", inner, outer, got)
335+ }
336+ }
+14−0internal/tui/theme.goView File
@@ -120,6 +120,15 @@ func lookupTheme(name string) theme {
120120 // applying it for real, so this changes while the program runs.
121121 var currentTheme = lookupTheme(defaultTheme)
122122
123+ // themeGen counts theme changes. Anything holding pre-rendered text — the
124+ // transcript's scrollback, above all — compares it against the generation its
125+ // cache was built under, because a palette swap has to repaint what is already
126+ // on screen and not only what arrives next.
127+ var themeGen int
128+
129+ // themeGeneration reports which palette the styles currently hold.
130+ func themeGeneration() int { return themeGen }
131+
123132 func init() { applyTheme(defaultTheme) }
124133
125134 // applyTheme rebuilds every style from the named palette.
@@ -133,6 +142,7 @@ func init() { applyTheme(defaultTheme) }
133142 func applyTheme(name string) {
134143 t := lookupTheme(name)
135144 currentTheme = t
145+ themeGen++
136146
137147 colCoral, colText, colUser = t.accent, t.text, t.user
138148 colDim, colFaint = t.dim, t.faint
@@ -182,6 +192,10 @@ func applyTheme(name string) {
182192 Border(lipgloss.RoundedBorder()).
183193 BorderForeground(colCoral).
184194 Padding(0, 1)
195+
196+ // Markdown is styled from the same palette, and its stylesheet is baked
197+ // rather than read per draw, so it has to be rebuilt here too.
198+ resetMarkdown()
185199 }
186200
187201 // themeNames reports the themes in offer order. It is derived from themes so
+37−7internal/tui/transcript.goView File
@@ -42,6 +42,11 @@ type entry struct {
4242 // open means more is still arriving: streamed text, or a tool call whose
4343 // result has not come back yet.
4444 open bool
45+ // md is text laid out as Markdown, held because laying it out again costs
46+ // milliseconds and a single streamed token redraws every entry there is.
47+ // It is dropped whenever the text, the width or the palette changes.
48+ md string
49+ mdWidth int
4550 }
4651
4752 // collapsedLines is how much of a tool result is shown before it is elided.
@@ -69,6 +74,8 @@ type transcript struct {
6974
7075 cache string
7176 dirty bool
77+ // gen is the theme generation the cache was built under.
78+ gen int
7279 }
7380
7481 func newTranscript(width int) *transcript {
@@ -109,6 +116,7 @@ func (t *transcript) closeOpen() {
109116 if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open {
110117 t.entries[n-1].open = false
111118 t.entries[n-1].text = strings.TrimRight(t.entries[n-1].text, "\n")
119+ t.entries[n-1].md = ""
112120 t.dirty = true
113121 }
114122 }
@@ -118,6 +126,7 @@ func (t *transcript) closeOpen() {
118126 func (t *transcript) appendDelta(s string) {
119127 if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open {
120128 t.entries[n-1].text += s
129+ t.entries[n-1].md = ""
121130 t.dirty = true
122131 return
123132 }
@@ -190,21 +199,31 @@ func (t *transcript) finishTool(name, result string, isErr bool) {
190199 // render produces the full scrollback. Entries are separated by a blank line,
191200 // which is most of what makes a wall of tool output readable.
192201 func (t *transcript) render() string {
202+ // A theme change repaints history, not just what comes next, so it
203+ // invalidates both this cache and every entry's own.
204+ if g := themeGeneration(); g != t.gen {
205+ t.gen, t.dirty = g, true
206+ for i := range t.entries {
207+ t.entries[i].md = ""
208+ }
209+ }
193210 if !t.dirty {
194211 return t.cache
195212 }
196213 var b strings.Builder
197- for i, e := range t.entries {
214+ for i := range t.entries {
198215 if i > 0 {
199216 b.WriteByte('\n')
200217 }
201- b.WriteString(t.renderEntry(e))
218+ b.WriteString(t.renderEntry(&t.entries[i]))
202219 }
203220 t.cache, t.dirty = b.String(), false
204221 return t.cache
205222 }
206223
207- func (t *transcript) renderEntry(e entry) string {
224+ // renderEntry draws one entry. It takes a pointer because an assistant entry
225+ // keeps its laid-out Markdown rather than producing it again every frame.
226+ func (t *transcript) renderEntry(e *entry) string {
208227 width := t.width
209228 if width < 20 {
210229 width = 20
@@ -217,7 +236,12 @@ func (t *transcript) renderEntry(e entry) string {
217236 return hang(styUserMark.Render(">"), styUserText.Width(width-2).Render(e.text))
218237
219238 case entryAssistant:
220- return hang(styTextDot.Render(dot), styText.Width(width-2).Render(strings.TrimRight(e.text, "\n")))
239+ // The dot and its space take the first two columns, so Markdown is
240+ // laid out for what is left rather than for the whole terminal.
241+ if e.md == "" || e.mdWidth != width {
242+ e.md, e.mdWidth = renderMarkdown(e.text, width-2), width
243+ }
244+ return hang(styTextDot.Render(dot), e.md)
221245
222246 case entryNotice:
223247 return styDim.Width(width).Render(e.text) + "\n"
@@ -243,12 +267,18 @@ func hang(mark, body string) string {
243267 b.WriteString(mark + " " + line + "\n")
244268 continue
245269 }
270+ // A blank line between blocks stays blank: indenting it would leave a
271+ // line of trailing whitespace that only shows up when selected.
272+ if line == "" {
273+ b.WriteString("\n")
274+ continue
275+ }
246276 b.WriteString(gutter + line + "\n")
247277 }
248278 return b.String()
249279 }
250280
251- func (t *transcript) renderTool(e entry, width int) string {
281+ func (t *transcript) renderTool(e *entry, width int) string {
252282 var b strings.Builder
253283
254284 name := camel(e.tool)
@@ -269,7 +299,7 @@ func (t *transcript) renderTool(e entry, width int) string {
269299 // the mode's colour, then the same result body a tool call gets. The colour is
270300 // the whole header rather than the marker alone, so a run is scannable in a
271301 // wall of tool calls without reading it.
272- func (t *transcript) renderLocal(e entry, width int) string {
302+ func (t *transcript) renderLocal(e *entry, width int) string {
273303 style := styLocalShell
274304 if e.tool == localSpecs[localPython].marker {
275305 style = styLocalPython
@@ -280,7 +310,7 @@ func (t *transcript) renderLocal(e entry, width int) string {
280310
281311 // renderResult draws what came back from a call — the waiting marker, a diff
282312 // in colour, or the output collapsed to a few lines with ctrl+r to see it all.
283- func (t *transcript) renderResult(e entry, width int) string {
313+ func (t *transcript) renderResult(e *entry, width int) string {
284314 var b strings.Builder
285315
286316 if e.open {
+84−0internal/tui/transcript_test.goView File
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 "github.com/charmbracelet/lipgloss"
9+ "github.com/charmbracelet/x/ansi"
910 )
1011
1112 func TestAppendDeltaCoalesces(t *testing.T) {
@@ -254,3 +255,86 @@ func TestRenderNarrowWidthDoesNotPanic(t *testing.T) {
254255 tr.addError("boom")
255256 _ = tr.render()
256257 }
258+
259+ // Assistant text is Markdown and the transcript renders it as such. The dot
260+ // gutter survives it: a heading is still one event hanging off one dot, with
261+ // everything under it indented to match.
262+ func TestAssistantTextRendersAsMarkdown(t *testing.T) {
263+ tr := newTranscript(60)
264+ tr.appendDelta("# Findings\n\nThe **refresh** path drops the token.\n")
265+ tr.closeOpen()
266+
267+ out := ansi.Strip(tr.render())
268+ for _, markup := range []string{"#", "**"} {
269+ if strings.Contains(out, markup) {
270+ t.Errorf("markup %q printed rather than rendered:\n%s", markup, out)
271+ }
272+ }
273+ lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
274+ if !strings.HasPrefix(lines[0], dot+" ") {
275+ t.Errorf("first line lost its dot: %q", lines[0])
276+ }
277+ for _, line := range lines[1:] {
278+ if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, gutter) {
279+ t.Errorf("continuation line not indented under the dot: %q", line)
280+ }
281+ }
282+ }
283+
284+ // A rendered entry is cached, so the three things that change what it should
285+ // look like all have to throw that cache away. Each of these is a way the
286+ // scrollback could show something that was true one frame ago.
287+ func TestRenderedMarkdownIsInvalidated(t *testing.T) {
288+ t.Run("by more text arriving", func(t *testing.T) {
289+ tr := newTranscript(60)
290+ tr.appendDelta("The **refresh** path")
291+ _ = tr.render()
292+ tr.appendDelta(" drops the token.")
293+
294+ if got := ansi.Strip(tr.render()); !strings.Contains(got, "drops the token") {
295+ t.Errorf("streamed text missing after a re-render:\n%s", got)
296+ }
297+ })
298+
299+ t.Run("by a resize", func(t *testing.T) {
300+ tr := newTranscript(100)
301+ tr.appendDelta(strings.Repeat("word ", 40))
302+ tr.closeOpen()
303+ wide := len(strings.Split(tr.render(), "\n"))
304+
305+ tr.setWidth(40)
306+ if narrow := len(strings.Split(tr.render(), "\n")); narrow <= wide {
307+ t.Errorf("narrowing the terminal did not rewrap: %d lines at 100, %d at 40", wide, narrow)
308+ }
309+ })
310+
311+ t.Run("by a theme change", func(t *testing.T) {
312+ forceColour(t)
313+ t.Cleanup(func() { applyTheme(defaultTheme) })
314+
315+ tr := newTranscript(60)
316+ tr.appendDelta("# Findings")
317+ tr.closeOpen()
318+ applyTheme("ember")
319+ ember := tr.render()
320+ applyTheme("iris")
321+
322+ if iris := tr.render(); iris == ember {
323+ t.Error("the transcript kept the old theme's colours after /setup changed it")
324+ }
325+ })
326+ }
327+
328+ // Markdown puts blank lines between blocks, and the gutter must not turn them
329+ // into lines of trailing whitespace.
330+ func TestBlankLinesCarryNoGutter(t *testing.T) {
331+ tr := newTranscript(60)
332+ tr.appendDelta("# Findings\n\nThe refresh path drops the token.")
333+ tr.closeOpen()
334+
335+ for _, line := range strings.Split(ansi.Strip(tr.render()), "\n") {
336+ if strings.TrimSpace(line) == "" && line != "" {
337+ t.Errorf("blank line padded to %q", line)
338+ }
339+ }
340+ }