diff --git a/go.mod b/go.mod index d3c814f04..fa09a4718 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,36 @@ module github.com/Gitlawb/zero -go 1.24.2 +go 1.25.0 toolchain go1.26.4 require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.7 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 - golang.org/x/sys v0.38.0 - mvdan.cc/sh/v3 v3.11.0 + golang.org/x/sys v0.46.0 + mvdan.cc/sh/v3 v3.13.1 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/go-quicktest/qt v1.102.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/sync v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 44a76c183..f0a616c6b 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,33 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I= +github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -34,33 +36,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw= -mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/internal/imageinput/pdf_test.go b/internal/imageinput/pdf_test.go index 7171ffcc5..72d61d081 100644 --- a/internal/imageinput/pdf_test.go +++ b/internal/imageinput/pdf_test.go @@ -10,6 +10,8 @@ import ( "testing" ) +const minimalPDFTextChunkSize = 80 + // buildMinimalPDF assembles a tiny, single-page PDF whose content stream draws // the given text. It computes a real cross-reference table and trailer so a // pure-Go PDF parser (ledongthuc/pdf) accepts it. Generating the fixture in-test @@ -28,10 +30,11 @@ func buildMinimalPDF(text string) []byte { startObj() // object 2: page tree buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + pageHeight := minimalPDFPageHeight(text) startObj() // object 3: page - buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n") + buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 " + strconv.Itoa(pageHeight) + "] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n") - content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET" + content := minimalPDFTextContent(text, pageHeight-92) startObj() // object 4: content stream buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n") buf.WriteString(content) @@ -52,6 +55,56 @@ func buildMinimalPDF(text string) []byte { return buf.Bytes() } +func minimalPDFPageHeight(text string) int { + lines := (len(text) + minimalPDFTextChunkSize - 1) / minimalPDFTextChunkSize + if lines < 1 { + lines = 1 + } + height := 184 + lines*10 + if height < 792 { + return 792 + } + return height +} + +func minimalPDFTextContent(text string, startY int) string { + var content strings.Builder + content.WriteString("BT /F1 8 Tf 10 TL 72 ") + content.WriteString(strconv.Itoa(startY)) + content.WriteString(" Td ") + for index := 0; len(text) > 0; index++ { + if index > 0 { + content.WriteString(" T* ") + } + chunk := text + if len(chunk) > minimalPDFTextChunkSize { + cut := minimalPDFTextChunkSize + for cut > 0 && !utf8RuneStart(chunk[cut]) { + cut-- + } + if cut == 0 { + cut = minimalPDFTextChunkSize + } + chunk = text[:cut] + } + content.WriteString("(") + content.WriteString(escapePDFLiteral(chunk)) + content.WriteString(") Tj") + text = text[len(chunk):] + } + content.WriteString(" ET") + return content.String() +} + +func escapePDFLiteral(text string) string { + text = strings.ReplaceAll(text, `\`, `\\`) + text = strings.ReplaceAll(text, `(`, `\(`) + text = strings.ReplaceAll(text, `)`, `\)`) + text = strings.ReplaceAll(text, "\r", `\r`) + text = strings.ReplaceAll(text, "\n", `\n`) + return text +} + func TestIsPDF(t *testing.T) { if !isPDF(buildMinimalPDF("hi")) { t.Fatal("isPDF should accept real %PDF- bytes") @@ -159,7 +212,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) { // Many short lines so the *extracted text* (not the file) exceeds the cap. var body strings.Builder line := "The quick brown fox jumps over the lazy dog. " - for body.Len() < MaxDocumentTextBytes+4096 { + for body.Len() < MaxDocumentTextBytes+65536 { body.WriteString(line) } if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil { @@ -173,7 +226,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) { t.Fatalf("capped text length %d exceeds cap %d (marker must be counted against the cap)", len(doc.Text), MaxDocumentTextBytes) } if !doc.Truncated { - t.Fatal("Truncated should be set when extracted text is capped") + t.Fatalf("Truncated should be set when extracted text is capped; extracted len=%d pages=%d", len(doc.Text), doc.Pages) } if !strings.Contains(doc.Text, documentTruncatedMarker) { t.Fatal("truncated text should carry the truncation marker") diff --git a/internal/tui/ask_user_test.go b/internal/tui/ask_user_test.go index eaf9140b4..1e21ea4b5 100644 --- a/internal/tui/ask_user_test.go +++ b/internal/tui/ask_user_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" ) @@ -62,7 +62,7 @@ func TestAskUserPromptCollectsAnswersInOrder(t *testing.T) { // Answer the first question. next.input.SetValue("React") - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("expected first answer to advance synchronously") @@ -76,7 +76,7 @@ func TestAskUserPromptCollectsAnswersInOrder(t *testing.T) { // Answer the second (final) question. next.input.SetValue("yes") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("expected final answer to resolve synchronously") @@ -109,7 +109,7 @@ func TestAskUserPromptEscDeliversCollectedAnswers(t *testing.T) { // Esc while an ask_user prompt is active cancels the questionnaire and must // still deliver a (partial/empty) answer set so the run never deadlocks. - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = next.Update(testKey(tea.KeyEsc)) next = updated.(model) if next.pendingAskUser != nil { @@ -137,7 +137,7 @@ func TestAskUserPromptBlocksNormalSubmit(t *testing.T) { next := updated.(model) next.input.SetValue("/help") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if transcriptContains(next.transcript, "Available commands") { @@ -174,7 +174,7 @@ func TestAskUserRequestClearsComposerDraft(t *testing.T) { t.Fatalf("ask_user should clear composer draft, active=%v value=%q", next.composerActive, next.composerValue()) } next.input.SetValue("yes") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if len(answers) != 1 || answers[0][0] != "yes" { t.Fatalf("expected answer to use ask_user input only, got %#v", answers) @@ -205,7 +205,7 @@ func TestAskUserRequestClearsStaleSuggestions(t *testing.T) { if len(next.suggestions) != 0 || next.suggestionsAreFiles { t.Fatalf("ask_user should clear stale suggestions, got suggestions=%#v files=%v", next.suggestions, next.suggestionsAreFiles) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if len(next.suggestions) != 0 || next.suggestionsAreFiles { t.Fatalf("ask_user resolve should keep suggestions clear, got suggestions=%#v files=%v", next.suggestions, next.suggestionsAreFiles) diff --git a/internal/tui/assistant_markdown.go b/internal/tui/assistant_markdown.go index f67fd3ea1..3127ce34e 100644 --- a/internal/tui/assistant_markdown.go +++ b/internal/tui/assistant_markdown.go @@ -5,7 +5,7 @@ import ( "unicode" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) const ( diff --git a/internal/tui/autocomplete_test.go b/internal/tui/autocomplete_test.go index e0af791cf..c094d854f 100644 --- a/internal/tui/autocomplete_test.go +++ b/internal/tui/autocomplete_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" ) @@ -17,7 +17,7 @@ import ( func typeRunes(t *testing.T, m model, s string) model { t.Helper() for _, r := range s { - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + updated, _ := m.Update(testKeyText(string(r))) m = updated.(model) } return m @@ -83,7 +83,7 @@ func TestTabCyclesSuggestions(t *testing.T) { m = typeRunes(t, m, "/mo") start := m.suggestionIdx - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + updated, _ := m.Update(testKey(tea.KeyTab)) m = updated.(model) if m.suggestionIdx == start { t.Fatal("Tab should advance the selected suggestion") @@ -91,7 +91,7 @@ func TestTabCyclesSuggestions(t *testing.T) { // Tab past the end wraps to 0. for i := 0; i < len(m.suggestions); i++ { - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) + updated, _ = m.Update(testKey(tea.KeyTab)) m = updated.(model) } if m.suggestionIdx != m.suggestionIdx%len(m.suggestions) { @@ -103,15 +103,15 @@ func TestUpDownMoveSuggestions(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "/mo") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ := m.Update(testKey(tea.KeyDown)) m = updated.(model) if m.suggestionIdx != 1 { t.Fatalf("Down should select index 1, got %d", m.suggestionIdx) } // Up from index 0 wraps to the last suggestion. - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ = m.Update(testKey(tea.KeyUp)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ = m.Update(testKey(tea.KeyUp)) m = updated.(model) if m.suggestionIdx != len(m.suggestions)-1 { t.Fatalf("Up past the top should wrap to last (%d), got %d", len(m.suggestions)-1, m.suggestionIdx) @@ -122,13 +122,13 @@ func TestMouseWheelMovesSuggestions(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "/") - updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + updated, _ := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0)) m = updated.(model) if m.suggestionIdx != 1 { t.Fatalf("wheel down should select index 1, got %d", m.suggestionIdx) } - updated, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + updated, _ = m.Update(testMouseWheel(tea.MouseWheelUp, 0, 0)) m = updated.(model) if m.suggestionIdx != 0 { t.Fatalf("wheel up should select index 0, got %d", m.suggestionIdx) @@ -139,7 +139,7 @@ func TestEnterRunsCommandSuggestion(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "/he") // selects /help - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { @@ -160,7 +160,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "/sp") // selects /spec - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { @@ -173,7 +173,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) { t.Fatalf("prefilled command should show argument hint, got %q", got) } - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("fix")}) + updated, cmd = m.Update(testKeyText("fix")) m = updated.(model) if cmd != nil { t.Fatal("typing the argument should not start an agent run") @@ -182,7 +182,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) { t.Fatalf("typing after the hint should insert one argument separator, got %q", got) } for range "fix" { - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + updated, cmd = m.Update(testKey(tea.KeyBackspace)) m = updated.(model) if cmd != nil { t.Fatal("backspacing the argument should not start an agent run") @@ -191,7 +191,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) { if got := plainRender(t, m.composerLine(96)); !strings.Contains(got, "/spec [task]") || strings.Contains(got, "/spec [task]") { t.Fatalf("empty argument command should render one visual separator, got %q", got) } - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + updated, cmd = m.Update(testKeyText("x")) m = updated.(model) if cmd != nil { t.Fatal("typing after deleting the argument should not start an agent run") @@ -227,7 +227,7 @@ func TestTabCompletesAfterSelection(t *testing.T) { // Move to /mode, then Tab again -> per spec Tab cycles, so we use Down then // Enter to lock the selection; verify Tab keeps cycling not completing. - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + updated, _ := m.Update(testKey(tea.KeyTab)) m = updated.(model) if m.input.Value() != "/mo" { t.Fatalf("Tab should cycle, not yet complete; input=%q", m.input.Value()) @@ -238,7 +238,7 @@ func TestEscDismissesCommandSuggestionsAndClearsInput(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "/mo") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := m.Update(testKey(tea.KeyEsc)) m = updated.(model) if m.suggestionsActive() { @@ -253,7 +253,7 @@ func TestEscWithoutSuggestionsClearsInputAsBefore(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "hello") // no suggestions - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := m.Update(testKey(tea.KeyEsc)) m = updated.(model) if m.input.Value() != "" { t.Fatalf("Esc with no suggestions should clear input, got %q", m.input.Value()) @@ -264,7 +264,7 @@ func TestEnterWithNoSuggestionStillSubmits(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("hello zero") // plain prompt, no suggestions - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.input.Value() != "" { @@ -399,7 +399,7 @@ func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) { } for range suggestionPaletteMaxVisible + 1 { - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ := m.Update(testKey(tea.KeyDown)) m = updated.(model) } plain = plainRender(t, m.View()) @@ -438,7 +438,7 @@ func TestEnterOnNoMatchCommandPaletteDoesNotSubmit(t *testing.T) { m := newModel(context.Background(), Options{}) m = typeRunes(t, m, "/,") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { @@ -481,7 +481,7 @@ func TestEscDismissesFilePaletteAndRemovesTrailingToken(t *testing.T) { m := newModel(context.Background(), Options{Cwd: t.TempDir()}) m = typeRunes(t, m, "read @missing") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := m.Update(testKey(tea.KeyEsc)) m = updated.(model) if m.suggestionsActive() { @@ -501,7 +501,7 @@ func TestEscDismissesFilePaletteAndRemovesTrailingToken(t *testing.T) { m.input.SetCursor(len([]rune("compare @old"))) m.recomputeSuggestions() - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = m.Update(testKey(tea.KeyEsc)) m = updated.(model) if m.suggestionsActive() { @@ -589,7 +589,7 @@ func TestEnterOnDirectorySuggestionKeepsFilePaletteOpen(t *testing.T) { t.Fatalf("expected internal directory first, got %q from %v", got, suggestionNames(m)) } - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { @@ -616,7 +616,7 @@ func TestEnterOnFileSuggestionClosesFilePalette(t *testing.T) { t.Fatalf("expected file suggestion first, got %q from %v", got, suggestionNames(m)) } - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { @@ -637,17 +637,17 @@ func TestFileSuggestionCompletionKeepsPastePreviewCollapsed(t *testing.T) { m := newModel(context.Background(), Options{Cwd: root}) m.width = 44 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + updated, _ = m.Update(testKey(tea.KeySpace)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")}) + updated, _ = m.Update(testKeyText("@guide")) m = updated.(model) if !m.suggestionsActive() || !m.suggestionsAreFiles { t.Fatalf("expected file suggestions after @guide, got suggestions=%v files=%v", m.suggestionsActive(), m.suggestionsAreFiles) } - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { @@ -674,17 +674,17 @@ func TestFileSuggestionDismissKeepsPastePreviewCollapsed(t *testing.T) { m := newModel(context.Background(), Options{Cwd: root}) m.width = 44 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + updated, _ = m.Update(testKey(tea.KeySpace)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")}) + updated, _ = m.Update(testKeyText("@guide")) m = updated.(model) if !m.suggestionsActive() || !m.suggestionsAreFiles { t.Fatalf("expected file suggestions after @guide, got suggestions=%v files=%v", m.suggestionsActive(), m.suggestionsAreFiles) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = m.Update(testKey(tea.KeyEsc)) m = updated.(model) if got := m.composerValue(); got != paste+" " { @@ -711,19 +711,19 @@ func TestBackspaceAfterCompletedFileSuggestionKeepsPastePreviewCollapsed(t *test m := newModel(context.Background(), Options{Cwd: root}) m.width = 44 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + updated, _ = m.Update(testKey(tea.KeySpace)) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")}) + updated, _ = m.Update(testKeyText("@guide")) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if got := m.composerValue(); got != paste+" @docs/guide.md " { t.Fatalf("composer value after file completion = %q", got) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + updated, _ = m.Update(testKey(tea.KeyBackspace)) m = updated.(model) if got := m.composerValue(); got != paste+" " { diff --git a/internal/tui/command_bash.go b/internal/tui/command_bash.go index 40380760a..274e6cf9c 100644 --- a/internal/tui/command_bash.go +++ b/internal/tui/command_bash.go @@ -7,7 +7,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index bf9532e31..67abd9ba8 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -6,7 +6,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/doctor" diff --git a/internal/tui/command_polish_test.go b/internal/tui/command_polish_test.go index 93e8ba9bc..aabcd51f5 100644 --- a/internal/tui/command_polish_test.go +++ b/internal/tui/command_polish_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" @@ -19,7 +19,7 @@ func TestHelpCommandRendersGroupedSections(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/help") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -57,7 +57,7 @@ func TestProviderAndConfigCommandsUseStableStatusOutput(t *testing.T) { }) m.input.SetValue("/provider status") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected /provider to be handled without starting an agent run") @@ -69,7 +69,7 @@ func TestProviderAndConfigCommandsUseStableStatusOutput(t *testing.T) { assertNotContains(t, providerText, "sk-sensitive") next.input.SetValue("/config") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("expected /config to be handled without starting an agent run") @@ -95,7 +95,7 @@ func TestProviderCommandRedactsCredentialBearingBaseURL(t *testing.T) { }) m.input.SetValue("/provider status") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -114,7 +114,7 @@ func TestToolsCommandRendersCommandCard(t *testing.T) { }) m.input.SetValue("/tools") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -140,7 +140,7 @@ func TestToolsCommandRendersCommandCard(t *testing.T) { }) m.input.SetValue("/tools") - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = m.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { @@ -195,7 +195,7 @@ func TestToolsCommandShowsFullSortedCatalog(t *testing.T) { }) m.input.SetValue("/tools") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -268,7 +268,7 @@ func TestContextAndPermissionsCommandsRenderProductState(t *testing.T) { }) m.input.SetValue("/context") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected /context to be handled without starting an agent run") @@ -294,7 +294,7 @@ func TestContextAndPermissionsCommandsRenderProductState(t *testing.T) { assertNotContains(t, contextText, "permission mode:") next.input.SetValue("/permissions") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("expected /permissions to be handled without starting an agent run") @@ -388,7 +388,7 @@ func TestCompactCommandAvoidsShellOnlyPlaceholder(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/compact") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { diff --git a/internal/tui/command_views.go b/internal/tui/command_views.go index 50915c733..a0834fff1 100644 --- a/internal/tui/command_views.go +++ b/internal/tui/command_views.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providercatalog" diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 7e405178f..1ddb4e7e8 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" @@ -176,7 +176,7 @@ func TestMCPCommandRendersConfiguredStateWithoutAgentRun(t *testing.T) { m.height = 42 m.input.SetValue("/mcp") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -230,7 +230,7 @@ func TestMCPManagerNavigationOpensAddWizard(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/mcp") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected /mcp to open synchronously") @@ -239,13 +239,13 @@ func TestMCPManagerNavigationOpensAddWizard(t *testing.T) { t.Fatal("expected MCP manager to open") } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("custom remote")}) + updated, cmd = next.Update(testKeyText("custom remote")) next = updated.(model) if cmd != nil { t.Fatal("expected MCP manager search to update synchronously") } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("expected MCP manager selection to prefill synchronously") @@ -267,9 +267,9 @@ func TestMCPManagerSearchFiltersMarketplace(t *testing.T) { m.height = 36 m.input.SetValue("/mcp") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("playwright")}) + updated, cmd := next.Update(testKeyText("playwright")) next = updated.(model) if cmd != nil { t.Fatal("expected search typing to be synchronous") @@ -295,11 +295,11 @@ func TestMCPManagerDeleteEditsSearchQuery(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/mcp") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("play")}) + updated, _ = next.Update(testKeyText("play")) next = updated.(model) - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyDelete}) + updated, cmd := next.Update(testKey(tea.KeyDelete)) next = updated.(model) if cmd != nil { @@ -314,11 +314,11 @@ func TestMCPManagerMarketplaceSelectionPrefillsInstallCommand(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/mcp") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("context7")}) + updated, _ = next.Update(testKeyText("context7")) next = updated.(model) - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("expected marketplace selection to prefill synchronously") @@ -337,9 +337,9 @@ func TestMCPManagerAddRemoteOpensWizard(t *testing.T) { m.height = 36 m.input.SetValue("/mcp") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a"), Alt: true}) + updated, cmd := next.Update(testKeyAltText("a")) next = updated.(model) if cmd != nil { @@ -375,12 +375,12 @@ func TestMCPAddWizardInvalidURLShowsUnsavedResult(t *testing.T) { m.height = 36 m.mcpAddWizard = newMCPAddWizard("http") - for _, key := range []tea.KeyMsg{ - {Type: tea.KeyRunes, Runes: []rune("adds")}, - {Type: tea.KeyEnter}, - {Type: tea.KeyEnter}, - {Type: tea.KeyRunes, Runes: []rune("sxas")}, - {Type: tea.KeyEnter}, + for _, key := range []tea.Msg{ + testKeyText("adds"), + testKey(tea.KeyEnter), + testKey(tea.KeyEnter), + testKeyText("sxas"), + testKey(tea.KeyEnter), } { updated, cmd := m.Update(key) if cmd != nil { @@ -431,13 +431,13 @@ func TestMCPAddWizardInvalidURLCanSaveDisabledDraft(t *testing.T) { m.mcpAddWizard = newMCPAddWizard("http") var cmd tea.Cmd - for index, key := range []tea.KeyMsg{ - {Type: tea.KeyRunes, Runes: []rune("draft")}, - {Type: tea.KeyEnter}, - {Type: tea.KeyEnter}, - {Type: tea.KeyRunes, Runes: []rune("sxas")}, - {Type: tea.KeyEnter}, - {Type: tea.KeyRunes, Runes: []rune("s")}, + for index, key := range []tea.Msg{ + testKeyText("draft"), + testKey(tea.KeyEnter), + testKey(tea.KeyEnter), + testKeyText("sxas"), + testKey(tea.KeyEnter), + testKeyText("s"), } { updated, nextCmd := m.Update(key) if nextCmd != nil && index != 5 { @@ -485,15 +485,15 @@ func TestMCPAddWizardSavesRemoteWithPastedHeader(t *testing.T) { m.mcpAddWizard = newMCPAddWizard("http") var cmd tea.Cmd - for index, key := range []tea.KeyMsg{ - {Type: tea.KeyRunes, Runes: []rune("docs")}, - {Type: tea.KeyEnter}, - {Type: tea.KeyEnter}, - {Type: tea.KeyRunes, Runes: []rune("https://docs.example/mcp")}, - {Type: tea.KeyEnter}, - {Type: tea.KeyRunes, Runes: []rune("Authorization: Bearer secret")}, - {Type: tea.KeyEnter}, - {Type: tea.KeyEnter}, + for index, key := range []tea.Msg{ + testKeyText("docs"), + testKey(tea.KeyEnter), + testKey(tea.KeyEnter), + testKeyText("https://docs.example/mcp"), + testKey(tea.KeyEnter), + testKeyText("Authorization: Bearer secret"), + testKey(tea.KeyEnter), + testKey(tea.KeyEnter), } { updated, nextCmd := m.Update(key) if nextCmd != nil && index != 7 { @@ -537,7 +537,7 @@ func TestChatMCPSetupStitchURLOpensPrefilledWizard(t *testing.T) { m.height = 36 m.input.SetValue("configure this MCP https://stitch.withgoogle.com/docs/mcp/setup") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -582,7 +582,7 @@ func TestChatMCPSetupFalsePositiveSendsPrompt(t *testing.T) { prompt := "how do I add a fetch call to my mcp client?" m.input.SetValue(prompt) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.mcpAddWizard != nil { @@ -693,13 +693,13 @@ func TestChatMCPSetupStitchConfirmSavesServer(t *testing.T) { m.height = 36 m.input.SetValue("setup stitch mcp from https://stitch.withgoogle.com/docs/mcp/setup") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected MCP setup intent to open synchronously") } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd == nil { t.Fatal("expected MCP setup save to run asynchronously") @@ -741,13 +741,13 @@ func TestMCPManagerRunsSelectedServerAction(t *testing.T) { }) m.input.SetValue("/mcp") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.mcpManager == nil { t.Fatal("expected MCP manager to open") } - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d"), Alt: true}) + updated, cmd := next.Update(testKeyAltText("d")) next = updated.(model) if cmd == nil { t.Fatal("expected MCP action to run asynchronously") @@ -797,7 +797,7 @@ func TestMCPCommandRunsManagerActionAndRefreshesState(t *testing.T) { }) m.input.SetValue("/mcp disable docs") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { @@ -836,7 +836,7 @@ func TestMCPCommandPreservesQuotedArguments(t *testing.T) { }) m.input.SetValue(`/mcp add docs -- "C:\Program Files\docs mcp.exe" --label "Zero Docs"`) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { @@ -867,7 +867,7 @@ func TestMCPCommandDoesNotApplyFailedConfig(t *testing.T) { }) m.input.SetValue("/mcp disable docs") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { diff --git a/internal/tui/composer.go b/internal/tui/composer.go index 80e312871..41700b2b8 100644 --- a/internal/tui/composer.go +++ b/internal/tui/composer.go @@ -7,8 +7,8 @@ import ( "unicode" "unicode/utf8" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type composerState struct { @@ -178,84 +178,70 @@ func composerDisplayCursor(state composerState) int { func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) { state := m.currentComposerState() switch { - case msg.Type == tea.KeyEnter && msg.Alt: + case keyIs(msg, tea.KeyEnter) && keyAlt(msg): m = m.insertComposerTextWithPastePreview(state, "\n", "") - case msg.Type == tea.KeyCtrlJ: + case keyCtrl(msg, 'j'): m = m.insertComposerTextWithPastePreview(state, "\n", "") - case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "d": + case keyAlt(msg) && keyText(msg) == "d": end := deleteComposerWordAfter(state).cursor nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end) m.setComposerState(nextState) m.composerPastePreviews = nextPreviews - case (msg.Type == tea.KeyLeft || msg.Type == tea.KeyCtrlLeft) && msg.Alt: + case keyAlt(msg) && keyIs(msg, tea.KeyLeft): m.setComposerState(moveComposerWordBefore(state)) - case (msg.Type == tea.KeyRight || msg.Type == tea.KeyCtrlRight) && msg.Alt: + case keyAlt(msg) && keyIs(msg, tea.KeyRight): m.setComposerState(moveComposerWordAfter(state)) - case msg.Type == tea.KeyCtrlLeft: + case keyCtrlArrow(msg, tea.KeyLeft): m.setComposerState(moveComposerWordBefore(state)) - case msg.Type == tea.KeyCtrlRight: + case keyCtrlArrow(msg, tea.KeyRight): m.setComposerState(moveComposerWordAfter(state)) - case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "b": + case keyAlt(msg) && keyText(msg) == "b": m.setComposerState(moveComposerWordBefore(state)) - case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "f": + case keyAlt(msg) && keyText(msg) == "f": m.setComposerState(moveComposerWordAfter(state)) - case msg.Type == tea.KeySpace: + case keyIs(msg, tea.KeySpace): m = m.insertComposerTextWithPastePreview(state, " ", "") - case msg.Type == tea.KeyRunes && !msg.Alt: - text := string(msg.Runes) - previewLabel := "" - if msg.Paste { - text = sanitizeComposerPaste(text) - previewLabel, _ = composerPastePreviewLabel(text, m.composerPastePreviewWrapWidth()) - } else { - text = sanitizeComposerInput(text) - } - if shouldInsertCommandArgumentSpace(state, text) { - text = " " + text - if previewLabel != "" { - previewLabel = " " + previewLabel - } - } - m = m.insertComposerTextWithPastePreview(state, text, previewLabel) - case msg.Type == tea.KeyLeft || msg.Type == tea.KeyCtrlB: + case keyPrintable(msg): + m = m.applyComposerText(state, keyText(msg), false) + case keyIs(msg, tea.KeyLeft) || keyCtrl(msg, 'b'): if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, -1); ok { m.setComposerState(nextState) break } state.cursor-- m.setComposerState(state) - case msg.Type == tea.KeyRight || msg.Type == tea.KeyCtrlF: + case keyIs(msg, tea.KeyRight) || keyCtrl(msg, 'f'): if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, 1); ok { m.setComposerState(nextState) break } state.cursor++ m.setComposerState(state) - case msg.Type == tea.KeyHome || msg.Type == tea.KeyCtrlA: + case keyIs(msg, tea.KeyHome) || keyCtrl(msg, 'a'): state.cursor = composerLineStart(state) m.setComposerState(state) - case msg.Type == tea.KeyEnd || msg.Type == tea.KeyCtrlE: + case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'): state.cursor = composerLineEnd(state) m.setComposerState(state) - case msg.Type == tea.KeyCtrlU: + case keyCtrl(msg, 'u'): nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, composerLineStart(state), state.cursor) m.setComposerState(nextState) m.composerPastePreviews = nextPreviews - case msg.Type == tea.KeyCtrlK: + case keyCtrl(msg, 'k'): nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, composerLineEnd(state)) m.setComposerState(nextState) m.composerPastePreviews = nextPreviews - case msg.Type == tea.KeyCtrlW || (msg.Alt && (msg.Type == tea.KeyBackspace || msg.Type == tea.KeyCtrlH)): + case keyCtrl(msg, 'w') || (keyAlt(msg) && keyBackspace(msg)): start := deleteComposerWordBefore(state).cursor nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, state.cursor) m.setComposerState(nextState) m.composerPastePreviews = nextPreviews - case msg.Alt && msg.Type == tea.KeyDelete: + case keyAlt(msg) && keyIs(msg, tea.KeyDelete): end := deleteComposerWordAfter(state).cursor nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end) m.setComposerState(nextState) m.composerPastePreviews = nextPreviews - case msg.Type == tea.KeyBackspace || msg.Type == tea.KeyCtrlH: + case keyBackspace(msg): if nextState, nextPreviews, ok := deleteComposerPastePreviewBefore(state, m.composerPastePreviews); ok && !m.suggestionsActive() { m.setComposerState(nextState) m.composerPastePreviews = nextPreviews @@ -268,7 +254,7 @@ func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) { m.setComposerState(nextState) m.composerPastePreviews = nextPreviews } - case msg.Type == tea.KeyDelete || msg.Type == tea.KeyCtrlD: + case keyIs(msg, tea.KeyDelete) || keyCtrl(msg, 'd'): if nextState, nextPreviews, ok := deleteComposerPastePreviewAfter(state, m.composerPastePreviews); ok { m.setComposerState(nextState) m.composerPastePreviews = nextPreviews @@ -289,6 +275,23 @@ func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) { return m, true } +func (m model) applyComposerText(state composerState, text string, paste bool) model { + previewLabel := "" + if paste { + text = sanitizeComposerPaste(text) + previewLabel, _ = composerPastePreviewLabel(text, m.composerPastePreviewWrapWidth()) + } else { + text = sanitizeComposerInput(text) + } + if shouldInsertCommandArgumentSpace(state, text) { + text = " " + text + if previewLabel != "" { + previewLabel = " " + previewLabel + } + } + return m.insertComposerTextWithPastePreview(state, text, previewLabel) +} + func (m model) insertComposerTextWithPastePreview(state composerState, text string, previewLabel string) model { state = normalizeComposerState(state) insertStart := state.cursor diff --git a/internal/tui/composer_test.go b/internal/tui/composer_test.go index 76b7d1cfd..6ba7717a3 100644 --- a/internal/tui/composer_test.go +++ b/internal/tui/composer_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) func TestComposerInsertNewlineAtCursor(t *testing.T) { @@ -87,7 +87,7 @@ func TestBackspaceAfterCompletedFileMentionRemovesWholeMention(t *testing.T) { m.input.SetValue(tc.start) m.input.CursorEnd() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + updated, _ := m.Update(testKey(tea.KeyBackspace)) next := updated.(model) if got := next.composerValue(); got != tc.want { @@ -109,7 +109,7 @@ func TestBackspaceInsideActiveFileMentionStillEditsQuery(t *testing.T) { m := newModel(context.Background(), Options{Cwd: root}) m = typeRunes(t, m, "@docs") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + updated, _ := m.Update(testKey(tea.KeyBackspace)) next := updated.(model) if got := next.composerValue(); got != "@doc" { @@ -134,7 +134,7 @@ func TestCtrlVDoesNotPasteIntoComposer(t *testing.T) { m.input.SetValue("hello") m.input.CursorEnd() - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) + updated, cmd := m.Update(testKeyCtrl('v')) next := updated.(model) if cmd != nil { @@ -154,7 +154,7 @@ func TestPastedMultilineComposerContentRendersAsPreview(t *testing.T) { }, "\n") m := newModel(context.Background(), Options{}) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) next := updated.(model) if got := next.composerValue(); got != paste { @@ -176,7 +176,7 @@ func TestPastedLongSingleLineComposerContentRendersWrappedLineCount(t *testing.T m := newModel(context.Background(), Options{}) m.width = 44 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) next := updated.(model) if got := next.composerValue(); got != paste { @@ -198,10 +198,10 @@ func TestPastedLongSingleLineComposerContentRendersWrappedLineCount(t *testing.T func TestBackspaceAfterPastedPreviewDeletesWholePaste(t *testing.T) { paste := "first line\nsecond line\nthird line" m := newModel(context.Background(), Options{}) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + updated, _ = next.Update(testKey(tea.KeyBackspace)) next = updated.(model) if got := next.composerValue(); got != "" { t.Fatalf("composer value after deleting paste preview = %q, want empty", got) @@ -217,9 +217,9 @@ func TestAltBackspaceAfterPastedPreviewDoesNotLeakPaste(t *testing.T) { m.input.SetValue("prefix ") m.input.CursorEnd() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}) + updated, _ = next.Update(testKeyAlt(tea.KeyBackspace)) next = updated.(model) if got := next.composerValue(); got != "prefix " { @@ -235,11 +235,11 @@ func TestBackspaceAfterTypedTextKeepsPastedPreviewCollapsed(t *testing.T) { paste := "first line\nsecond line\nthird line" m := newModel(context.Background(), Options{}) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true}) + updated, _ := m.Update(testPaste(paste)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + updated, _ = next.Update(testKeyText("x")) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + updated, _ = next.Update(testKey(tea.KeyBackspace)) next = updated.(model) if got := next.composerValue(); got != paste { @@ -270,11 +270,11 @@ func TestPastingTwiceKeepsBothComposerPreviews(t *testing.T) { }, "\n") m := newModel(context.Background(), Options{}) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(firstPaste), Paste: true}) + updated, _ := m.Update(testPaste(firstPaste)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + updated, _ = next.Update(testKeyCtrl('j')) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secondPaste), Paste: true}) + updated, _ = next.Update(testPaste(secondPaste)) next = updated.(model) if got := next.composerValue(); got != firstPaste+"\n"+secondPaste { @@ -296,10 +296,10 @@ func TestPastingTwiceKeepsBothComposerPreviews(t *testing.T) { func TestModifiedEnterInsertsNewlineWithoutSubmitting(t *testing.T) { tests := []struct { name string - key tea.KeyMsg + key tea.Msg }{ - {name: "alt enter", key: tea.KeyMsg{Type: tea.KeyEnter, Alt: true}}, - {name: "shift enter", key: tea.KeyMsg{Type: tea.KeyCtrlJ}}, + {name: "alt enter", key: testKeyAlt(tea.KeyEnter)}, + {name: "shift enter", key: testKeyCtrl('j')}, } for _, tc := range tests { @@ -328,7 +328,7 @@ func TestMultilineComposerEditingDoesNotFallBackToFlatInput(t *testing.T) { m := newModel(context.Background(), Options{}) m.setComposerState(composerState{text: "alpha\nbeta gamma", cursor: len([]rune("alpha\nbeta"))}) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) + updated, _ := m.Update(testKeyCtrl('u')) next := updated.(model) if got := next.composerValue(); got != "alpha\n gamma" { t.Fatalf("ctrl+u composer value = %q, want current line prefix removed", got) @@ -337,7 +337,7 @@ func TestMultilineComposerEditingDoesNotFallBackToFlatInput(t *testing.T) { t.Fatal("ctrl+u should keep multiline composer state active") } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlK}) + updated, _ = next.Update(testKeyCtrl('k')) next = updated.(model) if got := next.composerValue(); got != "alpha\n" { t.Fatalf("ctrl+k composer value = %q, want current line suffix removed", got) @@ -351,7 +351,7 @@ func TestMultilineComposerAcceptsSpaceKey(t *testing.T) { m := newModel(context.Background(), Options{}) m.setComposerState(composerState{text: "alpha\nbetagamma", cursor: len([]rune("alpha\nbeta"))}) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + updated, _ := m.Update(testKey(tea.KeySpace)) next := updated.(model) if got := next.composerValue(); got != "alpha\nbeta gamma" { @@ -370,7 +370,7 @@ func TestWrappedComposerArrowKeysMoveByVisualLine(t *testing.T) { m.input.CursorEnd() startCursor := len([]rune(text)) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ := m.Update(testKey(tea.KeyUp)) next := updated.(model) if got := next.composerValue(); got != text { t.Fatalf("composer value = %q, want unchanged text %q", got, text) @@ -380,7 +380,7 @@ func TestWrappedComposerArrowKeysMoveByVisualLine(t *testing.T) { t.Fatalf("up cursor = %d, want before end cursor %d", upCursor, startCursor) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = next.Update(testKey(tea.KeyDown)) next = updated.(model) if got := next.currentComposerState().cursor; got != startCursor { t.Fatalf("down cursor = %d, want restored end cursor %d", got, startCursor) @@ -392,7 +392,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name string start string cursor int - key tea.KeyMsg + key tea.Msg want string wantCursor int }{ @@ -400,7 +400,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name: "alt backspace skips trailing spaces", start: "alpha beta ", cursor: len([]rune("alpha beta ")), - key: tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}, + key: testKeyAlt(tea.KeyBackspace), want: "alpha ", wantCursor: len([]rune("alpha ")), }, @@ -408,7 +408,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name: "ctrl w skips trailing spaces", start: "alpha beta ", cursor: len([]rune("alpha beta ")), - key: tea.KeyMsg{Type: tea.KeyCtrlW}, + key: testKeyCtrl('w'), want: "alpha ", wantCursor: len([]rune("alpha ")), }, @@ -416,7 +416,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name: "alt b moves back a word", start: "alpha beta gamma", cursor: len([]rune("alpha beta gamma")), - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true}, + key: testKeyAltText("b"), want: "alpha beta gamma", wantCursor: len([]rune("alpha beta ")), }, @@ -424,7 +424,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name: "ctrl left moves back a word", start: "alpha beta gamma", cursor: len([]rune("alpha beta gamma")), - key: tea.KeyMsg{Type: tea.KeyCtrlLeft}, + key: testKeyPressMod(tea.KeyLeft, tea.ModCtrl), want: "alpha beta gamma", wantCursor: len([]rune("alpha beta ")), }, @@ -432,7 +432,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name: "alt f moves forward a word", start: "alpha beta gamma", cursor: len([]rune("alpha ")), - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true}, + key: testKeyAltText("f"), want: "alpha beta gamma", wantCursor: len([]rune("alpha beta")), }, @@ -440,7 +440,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) { name: "ctrl right moves forward a word", start: "alpha beta gamma", cursor: len([]rune("alpha ")), - key: tea.KeyMsg{Type: tea.KeyCtrlRight}, + key: testKeyPressMod(tea.KeyRight, tea.ModCtrl), want: "alpha beta gamma", wantCursor: len([]rune("alpha beta")), }, diff --git a/internal/tui/doctor_command_test.go b/internal/tui/doctor_command_test.go index 13d1f2892..563a021cf 100644 --- a/internal/tui/doctor_command_test.go +++ b/internal/tui/doctor_command_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/doctor" @@ -121,7 +121,7 @@ func TestDoctorConnectivityCommandRunsProbeAsynchronously(t *testing.T) { }) m.input.SetValue("/doctor --connectivity") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected /doctor --connectivity to return an async command") @@ -157,7 +157,7 @@ func TestDoctorCommandUsesDiagnosticCenterRow(t *testing.T) { }) m.input.SetValue("/doctor") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected plain /doctor to render synchronously") @@ -204,7 +204,7 @@ func TestDoctorConnectivityCommandAnimatesAndReplacesStatusRow(t *testing.T) { }) m.input.SetValue("/doctor --connectivity") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected /doctor --connectivity to start an async command") @@ -242,7 +242,7 @@ func TestDoctorFixOpensProviderWizardWhenProviderMissing(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/doctor fix") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected /doctor fix provider setup path to be handled synchronously") @@ -322,7 +322,7 @@ func TestDoctorFixRunsConnectivityWhenProviderConfigured(t *testing.T) { }) m.input.SetValue("/doctor fix") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected /doctor fix to run provider connectivity asynchronously") diff --git a/internal/tui/flush.go b/internal/tui/flush.go index d9856a87e..6066bc1d1 100644 --- a/internal/tui/flush.go +++ b/internal/tui/flush.go @@ -3,7 +3,7 @@ package tui import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" ) diff --git a/internal/tui/flush_test.go b/internal/tui/flush_test.go index 5eb91f3d2..ab463a90f 100644 --- a/internal/tui/flush_test.go +++ b/internal/tui/flush_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/tools" ) @@ -30,7 +30,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) { if cmd == nil { t.Fatal("expected a scrollback print command for the settled rows") } - view := next.View() + view := viewString(next.View()) if strings.Contains(view, "hello there") || strings.Contains(view, "noted") { t.Fatalf("flushed rows must not re-render in the live view, got %q", view) } @@ -50,7 +50,7 @@ func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) { if next.flushed != 0 { t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed) } - view := next.View() + view := viewString(next.View()) if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") { t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view) } @@ -103,7 +103,7 @@ func TestClearResetsFlushFrontier(t *testing.T) { } m.input.SetValue("/clear") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome { t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript) @@ -120,7 +120,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) { m.runCancel = func() {} m.streamingText = "half an answer" - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := m.Update(testKey(tea.KeyEsc)) next := updated.(model) if !transcriptContains(next.transcript, "Run cancelled.") { t.Fatalf("expected visible cancellation marker, got %#v", next.transcript) @@ -154,21 +154,21 @@ func TestComposerHistoryRecall(t *testing.T) { m := sizedTestModel(80) for _, prompt := range []string{"first input", "second input"} { m.input.SetValue(prompt) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) } - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ := m.Update(testKey(tea.KeyUp)) m = updated.(model) if got := m.input.Value(); got != "second input" { t.Fatalf("first ↑ should recall the newest input, got %q", got) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ = m.Update(testKey(tea.KeyUp)) m = updated.(model) if got := m.input.Value(); got != "first input" { t.Fatalf("second ↑ should recall the older input, got %q", got) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.Update(testKey(tea.KeyDown)) m = updated.(model) if got := m.input.Value(); got != "second input" { t.Fatalf("↓ should walk back toward the newest input, got %q", got) diff --git a/internal/tui/input_compat.go b/internal/tui/input_compat.go new file mode 100644 index 000000000..6a520957e --- /dev/null +++ b/internal/tui/input_compat.go @@ -0,0 +1,89 @@ +package tui + +import tea "charm.land/bubbletea/v2" + +func keyCode(msg tea.KeyMsg) rune { + return msg.Key().Code +} + +func keyText(msg tea.KeyMsg) string { + return msg.Key().Text +} + +func keyRunes(msg tea.KeyMsg) []rune { + return []rune(keyText(msg)) +} + +func keyHasMod(msg tea.KeyMsg, mod tea.KeyMod) bool { + return msg.Key().Mod.Contains(mod) +} + +func keyAlt(msg tea.KeyMsg) bool { + return keyHasMod(msg, tea.ModAlt) +} + +func keyShift(msg tea.KeyMsg) bool { + return keyHasMod(msg, tea.ModShift) +} + +func keyIs(msg tea.KeyMsg, code rune) bool { + return keyCode(msg) == code +} + +func keyCtrl(msg tea.KeyMsg, code rune) bool { + return keyCode(msg) == code && keyHasMod(msg, tea.ModCtrl) +} + +func keyCtrlArrow(msg tea.KeyMsg, code rune) bool { + return keyIs(msg, code) && keyHasMod(msg, tea.ModCtrl) +} + +func keyPrintable(msg tea.KeyMsg) bool { + return keyText(msg) != "" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) +} + +func keyBackspace(msg tea.KeyMsg) bool { + return keyIs(msg, tea.KeyBackspace) || keyCtrl(msg, 'h') +} + +func mouseEvent(msg tea.MouseMsg) tea.Mouse { + return msg.Mouse() +} + +func mouseX(msg tea.MouseMsg) int { + return mouseEvent(msg).X +} + +func mouseY(msg tea.MouseMsg) int { + return mouseEvent(msg).Y +} + +func mouseLeftPress(msg tea.MouseMsg) bool { + event := mouseEvent(msg) + return event.Button == tea.MouseLeft && isMouseClick(msg) +} + +func mouseMotion(msg tea.MouseMsg) bool { + _, ok := msg.(tea.MouseMotionMsg) + return ok +} + +func mouseRelease(msg tea.MouseMsg) bool { + _, ok := msg.(tea.MouseReleaseMsg) + return ok +} + +func mouseWheelUp(msg tea.MouseMsg) bool { + event := mouseEvent(msg) + return event.Button == tea.MouseWheelUp +} + +func mouseWheelDown(msg tea.MouseMsg) bool { + event := mouseEvent(msg) + return event.Button == tea.MouseWheelDown +} + +func isMouseClick(msg tea.MouseMsg) bool { + _, ok := msg.(tea.MouseClickMsg) + return ok +} diff --git a/internal/tui/input_compat_test.go b/internal/tui/input_compat_test.go new file mode 100644 index 000000000..4e6fb9822 --- /dev/null +++ b/internal/tui/input_compat_test.go @@ -0,0 +1,85 @@ +package tui + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" +) + +func testKey(code rune) tea.KeyPressMsg { + text := "" + if code == tea.KeySpace { + text = " " + } + return tea.KeyPressMsg(tea.Key{Code: code, Text: text}) +} + +func testKeyText(text string) tea.KeyPressMsg { + runes := []rune(text) + code := tea.KeyExtended + if len(runes) == 1 { + code = runes[0] + } + return tea.KeyPressMsg(tea.Key{Code: code, Text: text}) +} + +func testKeyAlt(code rune) tea.KeyPressMsg { + return testKeyPressMod(code, tea.ModAlt) +} + +func testKeyAltText(text string) tea.KeyPressMsg { + msg := testKeyText(text) + key := msg.Key() + key.Mod = tea.ModAlt + return tea.KeyPressMsg(key) +} + +func testKeyCtrl(code rune) tea.KeyPressMsg { + return testKeyPressMod(code, tea.ModCtrl) +} + +func testKeyShift(code rune) tea.KeyPressMsg { + return testKeyPressMod(code, tea.ModShift) +} + +func testKeyPressMod(code rune, mod tea.KeyMod) tea.KeyPressMsg { + msg := testKey(code) + key := msg.Key() + key.Mod = mod + return tea.KeyPressMsg(key) +} + +func testPaste(content string) tea.PasteMsg { + return tea.PasteMsg{Content: content} +} + +func testMouseClick(button tea.MouseButton, x int, y int) tea.MouseClickMsg { + return tea.MouseClickMsg(tea.Mouse{Button: button, X: x, Y: y}) +} + +func testMouseWheel(button tea.MouseButton, x int, y int) tea.MouseWheelMsg { + return tea.MouseWheelMsg(tea.Mouse{Button: button, X: x, Y: y}) +} + +func testMouseMotion(button tea.MouseButton, x int, y int) tea.MouseMotionMsg { + return tea.MouseMotionMsg(tea.Mouse{Button: button, X: x, Y: y}) +} + +func testMouseRelease(button tea.MouseButton, x int, y int) tea.MouseReleaseMsg { + return tea.MouseReleaseMsg(tea.Mouse{Button: button, X: x, Y: y}) +} + +func viewString(view tea.View) string { + return view.Content +} + +func renderContent(rendered any) string { + switch v := rendered.(type) { + case string: + return v + case tea.View: + return v.Content + default: + return fmt.Sprint(v) + } +} diff --git a/internal/tui/mcp_add_wizard.go b/internal/tui/mcp_add_wizard.go index 5f1abc29f..b97d122c9 100644 --- a/internal/tui/mcp_add_wizard.go +++ b/internal/tui/mcp_add_wizard.go @@ -6,7 +6,7 @@ import ( "strings" "unicode" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" internalmcp "github.com/Gitlawb/zero/internal/mcp" "github.com/Gitlawb/zero/internal/redaction" @@ -112,37 +112,37 @@ func (m model) handleMCPAddWizardKey(msg tea.KeyMsg) (model, tea.Cmd) { return m, nil } wizard := m.mcpAddWizard - switch msg.Type { - case tea.KeyEsc: + switch { + case keyIs(msg, tea.KeyEsc): m.cancelMCPCommand() m.mcpAddWizard = nil return m, nil - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): wizard.deleteRune() return m, nil - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): wizard.clearCurrentInput() return m, nil - case tea.KeyLeft: + case keyIs(msg, tea.KeyLeft): wizard.back() return m, nil - case tea.KeyUp: + case keyIs(msg, tea.KeyUp): if wizard.step == mcpAddWizardStepType || wizard.step == mcpAddWizardStepResult { wizard.move(-1) } return m, nil - case tea.KeyDown, tea.KeyTab: + case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab): if wizard.step == mcpAddWizardStepType || wizard.step == mcpAddWizardStepResult { wizard.move(1) } return m, nil - case tea.KeyRunes: + case keyText(msg) != "": if wizard.step == mcpAddWizardStepResult { return m.handleMCPAddWizardResultShortcut(msg) } - wizard.appendRunes(msg.Runes) + wizard.appendRunes(keyRunes(msg)) return m, nil - case tea.KeyEnter, tea.KeyRight: + case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight): if wizard.step == mcpAddWizardStepResult { return m.handleMCPAddWizardResultEnter() } @@ -235,7 +235,7 @@ func (m model) handleMCPAddWizardResultShortcut(msg tea.KeyMsg) (model, tea.Cmd) if m.mcpAddWizard == nil { return m, nil } - switch strings.ToLower(string(msg.Runes)) { + switch strings.ToLower(keyText(msg)) { case "e", "r": m.mcpAddWizard.step = mcpAddWizardStepEndpoint m.mcpAddWizard.err = "" diff --git a/internal/tui/mcp_add_wizard_view.go b/internal/tui/mcp_add_wizard_view.go index d222c2b0d..4e162bdc0 100644 --- a/internal/tui/mcp_add_wizard_view.go +++ b/internal/tui/mcp_add_wizard_view.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/redaction" ) diff --git a/internal/tui/mcp_manager.go b/internal/tui/mcp_manager.go index 694de3665..3be6f84f2 100644 --- a/internal/tui/mcp_manager.go +++ b/internal/tui/mcp_manager.go @@ -4,8 +4,8 @@ import ( "strings" "unicode" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) const ( @@ -109,26 +109,26 @@ func (m model) handleMCPManagerKey(msg tea.KeyMsg) (model, tea.Cmd) { if m.mcpManager == nil { return m, nil } - switch msg.Type { - case tea.KeyEsc: + switch { + case keyIs(msg, tea.KeyEsc): m.mcpManager = nil - case tea.KeyUp: + case keyIs(msg, tea.KeyUp): m.moveMCPManager(-1) - case tea.KeyDown, tea.KeyTab: + case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab): m.moveMCPManager(1) - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): return m.chooseMCPManagerItem() - case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlH: + case keyBackspace(msg) || keyIs(msg, tea.KeyDelete): m.deleteMCPManagerQueryRune() - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.mcpManager.query = "" m.mcpManager.selected = 0 - case tea.KeyRunes: - if !msg.Alt { - m.appendMCPManagerQuery(msg.Runes) + case keyText(msg) != "": + if !keyAlt(msg) { + m.appendMCPManagerQuery(keyRunes(msg)) return m, nil } - switch strings.ToLower(string(msg.Runes)) { + switch strings.ToLower(keyText(msg)) { case "a": return m.openMCPAddWizard("http"), nil case "s": diff --git a/internal/tui/mcp_view_test.go b/internal/tui/mcp_view_test.go index 302d2ff5e..1a74d12aa 100644 --- a/internal/tui/mcp_view_test.go +++ b/internal/tui/mcp_view_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestMCPViewRendersEmptyState(t *testing.T) { diff --git a/internal/tui/model.go b/internal/tui/model.go index 0bd37257b..80fe537c8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -9,10 +9,10 @@ import ( "time" "unicode" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/Gitlawb/zero/internal/agent" @@ -33,7 +33,7 @@ import ( const tuiToolOutputLimit = 240 const defaultResponseStyle = "balanced" -const chatWheelScrollLines = 3 +const chatWheelScrollLines = 5 type model struct { ctx context.Context @@ -369,16 +369,13 @@ func newModel(ctx context.Context, options Options) model { input := textinput.New() input.Prompt = "❯ " - input.PromptStyle = zeroTheme.userPrompt - input.TextStyle = zeroTheme.ink - input.PlaceholderStyle = zeroTheme.faint input.Placeholder = composerPlaceholder // Bubble's Ctrl+V binding reads the clipboard itself. Keep it disabled so // terminal bracketed paste (Paste: true) is the single paste path. input.KeyMap.Paste.SetEnabled(false) input.Focus() - runSpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(zeroTheme.accent)) + runSpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot)) notifier := notify.New(os.Stderr, notify.Config{ Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)), @@ -563,14 +560,27 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.applyProviderWizardOAuth(msg) case providerWizardDeviceCodeMsg: return m.applyProviderWizardDeviceCode(msg) - case tea.KeyMsg: + case tea.PasteMsg: + if m.setup.visible || m.pendingAskUser != nil { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil { + return m, nil + } + state := m.currentComposerState() + m = m.applyComposerText(state, msg.Content, true) + m.recomputeSuggestions() + return m, nil + case tea.KeyPressMsg: if m.setup.visible { return m.handleSetupKey(msg) } m.transcriptSelection = transcriptSelectionState{} m.clearMouseSelection() - switch msg.Type { - case tea.KeyCtrlC: + switch { + case keyCtrl(msg, 'c'): // cancelRun records the in-flight run into flushRunIDs and writes the // "Run cancelled." marker, exactly like the Esc path. While ANY cancelled // run is still flushing we must NOT quit yet: each cancelled goroutine @@ -588,9 +598,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m.quit() - case tea.KeyCtrlO: + case keyCtrl(msg, 'o'): return m.toggleDetailedTranscript(), nil - case tea.KeyEsc: + case keyIs(msg, tea.KeyEsc): if m.mcpCommandCancel != nil { m.cancelMCPCommand() if m.mcpAddWizard != nil { @@ -647,7 +657,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelRun() } return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): if m.transcriptDetailed { if command := parseCommand(m.input.Value()); command.kind == commandTranscript { m.input.SetValue("") @@ -676,7 +686,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.picker != nil { return m.choosePicker() } - if msg.Alt { + if keyAlt(msg) { if next, ok := m.applyComposerKey(msg); ok { return next, nil } @@ -689,7 +699,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.chooseSuggestion() } return m.handleSubmit() - case tea.KeyShiftTab: + case keyIs(msg, tea.KeyTab) && keyShift(msg): if m.transcriptDetailed { return m, nil } @@ -702,14 +712,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.permissionMode = nextPermissionMode(m.permissionMode) return m, nil } - case tea.KeyCtrlF: + case keyCtrl(msg, 'f'): if m.picker != nil && m.picker.kind == pickerModel { if m.modelPickerIsLoading() { return m, nil } return m.toggleModelFavorite(), nil } - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -717,7 +727,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.picker.deleteQueryRune() return m, nil } - case tea.KeyTab: + case keyIs(msg, tea.KeyTab): if m.transcriptDetailed { return m, nil } @@ -734,17 +744,17 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.moveSuggestion(1) return m, nil } - case tea.KeyPgUp: + case keyIs(msg, tea.KeyPgUp): if m.transcriptDetailed { return m, nil } return m.scrollChat(m.chatPageScrollLines()), nil - case tea.KeyPgDown: + case keyIs(msg, tea.KeyPgDown): if m.transcriptDetailed { return m, nil } return m.scrollChat(-m.chatPageScrollLines()), nil - case tea.KeyDown: + case keyIs(msg, tea.KeyDown): if m.transcriptDetailed { return m, nil } @@ -774,7 +784,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.historyRecallActive() { return m.recallHistory(1), nil } - case tea.KeyUp: + case keyIs(msg, tea.KeyUp): if m.transcriptDetailed { return m, nil } @@ -836,8 +846,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.modelPickerIsLoading() { return m, nil } - if msg.Type == tea.KeyRunes { - m.picker.appendQuery(msg.Runes) + if keyPrintable(msg) { + m.picker.appendQuery(keyRunes(msg)) } return m, nil } @@ -898,7 +908,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height // Size the composer so long input scrolls horizontally with the cursor // visible instead of being clipped invisibly past the right edge. - m.input.Width = maxInt(20, chatWidth(msg.Width)-14) + m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14)) // The title bar prints once into native scrollback when the inline // renderer is active. In alt-screen mode tea.Println is ignored, so the // title stays managed inside View. @@ -1139,14 +1149,23 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m model) View() string { +func (m model) View() tea.View { + var content string if m.setup.visible { - return m.setupView(chatWidth(m.width)) + content = m.setupView(chatWidth(m.width)) + } else if m.transcriptDetailed { + content = m.detailedTranscriptView() + } else { + content = m.transcriptView() } - if m.transcriptDetailed { - return m.detailedTranscriptView() + + view := tea.NewView(content) + view.AltScreen = m.altScreen + view.ReportFocus = m.notifier != nil + if m.wantsMouseCapture() { + view.MouseMode = tea.MouseModeCellMotion } - return m.transcriptView() + return view } // transcriptEmpty reports whether the chat surface has no real content yet @@ -1358,10 +1377,39 @@ func (m model) scrollChat(delta int) model { if !m.altScreen || delta == 0 { return m } - m.chatScrollOffset = maxInt(0, m.chatScrollOffset+delta) + maxOffset := m.chatMaxScrollOffset() + current := clampInt(m.chatScrollOffset, 0, maxOffset) + m.chatScrollOffset = clampInt(current+delta, 0, maxOffset) + if m.chatScrollOffset == 0 { + m.chatBodyLines = 0 + } return m } +func (m model) chatMaxScrollOffset() int { + _, maxOffset := m.chatScrollMetrics() + return maxOffset +} + +func (m model) chatScrollMetrics() (int, int) { + if !m.altScreen || m.height <= 0 { + return 0, 0 + } + width := chatWidth(m.width) + body, _ := m.transcriptBody(width, "") + bodyLines := len(viewLines(body)) + footerLines := viewLines(m.footerView(width)) + maxFooterLines := maxInt(0, m.height-1) + if len(footerLines) > maxFooterLines { + footerLines = footerLines[len(footerLines)-maxFooterLines:] + } + available := m.height - len(footerLines) + if available < 1 { + available = 1 + } + return bodyLines, maxInt(0, bodyLines-available) +} + // syncChatScroll pins the viewport to what the user is reading. The scroll offset // is measured from the bottom, so when the transcript grows (streaming) the window // would otherwise follow the new bottom and drag the user off their spot. While @@ -1374,7 +1422,12 @@ func (m model) syncChatScroll() model { m.chatBodyLines = 0 return m } - current := m.chatBodyLineCount() + current, maxOffset := m.chatScrollMetrics() + m.chatScrollOffset = clampInt(m.chatScrollOffset, 0, maxOffset) + if m.chatScrollOffset <= 0 { + m.chatBodyLines = 0 + return m + } if m.chatBodyLines == 0 { // Just scrolled up: establish the baseline, no adjustment this frame. m.chatBodyLines = current @@ -1384,18 +1437,11 @@ func (m model) syncChatScroll() model { // (streaming appended lines) or shrank (a tool card collapsed, transcript // cleared). Clamp at zero so a large shrink lands the user back at the tail // rather than underflowing past it. - m.chatScrollOffset = maxInt(0, m.chatScrollOffset+current-m.chatBodyLines) + m.chatScrollOffset = clampInt(m.chatScrollOffset+current-m.chatBodyLines, 0, maxOffset) m.chatBodyLines = current return m } -// chatBodyLineCount renders the live transcript body and returns its line count. -// Only called while the user is scrolled up (see syncChatScroll). -func (m model) chatBodyLineCount() int { - body, _ := m.transcriptBody(chatWidth(m.width), "") - return len(viewLines(body)) -} - func (m model) chatPageScrollLines() int { if m.height <= 0 { return 10 @@ -1420,7 +1466,7 @@ func (m model) interimBlock(width int) string { if len(blocks) > 0 { return strings.Join(blocks, "\n") } - return m.spinner.View() + " " + zeroTheme.muted.Render("working…") + return zeroTheme.accent.Render(m.spinner.View()) + " " + zeroTheme.muted.Render("working…") } lines := renderAssistantMarkdownText(text, assistantMeasure(width), width) for index, line := range lines { @@ -1465,7 +1511,7 @@ func (m model) composerLine(width int) string { argumentHint = "" } if argumentHint != "" { - input.Width = 0 + input.SetWidth(0) return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width) } previews := validComposerPastePreviews(state, m.composerPastePreviews) @@ -1485,7 +1531,7 @@ func renderComposerInput(input textinput.Model, state composerState, width int) return "" } if state.text == "" { - return fitStyledLine(input.View(), width) + return fitStyledLine(composerVisualLinePrefix(input, true)+zeroTheme.faint.Render(input.Placeholder), width) } segments := composerWrappedVisualLines(input, state, width) @@ -1571,7 +1617,7 @@ func composerCursorVisualLine(segments []composerVisualLine, cursor int) int { func renderComposerVisualLine(input textinput.Model, state composerState, segment composerVisualLine, hasCursor bool) string { runes := []rune(state.text) prefix := composerVisualLinePrefix(input, segment.first) - textStyle := input.TextStyle.Inline(true) + textStyle := zeroTheme.ink.Inline(true) if !hasCursor { return prefix + textStyle.Render(string(runes[segment.start:segment.end])) } @@ -1579,19 +1625,16 @@ func renderComposerVisualLine(input textinput.Model, state composerState, segmen offset := clamp(state.cursor-segment.start, 0, segment.end-segment.start) cursorIndex := segment.start + offset before := string(runes[segment.start:cursorIndex]) - cursor := input.Cursor if cursorIndex < segment.end { - cursor.SetChar(string(runes[cursorIndex])) after := string(runes[cursorIndex+1 : segment.end]) - return prefix + textStyle.Render(before) + cursor.View() + textStyle.Render(after) + return prefix + textStyle.Render(before) + composerCursor(string(runes[cursorIndex])) + textStyle.Render(after) } - cursor.SetChar(" ") - return prefix + textStyle.Render(before) + cursor.View() + return prefix + textStyle.Render(before) + composerCursor(" ") } func composerVisualLinePrefix(input textinput.Model, first bool) string { if first { - return input.PromptStyle.Render(input.Prompt) + return zeroTheme.userPrompt.Render(input.Prompt) } return " " } @@ -1719,16 +1762,18 @@ func commandArgumentHintComposerLine(input textinput.Model, argumentHint string) if len(hintRunes) == 0 { return input.View() } - input.Cursor.TextStyle = zeroTheme.faint - input.Cursor.SetChar(string(hintRunes[0])) displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace) - return input.PromptStyle.Render(input.Prompt) + - input.TextStyle.Inline(true).Render(displayValue) + + return zeroTheme.userPrompt.Render(input.Prompt) + + zeroTheme.ink.Inline(true).Render(displayValue) + zeroTheme.faint.Render(" ") + - input.Cursor.View() + + composerCursor(zeroTheme.faint.Render(string(hintRunes[0]))) + zeroTheme.faint.Render(string(hintRunes[1:])) } +func composerCursor(char string) string { + return zeroTheme.selection.Render(char) +} + func commandArgumentHintForInput(value string) string { command := parseCommand(value) if command.name == "" || strings.TrimSpace(command.text) != "" { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index a48442cf0..6a2410676 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" @@ -76,7 +76,7 @@ func TestPromptSubmitInjectsLiveSessionModelContext(t *testing.T) { }) m.input.SetValue("which model are you") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -136,7 +136,7 @@ func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) { } m.input.SetValue("hello") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -254,7 +254,7 @@ func TestInitialRenderShowsLimeChatSurface(t *testing.T) { model.width = 120 model.height = 34 - view := model.View() + view := viewString(model.View()) assertContains(t, view, `/workspace/zero`) assertContains(t, view, "openai/gpt-4.1") assertContains(t, view, emptyStateTagline) @@ -273,7 +273,7 @@ func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) { m.height = 30 m.input.SetValue("inspect the repo") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) next.width = m.width next.height = m.height @@ -287,7 +287,7 @@ func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) { if next.flushed != len(next.transcript) { t.Fatalf("expected settled rows to flush to scrollback, flushed=%d rows=%d", next.flushed, len(next.transcript)) } - view := next.View() + view := viewString(next.View()) if strings.Contains(view, emptyStateTagline) { t.Fatalf("empty state should collapse after first prompt, got %q", view) } @@ -302,12 +302,12 @@ func TestEmptyStateStaysVisibleOnEmptySubmit(t *testing.T) { m.height = 30 m.input.SetValue(" ") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) next.width = m.width next.height = m.height - view := next.View() + view := viewString(next.View()) assertContains(t, view, emptyStateTagline) assertNotContains(t, view, "❯ inspect") } @@ -316,7 +316,7 @@ func TestHelpCommandAppendsHelpRow(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/help") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "/tools") { @@ -332,7 +332,7 @@ func TestClearCommandResetsTranscript(t *testing.T) { m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"}) m.input.SetValue("/clear") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome { @@ -346,7 +346,7 @@ func TestToolsCommandListsRegisteredTools(t *testing.T) { m := newModel(context.Background(), Options{Registry: registry}) m.input.SetValue("/tools") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "read_file") { @@ -380,7 +380,7 @@ func TestPermissionsCommandListsPersistentSandboxGrants(t *testing.T) { }) m.input.SetValue("/permissions") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -428,7 +428,7 @@ func TestPlanCommandShowsCurrentPlan(t *testing.T) { m := newModel(context.Background(), Options{Registry: registry}) m.input.SetValue("/plan") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -445,7 +445,7 @@ func TestPlanCommandHandlesMissingPlanTool(t *testing.T) { m := newModel(context.Background(), Options{Registry: tools.NewRegistry()}) m.input.SetValue("/plan") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "No plan is active") { @@ -465,7 +465,7 @@ func TestContextCommandShowsSessionState(t *testing.T) { }) m.input.SetValue("/context") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -494,7 +494,7 @@ func TestModelCommandShowsActiveModelWithoutRunningAgent(t *testing.T) { }) m.input.SetValue("/model list") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -534,7 +534,7 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) { }) m.input.SetValue("/model gpt-4.1-mini") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -613,7 +613,7 @@ func TestModelCommandRequestsCompactionBeforeDirtyContextSwitch(t *testing.T) { m.sessionEvents = []sessions.Event{{Sequence: 1, Type: sessions.EventMessage}} m.input.SetValue("/model gpt-4.1-mini") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -660,7 +660,7 @@ func TestModelCommandRequiresProviderRebuildForSwitch(t *testing.T) { }) m.input.SetValue("/model gpt-4.1-mini") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -687,7 +687,7 @@ func TestModelCommandRejectsSwitchWhilePending(t *testing.T) { m.pending = true m.input.SetValue("/model gpt-4.1-mini") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -716,7 +716,7 @@ func TestModelCommandReportsProviderRebuildErrors(t *testing.T) { }) m.input.SetValue("/model gpt-4.1-mini") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.modelName != "gpt-4.1" { @@ -738,7 +738,7 @@ func TestDoctorCommandUsesCurrentProviderProfile(t *testing.T) { }) m.input.SetValue("/doctor") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -774,7 +774,7 @@ func TestSearchCommandUsesSessionStore(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: store}) m.input.SetValue("/search needle") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -789,7 +789,7 @@ func TestSearchCommandRequiresQuery(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/search") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "usage: /search ") { @@ -816,7 +816,7 @@ func TestResumeCommandListsRecentSessions(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: store}) m.input.SetValue("/resume") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -826,7 +826,7 @@ func TestResumeCommandListsRecentSessions(t *testing.T) { t.Fatalf("expected session list in transcript, got %#v", next.transcript) } // The list renders as stacked cards: id + age + title + meta per session. - view := next.View() + view := viewString(next.View()) for _, want := range []string{first.SessionID, second.SessionID, "1 events", "anthropic"} { if !strings.Contains(view, want) { t.Fatalf("sessions card view missing %q:\n%s", want, view) @@ -838,7 +838,7 @@ func TestResumeCommandWithUnknownIDReportsMissingSession(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)}) m.input.SetValue("/resume zero_123") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "zero session not found: zero_123") { @@ -859,7 +859,7 @@ func TestPromptSubmitAppendsUserAndAssistantRows(t *testing.T) { }) m.input.SetValue("say hi") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "say hi") { t.Fatalf("expected user row after submit, got %#v", next.transcript) @@ -884,7 +884,7 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) { m.pending = true m.input.SetValue("second prompt") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -905,7 +905,7 @@ func TestEscCancelsPendingRun(t *testing.T) { m.activeRunID = 1 m.runCancel = func() { cancelled = true } - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := m.Update(testKey(tea.KeyEsc)) next := updated.(model) if !cancelled { @@ -1059,7 +1059,7 @@ func TestPermissionPromptChoicesResolveDecision(t *testing.T) { }) next := updated.(model) - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)}) + updated, cmd := next.Update(testKeyText(tc.key)) next = updated.(model) if cmd != nil { @@ -1090,7 +1090,7 @@ func TestPermissionPromptBlocksNormalSubmit(t *testing.T) { next := updated.(model) next.input.SetValue("second prompt") - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { @@ -1274,7 +1274,7 @@ func TestShiftTabCyclesPermissionMode(t *testing.T) { agent.PermissionModeAsk, agent.PermissionModeAuto, } { - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + updated, cmd := m.Update(testKeyShift(tea.KeyTab)) m = updated.(model) if cmd != nil { t.Fatalf("expected shift+tab to cycle mode synchronously, got command") @@ -1303,7 +1303,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) { m.activeRunID = 7 updated, _ := m.Update(permissionRequestMsg{runID: 7, request: testPromptPermissionRequest()}) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + updated, _ = next.Update(testKeyShift(tea.KeyTab)) next = updated.(model) if next.permissionMode != agent.PermissionModeAuto { t.Fatalf("expected mode unchanged while permission modal is up, got %q", next.permissionMode) @@ -1318,7 +1318,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) { m.activeRunID = 7 updated, _ := m.Update(askUserRequestMsg{runID: 7, request: testAskUserRequest(), answer: func([]string) {}}) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + updated, _ = next.Update(testKeyShift(tea.KeyTab)) next = updated.(model) if next.permissionMode != agent.PermissionModeAuto { t.Fatalf("expected mode unchanged while ask_user prompt is up, got %q", next.permissionMode) @@ -1335,12 +1335,12 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) { PermissionMode: agent.PermissionModeAuto, }) m.input.SetValue("/model") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.picker == nil { t.Skip("model picker unavailable in test environment") } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + updated, _ = next.Update(testKeyShift(tea.KeyTab)) next = updated.(model) if next.permissionMode != agent.PermissionModeAuto { t.Fatalf("expected mode unchanged while picker is open, got %q", next.permissionMode) @@ -1351,7 +1351,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) { func TestCtrlCExits(t *testing.T) { m := newModel(context.Background(), Options{}) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + updated, cmd := m.Update(testKeyCtrl('c')) next := updated.(model) if !next.exiting { @@ -1362,19 +1362,21 @@ func TestCtrlCExits(t *testing.T) { } } -func assertContains(t *testing.T, text string, want string) { +func assertContains(t *testing.T, text any, want string) { t.Helper() - if !strings.Contains(text, want) { - t.Fatalf("expected %q to contain %q", text, want) + content := plainRender(t, text) + if !strings.Contains(content, want) { + t.Fatalf("expected %q to contain %q", content, want) } } -func assertNotContains(t *testing.T, text string, unwanted string) { +func assertNotContains(t *testing.T, text any, unwanted string) { t.Helper() - if strings.Contains(text, unwanted) { - t.Fatalf("expected %q not to contain %q", text, unwanted) + content := plainRender(t, text) + if strings.Contains(content, unwanted) { + t.Fatalf("expected %q not to contain %q", content, unwanted) } } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 4c10b467b..f244cc85b 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -1,6 +1,6 @@ package tui -import tea "github.com/charmbracelet/bubbletea" +import tea "charm.land/bubbletea/v2" type mouseOverlayHit struct { y int @@ -170,34 +170,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) { return m, nil } m.mouseCapture = want - if want { - return m, tea.EnableMouseCellMotion - } - return m, tea.DisableMouse -} - -// Mouse classification uses the current Button/Action pair only. Bubble Tea's -// parser always populates Button+Action and merely derives the deprecated Type -// field from them, so checking Type adds nothing — and a left-button drag is -// Action==Motion (which mouseMotion already covers), not a press. -func mouseLeftPress(msg tea.MouseMsg) bool { - return msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress -} - -func mouseMotion(msg tea.MouseMsg) bool { - return msg.Action == tea.MouseActionMotion -} - -func mouseRelease(msg tea.MouseMsg) bool { - return msg.Action == tea.MouseActionRelease -} - -func mouseWheelUp(msg tea.MouseMsg) bool { - return msg.Button == tea.MouseButtonWheelUp -} - -func mouseWheelDown(msg tea.MouseMsg) bool { - return msg.Button == tea.MouseButtonWheelDown + return m, nil } func (m model) mouseOverComposer(msg tea.MouseMsg) bool { @@ -231,7 +204,7 @@ func (m model) mouseOverComposer(msg tea.MouseMsg) bool { } footerTop := maxInt(0, m.height-len(footerLines)) top := footerTop + visibleTop - clippedPrefix - return msg.Y >= top && msg.Y < top+visibleBottom-visibleTop + return mouseY(msg) >= top && mouseY(msg) < top+visibleBottom-visibleTop } func lineSequenceIndex(lines []string, sequence []string) int { @@ -494,13 +467,13 @@ func (m model) overlayMouseHit(msg tea.MouseMsg, overlay string, width int) (mou return mouseOverlayHit{}, false } top := m.overlayMouseTop(len(lines), width) - if msg.Y < top || msg.Y >= top+len(lines) { + if mouseY(msg) < top || mouseY(msg) >= top+len(lines) { return mouseOverlayHit{}, false } - if msg.X < left || msg.X >= left+overlayWidth { + if mouseX(msg) < left || mouseX(msg) >= left+overlayWidth { return mouseOverlayHit{}, false } - return mouseOverlayHit{y: msg.Y - top}, true + return mouseOverlayHit{y: mouseY(msg) - top}, true } func (m model) overlayMouseTop(overlayHeight int, width int) int { diff --git a/internal/tui/mouse_filter.go b/internal/tui/mouse_filter.go new file mode 100644 index 000000000..0ead3e000 --- /dev/null +++ b/internal/tui/mouse_filter.go @@ -0,0 +1,28 @@ +package tui + +import ( + "time" + + tea "charm.land/bubbletea/v2" +) + +const mouseEventThrottleInterval = 15 * time.Millisecond + +func mouseEventFilter() func(tea.Model, tea.Msg) tea.Msg { + return newMouseEventFilter(time.Now, mouseEventThrottleInterval) +} + +func newMouseEventFilter(now func() time.Time, minInterval time.Duration) func(tea.Model, tea.Msg) tea.Msg { + var last time.Time + return func(_ tea.Model, msg tea.Msg) tea.Msg { + switch msg.(type) { + case tea.MouseWheelMsg, tea.MouseMotionMsg: + current := now() + if !last.IsZero() && current.Sub(last) < minInterval { + return nil + } + last = current + } + return msg + } +} diff --git a/internal/tui/mouse_filter_test.go b/internal/tui/mouse_filter_test.go new file mode 100644 index 000000000..323d291bd --- /dev/null +++ b/internal/tui/mouse_filter_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func TestMouseEventFilterThrottlesWheelAndMotion(t *testing.T) { + base := time.Unix(0, 0) + times := []time.Time{ + base, + base.Add(10 * time.Millisecond), + base.Add(14 * time.Millisecond), + base.Add(15 * time.Millisecond), + } + index := 0 + filter := newMouseEventFilter(func() time.Time { + current := times[index] + index++ + return current + }, 15*time.Millisecond) + + if got := filter(nil, testMouseWheel(tea.MouseWheelDown, 0, 0)); got == nil { + t.Fatal("first wheel event should pass through") + } + if got := filter(nil, tea.MouseMotionMsg(tea.Mouse{X: 1, Y: 1})); got != nil { + t.Fatal("motion event inside throttle window should be dropped") + } + if got := filter(nil, testMouseWheel(tea.MouseWheelUp, 0, 0)); got != nil { + t.Fatal("wheel event inside throttle window should be dropped") + } + if got := filter(nil, tea.MouseMotionMsg(tea.Mouse{X: 2, Y: 2})); got == nil { + t.Fatal("mouse event at throttle boundary should pass through") + } +} + +func TestMouseEventFilterDoesNotThrottleKeyboard(t *testing.T) { + called := false + filter := newMouseEventFilter(func() time.Time { + called = true + return time.Unix(0, 0) + }, 15*time.Millisecond) + + msg := testKey('x') + if got := filter(nil, msg); got != msg { + t.Fatalf("keyboard event = %#v, want original message", got) + } + if called { + t.Fatal("keyboard events should not touch the mouse throttle clock") + } +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index fee5e1b69..efdb22294 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" ) @@ -19,12 +19,7 @@ func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) { width := chatWidth(m.width) top := m.overlayMouseTop(len(viewLines(m.suggestionOverlay(width))), width) - click := tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: width / 2, - Y: top + 3, - } + click := testMouseClick(tea.MouseLeft, width/2, top+3) updated, cmd := m.Update(click) next := updated.(model) if cmd != nil { @@ -65,12 +60,7 @@ func TestMouseClickSelectsThenAppliesPickerRow(t *testing.T) { width := chatWidth(m.width) top := m.overlayMouseTop(len(viewLines(m.pickerOverlay(width))), width) - click := tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: width / 2, - Y: top + 3, - } + click := testMouseClick(tea.MouseLeft, width/2, top+3) updated, cmd := m.Update(click) next := updated.(model) if cmd != nil { @@ -105,12 +95,7 @@ func TestMouseClickSelectsProviderWizardRow(t *testing.T) { width := chatWidth(m.width) top := m.overlayMouseTop(len(viewLines(m.providerWizardOverlay(width))), width) - click := tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: width / 2, - Y: top + 5, - } + click := testMouseClick(tea.MouseLeft, width/2, top+5) updated, cmd := m.Update(click) next := updated.(model) if cmd != nil { @@ -137,7 +122,7 @@ func TestMouseWheelMovesProviderWizardRows(t *testing.T) { m.providerWizard.step = providerWizardStepProvider // skip the new method chooser m.mouseCapture = true - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0)) next := updated.(model) if cmd != nil { t.Fatal("mouse wheel should not return a command") @@ -156,12 +141,7 @@ func TestMouseClickSelectsThenContinuesSetupProviderRow(t *testing.T) { height := normalizedStartupHeight(m.height) rowWidth := setupProviderBlockWidth(width, m.setup.providers) top := setupContentTop(height, len(m.setupProviderLines(width, height)), m.setup.err != "") - click := tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: maxInt(0, (width-rowWidth)/2) + 2, - Y: top + 3, - } + click := testMouseClick(tea.MouseLeft, maxInt(0, (width-rowWidth)/2)+2, top+3) updated, cmd := m.Update(click) next := updated.(model) if cmd != nil { @@ -196,12 +176,7 @@ func TestMouseClickSelectsThenContinuesSetupModelRow(t *testing.T) { height := normalizedStartupHeight(m.height) rowWidth := setupModelBlockWidth(width, m.setup.models) top := setupContentTop(height, len(m.setupModelLines(width, height)), m.setup.err != "") - click := tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: maxInt(0, (width-rowWidth)/2) + 2, - Y: top + 5, - } + click := testMouseClick(tea.MouseLeft, maxInt(0, (width-rowWidth)/2)+2, top+5) updated, cmd := m.Update(click) next := updated.(model) if cmd != nil { @@ -234,7 +209,7 @@ func TestMouseCaptureOnlyWhileInteractiveSurfaceOpen(t *testing.T) { t.Fatalf("open command palette should capture mouse, wants=%v active=%v", m.wantsMouseCapture(), m.mouseCapture) } - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd := m.Update(testKey(tea.KeyEsc)) m = updated.(model) _ = cmd if !m.wantsMouseCapture() || !m.mouseCapture { @@ -259,12 +234,7 @@ func TestTranscriptSelectionOnlyStartsOnTranscriptText(t *testing.T) { m.mouseCapture = true m.transcript = appendRow(m.transcript, rowUser, "hello world") - updated, cmd := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: 40, - Y: 20, - }) + updated, cmd := m.Update(testMouseClick(tea.MouseLeft, 40, 20)) next := updated.(model) if cmd != nil { t.Fatal("empty-area click should not return a command") @@ -273,12 +243,7 @@ func TestTranscriptSelectionOnlyStartsOnTranscriptText(t *testing.T) { t.Fatal("empty-area click should not start transcript selection") } - updated, cmd = next.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: 3, - Y: 1, - }) + updated, cmd = next.Update(testMouseClick(tea.MouseLeft, 3, 1)) next = updated.(model) if cmd != nil { t.Fatal("transcript press should not copy yet") @@ -293,19 +258,9 @@ func TestTranscriptSelectionExtractsVisibleTextRange(t *testing.T) { m.mouseCapture = true m.transcript = appendRow(m.transcript, rowUser, "hello world") - updated, _ := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: 3, - Y: 1, - }) + updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, 1)) m = updated.(model) - updated, _ = m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionMotion, - X: 8, - Y: 1, - }) + updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 8, 1)) m = updated.(model) if got := m.selectedTranscriptText(); got != "hello" { @@ -318,19 +273,9 @@ func TestTranscriptSelectionUpdatesOnGenericMotion(t *testing.T) { m.mouseCapture = true m.transcript = appendRow(m.transcript, rowUser, "hello world") - updated, _ := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: 3, - Y: 1, - }) + updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, 1)) m = updated.(model) - updated, _ = m.Update(tea.MouseMsg{ - Button: tea.MouseButtonNone, - Action: tea.MouseActionMotion, - X: 8, - Y: 1, - }) + updated, _ = m.Update(testMouseMotion(tea.MouseNone, 8, 1)) m = updated.(model) if got := m.selectedTranscriptText(); got != "hello" { @@ -343,21 +288,11 @@ func TestTranscriptSelectionLeftDragDoesNotResetAnchor(t *testing.T) { m.mouseCapture = true m.transcript = appendRow(m.transcript, rowUser, "hello world") - updated, _ := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: 3, - Y: 1, - }) + updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, 1)) m = updated.(model) // A left-button drag is Action==Motion with Button==Left; this must update the // cursor without resetting the selection anchor. - updated, _ = m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionMotion, - X: 8, - Y: 1, - }) + updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 8, 1)) m = updated.(model) if got := m.selectedTranscriptText(); got != "hello" { @@ -370,19 +305,9 @@ func TestTranscriptSelectionReleaseExtendsRangeWithoutMotion(t *testing.T) { m.mouseCapture = true m.transcript = appendRow(m.transcript, rowUser, "hello world") - updated, _ := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: 3, - Y: 1, - }) + updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, 1)) m = updated.(model) - updated, cmd := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonNone, - Action: tea.MouseActionRelease, - X: 8, - Y: 1, - }) + updated, cmd := m.Update(testMouseRelease(tea.MouseNone, 8, 1)) m = updated.(model) if cmd == nil { t.Fatal("release after range selection should return copy command") @@ -428,12 +353,7 @@ func TestMouseClickTogglesReasoningRow(t *testing.T) { t.Fatalf("expected reasoning header to be clickable, selectable=%#v", selectable) } - updated, cmd := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: target.textStart, - Y: target.bodyY - start, - }) + updated, cmd := m.Update(testMouseClick(tea.MouseLeft, target.textStart, target.bodyY-start)) next := updated.(model) if cmd != nil { t.Fatal("reasoning toggle click should not return a command") @@ -467,12 +387,7 @@ func TestMouseClickTogglesStreamingReasoning(t *testing.T) { t.Fatalf("expected live reasoning header to be clickable, selectable=%#v", selectable) } - updated, cmd := m.Update(tea.MouseMsg{ - Button: tea.MouseButtonLeft, - Action: tea.MouseActionPress, - X: target.textStart, - Y: target.bodyY - start, - }) + updated, cmd := m.Update(testMouseClick(tea.MouseLeft, target.textStart, target.bodyY-start)) next := updated.(model) if cmd != nil { t.Fatal("streaming reasoning toggle click should not return a command") @@ -525,7 +440,7 @@ func TestMCPManagerMouseSelectsFirstItemRow(t *testing.T) { left, _, _ := normalizeOverlayBlock(lines, width) y := m.overlayMouseTop(len(lines), width) + mcpManagerFirstItemRow(m.mcpViewState()) - target, ok := m.selectMCPManagerAtMouse(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: left + 2, Y: y}) + target, ok := m.selectMCPManagerAtMouse(testMouseClick(tea.MouseLeft, left+2, y)) if !ok { t.Fatal("expected click on first manager item row to select") } @@ -545,7 +460,7 @@ func TestMCPAddWizardMouseSelectsAndActivatesType(t *testing.T) { lines := viewLines(overlay) left, _, _ := normalizeOverlayBlock(lines, width) y := m.overlayMouseTop(len(lines), width) + 5 // second type row: top border + step + rule + title + first row - msg := tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: left + 2, Y: y} + msg := testMouseClick(tea.MouseLeft, left+2, y) updated, cmd := m.Update(msg) next := updated.(model) diff --git a/internal/tui/onboarding.go b/internal/tui/onboarding.go index b25829a20..ff11daf4b 100644 --- a/internal/tui/onboarding.go +++ b/internal/tui/onboarding.go @@ -7,9 +7,9 @@ import ( "time" "unicode" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/browser" "github.com/Gitlawb/zero/internal/config" @@ -106,9 +106,6 @@ func newSetupState(options SetupOptions) setupState { } apiKey := textinput.New() apiKey.Prompt = "" - apiKey.PromptStyle = zeroTheme.faint - apiKey.TextStyle = zeroTheme.ink - apiKey.PlaceholderStyle = zeroTheme.faint apiKey.Placeholder = "paste key or leave blank" apiKey.EchoMode = textinput.EchoPassword apiKey.EchoCharacter = '*' @@ -131,10 +128,10 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // While a browser OAuth login is in flight, ignore input except Ctrl+C (quit) // and Esc (cancel back to the OAuth provider list). if m.setup.oauthPending { - switch msg.Type { - case tea.KeyCtrlC: + switch { + case keyCtrl(msg, 'c'): return m, tea.Quit - case tea.KeyEsc: + case keyIs(msg, tea.KeyEsc): m.setup.oauthPending = false m.setup.oauthDevice = false } @@ -149,10 +146,10 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.setupCredentialInputActive() { return m.handleSetupCredentialKey(msg) } - switch msg.Type { - case tea.KeyCtrlC: + switch { + case keyCtrl(msg, 'c'): return m, tea.Quit - case tea.KeyEsc: + case keyIs(msg, tea.KeyEsc): if m.setup.stage > setupStageWelcome { prev := m.previousSetupStage() if prev == setupStageMethod { @@ -166,7 +163,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } return m.exitSetupToChat() - case tea.KeyLeft: + case keyIs(msg, tea.KeyLeft): if m.setup.stage > setupStageWelcome { prev := m.previousSetupStage() if prev == setupStageMethod { @@ -176,17 +173,17 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setup.err = "" } return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): if m.setup.stage == setupStageMethod || m.setup.stage == setupStageProvider || m.setup.stage == setupStageModel || m.setup.stage == setupStageReady { return m.advanceSetup() } return m, nil - case tea.KeySpace: + case keyIs(msg, tea.KeySpace): if m.setup.stage < setupStageReady && m.setup.stage != setupStageProvider && m.setup.stage != setupStageModel { return m.advanceSetup() } return m, nil - case tea.KeyUp: + case keyIs(msg, tea.KeyUp): if m.setup.stage == setupStageMethod { m.moveSetupMethod(-1) } else if m.setup.stage == setupStageProvider { @@ -195,7 +192,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveSetupModel(-1) } return m, nil - case tea.KeyDown: + case keyIs(msg, tea.KeyDown): if m.setup.stage == setupStageMethod { m.moveSetupMethod(1) } else if m.setup.stage == setupStageProvider { @@ -204,12 +201,12 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveSetupModel(1) } return m, nil - case tea.KeyRunes: + case keyText(msg) != "": if m.setup.stage == setupStageModel { - m.appendSetupModelQuery(msg.Runes) + m.appendSetupModelQuery(keyRunes(msg)) return m, nil } - switch msg.String() { + switch keyText(msg) { case "q": return m, tea.Quit case "k": @@ -227,12 +224,12 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): if m.setup.stage == setupStageModel { m.deleteSetupModelQueryRune() } return m, nil - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): if m.setup.stage == setupStageModel { m.setup.modelQuery = "" m.setup.modelIndex = 0 @@ -316,7 +313,7 @@ func (m *model) selectSetupProviderAtMouse(msg tea.MouseMsg) (mouseSelectionTarg width := chatWidth(m.width) height := normalizedStartupHeight(m.height) rowWidth := setupProviderBlockWidth(width, m.setup.providers) - if !setupBlockContainsMouseX(msg.X, width, rowWidth) { + if !setupBlockContainsMouseX(mouseX(msg), width, rowWidth) { return mouseSelectionTarget{}, false } maxVisible := setupProviderMaxVisible(height, len(m.setup.providers)) @@ -325,7 +322,7 @@ func (m *model) selectSetupProviderAtMouse(msg tea.MouseMsg) (mouseSelectionTarg } content := m.setupProviderLines(width, height) top := setupContentTop(height, len(content), m.setup.err != "") - row := msg.Y - top - 2 + row := mouseY(msg) - top - 2 if row < 0 || row >= maxVisible { return mouseSelectionTarget{}, false } @@ -355,7 +352,7 @@ func (m *model) selectSetupModelAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, width := chatWidth(m.width) height := normalizedStartupHeight(m.height) rowWidth := setupModelBlockWidth(width, m.setup.models) - if !setupBlockContainsMouseX(msg.X, width, rowWidth) { + if !setupBlockContainsMouseX(mouseX(msg), width, rowWidth) { return mouseSelectionTarget{}, false } maxVisible := setupModelMaxVisible(height, len(models)) @@ -369,7 +366,7 @@ func (m *model) selectSetupModelAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, if m.setupModelStatus() != "" { rowStart++ } - row := msg.Y - top - rowStart + row := mouseY(msg) - top - rowStart if row < 0 || row >= maxVisible { return mouseSelectionTarget{}, false } @@ -958,20 +955,20 @@ func (m model) setupNameInputActive() bool { } func (m model) handleSetupEndpointKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyCtrlC: + switch { + case keyCtrl(msg, 'c'): return m, tea.Quit - case tea.KeyEsc, tea.KeyLeft: + case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft): m.setup.stage = m.previousSetupStage() m.setup.err = "" return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): return m.advanceSetup() - case tea.KeyRunes: - m.appendSetupBaseURL(msg.Runes) - case tea.KeyBackspace, tea.KeyCtrlH: + case keyText(msg) != "": + m.appendSetupBaseURL(keyRunes(msg)) + case keyBackspace(msg): m.deleteSetupBaseURLRune() - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.setup.baseURL = "" m.setup.err = "" } @@ -979,20 +976,20 @@ func (m model) handleSetupEndpointKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m model) handleSetupNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyCtrlC: + switch { + case keyCtrl(msg, 'c'): return m, tea.Quit - case tea.KeyEsc, tea.KeyLeft: + case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft): m.setup.stage = m.previousSetupStage() m.setup.err = "" return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): return m.advanceSetup() - case tea.KeyRunes: - m.appendSetupName(msg.Runes) - case tea.KeyBackspace, tea.KeyCtrlH: + case keyText(msg) != "": + m.appendSetupName(keyRunes(msg)) + case keyBackspace(msg): m.deleteSetupNameRune() - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.setup.name = "" m.setup.err = "" } @@ -1000,16 +997,16 @@ func (m model) handleSetupNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m model) handleSetupCredentialKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyCtrlC: + switch { + case keyCtrl(msg, 'c'): return m, tea.Quit - case tea.KeyEsc, tea.KeyLeft: + case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft): m.setup.stage = m.previousSetupStage() m.setup.err = "" return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): return m.advanceSetup() - case tea.KeyUp, tea.KeyDown: + case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown): return m, nil } previousAPIKey := m.setup.apiKey.Value() @@ -1563,13 +1560,13 @@ func (m model) setupCredentialLines(width int) []string { func (m model) setupAPIKeyInputLine(width int) string { input := m.setup.apiKey if strings.TrimSpace(input.Value()) == "" { - return input.PlaceholderStyle.Render(input.Placeholder) + return zeroTheme.faint.Render(input.Placeholder) } contentWidth := lipgloss.Width(input.Value()) if contentWidth == 0 { contentWidth = lipgloss.Width(input.Placeholder) } - input.Width = minInt(maxInt(contentWidth, 1), maxInt(1, width-lipgloss.Width(input.Prompt))) + input.SetWidth(minInt(maxInt(contentWidth, 1), maxInt(1, width-lipgloss.Width(input.Prompt)))) return input.View() } diff --git a/internal/tui/onboarding_test.go b/internal/tui/onboarding_test.go index 9f6fe682d..7a90bc194 100644 --- a/internal/tui/onboarding_test.go +++ b/internal/tui/onboarding_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providermodeldiscovery" @@ -92,11 +92,11 @@ func TestSetupTakeoverRendersAndCompletes(t *testing.T) { m.width = 100 m.height = 30 - if view := m.View(); !strings.Contains(view, "Welcome to Zero") || !strings.Contains(view, "Space to set up Zero") || !strings.Contains(view, "terminal agent for changing real code") { + if view := plainRender(t, m.View()); !strings.Contains(view, "Welcome to Zero") || !strings.Contains(view, "Space to set up Zero") || !strings.Contains(view, "terminal agent for changing real code") { t.Fatalf("setup welcome view missing expected text:\n%s", view) } - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + updated, cmd := m.Update(testKey(tea.KeySpace)) if cmd != nil { t.Fatal("setup navigation should not launch a command") } @@ -105,13 +105,13 @@ func TestSetupTakeoverRendersAndCompletes(t *testing.T) { t.Fatalf("stage = %v, want method chooser", m.setup.stage) } m.setup.selectedMethod = len(m.setupMethodOptions()) - 1 // API-key / browse path - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.setup.stage != setupStageProvider { t.Fatalf("stage = %v, want provider", m.setup.stage) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.Update(testKey(tea.KeyDown)) m = updated.(model) if got := m.setupProvider().ID; got != "ollama" { t.Fatalf("selected provider = %q, want ollama", got) @@ -120,7 +120,7 @@ func TestSetupTakeoverRendersAndCompletes(t *testing.T) { for m.setup.stage != setupStageReady { m = pressSetupContinue(m) } - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { t.Fatal("setup completion should stay in the fullscreen chat surface") @@ -178,14 +178,14 @@ func TestSetupTakeoverCustomCompatibleCollectsEndpointNameAndModel(t *testing.T) assertContains(t, view, "url >") assertContains(t, view, "https://api.example.com/v1") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.setup.stage != setupStageEndpoint { t.Fatalf("blank endpoint advanced to %v, want endpoint", m.setup.stage) } assertContains(t, plainRender(t, m.View()), "enter an endpoint URL") - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("https://api.minimax.io/v1")}) + updated, _ = m.Update(testKeyText("https://api.minimax.io/v1")) m = updated.(model) m = pressSetupContinue(m) if m.setup.stage != setupStageName { @@ -209,14 +209,14 @@ func TestSetupTakeoverCustomCompatibleCollectsEndpointNameAndModel(t *testing.T) assertContains(t, view, "model >") assertContains(t, view, "custom-model") - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.setup.stage != setupStageModel { t.Fatalf("blank model advanced to %v, want model", m.setup.stage) } assertContains(t, plainRender(t, m.View()), "Enter a model name") - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("MiniMax-M3")}) + updated, _ = m.Update(testKeyText("MiniMax-M3")) m = updated.(model) m = pressSetupContinue(m) if m.setup.stage != setupStageSafety { @@ -314,7 +314,7 @@ func TestSetupTakeoverBlocksPromptSubmission(t *testing.T) { }) m.input.SetValue("run tests") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { t.Fatal("setup enter should not launch an agent run") @@ -350,7 +350,7 @@ func TestSetupRightArrowDoesNotAdvance(t *testing.T) { }) m.setup.stage = stage - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, cmd := m.Update(testKey(tea.KeyRight)) m = updated.(model) if cmd != nil { t.Fatalf("right arrow at stage %v should not return a command", stage) @@ -379,7 +379,7 @@ func TestSetupProviderMouseWheelChangesSelection(t *testing.T) { }) m.setup.stage = setupStageProvider - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0)) m = updated.(model) if cmd != nil { t.Fatal("provider wheel should not return a command") @@ -388,7 +388,7 @@ func TestSetupProviderMouseWheelChangesSelection(t *testing.T) { t.Fatalf("provider after wheel down = %q, want anthropic", got) } - updated, cmd = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + updated, cmd = m.Update(testMouseWheel(tea.MouseWheelUp, 0, 0)) m = updated.(model) if cmd != nil { t.Fatal("provider wheel should not return a command") @@ -410,7 +410,7 @@ func TestSetupModelMouseWheelChangesSelection(t *testing.T) { m.setup.stage = setupStageModel m.resetSetupModels() - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0)) m = updated.(model) if cmd != nil { t.Fatal("model wheel should not return a command") @@ -419,7 +419,7 @@ func TestSetupModelMouseWheelChangesSelection(t *testing.T) { t.Fatalf("model after wheel down = %q, want non-default model", got) } - updated, cmd = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + updated, cmd = m.Update(testMouseWheel(tea.MouseWheelUp, 0, 0)) m = updated.(model) if cmd != nil { t.Fatal("model wheel should not return a command") @@ -442,7 +442,7 @@ func TestSetupModelMouseWheelIgnoredWhileLoading(t *testing.T) { m.resetSetupModels() m.setup.modelLoad = true - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0)) m = updated.(model) if cmd != nil { t.Fatal("loading model wheel should not return a command") @@ -473,7 +473,7 @@ func TestSetupEnterDoesNotAdvanceSpaceOnlyStages(t *testing.T) { }) m.setup.stage = stage - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { t.Fatalf("enter at stage %v should not return a command", stage) @@ -504,7 +504,7 @@ func TestSetupProviderRequiresEnter(t *testing.T) { }) m.setup.stage = setupStageProvider - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + updated, cmd := m.Update(testKey(tea.KeySpace)) m = updated.(model) if cmd != nil { t.Fatal("space on provider step should not return a command") @@ -513,7 +513,7 @@ func TestSetupProviderRequiresEnter(t *testing.T) { t.Fatalf("space on provider step advanced to %v", m.setup.stage) } - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { t.Fatal("enter on provider step should not return a command") @@ -545,7 +545,7 @@ func TestSetupReadyRequiresEnter(t *testing.T) { }) m.setup.stage = setupStageReady - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + updated, cmd := m.Update(testKey(tea.KeySpace)) m = updated.(model) if cmd != nil { t.Fatal("space on ready step should not return a command") @@ -557,7 +557,7 @@ func TestSetupReadyRequiresEnter(t *testing.T) { t.Fatalf("space on ready step should keep setup visible at ready, visible=%v stage=%v", m.setup.visible, m.setup.stage) } - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { t.Fatal("enter on ready step should stay in the fullscreen chat surface") @@ -600,7 +600,7 @@ func TestSetupCredentialsAcceptsPastedAPIKeyWithoutRenderingSecret(t *testing.T) m.height = 30 m.setup.stage = setupStageCredentials - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secret)}) + updated, _ := m.Update(testKeyText(secret)) m = updated.(model) view := plainRender(t, m.View()) if strings.Contains(view, secret) { @@ -613,7 +613,7 @@ func TestSetupCredentialsAcceptsPastedAPIKeyWithoutRenderingSecret(t *testing.T) for m.setup.stage != setupStageReady { m = pressSetupContinue(m) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if saved.APIKey != secret { @@ -637,7 +637,7 @@ func TestSetupCredentialsCtrlVDoesNotRunClipboardPaste(t *testing.T) { m.setup.apiKey.SetValue("existing") m.setup.apiKey.CursorEnd() - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) + updated, cmd := m.Update(testKeyCtrl('v')) next := updated.(model) if cmd != nil { @@ -675,7 +675,7 @@ func TestSetupModelStepSavesCatalogModelChoice(t *testing.T) { m.height = 30 m.setup.stage = setupStageCredentials - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.setup.stage != setupStageModel { t.Fatalf("stage = %v, want model", m.setup.stage) @@ -696,7 +696,7 @@ func TestSetupModelStepSavesCatalogModelChoice(t *testing.T) { } } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.Update(testKey(tea.KeyDown)) m = updated.(model) selected := m.setupCurrentModel().ID if selected == "" || selected == "llama-3.3-70b-versatile" { @@ -705,7 +705,7 @@ func TestSetupModelStepSavesCatalogModelChoice(t *testing.T) { for m.setup.stage != setupStageReady { m = pressSetupContinue(m) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if saved.Model != selected { @@ -734,7 +734,7 @@ func TestSetupModelSearchFiltersAndSavesMatch(t *testing.T) { }, }) m.setup.stage = setupStageCredentials - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd == nil { t.Fatal("entering the model step should start model discovery") @@ -742,7 +742,7 @@ func TestSetupModelSearchFiltersAndSavesMatch(t *testing.T) { updated, _ = m.Update(cmd()) m = updated.(model) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("oss")}) + updated, _ = m.Update(testKeyText("oss")) m = updated.(model) if got := m.setupCurrentModel().ID; got != "openai/gpt-oss-120b" { t.Fatalf("filtered model = %q, want openai/gpt-oss-120b", got) @@ -754,7 +754,7 @@ func TestSetupModelSearchFiltersAndSavesMatch(t *testing.T) { for m.setup.stage != setupStageReady { m = pressSetupContinue(m) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if saved.Model != "openai/gpt-oss-120b" { @@ -778,7 +778,7 @@ func TestSetupModelLoadingBlocksSelectionAndSearch(t *testing.T) { m.height = 30 m.setup.stage = setupStageCredentials - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd == nil { t.Fatal("entering the model step should start model discovery") @@ -788,13 +788,13 @@ func TestSetupModelLoadingBlocksSelectionAndSearch(t *testing.T) { t.Fatalf("loading model step should not render fallback models:\n%s", view) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("oss")}) + updated, _ = m.Update(testKeyText("oss")) m = updated.(model) if m.setup.modelQuery != "" { t.Fatalf("model query while loading = %q, want empty", m.setup.modelQuery) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.setup.stage != setupStageModel { t.Fatalf("enter while loading advanced stage to %v", m.setup.stage) @@ -816,7 +816,7 @@ func TestSetupModelSearchAcceptsQ(t *testing.T) { m.setup.stage = setupStageModel m.resetSetupModels() - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + updated, cmd := m.Update(testKeyText("q")) m = updated.(model) if cmd != nil { t.Fatal("q should search on the model step, not quit setup") @@ -885,7 +885,7 @@ func TestSetupModelStepDoesNotSpinWithoutDiscovery(t *testing.T) { }) m.setup.stage = setupStageCredentials - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + updated, cmd := m.Update(testKey(tea.KeySpace)) m = updated.(model) if cmd != nil { t.Fatal("custom setup provider should not start model discovery") @@ -916,7 +916,7 @@ func TestSetupModelStepUsesDiscoveredModels(t *testing.T) { m.height = 30 m.setup.stage = setupStageCredentials - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + updated, cmd := m.Update(testKey(tea.KeySpace)) m = updated.(model) if m.setup.stage != setupStageModel { t.Fatalf("stage = %v, want model", m.setup.stage) @@ -1052,7 +1052,7 @@ func TestSetupModelDiscoveryRedactsRequestAPIKey(t *testing.T) { m.setup.stage = setupStageCredentials m.setup.apiKey.SetValue(oldSecret) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd == nil { t.Fatal("entering model step should start discovery") @@ -1315,7 +1315,7 @@ func TestSetupMethodChooserOAuthPath(t *testing.T) { t.Fatalf("stage = %v, want method chooser", m.setup.stage) } m.setup.selectedMethod = 0 // "Sign in with OAuth" - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.setup.stage != setupStageProvider || !m.setup.oauthMode { t.Fatalf("OAuth method should enter the OAuth provider list, got stage=%v oauth=%v", m.setup.stage, m.setup.oauthMode) @@ -1329,7 +1329,7 @@ func TestSetupMethodChooserOAuthPath(t *testing.T) { } // Left returns to the method chooser and clears the OAuth selection. - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyLeft}) + updated, _ = m.Update(testKey(tea.KeyLeft)) m = updated.(model) if m.setup.stage != setupStageMethod || m.setup.oauthMode { t.Fatalf("retreat should return to method without oauthMode, got stage=%v oauth=%v", m.setup.stage, m.setup.oauthMode) @@ -1349,7 +1349,7 @@ func setupAtOAuthList(t *testing.T) model { m.height = 30 m = pressSetupContinueOnce(m) // Welcome → Method m.setup.selectedMethod = 0 // Sign in with OAuth - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) return updated.(model) } @@ -1361,7 +1361,7 @@ func TestSetupDeviceShortcutStartsDeviceFlow(t *testing.T) { break } } - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + updated, cmd := m.Update(testKeyText("d")) m = updated.(model) if !m.setup.oauthPending || !m.setup.oauthDevice { t.Fatalf("'d' should start device login (pending=%v device=%v)", m.setup.oauthPending, m.setup.oauthDevice) @@ -1411,7 +1411,7 @@ func TestApplySetupOAuthSuccessAdvancesToModel(t *testing.T) { m.height = 30 m = pressSetupContinueOnce(m) // Welcome → Method m.setup.selectedMethod = 0 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) // OAuth provider stage m.setup.oauthPending = true @@ -1443,9 +1443,9 @@ func pressSetupContinueOnce(m model) model { var updated tea.Model var cmd tea.Cmd if m.setup.stage == setupStageMethod || m.setup.stage == setupStageProvider || m.setupEndpointInputActive() || m.setupNameInputActive() || m.setupCredentialInputActive() || m.setup.stage == setupStageModel || m.setup.stage == setupStageReady { - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = m.Update(testKey(tea.KeyEnter)) } else { - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + updated, cmd = m.Update(testKey(tea.KeySpace)) } m = updated.(model) if cmd != nil { @@ -1455,14 +1455,15 @@ func pressSetupContinueOnce(m model) model { return m } -func displayColumnForVisibleLine(t *testing.T, view string, marker string) int { +func displayColumnForVisibleLine(t *testing.T, view any, marker string) int { t.Helper() - for _, line := range strings.Split(plainRender(t, view), "\n") { + rendered := plainRender(t, view) + for _, line := range strings.Split(rendered, "\n") { if strings.Contains(line, marker) { return displayColumn(line, marker) } } - t.Fatalf("marker %q missing from view:\n%s", marker, view) + t.Fatalf("marker %q missing from view:\n%s", marker, rendered) return -1 } @@ -1474,7 +1475,7 @@ func displayColumn(line string, marker string) int { return lipgloss.Width(line[:index]) } -func assertSetupLineCentered(t *testing.T, view string, marker string, width int) { +func assertSetupLineCentered(t *testing.T, view any, marker string, width int) { t.Helper() line := visibleLineForMarker(t, view, marker) trimmed := strings.TrimSpace(line) @@ -1486,14 +1487,15 @@ func assertSetupLineCentered(t *testing.T, view string, marker string, width int } } -func visibleLineForMarker(t *testing.T, view string, marker string) string { +func visibleLineForMarker(t *testing.T, view any, marker string) string { t.Helper() - for _, line := range strings.Split(plainRender(t, view), "\n") { + rendered := plainRender(t, view) + for _, line := range strings.Split(rendered, "\n") { if strings.Contains(line, marker) { return line } } - t.Fatalf("marker %q missing from view:\n%s", marker, view) + t.Fatalf("marker %q missing from view:\n%s", marker, rendered) return "" } diff --git a/internal/tui/options.go b/internal/tui/options.go index 35010a232..5011d27cc 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -3,7 +3,7 @@ package tui import ( "context" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 76b0ef93c..f2350a269 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -7,7 +7,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/modelregistry" diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index a9fb8126f..c39b3f58c 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providermodeldiscovery" @@ -71,7 +71,7 @@ func TestModelPickerRefreshesLiveModelsForActiveProvider(t *testing.T) { }) m.input.SetValue("/model") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.picker == nil { t.Fatal("expected model picker to open") @@ -111,7 +111,7 @@ func TestModelPickerShowsLoadingUntilDiscoveryCompletes(t *testing.T) { }, }) m.input.SetValue("/model") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd == nil { t.Fatal("expected opening the model picker to start discovery") @@ -120,7 +120,7 @@ func TestModelPickerShowsLoadingUntilDiscoveryCompletes(t *testing.T) { assertContains(t, loading, "Checking available models...") assertNotContains(t, loading, "Live Cloud A") - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.picker == nil { t.Fatal("Enter while loading should not choose the fallback list") @@ -192,7 +192,7 @@ func TestModelPickerFallsBackWhenDiscoveryFails(t *testing.T) { }, }) m.input.SetValue("/model") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd == nil { t.Fatal("expected opening the model picker to start discovery") @@ -228,7 +228,7 @@ func TestModelPickerAppliesLiveDiscoveredModelID(t *testing.T) { m.picker = m.newModelPicker() m.picker.selected = pickerIndex(m.picker.items, "glm-5.1") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if captured.Model != "glm-5.1" { t.Fatalf("captured model = %q, want glm-5.1", captured.Model) @@ -261,7 +261,7 @@ func TestModelSwitchNormalizesDetectedOllamaCloudProfile(t *testing.T) { m.modelPickerLiveModels = []providermodeldiscovery.Model{{ID: "glm-5.1", Description: "GLM 5.1"}} m.input.SetValue("/model glm-5.1") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if captured.Name != "ollama-cloud" || captured.CatalogID != "ollama-cloud" { @@ -293,7 +293,7 @@ func TestModelPickerSearchFiltersModels(t *testing.T) { }) m.picker = m.newModelPicker() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("qwen")}) + updated, _ := m.Update(testKeyText("qwen")) next := updated.(model) if next.picker.query != "qwen" { t.Fatalf("picker query = %q, want qwen", next.picker.query) @@ -329,7 +329,7 @@ func TestModelPickerFavoriteShortcutTogglesSelectedModel(t *testing.T) { } m.picker.selected = target - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) + updated, _ := m.Update(testKeyCtrl('f')) next := updated.(model) if !next.favoriteModels["qwen3-coder:480b"] { t.Fatalf("favorite map = %#v, want qwen3-coder:480b favorited", next.favoriteModels) @@ -342,7 +342,7 @@ func TestModelPickerFavoriteShortcutTogglesSelectedModel(t *testing.T) { t.Fatalf("persisted FavoriteModels = %#v, want qwen3-coder:480b", persisted.Preferences.FavoriteModels) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) + updated, _ = next.Update(testKeyCtrl('f')) next = updated.(model) if next.favoriteModels["qwen3-coder:480b"] { t.Fatalf("favorite map = %#v, want qwen3-coder:480b unfavorited", next.favoriteModels) @@ -431,7 +431,7 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) { }) m.input.SetValue("/model openai/gpt-4.1") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected /model to be handled without starting a run") @@ -451,7 +451,7 @@ func TestModelPickerOpensAndCancels(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) m.input.SetValue("/model") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd != nil { t.Fatal("opening the model picker should not start a run") @@ -461,7 +461,7 @@ func TestModelPickerOpensAndCancels(t *testing.T) { } // Esc cancels the picker without touching the run or transcript. - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = m.Update(testKey(tea.KeyEsc)) m = updated.(model) if m.picker != nil { t.Fatal("Esc should close the picker") @@ -485,7 +485,7 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) { }, }) m.input.SetValue("/model") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.picker == nil { t.Fatal("expected model picker open") @@ -510,7 +510,7 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) { } m.picker.selected = target - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.picker != nil { t.Fatal("choosing should close the picker") @@ -527,7 +527,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) m.input.SetValue("/effort") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.picker == nil || m.picker.kind != pickerEffort { t.Fatalf("expected an open effort picker, got %#v", m.picker) @@ -543,7 +543,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) { m.picker.selected = i } } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.reasoningEffort != "high" { t.Fatalf("expected effort applied via handler, got %q", m.reasoningEffort) @@ -554,7 +554,7 @@ func TestThemeCommandOpensNoPicker(t *testing.T) { // /theme keeps the existing shell-only message; no picker opens. m := newModel(context.Background(), Options{}) m.input.SetValue("/theme") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.picker != nil { t.Fatal("/theme should not open a picker") @@ -581,7 +581,7 @@ func TestPickersRefuseToOpenWhileRunPending(t *testing.T) { m.pending = true m.input.SetValue(tc.command) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatalf("%s while pending should not start a run", tc.command) @@ -603,9 +603,9 @@ func TestPickerRenders(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) m.width, m.height = 96, 30 m.input.SetValue("/model") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) - if !strings.Contains(m.View(), "Choose a model") { + if !strings.Contains(viewString(m.View()), "Choose a model") { t.Fatal("view should render the picker title") } } diff --git a/internal/tui/pr_status.go b/internal/tui/pr_status.go index d7914cdac..ae35cccf1 100644 --- a/internal/tui/pr_status.go +++ b/internal/tui/pr_status.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type PrStatus string diff --git a/internal/tui/provider_onboarding_test.go b/internal/tui/provider_onboarding_test.go index 5c9ffe5a2..2f7e521bc 100644 --- a/internal/tui/provider_onboarding_test.go +++ b/internal/tui/provider_onboarding_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" ) @@ -102,7 +102,7 @@ func renderProviderCommand(t *testing.T, options Options) string { m := newModel(context.Background(), options) m.input.SetValue("/provider status") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 8313f7883..b68270c62 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -10,8 +10,8 @@ import ( "time" "unicode" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/browser" "github.com/Gitlawb/zero/internal/config" @@ -26,6 +26,8 @@ import ( // flow stored an OAuth token in the oauth store (xAI) that the runtime resolver // will attach — in that case no key is needed on the profile. type providerWizardOAuthMsg struct { + providerID string + attemptID int apiKey string tokenLogin bool err error @@ -35,7 +37,7 @@ type providerWizardOAuthMsg struct { // success the minted key fills the credential and the wizard advances; on failure // the (redacted) error is shown and the user can retry or paste a key. func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.Cmd) { - if m.providerWizard == nil { + if m.providerWizard == nil || !m.providerWizard.oauthResultMatches(msg.providerID, msg.attemptID) { return m, nil } m.providerWizard.oauthPending = false @@ -62,7 +64,7 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea. // user_code + verification URI, then kick off phase 2 (the token poll). On error // the redacted message is surfaced and the login is abandoned. func (m model) applyProviderWizardDeviceCode(msg providerWizardDeviceCodeMsg) (model, tea.Cmd) { - if m.providerWizard == nil || !m.providerWizard.oauthPending { + if m.providerWizard == nil || !m.providerWizard.oauthResultMatches(msg.providerID, msg.attemptID) { return m, nil } if msg.err != nil { @@ -73,7 +75,7 @@ func (m model) applyProviderWizardDeviceCode(msg providerWizardDeviceCodeMsg) (m } m.providerWizard.deviceUserCode = msg.userCode m.providerWizard.deviceVerificationURI = msg.verifyURL - return m, providerWizardDevicePollCmd(msg.providerID, msg.cfg, msg.auth) + return m, providerWizardDevicePollCmd(msg.providerID, msg.attemptID, msg.cfg, msg.auth) } // providerWizardSupportsOAuth reports whether the credential step should offer a @@ -88,19 +90,19 @@ func providerWizardSupportsOAuth(provider providercatalog.Descriptor) bool { // providerWizardOAuthCmdFor runs the chosen provider's browser OAuth login off the // UI goroutine and reports the outcome. OpenRouter mints an API key; other OAuth // providers (xAI) run the generic engine login which stores a refreshable token. -func providerWizardOAuthCmdFor(provider providercatalog.Descriptor) tea.Cmd { +func providerWizardOAuthCmdFor(provider providercatalog.Descriptor, attemptID int) tea.Cmd { + providerID := provider.ID if provider.OAuthMintsKey { return func() tea.Msg { key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{ OpenBrowser: browser.OpenURL, Timeout: 3 * time.Minute, }) - return providerWizardOAuthMsg{apiKey: key, err: err} + return providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, apiKey: key, err: err} } } - name := provider.ID return func() tea.Msg { - return providerWizardOAuthMsg{tokenLogin: true, err: runProviderTokenLogin(name)} + return providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, tokenLogin: true, err: runProviderTokenLogin(providerID)} } } @@ -130,6 +132,7 @@ func runProviderTokenLogin(name string) error { // the user_code + verification URI to display, plus the cfg/auth to poll with. type providerWizardDeviceCodeMsg struct { providerID string + attemptID int userCode string verifyURL string cfg oauth.Config @@ -139,14 +142,15 @@ type providerWizardDeviceCodeMsg struct { // providerWizardDevicePrepareCmd runs phase 1 of the device-code login off the UI // goroutine and reports the code to display (or an error). -func providerWizardDevicePrepareCmd(name string) tea.Cmd { +func providerWizardDevicePrepareCmd(name string, attemptID int) tea.Cmd { return func() tea.Msg { auth, cfg, err := oauthDevicePrepare(name) if err != nil { - return providerWizardDeviceCodeMsg{providerID: name, err: err} + return providerWizardDeviceCodeMsg{providerID: name, attemptID: attemptID, err: err} } return providerWizardDeviceCodeMsg{ providerID: name, + attemptID: attemptID, userCode: auth.UserCode, verifyURL: oauthDeviceVerifyTarget(auth), cfg: cfg, @@ -157,9 +161,9 @@ func providerWizardDevicePrepareCmd(name string) tea.Cmd { // providerWizardDevicePollCmd runs phase 2 (poll for the token + store) off the // UI goroutine and reports completion as a regular OAuth result. -func providerWizardDevicePollCmd(name string, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd { +func providerWizardDevicePollCmd(name string, attemptID int, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd { return func() tea.Msg { - return providerWizardOAuthMsg{tokenLogin: true, err: oauthDeviceComplete(name, cfg, auth)} + return providerWizardOAuthMsg{providerID: name, attemptID: attemptID, tokenLogin: true, err: oauthDeviceComplete(name, cfg, auth)} } } @@ -170,12 +174,8 @@ func (m model) startProviderDeviceLogin() (model, tea.Cmd) { if !provider.OAuth || !provider.OAuthDeviceFlow { return m, nil } - m.providerWizard.oauthPending = true - m.providerWizard.oauthDevice = true - m.providerWizard.oauthErr = "" - m.providerWizard.deviceUserCode = "" - m.providerWizard.deviceVerificationURI = "" - return m, providerWizardDevicePrepareCmd(provider.ID) + attemptID := m.providerWizard.beginOAuthAttempt(true) + return m, providerWizardDevicePrepareCmd(provider.ID, attemptID) } const maxProviderWizardProvidersVisible = 8 @@ -255,6 +255,7 @@ type providerWizardState struct { selectedMethod int oauthMode bool oauthPending bool + oauthAttemptID int oauthErr string // Device-code login (RFC 8628) state while an OAuth login is in flight. oauthDevice bool @@ -292,6 +293,23 @@ func (wizard *providerWizardState) currentProvider() providercatalog.Descriptor return wizard.providers[wizard.selectedProvider] } +func (wizard *providerWizardState) beginOAuthAttempt(device bool) int { + wizard.oauthAttemptID++ + wizard.oauthPending = true + wizard.oauthDevice = device + wizard.oauthErr = "" + wizard.deviceUserCode = "" + wizard.deviceVerificationURI = "" + return wizard.oauthAttemptID +} + +func (wizard *providerWizardState) oauthResultMatches(providerID string, attemptID int) bool { + if wizard == nil || !wizard.oauthPending || strings.TrimSpace(providerID) == "" { + return false + } + return wizard.currentProvider().ID == providerID && wizard.oauthAttemptID == attemptID +} + func (wizard *providerWizardState) currentModel() providerWizardModel { if wizard == nil { return providerWizardModel{} @@ -523,7 +541,7 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) { // While a browser/device OAuth login is in flight, ignore input except Esc, // which abandons the wizard (the background flow times out and is dropped). if m.providerWizard.oauthPending { - if msg.Type == tea.KeyEsc { + if keyIs(msg, tea.KeyEsc) { m.providerWizard = nil } return m, nil @@ -531,115 +549,115 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) { // On the OAuth provider list, "d" forces device-code login for a device-capable // provider (xAI) — useful on a desktop when the browser flow won't work. if m.providerWizard.step == providerWizardStepProvider && m.providerWizard.oauthMode && - msg.Type == tea.KeyRunes && len(msg.Runes) == 1 && (msg.Runes[0] == 'd' || msg.Runes[0] == 'D') && + (keyText(msg) == "d" || keyText(msg) == "D") && m.providerWizard.currentProvider().OAuthDeviceFlow { return m.startProviderDeviceLogin() } if m.providerWizard.step == providerWizardStepEndpoint { - switch msg.Type { - case tea.KeyRunes: - m.providerWizard.appendBaseURL(msg.Runes) + switch { + case keyText(msg) != "": + m.providerWizard.appendBaseURL(keyRunes(msg)) return m, nil - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): m.providerWizard.deleteBaseURLRune() return m, nil - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.providerWizard.baseURL = "" m.providerWizard.err = "" return m, nil - case tea.KeyLeft: + case keyIs(msg, tea.KeyLeft): m.providerWizard.retreat() return m, nil - case tea.KeyRight: + case keyIs(msg, tea.KeyRight): if m.providerWizard.canAdvanceWithRight() { return m.advanceProviderWizard() } return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): return m.advanceProviderWizard() } } if m.providerWizard.step == providerWizardStepName { - switch msg.Type { - case tea.KeyRunes: - m.providerWizard.appendProfileName(msg.Runes) + switch { + case keyText(msg) != "": + m.providerWizard.appendProfileName(keyRunes(msg)) return m, nil - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): m.providerWizard.deleteProfileNameRune() return m, nil - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.providerWizard.profileName = "" m.providerWizard.err = "" return m, nil - case tea.KeyLeft: + case keyIs(msg, tea.KeyLeft): m.providerWizard.retreat() return m, nil - case tea.KeyRight, tea.KeyEnter: + case keyIs(msg, tea.KeyRight) || keyIs(msg, tea.KeyEnter): return m.advanceProviderWizard() } } if m.providerWizard.step == providerWizardStepCredential { - switch msg.Type { - case tea.KeyEsc: + switch { + case keyIs(msg, tea.KeyEsc): m.providerWizard = nil return m, nil - case tea.KeyCtrlO: + case keyCtrl(msg, 'o'): if providerWizardSupportsOAuth(m.providerWizard.currentProvider()) { - m.providerWizard.oauthPending = true - m.providerWizard.oauthErr = "" - return m, providerWizardOAuthCmdFor(m.providerWizard.currentProvider()) + provider := m.providerWizard.currentProvider() + attemptID := m.providerWizard.beginOAuthAttempt(false) + return m, providerWizardOAuthCmdFor(provider, attemptID) } return m, nil - case tea.KeyRunes: - m.providerWizard.appendAPIKey(msg.Runes) + case keyText(msg) != "": + m.providerWizard.appendAPIKey(keyRunes(msg)) return m, nil - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): m.providerWizard.deleteAPIKeyRune() return m, nil - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.providerWizard.apiKey = "" return m, nil - case tea.KeyLeft: + case keyIs(msg, tea.KeyLeft): m.providerWizard.retreat() return m, nil - case tea.KeyRight: + case keyIs(msg, tea.KeyRight): if m.providerWizard.canAdvanceWithRight() { return m.advanceProviderWizard() } return m, nil - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): return m.advanceProviderWizard() } return m, nil } if m.providerWizard.step == providerWizardStepModel { - switch msg.Type { - case tea.KeyRunes: - m.providerWizard.appendModelSearch(msg.Runes) + switch { + case keyText(msg) != "": + m.providerWizard.appendModelSearch(keyRunes(msg)) return m, nil - case tea.KeyBackspace, tea.KeyCtrlH: + case keyBackspace(msg): m.providerWizard.deleteModelSearchRune() return m, nil - case tea.KeyCtrlU: + case keyCtrl(msg, 'u'): m.providerWizard.modelSearch = "" m.providerWizard.selectedModel = 0 return m, nil } } - switch msg.Type { - case tea.KeyEsc: + switch { + case keyIs(msg, tea.KeyEsc): m.providerWizard = nil - case tea.KeyUp: + case keyIs(msg, tea.KeyUp): m.providerWizard.move(-1) - case tea.KeyDown, tea.KeyTab: + case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab): m.providerWizard.move(1) - case tea.KeyLeft: + case keyIs(msg, tea.KeyLeft): m.providerWizard.retreat() - case tea.KeyRight: + case keyIs(msg, tea.KeyRight): if m.providerWizard.canAdvanceWithRight() { return m.advanceProviderWizard() } - case tea.KeyEnter: + case keyIs(msg, tea.KeyEnter): if m.providerWizard.step == providerWizardStepDone { return m.applyProviderWizard() } diff --git a/internal/tui/provider_wizard_discovery.go b/internal/tui/provider_wizard_discovery.go index ddb7b4dba..754b93a8f 100644 --- a/internal/tui/provider_wizard_discovery.go +++ b/internal/tui/provider_wizard_discovery.go @@ -6,7 +6,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providercatalog" @@ -37,10 +37,8 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) { if provider.OAuthDeviceFlow && oauthPreferDeviceFlow() { return m.startProviderDeviceLogin() } - m.providerWizard.oauthPending = true - m.providerWizard.oauthDevice = false - m.providerWizard.oauthErr = "" - return m, providerWizardOAuthCmdFor(provider) + attemptID := m.providerWizard.beginOAuthAttempt(false) + return m, providerWizardOAuthCmdFor(provider, attemptID) } previous := m.providerWizard.step m.providerWizard.advance() diff --git a/internal/tui/provider_wizard_oauth_test.go b/internal/tui/provider_wizard_oauth_test.go index 1db5a2061..9989f27ab 100644 --- a/internal/tui/provider_wizard_oauth_test.go +++ b/internal/tui/provider_wizard_oauth_test.go @@ -5,8 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" - "github.com/Gitlawb/zero/internal/providercatalog" ) @@ -80,6 +78,11 @@ func selectWizardOAuthProvider(t *testing.T, m model, id string) model { return m } +func beginTestOAuthAttempt(wizard *providerWizardState, device bool) (string, int) { + providerID := wizard.currentProvider().ID + return providerID, wizard.beginOAuthAttempt(device) +} + func TestProviderWizardDeviceShortcutStartsDeviceFlow(t *testing.T) { m := mouseTestModel() m.providerWizard = m.newProviderWizard() @@ -87,10 +90,13 @@ func TestProviderWizardDeviceShortcutStartsDeviceFlow(t *testing.T) { next, _ := m.advanceProviderWizard() // → OAuth list m = selectWizardOAuthProvider(t, next, "xai") - out, cmd := m.handleProviderWizardKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + out, cmd := m.handleProviderWizardKey(testKeyText("d")) if !out.providerWizard.oauthPending || !out.providerWizard.oauthDevice { t.Fatalf("'d' should start device login (pending=%v device=%v)", out.providerWizard.oauthPending, out.providerWizard.oauthDevice) } + if out.providerWizard.oauthAttemptID == 0 { + t.Fatal("'d' should assign an OAuth attempt id") + } if cmd == nil { t.Fatal("'d' should return the device-prepare command") } @@ -102,11 +108,10 @@ func TestProviderWizardDeviceCodeMsgShowsCodeAndPolls(t *testing.T) { m.providerWizard.selectedMethod = 0 next, _ := m.advanceProviderWizard() m = selectWizardOAuthProvider(t, next, "xai") - m.providerWizard.oauthPending = true - m.providerWizard.oauthDevice = true + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, true) out, cmd := m.applyProviderWizardDeviceCode(providerWizardDeviceCodeMsg{ - providerID: "xai", userCode: "ABCD-1234", verifyURL: "https://x.ai/device", + providerID: providerID, attemptID: attemptID, userCode: "ABCD-1234", verifyURL: "https://x.ai/device", }) if out.providerWizard.deviceUserCode != "ABCD-1234" || out.providerWizard.deviceVerificationURI != "https://x.ai/device" { t.Fatalf("device code not stored: %+v", out.providerWizard) @@ -123,11 +128,10 @@ func TestProviderWizardDeviceCodeMsgShowsCodeAndPolls(t *testing.T) { func TestProviderWizardDeviceErrorSurfaced(t *testing.T) { m := mouseTestModel() m.providerWizard = m.newProviderWizard() - m.providerWizard.oauthPending = true - m.providerWizard.oauthDevice = true + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, true) out, cmd := m.applyProviderWizardDeviceCode(providerWizardDeviceCodeMsg{ - providerID: "xai", err: errors.New("device endpoint unreachable"), + providerID: providerID, attemptID: attemptID, err: errors.New("device endpoint unreachable"), }) if out.providerWizard.oauthPending || out.providerWizard.oauthDevice { t.Fatal("device error should clear pending/device state") @@ -143,12 +147,11 @@ func TestProviderWizardDeviceErrorSurfaced(t *testing.T) { func TestProviderWizardOAuthSuccessClearsDeviceState(t *testing.T) { m := mouseTestModel() m.providerWizard = m.newProviderWizard() - m.providerWizard.oauthPending = true - m.providerWizard.oauthDevice = true + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, true) m.providerWizard.deviceUserCode = "X-1" m.providerWizard.deviceVerificationURI = "https://x.ai/device" - out, _ := m.applyProviderWizardOAuth(providerWizardOAuthMsg{tokenLogin: true}) + out, _ := m.applyProviderWizardOAuth(providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, tokenLogin: true}) if out.providerWizard.oauthDevice || out.providerWizard.deviceUserCode != "" || out.providerWizard.deviceVerificationURI != "" { t.Fatalf("success should clear device state: %+v", out.providerWizard) } @@ -178,6 +181,9 @@ func TestProviderWizardOAuthDispatchFromList(t *testing.T) { if !next.providerWizard.oauthPending { t.Fatal("advancing from the OAuth list should start the login (oauthPending)") } + if next.providerWizard.oauthAttemptID == 0 { + t.Fatal("advancing from the OAuth list should assign an OAuth attempt id") + } if cmd == nil { t.Fatal("advancing from the OAuth list should return the OAuth command") } @@ -210,10 +216,13 @@ func TestProviderWizardSupportsOAuth(t *testing.T) { func TestProviderWizardCtrlOStartsOAuthForOpenRouter(t *testing.T) { m := wizardModelAt(t, "openrouter", providerWizardStepCredential) - next, cmd := m.handleProviderWizardKey(tea.KeyMsg{Type: tea.KeyCtrlO}) + next, cmd := m.handleProviderWizardKey(testKeyCtrl('o')) if next.providerWizard == nil || !next.providerWizard.oauthPending { t.Fatal("ctrl+o should mark the wizard oauthPending") } + if next.providerWizard.oauthAttemptID == 0 { + t.Fatal("ctrl+o should assign an OAuth attempt id") + } if cmd == nil { t.Fatal("ctrl+o should return a command to run the OAuth flow") } @@ -221,7 +230,7 @@ func TestProviderWizardCtrlOStartsOAuthForOpenRouter(t *testing.T) { func TestProviderWizardCtrlONoopForNonOAuthProvider(t *testing.T) { m := wizardModelAt(t, "openai", providerWizardStepCredential) - next, _ := m.handleProviderWizardKey(tea.KeyMsg{Type: tea.KeyCtrlO}) + next, _ := m.handleProviderWizardKey(testKeyCtrl('o')) if next.providerWizard != nil && next.providerWizard.oauthPending { t.Fatal("ctrl+o must not start OAuth for a provider that doesn't support it") } @@ -229,8 +238,8 @@ func TestProviderWizardCtrlONoopForNonOAuthProvider(t *testing.T) { func TestApplyProviderWizardOAuthSuccessAdvances(t *testing.T) { m := wizardModelAt(t, "openrouter", providerWizardStepCredential) - m.providerWizard.oauthPending = true - next, _ := m.applyProviderWizardOAuth(providerWizardOAuthMsg{apiKey: "sk-or-minted"}) + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, false) + next, _ := m.applyProviderWizardOAuth(providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, apiKey: "sk-or-minted"}) if next.providerWizard == nil { t.Fatal("wizard should remain open") } @@ -247,8 +256,8 @@ func TestApplyProviderWizardOAuthSuccessAdvances(t *testing.T) { func TestApplyProviderWizardOAuthErrorStays(t *testing.T) { m := wizardModelAt(t, "openrouter", providerWizardStepCredential) - m.providerWizard.oauthPending = true - next, _ := m.applyProviderWizardOAuth(providerWizardOAuthMsg{err: errors.New("nope")}) + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, false) + next, _ := m.applyProviderWizardOAuth(providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, err: errors.New("nope")}) if next.providerWizard == nil { t.Fatal("wizard should remain open on error") } @@ -274,3 +283,51 @@ func TestRenderCredentialStepShowsOAuthHintAndPending(t *testing.T) { t.Fatal("pending state should show the browser-waiting message") } } + +func TestApplyProviderWizardOAuthIgnoresStaleAttempt(t *testing.T) { + m := wizardModelAt(t, "openrouter", providerWizardStepCredential) + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, false) + + next, cmd := m.applyProviderWizardOAuth(providerWizardOAuthMsg{ + providerID: providerID, + attemptID: attemptID - 1, + apiKey: "sk-or-stale", + }) + if cmd != nil { + t.Fatal("stale OAuth result should not start a command") + } + if !next.providerWizard.oauthPending { + t.Fatal("stale OAuth result should leave the active attempt pending") + } + if next.providerWizard.apiKey != "" { + t.Fatalf("stale OAuth result applied api key %q", next.providerWizard.apiKey) + } + if next.providerWizard.step != providerWizardStepCredential { + t.Fatalf("stale OAuth result moved step to %v", next.providerWizard.step) + } +} + +func TestProviderWizardDeviceCodeIgnoresStaleAttempt(t *testing.T) { + m := mouseTestModel() + m.providerWizard = m.newProviderWizard() + m.providerWizard.selectedMethod = 0 + next, _ := m.advanceProviderWizard() + m = selectWizardOAuthProvider(t, next, "xai") + providerID, attemptID := beginTestOAuthAttempt(m.providerWizard, true) + + out, cmd := m.applyProviderWizardDeviceCode(providerWizardDeviceCodeMsg{ + providerID: providerID, + attemptID: attemptID - 1, + userCode: "STALE", + verifyURL: "https://x.ai/device", + }) + if cmd != nil { + t.Fatal("stale device-code result should not start polling") + } + if !out.providerWizard.oauthPending { + t.Fatal("stale device-code result should leave the active attempt pending") + } + if out.providerWizard.deviceUserCode != "" || out.providerWizard.deviceVerificationURI != "" { + t.Fatalf("stale device-code result applied device details: %+v", out.providerWizard) + } +} diff --git a/internal/tui/provider_wizard_test.go b/internal/tui/provider_wizard_test.go index eeeed379e..200035beb 100644 --- a/internal/tui/provider_wizard_test.go +++ b/internal/tui/provider_wizard_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providercatalog" @@ -22,7 +22,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/provider") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -50,7 +50,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) { // Choosing the API-key method reveals the full provider catalog. next.providerWizard.selectedMethod = len(providerWizardMethodOptions()) - 1 - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) listView := plainRender(t, next.View()) for _, want := range []string{"Choose provider", "OpenAI", "Anthropic", "Google", "Groq", "OpenRouter", "Ollama"} { @@ -84,7 +84,7 @@ func TestProviderWizardReplacesEmptyStateWordmark(t *testing.T) { m.height = 34 m.input.SetValue("/provider") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) next.width = m.width next.height = m.height @@ -172,13 +172,13 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) { m := newModel(context.Background(), Options{}) m = openProviderWizardForTest(t, m) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ := m.Update(testKey(tea.KeyDown)) next := updated.(model) if got := next.providerWizard.currentProvider().ID; got != "anthropic" { t.Fatalf("after down, selected provider = %q, want anthropic", got) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepCredential { t.Fatalf("wizard step = %v, want credential", next.providerWizard.step) @@ -193,7 +193,7 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) { } assertNotContains(t, view, "zero providers add anthropic") - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("wizard step = %v, want model", next.providerWizard.step) @@ -222,7 +222,7 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) { assertContains(t, view, want) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepDone { t.Fatalf("wizard step = %v, want done", next.providerWizard.step) @@ -244,30 +244,30 @@ func TestProviderWizardSupportsLeftAndGuardedRightNavigation(t *testing.T) { m := newModel(context.Background(), Options{}) m = openProviderWizardForTest(t, m) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, _ := m.Update(testKey(tea.KeyRight)) next := updated.(model) if next.providerWizard.step != providerWizardStepCredential { t.Fatalf("right from provider step = %v, want credential", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + updated, _ = next.Update(testKey(tea.KeyLeft)) next = updated.(model) if next.providerWizard.step != providerWizardStepProvider { t.Fatalf("left from credential step = %v, want provider", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) clearProviderAuthEnvForTest(t, next.providerWizard.currentProvider()) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, _ = next.Update(testKey(tea.KeyRight)) next = updated.(model) if next.providerWizard.step != providerWizardStepCredential { t.Fatalf("right from empty credential step = %v, want credential", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("sk-test")}) + updated, _ = next.Update(testKeyText("sk-test")) next = updated.(model) - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, cmd := next.Update(testKey(tea.KeyRight)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("right from entered credential step = %v, want model", next.providerWizard.step) @@ -276,7 +276,7 @@ func TestProviderWizardSupportsLeftAndGuardedRightNavigation(t *testing.T) { t.Fatal("right from entered credential should start live model discovery") } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, _ = next.Update(testKey(tea.KeyRight)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("right while loading step = %v, want model", next.providerWizard.step) @@ -291,13 +291,13 @@ func TestProviderWizardSupportsLeftAndGuardedRightNavigation(t *testing.T) { }}, }) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, _ = next.Update(testKey(tea.KeyRight)) next = updated.(model) if next.providerWizard.step != providerWizardStepDone { t.Fatalf("right from model step = %v, want ready", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + updated, _ = next.Update(testKey(tea.KeyLeft)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("left from ready step = %v, want model", next.providerWizard.step) @@ -309,13 +309,13 @@ func TestProviderWizardRightAllowsExistingCredentialEnv(t *testing.T) { m := newModel(context.Background(), Options{}) m = openProviderWizardForTest(t, m) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.providerWizard.step != providerWizardStepCredential { t.Fatalf("enter from provider step = %v, want credential", next.providerWizard.step) } - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRight}) + updated, cmd := next.Update(testKey(tea.KeyRight)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("right with env credential step = %v, want model", next.providerWizard.step) @@ -336,7 +336,7 @@ func TestProviderWizardCustomCompatibleProviderCollectsEndpointAndModel(t *testi m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "custom-openai-compatible") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("custom endpoint step should not start model discovery") @@ -354,16 +354,16 @@ func TestProviderWizardCustomCompatibleProviderCollectsEndpointAndModel(t *testi assertContains(t, view, want) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepEndpoint { t.Fatalf("blank endpoint advanced to %v, want endpoint", next.providerWizard.step) } assertContains(t, plainRender(t, next.View()), "enter an endpoint URL") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("https://proxy.example/v1")}) + updated, _ = next.Update(testKeyText("https://proxy.example/v1")) next = updated.(model) - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("endpoint step should not start model discovery") @@ -380,7 +380,7 @@ func TestProviderWizardCustomCompatibleProviderCollectsEndpointAndModel(t *testi assertContains(t, view, want) } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("name step should not start model discovery") @@ -389,7 +389,7 @@ func TestProviderWizardCustomCompatibleProviderCollectsEndpointAndModel(t *testi t.Fatalf("name step advanced to %v, want credential", next.providerWizard.step) } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { t.Fatal("custom model step should not discover against the placeholder endpoint") @@ -406,16 +406,16 @@ func TestProviderWizardCustomCompatibleProviderCollectsEndpointAndModel(t *testi assertContains(t, view, want) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("blank model advanced to %v, want model", next.providerWizard.step) } assertContains(t, plainRender(t, next.View()), "enter a model name") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("my-custom-model")}) + updated, _ = next.Update(testKeyText("my-custom-model")) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepDone { t.Fatalf("model step advanced to %v, want ready", next.providerWizard.step) @@ -425,7 +425,7 @@ func TestProviderWizardCustomCompatibleProviderCollectsEndpointAndModel(t *testi assertContains(t, view, "Name proxy") assertContains(t, view, "Model my-custom-model") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard != nil { t.Fatal("saving custom provider should close the wizard") @@ -452,15 +452,15 @@ func TestProviderWizardCustomCompatibleProviderRejectsRemoteHTTP(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "custom-openai-compatible") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.providerWizard.step != providerWizardStepEndpoint { t.Fatalf("custom provider first step = %v, want endpoint", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("http://api.example.com/v1")}) + updated, _ = next.Update(testKeyText("http://api.example.com/v1")) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepEndpoint { t.Fatalf("remote http endpoint advanced to %v, want endpoint", next.providerWizard.step) @@ -479,29 +479,29 @@ func TestProviderWizardCustomCompatibleProviderDerivesIPName(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "custom-openai-compatible") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("https://127.0.0.1:1234/v1")}) + updated, _ = next.Update(testKeyText("https://127.0.0.1:1234/v1")) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepName { t.Fatalf("endpoint step advanced to %v, want name", next.providerWizard.step) } assertContains(t, plainRender(t, next.View()), "ip-127-0-0-1") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("local-test-model")}) + updated, _ = next.Update(testKeyText("local-test-model")) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepDone { t.Fatalf("model step advanced to %v, want ready", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard != nil { t.Fatal("saving custom provider should close the wizard") @@ -522,7 +522,7 @@ func TestProviderWizardSkipsAPIKeyForLocalProvidersAndEscCloses(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("local provider step = %v, want model", next.providerWizard.step) @@ -537,7 +537,7 @@ func TestProviderWizardSkipsAPIKeyForLocalProvidersAndEscCloses(t *testing.T) { assertContains(t, view, "Checking available models") assertNotContains(t, view, "llama3.1") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = next.Update(testKey(tea.KeyEsc)) next = updated.(model) if next.providerWizard != nil { t.Fatal("Esc should close provider wizard") @@ -550,13 +550,13 @@ func TestProviderWizardAcceptsPastedAPIKeyWithoutRenderingSecret(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "google") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.providerWizard.step != providerWizardStepCredential { t.Fatalf("wizard step = %v, want credential", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secret)}) + updated, _ = next.Update(testKeyText(secret)) next = updated.(model) if next.providerWizard.apiKey != secret { t.Fatalf("wizard api key was not captured from paste") @@ -580,22 +580,22 @@ func TestProviderWizardAppliesPastedKeyToCurrentSession(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "google") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secret)}) + updated, _ = next.Update(testKeyText(secret)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("wizard step = %v, want model", next.providerWizard.step) } next = finishProviderWizardModelDiscoveryForTest(t, next) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepDone { t.Fatalf("wizard step = %v, want done", next.providerWizard.step) } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard != nil { @@ -629,18 +629,18 @@ func TestProviderWizardPersistsPastedKeyToUserConfig(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama-cloud") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secret)}) + updated, _ = next.Update(testKeyText(secret)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) next = finishProviderWizardModelDiscoveryForTest(t, next) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if captured.APIKey != secret { @@ -680,16 +680,16 @@ func TestProviderWizardUsesAPIKeyEnvForCurrentSessionWithoutPersistingSecret(t * m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama-cloud") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) next = finishProviderWizardModelDiscoveryForTest(t, next) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if captured.APIKey != secret { @@ -728,7 +728,7 @@ func TestProviderWizardUsesLiveDiscoveredModels(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.providerWizard.step != providerWizardStepModel { t.Fatalf("wizard step = %v, want model", next.providerWizard.step) @@ -740,7 +740,7 @@ func TestProviderWizardUsesLiveDiscoveredModels(t *testing.T) { assertContains(t, view, "Checking available models") assertNotContains(t, view, "llama3.1") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) waiting := updated.(model) if waiting.providerWizard.step != providerWizardStepModel { t.Fatalf("enter while loading step = %v, want model", waiting.providerWizard.step) @@ -768,20 +768,20 @@ func TestProviderWizardIgnoresStaleDiscoveryForSameProvider(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("first model entry should start discovery") } staleToken := next.providerWizard.discoveryToken - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + updated, _ = next.Update(testKey(tea.KeyLeft)) next = updated.(model) if next.providerWizard.step != providerWizardStepProvider { t.Fatalf("left from local model step = %v, want provider", next.providerWizard.step) } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd == nil { t.Fatal("second model entry should start discovery") @@ -828,7 +828,7 @@ func TestProviderWizardKeepsFallbackModelsWhenLiveDiscoveryFails(t *testing.T) { m = openProviderWizardForTest(t, m) m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("entering model step should start live model discovery") @@ -923,7 +923,7 @@ func TestProviderWizardModelSearchFiltersAndAppliesRawModelID(t *testing.T) { }, } - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("deep")}) + updated, _ := m.Update(testKeyText("deep")) next := updated.(model) if next.providerWizard.modelSearch != "deep" { @@ -958,9 +958,9 @@ func TestProviderWizardBlocksAdvanceWhenModelSearchHasNoMatches(t *testing.T) { }, } - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("nomatch")}) + updated, _ := m.Update(testKeyText("nomatch")) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if next.providerWizard.step != providerWizardStepModel { @@ -980,7 +980,7 @@ func TestProviderWizardBlocksAdvanceWhenModelSearchHasNoMatches(t *testing.T) { func openProviderWizardForTest(t *testing.T, m model) model { t.Helper() m.input.SetValue("/provider") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.providerWizard == nil { t.Fatal("expected provider wizard to be open") @@ -991,7 +991,7 @@ func openProviderWizardForTest(t *testing.T, m model) model { if next.providerWizard.step == providerWizardStepMethod { options := providerWizardMethodOptions() next.providerWizard.selectedMethod = len(options) - 1 // last = "browse / API key" - u2, _ := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + u2, _ := next.Update(testKey(tea.KeyEnter)) next = u2.(model) if next.providerWizard == nil || next.providerWizard.step != providerWizardStepProvider { t.Fatalf("expected provider step after choosing API-key method, got %#v", next.providerWizard) diff --git a/internal/tui/queued_message_test.go b/internal/tui/queued_message_test.go index 5e7290cb5..b59dc164a 100644 --- a/internal/tui/queued_message_test.go +++ b/internal/tui/queued_message_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/zeroruntime" @@ -19,7 +19,7 @@ func TestEnterWhilePendingQueuesPromptWithoutStartingRun(t *testing.T) { m.runID = 1 m.input.SetValue("second prompt") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -44,10 +44,10 @@ func TestQueuedPromptPreviewAppearsInView(t *testing.T) { m.width = 96 m.input.SetValue("summarize the failing test output") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - view := next.View() + view := viewString(next.View()) for _, want := range []string{"queued", "summarize the failing test output"} { if !strings.Contains(view, want) { t.Fatalf("expected queued prompt preview to contain %q, got:\n%s", want, view) @@ -67,7 +67,7 @@ func TestAgentResponseLaunchesQueuedPrompt(t *testing.T) { m.runID = 7 m.input.SetValue("run queued followup") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { t.Fatal("expected queued prompt not to launch until active run completes") @@ -102,10 +102,10 @@ func TestEscClearsQueuedPromptBeforeCancelingRun(t *testing.T) { m.width = 96 m.input.SetValue("queued followup") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = next.Update(testKey(tea.KeyEsc)) next = updated.(model) if cancelled { @@ -114,8 +114,9 @@ func TestEscClearsQueuedPromptBeforeCancelingRun(t *testing.T) { if !next.pending { t.Fatal("expected active run to remain pending after clearing queued prompt") } - if strings.Contains(next.View(), "queued followup") { - t.Fatalf("expected queued prompt preview to clear, got:\n%s", next.View()) + view := viewString(next.View()) + if strings.Contains(view, "queued followup") { + t.Fatalf("expected queued prompt preview to clear, got:\n%s", view) } if transcriptContains(next.transcript, "Run cancelled.") { t.Fatalf("expected Esc not to cancel run while clearing queued prompt, got %#v", next.transcript) @@ -130,7 +131,7 @@ func TestEnterWhileExitingDoesNotQueuePrompt(t *testing.T) { m.runID = 1 m.input.SetValue("do not run during shutdown") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -156,7 +157,7 @@ func TestAgentResponseLaunchesQueuedPromptAfterError(t *testing.T) { m.runID = 9 m.input.SetValue("retry with more detail") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) updated, cmd := next.Update(agentResponseMsg{runID: 9, err: errors.New("first run failed")}) diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index d5053fce7..673949cf2 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -10,7 +10,7 @@ import ( "time" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/tools" @@ -914,7 +914,7 @@ type cardBody struct { func (m model) renderRunningToolCard(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string { glyph := zeroTheme.faintest.Render("…") if m.pending && row.runID != 0 && row.runID == m.activeRunID { - glyph = m.spinner.View() + glyph = zeroTheme.accent.Render(m.spinner.View()) } // The call row carries its own argHints; rc.hints/args only matter for // result rows, whose detail is the tool output. diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 45bca0748..a59b13e7f 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" @@ -23,9 +23,9 @@ var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m|\x1b\][^\a\x1b]*(?:\a|\x1b\ // plainRender strips styling so assertions run against text, not styled // bytes. (Without a TTY lipgloss already renders plain; this keeps the tests // honest either way.) -func plainRender(t *testing.T, rendered string) string { +func plainRender(t *testing.T, rendered any) string { t.Helper() - return ansiPattern.ReplaceAllString(rendered, "") + return ansiPattern.ReplaceAllString(renderContent(rendered), "") } func limeTestModel() model { @@ -838,7 +838,7 @@ func TestComposerLineTracksRunState(t *testing.T) { func TestComposerLineShowsRequiredCommandArgumentHint(t *testing.T) { m := limeTestModel() - m.input.Width = 40 + m.input.SetWidth(40) m.input.SetValue("/spec") m.input.CursorEnd() if got := plainRender(t, m.composerLine(96)); !strings.Contains(got, "/spec [task]") { @@ -987,10 +987,10 @@ func TestTitleBarShowsWorkspaceAndModel(t *testing.T) { } func TestTitleBarHighlightsBranchOverWorkspace(t *testing.T) { - oldProfile := lipgloss.ColorProfile() - lipgloss.SetColorProfile(termenv.TrueColor) + oldProfile := lipgloss.Writer.Profile + lipgloss.Writer.Profile = colorprofile.TrueColor t.Cleanup(func() { - lipgloss.SetColorProfile(oldProfile) + lipgloss.Writer.Profile = oldProfile }) m := limeTestModel() diff --git a/internal/tui/run.go b/internal/tui/run.go index f68b40aa1..bc3dcad49 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -4,11 +4,8 @@ import ( "context" "fmt" "os" - "strings" - tea "github.com/charmbracelet/bubbletea" - - "github.com/Gitlawb/zero/internal/notify" + tea "charm.land/bubbletea/v2" ) // Run starts the Zero Bubble Tea shell and returns a process-style exit code. @@ -29,20 +26,12 @@ func Run(ctx context.Context, options Options) int { tea.WithContext(ctx), tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout), - } - if options.AltScreen { - programOpts = append(programOpts, tea.WithAltScreen()) - } - if notify.Enabled(notify.Mode(strings.TrimSpace(options.Notify.Mode))) { - programOpts = append(programOpts, tea.WithReportFocus()) + tea.WithFilter(mouseEventFilter()), } initialModel := newModel(ctx, options) if initialModel.wantsMouseCapture() { - programOpts = append(programOpts, tea.WithMouseCellMotion()) initialModel.mouseCapture = true } - // Mouse capture starts enabled only when the initial surface needs it; later - // surfaces enable/disable it through syncMouseCapture after each update. program = tea.NewProgram(initialModel, programOpts...) if _, err := program.Run(); err != nil { diff --git a/internal/tui/scroll_pin_test.go b/internal/tui/scroll_pin_test.go index 39564d87d..d8a1f9782 100644 --- a/internal/tui/scroll_pin_test.go +++ b/internal/tui/scroll_pin_test.go @@ -5,8 +5,12 @@ import "testing" func TestSyncChatScrollPinsWhileScrolledUp(t *testing.T) { m := transcriptViewTestModel() m.altScreen = true - m.transcript = []transcriptRow{{kind: rowAssistant, text: numberedLines(40)}} + m.height = 12 + m.transcript = []transcriptRow{{kind: rowAssistant, text: numberedLines(120)}} m.chatScrollOffset = 5 + if maxOffset := m.chatMaxScrollOffset(); maxOffset < m.chatScrollOffset { + t.Fatalf("test transcript should be scrollable, maxOffset=%d", maxOffset) + } m = m.syncChatScroll() // establishes the baseline, no adjustment yet if m.chatBodyLines == 0 { diff --git a/internal/tui/scroll_test.go b/internal/tui/scroll_test.go index 2b827b4bf..37cae76b7 100644 --- a/internal/tui/scroll_test.go +++ b/internal/tui/scroll_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) func TestMouseWheelScrollsChatWithoutRecallingInputHistory(t *testing.T) { @@ -19,7 +19,7 @@ func TestMouseWheelScrollsChatWithoutRecallingInputHistory(t *testing.T) { m.transcript = appendRow(m.transcript, rowAssistant, "message "+string(rune('A'+index))) } - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelUp, 0, 0)) m = updated.(model) if cmd != nil { t.Fatal("mouse wheel should not return a command") @@ -32,6 +32,42 @@ func TestMouseWheelScrollsChatWithoutRecallingInputHistory(t *testing.T) { } } +func TestScrollChatClampsOffsetAtTranscriptTop(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width = 90 + m.height = 14 + for index := 0; index < 40; index++ { + m.transcript = appendRow(m.transcript, rowAssistant, "message "+string(rune('A'+index%26))) + } + maxOffset := m.chatMaxScrollOffset() + if maxOffset <= chatWheelScrollLines { + t.Fatalf("test transcript should be scrollable, maxOffset=%d", maxOffset) + } + + m = m.scrollChat(maxOffset + 100) + if m.chatScrollOffset != maxOffset { + t.Fatalf("scroll beyond top offset = %d, want %d", m.chatScrollOffset, maxOffset) + } + + m.chatScrollOffset = maxOffset + 100 // Simulate an offset saved before clamping existed. + m = m.scrollChat(-chatWheelScrollLines) + if want := maxOffset - chatWheelScrollLines; m.chatScrollOffset != want { + t.Fatalf("scroll down from inflated offset = %d, want %d", m.chatScrollOffset, want) + } +} + +func TestScrollChatDoesNotAccumulateWhenTranscriptFits(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width = 90 + m.height = 20 + m.transcript = appendRow(m.transcript, rowAssistant, "short") + + m = m.scrollChat(100) + if m.chatScrollOffset != 0 { + t.Fatalf("non-scrollable transcript offset = %d, want 0", m.chatScrollOffset) + } +} + func TestMouseWheelOverWrappedComposerMovesComposerCursor(t *testing.T) { text := "Create a book library dashboard page with cards, filters, charts, and responsive behavior." m := newModel(context.Background(), Options{AltScreen: true}) @@ -42,7 +78,7 @@ func TestMouseWheelOverWrappedComposerMovesComposerCursor(t *testing.T) { m.input.CursorEnd() startCursor := len([]rune(text)) - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp, Y: 14}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelUp, 0, 14)) next := updated.(model) if cmd != nil { t.Fatal("mouse wheel over composer should not return a command") @@ -65,7 +101,7 @@ func TestMouseWheelOnClippedFooterStatusDoesNotMoveComposerCursor(t *testing.T) m.input.CursorEnd() startCursor := len([]rune(text)) - updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp, Y: m.height - 1}) + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelUp, 0, m.height-1)) next := updated.(model) if cmd != nil { t.Fatal("mouse wheel on clipped footer should not return a command") @@ -146,14 +182,17 @@ func TestPageKeysScrollAltScreenTranscript(t *testing.T) { m := newModel(context.Background(), Options{AltScreen: true}) m.width = 90 m.height = 20 + for index := 0; index < 30; index++ { + m.transcript = appendRow(m.transcript, rowAssistant, "message "+string(rune('A'+index%26))) + } - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + updated, _ := m.Update(testKey(tea.KeyPgUp)) m = updated.(model) if m.chatScrollOffset != m.chatPageScrollLines() { t.Fatalf("page up offset = %d, want %d", m.chatScrollOffset, m.chatPageScrollLines()) } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + updated, _ = m.Update(testKey(tea.KeyPgDown)) m = updated.(model) if m.chatScrollOffset != 0 { t.Fatalf("page down should return to bottom, got offset %d", m.chatScrollOffset) diff --git a/internal/tui/selectable_list.go b/internal/tui/selectable_list.go index f89bd74af..454630933 100644 --- a/internal/tui/selectable_list.go +++ b/internal/tui/selectable_list.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type selectableListItem struct { diff --git a/internal/tui/selectable_list_test.go b/internal/tui/selectable_list_test.go index 436973e2f..eeebfb772 100644 --- a/internal/tui/selectable_list_test.go +++ b/internal/tui/selectable_list_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestSelectableListAnchorsSelectionAroundFourthVisibleRow(t *testing.T) { diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 06c83df5f..a0d50c291 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/sessions" diff --git a/internal/tui/session_controls_test.go b/internal/tui/session_controls_test.go index 247d25433..d06c38d1e 100644 --- a/internal/tui/session_controls_test.go +++ b/internal/tui/session_controls_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" @@ -19,7 +19,7 @@ func TestEffortCommandListsAndSetsSupportedEffort(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) m.input.SetValue("/effort list") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -32,7 +32,7 @@ func TestEffortCommandListsAndSetsSupportedEffort(t *testing.T) { } next.input.SetValue("/effort high") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { @@ -50,7 +50,7 @@ func TestEffortCommandRejectsUnsupportedActiveModel(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "gpt-4.1"}) m.input.SetValue("/effort high") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.reasoningEffort != "" { @@ -65,7 +65,7 @@ func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/style") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -76,7 +76,7 @@ func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) { } next.input.SetValue("/style concise") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { @@ -102,7 +102,7 @@ func TestCompactStatusShowsManualFlowState(t *testing.T) { m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: strings.Repeat("abcd ", 80)}) m.input.SetValue("/compact status") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -147,7 +147,7 @@ func TestCompactCommandCallsInjectedCompactorAndReportsResult(t *testing.T) { m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: strings.Repeat("context ", 90)}) m.input.SetValue("/compact") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { @@ -224,7 +224,7 @@ func TestCompactSpinnerTickRefreshesProgressFrame(t *testing.T) { m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: strings.Repeat("context ", 90)}) m.input.SetValue("/compact") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil || !next.compactInFlight { @@ -258,7 +258,7 @@ func TestCompactRunningRowRendersAsAmberCompressionCard(t *testing.T) { m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: strings.Repeat("context ", 90)}) m.input.SetValue("/compact") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) rendered := plainRender(t, next.renderRow(transcriptRow{ kind: rowSystem, @@ -350,7 +350,7 @@ func TestCompactCommandRecordsSessionCompactionAndShrinksReplayContext(t *testin eventsBefore := len(m.sessionEvents) m.input.SetValue("/compact") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { @@ -423,7 +423,7 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) { } m.input.SetValue("/compact") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected /compact to start an async provider-backed compaction command") @@ -457,7 +457,7 @@ func TestCompactCommandRecordsRequestWhenNoCompactorIsAvailable(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/compact") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -476,7 +476,7 @@ func TestCompactCommandRecordsRequestWhenNoCompactorIsAvailable(t *testing.T) { } next.input.SetValue("/compact status") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { @@ -519,7 +519,7 @@ func TestUsageEventsUpdateFooterAndContext(t *testing.T) { }) m.input.SetValue("track usage") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt to start agent run") @@ -535,7 +535,7 @@ func TestUsageEventsUpdateFooterAndContext(t *testing.T) { } next.input.SetValue("/context") - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { @@ -568,7 +568,7 @@ func TestUsageEventsForwardExistingAgentCallback(t *testing.T) { }) m.input.SetValue("track usage") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt to start agent run") @@ -599,7 +599,7 @@ func TestUsageEventsForCustomModelUseTokenOnlyFallback(t *testing.T) { }) m.input.SetValue("track usage") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt to start agent run") @@ -632,7 +632,7 @@ func TestInvalidUsageEventsAppendTranscriptError(t *testing.T) { }) m.input.SetValue("track usage") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt to start agent run") @@ -680,7 +680,7 @@ func TestModelSwitchClearsUnsupportedEffortPreference(t *testing.T) { }) m.input.SetValue("/model gpt-4.1") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -710,7 +710,7 @@ func TestModelSwitchRedirectsDeprecatedModelWithNotice(t *testing.T) { }) m.input.SetValue("/model gpt-4-turbo") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -740,7 +740,7 @@ func TestModelSwitchUnknownModelReportsError(t *testing.T) { }) m.input.SetValue("/model totally-unknown-model") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.modelName != "gpt-4.1" { @@ -776,7 +776,7 @@ func TestModeCommandListsPresets(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) m.input.SetValue("/mode list") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -794,7 +794,7 @@ func TestModeCommandNoArgOpensPicker(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"}) m.input.SetValue("/mode") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -825,7 +825,7 @@ func TestModeCommandSwitchesModelEffortAndTurns(t *testing.T) { }) m.input.SetValue("/mode deep") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -863,7 +863,7 @@ func TestModeCommandUnknownReportsError(t *testing.T) { }) m.input.SetValue("/mode turbo") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if next.modelName != "claude-sonnet-4.5" { diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index cfe9acda2..04b34896e 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/sandbox" @@ -64,7 +64,7 @@ func TestPromptSubmitPersistsTUISessionEvents(t *testing.T) { }) m.input.SetValue("inspect repo") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -113,7 +113,7 @@ func TestPromptWithoutProviderDoesNotCreateSession(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: store}) m.input.SetValue("hello") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -159,7 +159,7 @@ func TestPromptSubmitPersistsToolSessionEvents(t *testing.T) { }) m.input.SetValue("read notes") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -236,7 +236,7 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) { }) m.input.SetValue("write notes") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -252,7 +252,7 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) { updated, _ = next.Update(runtimeMsg) next = updated.(model) if _, ok := runtimeMsg.(permissionRequestMsg); ok { - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + updated, _ = next.Update(testKeyText("d")) next = updated.(model) } } @@ -444,7 +444,7 @@ func receiveFinalMessage(t *testing.T, messages <-chan tea.Msg) tea.Msg { func submitAndDrivePermissionRun(t *testing.T, m model, prompt string, key string, runtimeMessages <-chan tea.Msg, expectedRuntimeMessages int) model { t.Helper() m.input.SetValue(prompt) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -460,7 +460,7 @@ func submitAndDrivePermissionRun(t *testing.T, m model, prompt string, key strin updated, _ = next.Update(runtimeMsg) next = updated.(model) if _, ok := runtimeMsg.(permissionRequestMsg); ok && key != "" { - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + updated, _ = next.Update(testKeyText(key)) next = updated.(model) } } @@ -584,7 +584,7 @@ func TestResumeCommandHydratesSessionTranscript(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: store}) m.input.SetValue("/resume " + session.SessionID) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -628,7 +628,7 @@ func TestRewindRefreshesInMemorySessionState(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: store}) m.input.SetValue("/resume " + session.SessionID) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if !transcriptContains(m.transcript, "DROPPED-AFTER-CHECKPOINT") { t.Fatalf("setup: resumed transcript should include the post-checkpoint message") @@ -672,7 +672,7 @@ func TestResumeCommandIsBlockedWhileRunPending(t *testing.T) { m.pending = true m.input.SetValue("/resume " + other.SessionID) - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd != nil { @@ -726,7 +726,7 @@ func TestResumeLatestHydratesNewestSession(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: store}) m.input.SetValue("/resume latest") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "Newer") || !transcriptContains(next.transcript, "new answer") { @@ -750,13 +750,13 @@ func TestEscCancelRecordsSessionError(t *testing.T) { }) m.input.SetValue("cancel me") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") } - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = next.Update(testKey(tea.KeyEsc)) next = updated.(model) list, err := store.List() @@ -796,7 +796,7 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) { m := newPermissionTestModel(root, provider, registry, store, nil, runtimeMessageCh) m.input.SetValue("rewrite notes") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -818,7 +818,7 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) { if _, ok := runtimeMsg.(permissionRequestMsg); ok { // Cancel mid-run via Esc while the permission prompt is pending: this // unblocks the goroutine through ctx cancellation. - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = next.Update(testKey(tea.KeyEsc)) next = updated.(model) cancelled = true } @@ -870,7 +870,7 @@ func TestCtrlCFlushesCheckpointSessionEvents(t *testing.T) { m := newPermissionTestModel(root, provider, registry, store, nil, runtimeMessageCh) m.input.SetValue("rewrite notes") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected prompt submit to start an agent run") @@ -895,7 +895,7 @@ func TestCtrlCFlushesCheckpointSessionEvents(t *testing.T) { // exiting — but it must NOT quit before the in-flight run's final // message has been drained, or the captured checkpoint is orphaned. var exitCmd tea.Cmd - updated, exitCmd = next.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + updated, exitCmd = next.Update(testKeyCtrl('c')) next = updated.(model) if !next.exiting { t.Fatal("expected Ctrl+C to mark model exiting") @@ -958,11 +958,11 @@ func TestResumedPromptIncludesSessionContext(t *testing.T) { SessionStore: store, }) m.input.SetValue("/resume " + session.SessionID) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) next.input.SetValue("continue") - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd == nil { t.Fatal("expected resumed prompt to start an agent run") @@ -988,7 +988,7 @@ func TestResumeCommandReportsMissingSession(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)}) m.input.SetValue("/resume missing_session") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if !transcriptContains(next.transcript, "zero session not found: missing_session") { diff --git a/internal/tui/spec_mode.go b/internal/tui/spec_mode.go index 4f4d41006..37ee9ece0 100644 --- a/internal/tui/spec_mode.go +++ b/internal/tui/spec_mode.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/sessions" diff --git a/internal/tui/spec_mode_test.go b/internal/tui/spec_mode_test.go index c70e27559..456be8fa8 100644 --- a/internal/tui/spec_mode_test.go +++ b/internal/tui/spec_mode_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/sessions" @@ -23,7 +23,7 @@ func TestSpecCommandCreatesDraftReview(t *testing.T) { m := newSpecModeTestModel(t.TempDir(), provider, store) m.input.SetValue("/spec add review flow") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) if cmd == nil { t.Fatal("expected /spec to start a draft run") @@ -58,7 +58,7 @@ func TestSpecApproveStartsImplementationSession(t *testing.T) { m := newSpecModeTestModel(t.TempDir(), provider, store) m.input.SetValue("/spec add review flow") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) updated, _ = next.Update(execCmd(cmd)) next = updated.(model) @@ -67,7 +67,7 @@ func TestSpecApproveStartsImplementationSession(t *testing.T) { t.Fatal("expected pending review before approval") } - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + updated, cmd = next.Update(testKeyText("a")) next = updated.(model) if cmd == nil { t.Fatal("expected approval to start implementation run") @@ -104,7 +104,7 @@ func TestSpecReviewBlocksShiftTabModeCycle(t *testing.T) { m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto}) m.pendingSpecReview = &pendingSpecReviewPrompt{SpecID: "spec", SpecFilePath: ".zero/specs/spec.md"} - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + updated, _ := m.Update(testKeyShift(tea.KeyTab)) next := updated.(model) if next.permissionMode != agent.PermissionModeAuto { @@ -123,7 +123,7 @@ func TestSpecReviewCancelLaunchesQueuedPrompt(t *testing.T) { m.pendingSpecReview = &pendingSpecReviewPrompt{SpecID: "spec", SpecFilePath: ".zero/specs/spec.md"} m.queuedMessage = "continue after cancel" - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd := m.Update(testKey(tea.KeyEsc)) next := updated.(model) if cmd == nil { @@ -157,7 +157,7 @@ func TestSpecReviewRejectLaunchesQueuedPrompt(t *testing.T) { m := newSpecModeTestModel(t.TempDir(), provider, store) m.input.SetValue("/spec add review flow") - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.Update(testKey(tea.KeyEnter)) next := updated.(model) updated, _ = next.Update(execCmd(cmd)) next = updated.(model) @@ -166,7 +166,7 @@ func TestSpecReviewRejectLaunchesQueuedPrompt(t *testing.T) { } next.queuedMessage = "continue after reject" - updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + updated, cmd = next.Update(testKeyText("r")) next = updated.(model) if cmd == nil { diff --git a/internal/tui/startup.go b/internal/tui/startup.go index acc3faaf1..bdc5dd490 100644 --- a/internal/tui/startup.go +++ b/internal/tui/startup.go @@ -4,7 +4,7 @@ import ( "strings" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) const ( diff --git a/internal/tui/startup_test.go b/internal/tui/startup_test.go index 533d3381a..c535c337b 100644 --- a/internal/tui/startup_test.go +++ b/internal/tui/startup_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) { @@ -26,7 +26,7 @@ func TestEmptyStateDisappearsAfterFirstRow(t *testing.T) { m.width, m.height = 100, 30 m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"}) - view := m.View() + view := viewString(m.View()) if strings.Contains(view, emptyStateTagline) { t.Fatal("empty state must disappear once the transcript has content") } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 893eb7db8..ae39c7b83 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,6 +1,6 @@ package tui -import "github.com/charmbracelet/lipgloss" +import "charm.land/lipgloss/v2" // tuiTheme is the single source of truth for Zero's terminal palette — the // Lime design: a near-black chat surface with one lime accent (the terminal diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 8f92593c3..1477c6fcf 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -6,9 +6,9 @@ import ( "time" "unicode/utf8" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/atotto/clipboard" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) @@ -316,15 +316,15 @@ func (m model) transcriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine width := chatWidth(m.width) body, selectable := m.transcriptBody(width, "") start, available := m.transcriptViewportStart(body, width) - if msg.Y < 0 || msg.Y >= available { + if mouseY(msg) < 0 || mouseY(msg) >= available { return transcriptSelectableLine{}, false } - bodyY := start + msg.Y + bodyY := start + mouseY(msg) for _, line := range selectable { if line.bodyY != bodyY { continue } - if msg.X < 0 { + if mouseX(msg) < 0 { return transcriptSelectableLine{}, false } return line, true @@ -372,7 +372,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, } return m, nil, true } - point := transcriptSelectionPointForMouse(line, msg.X) + point := transcriptSelectionPointForMouse(line, mouseX(msg)) m.copyStatus = "" m.transcriptSelection = transcriptSelectionState{active: true, anchor: point, cursor: point} return m, nil, true @@ -382,7 +382,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, } line, ok := m.transcriptLineAtMouse(msg) if ok { - m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, msg.X) + m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg)) } return m, nil, true case mouseRelease(msg): @@ -390,7 +390,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, return m, nil, false } if line, ok := m.transcriptLineAtMouse(msg); ok { - m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, msg.X) + m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg)) } text := m.selectedTranscriptText() if strings.TrimSpace(text) == "" { diff --git a/internal/tui/transcript_view_test.go b/internal/tui/transcript_view_test.go index 32e9896e0..355a2f2ff 100644 --- a/internal/tui/transcript_view_test.go +++ b/internal/tui/transcript_view_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/tools" ) @@ -16,12 +16,12 @@ func TestTranscriptCommandTogglesDetailedView(t *testing.T) { m := transcriptViewTestModel() m.input.SetValue("/transcript") - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(testKey(tea.KeyEnter)) next := updated.(model) assertContains(t, plainRender(t, next.View()), "Transcript") next.input.SetValue("/transcript") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) assertNotContains(t, plainRender(t, next.View()), "Transcript") } @@ -29,22 +29,22 @@ func TestTranscriptCommandTogglesDetailedView(t *testing.T) { func TestCtrlOTogglesDetailedTranscriptView(t *testing.T) { m := transcriptViewTestModel() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + updated, _ := m.Update(testKeyCtrl('o')) next := updated.(model) assertContains(t, plainRender(t, next.View()), "Transcript") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + updated, _ = next.Update(testKeyCtrl('o')) next = updated.(model) assertNotContains(t, plainRender(t, next.View()), "Transcript") } func TestEscExitsDetailedTranscriptView(t *testing.T) { m := transcriptViewTestModel() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + updated, _ := m.Update(testKeyCtrl('o')) next := updated.(model) assertContains(t, plainRender(t, next.View()), "Transcript") - updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = next.Update(testKey(tea.KeyEsc)) next = updated.(model) assertNotContains(t, plainRender(t, next.View()), "Transcript") } @@ -63,7 +63,7 @@ func TestDetailedTranscriptIncludesToolOutputBeyondLiveCap(t *testing.T) { assertContains(t, compact, "click to expand") // The detailed transcript view (Ctrl+O) still shows the full, uncapped output. - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + updated, _ := m.Update(testKeyCtrl('o')) next := updated.(model) view := plainRender(t, next.View()) assertContains(t, view, "line-404") @@ -82,9 +82,9 @@ func TestDetailedTranscriptViewNeverExceedsTerminalWidth(t *testing.T) { ) m.flushed = len(m.transcript) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + updated, _ := m.Update(testKeyCtrl('o')) next := updated.(model) - view := next.View() + view := viewString(next.View()) assertContains(t, plainRender(t, view), "Transcript") for index, line := range strings.Split(view, "\n") { if got := lipgloss.Width(line); got > chatWidth(width) { @@ -96,11 +96,11 @@ func TestDetailedTranscriptViewNeverExceedsTerminalWidth(t *testing.T) { func TestDetailedTranscriptSwallowsNormalChatSubmit(t *testing.T) { m := transcriptViewTestModel() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + updated, _ := m.Update(testKeyCtrl('o')) next := updated.(model) next.input.SetValue("this should not launch") - updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := next.Update(testKey(tea.KeyEnter)) next = updated.(model) if cmd != nil { diff --git a/internal/tui/view.go b/internal/tui/view.go index 4ab613cea..9e69c9760 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/agent" ) diff --git a/internal/tui/width_tiers_test.go b/internal/tui/width_tiers_test.go index aaa081d69..64d789db1 100644 --- a/internal/tui/width_tiers_test.go +++ b/internal/tui/width_tiers_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/sandbox" @@ -159,7 +159,7 @@ func TestViewNeverExceedsTerminalWidth(t *testing.T) { m.width, m.height = width, 24 // Empty state first: the centered tagline/hint must also fit. - for index, line := range strings.Split(m.View(), "\n") { + for index, line := range strings.Split(viewString(m.View()), "\n") { if got := lipgloss.Width(line); got > width { t.Fatalf("width %d: empty-state line %d is %d cells wide: %q", width, index, got, line) } @@ -177,7 +177,7 @@ func TestViewNeverExceedsTerminalWidth(t *testing.T) { transcriptRow{kind: rowAssistant, text: "Done — the change is in.", final: true, turnTools: 2}, ) - for index, line := range strings.Split(m.View(), "\n") { + for index, line := range strings.Split(viewString(m.View()), "\n") { if got := lipgloss.Width(line); got > width { t.Fatalf("width %d: frame line %d is %d cells wide: %q", width, index, got, line) }