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>
Showing 9 changed files with 1021 additions and 13 deletions.
| @@ -87,6 +87,36 @@ is indented under that dot, so a screen of tool output still reads as a | ||
| 87 | 87 | sequence of things that happened. Diffs are shown in full and in colour with a |
| 88 | 88 | count of what changed; other output is elided after six lines. |
| 89 | 89 | |
| 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 | + | |
| 90 | 120 | The status bar reports the context gauge — the last turn's input size against |
| 91 | 121 | the window, which drops when the conversation is compacted — and separates |
| 92 | 122 | fresh input tokens from cache reads, because the gap between the two is what |
| @@ -3,10 +3,12 @@ module harness | ||
| 3 | 3 | go 1.26.8 |
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | + github.com/alecthomas/chroma/v2 v2.20.0 | |
| 6 | 7 | github.com/bmatcuk/doublestar/v4 v4.10.0 |
| 7 | 8 | github.com/charmbracelet/bubbles v1.0.0 |
| 8 | 9 | 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 | |
| 10 | 12 | github.com/charmbracelet/x/ansi v0.11.6 |
| 11 | 13 | github.com/invopop/jsonschema v0.14.0 |
| 12 | 14 | github.com/muesli/termenv v0.16.0 |
| @@ -17,25 +19,35 @@ require ( | ||
| 17 | 19 | require ( |
| 18 | 20 | github.com/atotto/clipboard v0.1.4 // indirect |
| 19 | 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect |
| 22 | + github.com/aymerick/douceur v0.2.0 // indirect | |
| 20 | 23 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| 21 | 24 | github.com/buger/jsonparser v1.1.2 // indirect |
| 22 | 25 | github.com/charmbracelet/colorprofile v0.4.1 // indirect |
| 23 | 26 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect |
| 27 | + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect | |
| 24 | 28 | github.com/charmbracelet/x/term v0.2.2 // indirect |
| 25 | 29 | github.com/clipperhouse/displaywidth v0.9.0 // indirect |
| 26 | 30 | github.com/clipperhouse/stringish v0.1.1 // indirect |
| 27 | 31 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect |
| 32 | + github.com/dlclark/regexp2 v1.11.5 // indirect | |
| 28 | 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect |
| 34 | + github.com/gorilla/css v1.0.1 // indirect | |
| 29 | 35 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect |
| 30 | 36 | github.com/mattn/go-isatty v0.0.20 // indirect |
| 31 | 37 | github.com/mattn/go-localereader v0.0.1 // indirect |
| 32 | 38 | github.com/mattn/go-runewidth v0.0.19 // indirect |
| 39 | + github.com/microcosm-cc/bluemonday v1.0.27 // indirect | |
| 33 | 40 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect |
| 34 | 41 | github.com/muesli/cancelreader v0.2.2 // indirect |
| 42 | + github.com/muesli/reflow v0.3.0 // indirect | |
| 35 | 43 | github.com/pb33f/ordered-map/v2 v2.3.1 // indirect |
| 36 | 44 | github.com/rivo/uniseg v0.4.7 // indirect |
| 37 | 45 | 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 | |
| 38 | 48 | go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect |
| 49 | + golang.org/x/net v0.38.0 // indirect | |
| 39 | 50 | 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 | |
| 41 | 53 | ) |
| @@ -1,11 +1,19 @@ | ||
| 1 | 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= |
| 2 | 2 | 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= | |
| 3 | 9 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= |
| 4 | 10 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= |
| 5 | 11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= |
| 6 | 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= |
| 7 | 13 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= |
| 8 | 14 | 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= | |
| 9 | 17 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= |
| 10 | 18 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= |
| 11 | 19 | github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= |
| @@ -18,12 +26,18 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv | ||
| 18 | 26 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= |
| 19 | 27 | github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= |
| 20 | 28 | 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= | |
| 23 | 33 | github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= |
| 24 | 34 | github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= |
| 25 | 35 | github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= |
| 26 | 36 | 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= | |
| 27 | 41 | github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= |
| 28 | 42 | github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= |
| 29 | 43 | 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 | ||
| 34 | 48 | github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= |
| 35 | 49 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 36 | 50 | 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= | |
| 37 | 53 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= |
| 38 | 54 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= |
| 39 | 55 | github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= |
| 40 | 56 | github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= |
| 41 | 57 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 42 | 58 | 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= | |
| 43 | 63 | github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= |
| 44 | 64 | github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= |
| 45 | 65 | 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 | ||
| 52 | 72 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| 53 | 73 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= |
| 54 | 74 | 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= | |
| 55 | 76 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= |
| 56 | 77 | 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= | |
| 57 | 80 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= |
| 58 | 81 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= |
| 59 | 82 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= |
| 60 | 83 | 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= | |
| 61 | 86 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= |
| 62 | 87 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= |
| 63 | 88 | github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= |
| 64 | 89 | github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= |
| 65 | 90 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 66 | 91 | 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= | |
| 67 | 94 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
| 68 | 95 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= |
| 69 | 96 | 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 | ||
| 72 | 99 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= |
| 73 | 100 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= |
| 74 | 101 | 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= | |
| 75 | 106 | go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= |
| 76 | 107 | go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= |
| 77 | 108 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= |
| 78 | 109 | 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= | |
| 79 | 112 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 80 | 113 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 81 | 114 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| 82 | 115 | 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= | |
| 85 | 120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
| 86 | 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 87 | 122 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| @@ -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 } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -120,6 +120,15 @@ func lookupTheme(name string) theme { | ||
| 120 | 120 | // applying it for real, so this changes while the program runs. |
| 121 | 121 | var currentTheme = lookupTheme(defaultTheme) |
| 122 | 122 | |
| 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 | + | |
| 123 | 132 | func init() { applyTheme(defaultTheme) } |
| 124 | 133 | |
| 125 | 134 | // applyTheme rebuilds every style from the named palette. |
| @@ -133,6 +142,7 @@ func init() { applyTheme(defaultTheme) } | ||
| 133 | 142 | func applyTheme(name string) { |
| 134 | 143 | t := lookupTheme(name) |
| 135 | 144 | currentTheme = t |
| 145 | + themeGen++ | |
| 136 | 146 | |
| 137 | 147 | colCoral, colText, colUser = t.accent, t.text, t.user |
| 138 | 148 | colDim, colFaint = t.dim, t.faint |
| @@ -182,6 +192,10 @@ func applyTheme(name string) { | ||
| 182 | 192 | Border(lipgloss.RoundedBorder()). |
| 183 | 193 | BorderForeground(colCoral). |
| 184 | 194 | 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() | |
| 185 | 199 | } |
| 186 | 200 | |
| 187 | 201 | // themeNames reports the themes in offer order. It is derived from themes so |
| @@ -42,6 +42,11 @@ type entry struct { | ||
| 42 | 42 | // open means more is still arriving: streamed text, or a tool call whose |
| 43 | 43 | // result has not come back yet. |
| 44 | 44 | 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 | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | // collapsedLines is how much of a tool result is shown before it is elided. |
| @@ -69,6 +74,8 @@ type transcript struct { | ||
| 69 | 74 | |
| 70 | 75 | cache string |
| 71 | 76 | dirty bool |
| 77 | + // gen is the theme generation the cache was built under. | |
| 78 | + gen int | |
| 72 | 79 | } |
| 73 | 80 | |
| 74 | 81 | func newTranscript(width int) *transcript { |
| @@ -109,6 +116,7 @@ func (t *transcript) closeOpen() { | ||
| 109 | 116 | if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open { |
| 110 | 117 | t.entries[n-1].open = false |
| 111 | 118 | t.entries[n-1].text = strings.TrimRight(t.entries[n-1].text, "\n") |
| 119 | + t.entries[n-1].md = "" | |
| 112 | 120 | t.dirty = true |
| 113 | 121 | } |
| 114 | 122 | } |
| @@ -118,6 +126,7 @@ func (t *transcript) closeOpen() { | ||
| 118 | 126 | func (t *transcript) appendDelta(s string) { |
| 119 | 127 | if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open { |
| 120 | 128 | t.entries[n-1].text += s |
| 129 | + t.entries[n-1].md = "" | |
| 121 | 130 | t.dirty = true |
| 122 | 131 | return |
| 123 | 132 | } |
| @@ -190,21 +199,31 @@ func (t *transcript) finishTool(name, result string, isErr bool) { | ||
| 190 | 199 | // render produces the full scrollback. Entries are separated by a blank line, |
| 191 | 200 | // which is most of what makes a wall of tool output readable. |
| 192 | 201 | 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 | + } | |
| 193 | 210 | if !t.dirty { |
| 194 | 211 | return t.cache |
| 195 | 212 | } |
| 196 | 213 | var b strings.Builder |
| 197 | - for i, e := range t.entries { | |
| 214 | + for i := range t.entries { | |
| 198 | 215 | if i > 0 { |
| 199 | 216 | b.WriteByte('\n') |
| 200 | 217 | } |
| 201 | - b.WriteString(t.renderEntry(e)) | |
| 218 | + b.WriteString(t.renderEntry(&t.entries[i])) | |
| 202 | 219 | } |
| 203 | 220 | t.cache, t.dirty = b.String(), false |
| 204 | 221 | return t.cache |
| 205 | 222 | } |
| 206 | 223 | |
| 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 { | |
| 208 | 227 | width := t.width |
| 209 | 228 | if width < 20 { |
| 210 | 229 | width = 20 |
| @@ -217,7 +236,12 @@ func (t *transcript) renderEntry(e entry) string { | ||
| 217 | 236 | return hang(styUserMark.Render(">"), styUserText.Width(width-2).Render(e.text)) |
| 218 | 237 | |
| 219 | 238 | 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) | |
| 221 | 245 | |
| 222 | 246 | case entryNotice: |
| 223 | 247 | return styDim.Width(width).Render(e.text) + "\n" |
| @@ -243,12 +267,18 @@ func hang(mark, body string) string { | ||
| 243 | 267 | b.WriteString(mark + " " + line + "\n") |
| 244 | 268 | continue |
| 245 | 269 | } |
| 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 | + } | |
| 246 | 276 | b.WriteString(gutter + line + "\n") |
| 247 | 277 | } |
| 248 | 278 | return b.String() |
| 249 | 279 | } |
| 250 | 280 | |
| 251 | - func (t *transcript) renderTool(e entry, width int) string { | |
| 281 | + func (t *transcript) renderTool(e *entry, width int) string { | |
| 252 | 282 | var b strings.Builder |
| 253 | 283 | |
| 254 | 284 | name := camel(e.tool) |
| @@ -269,7 +299,7 @@ func (t *transcript) renderTool(e entry, width int) string { | ||
| 269 | 299 | // the mode's colour, then the same result body a tool call gets. The colour is |
| 270 | 300 | // the whole header rather than the marker alone, so a run is scannable in a |
| 271 | 301 | // 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 { | |
| 273 | 303 | style := styLocalShell |
| 274 | 304 | if e.tool == localSpecs[localPython].marker { |
| 275 | 305 | style = styLocalPython |
| @@ -280,7 +310,7 @@ func (t *transcript) renderLocal(e entry, width int) string { | ||
| 280 | 310 | |
| 281 | 311 | // renderResult draws what came back from a call — the waiting marker, a diff |
| 282 | 312 | // 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 { | |
| 284 | 314 | var b strings.Builder |
| 285 | 315 | |
| 286 | 316 | if e.open { |
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "testing" |
| 7 | 7 | |
| 8 | 8 | "github.com/charmbracelet/lipgloss" |
| 9 | + "github.com/charmbracelet/x/ansi" | |
| 9 | 10 | ) |
| 10 | 11 | |
| 11 | 12 | func TestAppendDeltaCoalesces(t *testing.T) { |
| @@ -254,3 +255,86 @@ func TestRenderNarrowWidthDoesNotPanic(t *testing.T) { | ||
| 254 | 255 | tr.addError("boom") |
| 255 | 256 | _ = tr.render() |
| 256 | 257 | } |
| 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 | + } | |