From 22793bcb43bbb3d70ed40251ab9e5fc0a09afd11 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Tue, 16 Jun 2026 18:02:07 +0530 Subject: [PATCH 1/3] Migrate TUI to Bubble Tea v2 Move the TUI stack to charm.land Bubble Tea, Bubbles, and Lip Gloss v2, including key, paste, mouse, and view handling changes for the v2 APIs. Add compatibility helpers for the new input event shapes and update TUI tests accordingly. Refresh related Go dependencies and make the PDF truncation fixture deterministic across poppler and pure-Go extraction. Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/imageinput -count=1 -v Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./... --- go.mod | 41 +++--- go.sum | 88 ++++++------- internal/imageinput/pdf_test.go | 61 ++++++++- internal/tui/ask_user_test.go | 14 +- internal/tui/assistant_markdown.go | 2 +- internal/tui/autocomplete_test.go | 74 +++++------ internal/tui/command_bash.go | 2 +- internal/tui/command_center.go | 2 +- internal/tui/command_polish_test.go | 22 ++-- internal/tui/command_views.go | 2 +- internal/tui/commands_test.go | 92 ++++++------- internal/tui/composer.go | 79 ++++++------ internal/tui/composer_test.go | 62 ++++----- internal/tui/doctor_command_test.go | 12 +- internal/tui/flush.go | 2 +- internal/tui/flush_test.go | 18 +-- internal/tui/input_compat.go | 93 ++++++++++++++ internal/tui/input_compat_test.go | 85 ++++++++++++ internal/tui/mcp_add_wizard.go | 24 ++-- internal/tui/mcp_add_wizard_view.go | 2 +- internal/tui/mcp_manager.go | 26 ++-- internal/tui/mcp_view_test.go | 2 +- internal/tui/model.go | 110 +++++++++------- internal/tui/model_test.go | 92 ++++++------- internal/tui/mouse.go | 39 +----- internal/tui/mouse_test.go | 129 ++++--------------- internal/tui/onboarding.go | 95 +++++++------- internal/tui/onboarding_test.go | 108 ++++++++-------- internal/tui/options.go | 2 +- internal/tui/picker.go | 2 +- internal/tui/picker_test.go | 42 +++--- internal/tui/pr_status.go | 2 +- internal/tui/provider_onboarding_test.go | 4 +- internal/tui/provider_wizard.go | 82 ++++++------ internal/tui/provider_wizard_discovery.go | 2 +- internal/tui/provider_wizard_oauth_test.go | 8 +- internal/tui/provider_wizard_test.go | 142 ++++++++++----------- internal/tui/queued_message_test.go | 23 ++-- internal/tui/rendering.go | 4 +- internal/tui/rendering_lime_test.go | 16 +-- internal/tui/run.go | 14 +- internal/tui/scroll_test.go | 12 +- internal/tui/selectable_list.go | 2 +- internal/tui/selectable_list_test.go | 2 +- internal/tui/session_controls.go | 2 +- internal/tui/session_controls_test.go | 52 ++++---- internal/tui/session_test.go | 42 +++--- internal/tui/spec_mode.go | 2 +- internal/tui/spec_mode_test.go | 16 +-- internal/tui/startup.go | 2 +- internal/tui/startup_test.go | 4 +- internal/tui/theme.go | 2 +- internal/tui/transcript_selection.go | 16 +-- internal/tui/transcript_view_test.go | 26 ++-- internal/tui/view.go | 2 +- internal/tui/width_tiers_test.go | 6 +- 56 files changed, 1014 insertions(+), 895 deletions(-) create mode 100644 internal/tui/input_compat.go create mode 100644 internal/tui/input_compat_test.go 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..dd205ab56 --- /dev/null +++ b/internal/tui/input_compat.go @@ -0,0 +1,93 @@ +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 mouseButton(msg tea.MouseMsg) tea.MouseButton { + return mouseEvent(msg).Button +} + +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..e8e9195ef 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" @@ -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 @@ -1420,7 +1439,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 +1484,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 +1504,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 +1590,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 +1598,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 +1735,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_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..66cbf910d 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" @@ -523,7 +523,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 +531,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()) } 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..2e8e61dad 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" diff --git a/internal/tui/provider_wizard_oauth_test.go b/internal/tui/provider_wizard_oauth_test.go index 1db5a2061..61219fe87 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" ) @@ -87,7 +85,7 @@ 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) } @@ -210,7 +208,7 @@ 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") } @@ -221,7 +219,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") } 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..1a65cf982 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. @@ -30,19 +27,10 @@ func Run(ctx context.Context, options Options) int { 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()) - } 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_test.go b/internal/tui/scroll_test.go index 2b827b4bf..fbc2a64da 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") @@ -42,7 +42,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 +65,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") @@ -147,13 +147,13 @@ func TestPageKeysScrollAltScreenTranscript(t *testing.T) { m.width = 90 m.height = 20 - 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) } From 7bd975fa21dd688b0ab0ed068ac9f7e909446acc Mon Sep 17 00:00:00 2001 From: anandh8x Date: Tue, 16 Jun 2026 18:24:45 +0530 Subject: [PATCH 2/3] Address provider wizard OAuth review feedback Remove the unused mouseButton compatibility helper flagged by lint. Correlate provider wizard OAuth and device-code async results with the active provider and attempt id so stale results from abandoned attempts are ignored. Add regression coverage for stale browser OAuth and stale device-code results. Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui -run 'ProviderWizard.*OAuth|ProviderWizard.*Device|ApplyProviderWizardOAuth|RenderCredentialStepShowsOAuth' -count=1 -v Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./... Tested: git diff --check --- internal/tui/input_compat.go | 4 - internal/tui/provider_wizard.go | 58 ++++++++++----- internal/tui/provider_wizard_discovery.go | 6 +- internal/tui/provider_wizard_oauth_test.go | 85 ++++++++++++++++++---- 4 files changed, 112 insertions(+), 41 deletions(-) diff --git a/internal/tui/input_compat.go b/internal/tui/input_compat.go index dd205ab56..6a520957e 100644 --- a/internal/tui/input_compat.go +++ b/internal/tui/input_compat.go @@ -58,10 +58,6 @@ func mouseY(msg tea.MouseMsg) int { return mouseEvent(msg).Y } -func mouseButton(msg tea.MouseMsg) tea.MouseButton { - return mouseEvent(msg).Button -} - func mouseLeftPress(msg tea.MouseMsg) bool { event := mouseEvent(msg) return event.Button == tea.MouseLeft && isMouseClick(msg) diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 66cbf910d..b68270c62 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -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{} @@ -585,9 +603,9 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) { return m, nil 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 keyText(msg) != "": diff --git a/internal/tui/provider_wizard_discovery.go b/internal/tui/provider_wizard_discovery.go index 2e8e61dad..754b93a8f 100644 --- a/internal/tui/provider_wizard_discovery.go +++ b/internal/tui/provider_wizard_discovery.go @@ -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 61219fe87..9989f27ab 100644 --- a/internal/tui/provider_wizard_oauth_test.go +++ b/internal/tui/provider_wizard_oauth_test.go @@ -78,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() @@ -89,6 +94,9 @@ func TestProviderWizardDeviceShortcutStartsDeviceFlow(t *testing.T) { 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") } @@ -100,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) @@ -121,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") @@ -141,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) } @@ -176,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") } @@ -212,6 +220,9 @@ func TestProviderWizardCtrlOStartsOAuthForOpenRouter(t *testing.T) { 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") } @@ -227,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") } @@ -245,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") } @@ -272,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) + } +} From 3967216ab33ecd38d1c109bf1f5779f08db8ecd3 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Tue, 16 Jun 2026 18:44:57 +0530 Subject: [PATCH 3/3] Smooth TUI mouse scrolling --- internal/tui/model.go | 49 +++++++++++++++++++++------- internal/tui/mouse_filter.go | 28 ++++++++++++++++ internal/tui/mouse_filter_test.go | 53 +++++++++++++++++++++++++++++++ internal/tui/run.go | 1 + internal/tui/scroll_pin_test.go | 6 +++- internal/tui/scroll_test.go | 39 +++++++++++++++++++++++ 6 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 internal/tui/mouse_filter.go create mode 100644 internal/tui/mouse_filter_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index e8e9195ef..80fe537c8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -33,7 +33,7 @@ import ( const tuiToolOutputLimit = 240 const defaultResponseStyle = "balanced" -const chatWheelScrollLines = 3 +const chatWheelScrollLines = 5 type model struct { ctx context.Context @@ -1377,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 @@ -1393,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 @@ -1403,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 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/run.go b/internal/tui/run.go index 1a65cf982..bc3dcad49 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -26,6 +26,7 @@ func Run(ctx context.Context, options Options) int { tea.WithContext(ctx), tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout), + tea.WithFilter(mouseEventFilter()), } initialModel := newModel(ctx, options) if initialModel.wantsMouseCapture() { 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 fbc2a64da..37cae76b7 100644 --- a/internal/tui/scroll_test.go +++ b/internal/tui/scroll_test.go @@ -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}) @@ -146,6 +182,9 @@ 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(testKey(tea.KeyPgUp)) m = updated.(model)