From d247b19608d2d560f7d0ab63b391ed16b1d80384 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 15:59:55 +0000 Subject: [PATCH] =?UTF-8?q?tui:=20migrate=20Bubble=20Tea=20v1=20=E2=86=92?= =?UTF-8?q?=20v2=20(charm.land),=20enable=20Shift+Enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2 ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard disambiguation is on by default, so Shift+Enter now reliably inserts a newline in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals). Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped keyMsg the handlers already expect, at the single Update boundary. The rest is mechanical: - key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg. - lipgloss.Color is now a func returning color.Color, not a type → fields/params retyped to image/color.Color (theme/diff/overlays/view). - v2 View() returns tea.View: render() builds the string, View() wraps it and carries AltScreen (alt-screen is a per-frame View field now, not a program opt). - WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style). - preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default). Build, vet, gofmt, and the full test suite are green; preview renders truecolor across kinds. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/cmd/preview/main.go | 7 +- apps/tui-go/go.mod | 31 +-- apps/tui-go/go.sum | 59 ++--- .../internal/app/adaptive_layout_test.go | 6 +- apps/tui-go/internal/app/approval_test.go | 13 +- apps/tui-go/internal/app/clarcard.go | 32 +-- apps/tui-go/internal/app/clarcard_test.go | 19 +- apps/tui-go/internal/app/cmdcard.go | 2 +- apps/tui-go/internal/app/config_overlay.go | 22 +- .../internal/app/config_overlay_test.go | 11 +- apps/tui-go/internal/app/demo.go | 2 +- apps/tui-go/internal/app/diff.go | 5 +- apps/tui-go/internal/app/ideacard.go | 2 +- apps/tui-go/internal/app/ideacard_test.go | 3 +- apps/tui-go/internal/app/key.go | 123 +++++++++++ .../internal/app/overlay_composite_test.go | 2 +- apps/tui-go/internal/app/overlays.go | 11 +- apps/tui-go/internal/app/proposecard.go | 26 +-- apps/tui-go/internal/app/proposecard_test.go | 17 +- .../tui-go/internal/app/render_matrix_test.go | 2 +- apps/tui-go/internal/app/sessions_overlay.go | 14 +- .../internal/app/sessions_overlay_test.go | 16 +- apps/tui-go/internal/app/slash_test.go | 6 +- apps/tui-go/internal/app/theme.go | 56 ++--- apps/tui-go/internal/app/update.go | 207 +++++++++--------- apps/tui-go/internal/app/view.go | 33 ++- apps/tui-go/main.go | 5 +- 27 files changed, 432 insertions(+), 300 deletions(-) create mode 100644 apps/tui-go/internal/app/key.go diff --git a/apps/tui-go/cmd/preview/main.go b/apps/tui-go/cmd/preview/main.go index 1dee08c9..0e569cb3 100644 --- a/apps/tui-go/cmd/preview/main.go +++ b/apps/tui-go/cmd/preview/main.go @@ -7,14 +7,13 @@ import ( "os" "strconv" - "github.com/charmbracelet/lipgloss" "github.com/correx/tui-go/internal/app" - "github.com/muesli/termenv" ) func main() { - lipgloss.SetColorProfile(termenv.TrueColor) - + // lipgloss v2 renders full truecolor ANSI by default (color downsampling now + // lives in the terminal writer, not a global profile), which is exactly what a + // static screenshot frame wants — so no color-profile setup is needed. kind := "idle" if len(os.Args) > 1 { kind = os.Args[1] diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index 0a9c3c3e..45c4557d 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -1,44 +1,45 @@ module github.com/correx/tui-go -go 1.24.2 +go 1.25.0 require ( + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 github.com/aymanbagabas/go-osc52/v2 v2.0.1 - github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/ansi v0.11.7 github.com/gorilla/websocket v1.5.3 - github.com/muesli/termenv v0.16.0 ) require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // 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/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/dlclark/regexp2 v1.11.5 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.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/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.36.0 // indirect golang.org/x/text v0.30.0 // indirect ) diff --git a/apps/tui-go/go.sum b/apps/tui-go/go.sum index 321fbe29..589ecead 100644 --- a/apps/tui-go/go.sum +++ b/apps/tui-go/go.sum @@ -1,3 +1,7 @@ +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/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= @@ -6,57 +10,53 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -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/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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= 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/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +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/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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/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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -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/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-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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -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/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -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/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= @@ -77,10 +77,11 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= diff --git a/apps/tui-go/internal/app/adaptive_layout_test.go b/apps/tui-go/internal/app/adaptive_layout_test.go index c1a27f9a..101f9148 100644 --- a/apps/tui-go/internal/app/adaptive_layout_test.go +++ b/apps/tui-go/internal/app/adaptive_layout_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // inSessionModel builds an in-session model with deliberately long fields (model name, @@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) { func TestViewNeverOverflowsWidth(t *testing.T) { for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} { m := inSessionModel(w, 30) - assertNoOverflow(t, m.View(), w) + assertNoOverflow(t, m.render(), w) } } @@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) { m.overlay = OverlayStats m.statsFor = m.selectedID m.stats = sampleStats() - assertNoOverflow(t, m.View(), w) + assertNoOverflow(t, m.render(), w) } } diff --git a/apps/tui-go/internal/app/approval_test.go b/apps/tui-go/internal/app/approval_test.go index 072f549c..e0c78585 100644 --- a/apps/tui-go/internal/app/approval_test.go +++ b/apps/tui-go/internal/app/approval_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -40,12 +39,12 @@ func apprReq(reqID, tier string) protocol.ServerMessage { } // applyKey drives a key through the production handleKey entry and returns the model. -func applyKey(m Model, k tea.KeyMsg) Model { +func applyKey(m Model, k keyMsg) Model { updated, _ := m.handleKey(k) return updated.(Model) } -func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} } +func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} } // decodeApproval pulls the single ApprovalResponse frame off the client and returns // its request id + decision. Fails if there isn't exactly one decision frame. @@ -146,7 +145,7 @@ func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) { // no decision may be emitted. m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3")) m = applyKey(m, runeKey('y')) // arm req-1 - m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown}) + m = applyKey(m, keyMsg{Type: keyDown}) if m.approvalArmed { t.Fatalf("nav key should disarm") } @@ -168,7 +167,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) { t.Fatalf("band should show queue count 1/3:\n%s", out) } // ↓ advances the selection; Pending follows the cursor. - m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown}) + m = applyKey(m, keyMsg{Type: keyDown}) s = m.session("s1") if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" { t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID) @@ -178,7 +177,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) { t.Fatalf("band should show 2/3 after ↓:\n%s", out) } // ↑ wraps back. - m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp}) + m = applyKey(m, keyMsg{Type: keyUp}) if m.session("s1").PendingIdx != 0 { t.Fatalf("↑ should return to index 0") } @@ -238,7 +237,7 @@ func TestApprovalRejectAndSteerRoute(t *testing.T) { for _, r := range "use sudo" { m2 = applyKey(m2, runeKey(r)) } - m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter}) + m2 = applyKey(m2, keyMsg{Type: keyEnter}) frames := client2.Drain() var note string for _, f := range frames { diff --git a/apps/tui-go/internal/app/clarcard.go b/apps/tui-go/internal/app/clarcard.go index 352f12b2..a474d778 100644 --- a/apps/tui-go/internal/app/clarcard.go +++ b/apps/tui-go/internal/app/clarcard.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string { // --- key handling --- -func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Clar == nil { return m, nil @@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleClarTyping(k), nil } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.clarDismissed = true - case tea.KeyUp: + case keyUp: m.clarFocusMove(-1, qs) - case tea.KeyDown: + case keyDown: m.clarFocusMove(1, qs) - case tea.KeyLeft: + case keyLeft: m.clarCursorMove(-1, qs) - case tea.KeyRight: + case keyRight: m.clarCursorMove(1, qs) - case tea.KeySpace: + case keySpace: m.clarSelect(qs) - case tea.KeyEnter: + case keyEnter: return m.clarSubmit() - case tea.KeyRunes: + case keyRunes: switch string(k.Runes) { case "k": m.clarFocusMove(-1, qs) @@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model { +func (m Model) handleClarTyping(k keyMsg) tea.Model { if m.clarFocus >= len(m.clarText) { return m } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.clarTyping = false - case tea.KeyEnter: + case keyEnter: m.clarTyping = false // committing custom text supersedes any chosen option for this question if m.clarFocus < len(m.clarChosen) { m.clarChosen[m.clarFocus] = map[int]bool{} } - case tea.KeyBackspace: + case keyBackspace: if t := m.clarText[m.clarFocus]; len(t) > 0 { m.clarText[m.clarFocus] = t[:len(t)-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.clarText[m.clarFocus] += string(k.Runes) } return m diff --git a/apps/tui-go/internal/app/clarcard_test.go b/apps/tui-go/internal/app/clarcard_test.go index 2a50e8f3..1b8e031c 100644 --- a/apps/tui-go/internal/app/clarcard_test.go +++ b/apps/tui-go/internal/app/clarcard_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) { func TestClarificationSelectAndSubmit(t *testing.T) { m := clarModel() // move the option cursor to "Vue" (index 1) and select it - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight}) - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) + m = applyClarKey(m, keyMsg{Type: keyRight}) + m = applyClarKey(m, keyMsg{Type: keySpace}) if !m.clarChosenAt(0, 1) { t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen) } // submit - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Clar != nil { t.Fatalf("expected clarification cleared after submit") } @@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { }, }) // answer only the first question, then attempt submit - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keySpace}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Clar == nil { t.Fatalf("expected clarification to remain with partial answers") } @@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { func TestClarificationCustomTextAnswer(t *testing.T) { m := clarModel() - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.clarTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "Solid" { - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if m.clarTyping { t.Fatalf("expected typing committed") } @@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) { } } -func applyClarKey(m Model, k tea.KeyMsg) Model { +func applyClarKey(m Model, k keyMsg) Model { updated, _ := m.handleClarificationKey(k) return updated.(Model) } diff --git a/apps/tui-go/internal/app/cmdcard.go b/apps/tui-go/internal/app/cmdcard.go index 5ad86f62..7c2b8c6e 100644 --- a/apps/tui-go/internal/app/cmdcard.go +++ b/apps/tui-go/internal/app/cmdcard.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval diff --git a/apps/tui-go/internal/app/config_overlay.go b/apps/tui-go/internal/app/config_overlay.go index a33326e8..490ca633 100644 --- a/apps/tui-go/internal/app/config_overlay.go +++ b/apps/tui-go/internal/app/config_overlay.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -23,40 +23,40 @@ func (m *Model) openConfig() { // handleConfigKey owns every key while the config overlay is open. In edit mode it captures the // value being typed; otherwise it navigates fields, stages edits, and saves. -func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) { if m.configEditing { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.configEditing = false m.configEditBuf = "" - case tea.KeyEnter: + case keyEnter: if m.configIndex >= 0 && m.configIndex < len(m.configFields) { m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf } m.configEditing = false m.configEditBuf = "" - case tea.KeyBackspace: + case keyBackspace: if n := len(m.configEditBuf); n > 0 { m.configEditBuf = m.configEditBuf[:n-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.configEditBuf += string(k.Runes) } return m, nil } switch { - case k.Type == tea.KeyEsc || runeIs(k, "g"): + case k.Type == keyEsc || runeIs(k, "g"): m.overlay = OverlayNone - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.configIndex > 0 { m.configIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.configIndex < len(m.configFields)-1 { m.configIndex++ } - case k.Type == tea.KeyEnter || k.Type == tea.KeySpace: + case k.Type == keyEnter || k.Type == keySpace: m = m.actOnConfigField() case runeIs(k, "s"): if len(m.configStaged) > 0 { diff --git a/apps/tui-go/internal/app/config_overlay_test.go b/apps/tui-go/internal/app/config_overlay_test.go index 4d0ccd4e..4ff007f5 100644 --- a/apps/tui-go/internal/app/config_overlay_test.go +++ b/apps/tui-go/internal/app/config_overlay_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" ) @@ -23,7 +22,7 @@ func configModel() Model { func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { m := configModel() - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configStaged["project.enabled"] != "true" { t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"]) @@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { func TestConfigEnumCycles(t *testing.T) { m := configModel() m.configIndex = 1 // tui.theme - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configStaged["tui.theme"] != "light" { t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"]) @@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) { m := configModel() m.configIndex = 2 // router.generation.max_tokens // Enter edit mode. - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if !m.configEditing { t.Fatalf("expected edit mode after enter on INT field") @@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) { // Clear the seeded buffer and type a new value. m.configEditBuf = "" for _, r := range "1024" { - updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}}) m = updated.(Model) } - updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configEditing { t.Fatalf("expected edit mode to end after commit") diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index cc0cb440..da68ee16 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -256,7 +256,7 @@ func PreviewFrame(kind string, w, h int) string { m.grantIndex = 1 m.overlay = OverlayGrants } - return m.View() + return m.render() } func sampleStats() *protocol.StatsDto { diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go index bf77ec35..906a5460 100644 --- a/apps/tui-go/internal/app/diff.go +++ b/apps/tui-go/internal/app/diff.go @@ -1,10 +1,11 @@ package app import ( + "image/color" "strconv" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type diffKind int @@ -196,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string { if kind == diffBlank { return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w)) } - var bg, textFg, signFg lipgloss.Color + var bg, textFg, signFg color.Color sign := " " switch kind { case diffAdd: diff --git a/apps/tui-go/internal/app/ideacard.go b/apps/tui-go/internal/app/ideacard.go index 2ae6b450..34ba62e4 100644 --- a/apps/tui-go/internal/app/ideacard.go +++ b/apps/tui-go/internal/app/ideacard.go @@ -3,7 +3,7 @@ package app import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router diff --git a/apps/tui-go/internal/app/ideacard_test.go b/apps/tui-go/internal/app/ideacard_test.go index 7a7d2dc1..101d40bd 100644 --- a/apps/tui-go/internal/app/ideacard_test.go +++ b/apps/tui-go/internal/app/ideacard_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) { m, client := ideaModel() _ = client.Drain() // drop the ListIdeas frame from openIdeas - updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")}) m = updated.(Model) if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" { diff --git a/apps/tui-go/internal/app/key.go b/apps/tui-go/internal/app/key.go new file mode 100644 index 00000000..ad65323e --- /dev/null +++ b/apps/tui-go/internal/app/key.go @@ -0,0 +1,123 @@ +package app + +import tea "charm.land/bubbletea/v2" + +// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form +// and dropped the v1 KeyType enum the handlers were written against. Rather than +// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same +// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic +// downstream stays untouched. The only place that ever touches the raw v2 key +// is toKeyMsg below, called once at the Update boundary. + +type keyType int + +const ( + keyRunes keyType = iota + keyEnter + keyUp + keyDown + keyLeft + keyRight + keySpace + keyEsc + keyBackspace + keyTab + keyPgUp + keyPgDown + keyCtrlA + keyCtrlC + keyCtrlD + keyCtrlJ + keyCtrlR + keyCtrlU + keyCtrlX + keyCtrlUp + keyCtrlDown + keyOther +) + +// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed +// Runes and modifier flags. Shift is newly meaningful under v2's kitty key +// disambiguation — it powers Shift+Enter newlines (see handleInsertKey). +type keyMsg struct { + Type keyType + Runes []rune + Alt bool + Shift bool + str string +} + +func (k keyMsg) String() string { return k.str } + +// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form. +func toKeyMsg(p tea.KeyPressMsg) keyMsg { + k := p.Key() + out := keyMsg{ + Alt: k.Mod.Contains(tea.ModAlt), + Shift: k.Mod.Contains(tea.ModShift), + str: k.Keystroke(), + } + ctrl := k.Mod.Contains(tea.ModCtrl) + switch k.Code { + case tea.KeyEnter: + out.Type = keyEnter + case tea.KeyEscape: + out.Type = keyEsc + case tea.KeyTab: + out.Type = keyTab + case tea.KeyBackspace: + out.Type = keyBackspace + case tea.KeySpace: + out.Type = keySpace + out.Runes = []rune{' '} + case tea.KeyUp: + if ctrl { + out.Type = keyCtrlUp + } else { + out.Type = keyUp + } + case tea.KeyDown: + if ctrl { + out.Type = keyCtrlDown + } else { + out.Type = keyDown + } + case tea.KeyLeft: + out.Type = keyLeft + case tea.KeyRight: + out.Type = keyRight + case tea.KeyPgUp: + out.Type = keyPgUp + case tea.KeyPgDown: + out.Type = keyPgDown + default: + if ctrl { + switch k.Code { + case 'a': + out.Type = keyCtrlA + case 'c': + out.Type = keyCtrlC + case 'd': + out.Type = keyCtrlD + case 'j': + out.Type = keyCtrlJ + case 'r': + out.Type = keyCtrlR + case 'u': + out.Type = keyCtrlU + case 'x': + out.Type = keyCtrlX + default: + out.Type = keyOther + } + return out + } + if k.Text != "" { + out.Type = keyRunes + out.Runes = []rune(k.Text) + } else { + out.Type = keyOther + } + } + return out +} diff --git a/apps/tui-go/internal/app/overlay_composite_test.go b/apps/tui-go/internal/app/overlay_composite_test.go index 6b9a687a..11a1b331 100644 --- a/apps/tui-go/internal/app/overlay_composite_test.go +++ b/apps/tui-go/internal/app/overlay_composite_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index c648f457..cbe99ef2 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -2,10 +2,11 @@ package app import ( "fmt" + "image/color" "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -21,8 +22,8 @@ import ( func (m Model) center(modal string) string { if m.lastBase == "" { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, - lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), - lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle(). + Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep))) } if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width { return modal // already a full-screen composite; don't re-dim it @@ -143,7 +144,7 @@ func (m Model) titleLine(text string) string { return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text) } -func mbg(t Theme, s string, fg lipgloss.Color) string { +func mbg(t Theme, s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s) } @@ -1217,7 +1218,7 @@ func shortID(id string) string { return id[:13] + "…" } -func tierColor(t Theme, tier int) lipgloss.Color { +func tierColor(t Theme, tier int) color.Color { switch { case tier >= 3: return t.P.Bad diff --git a/apps/tui-go/internal/app/proposecard.go b/apps/tui-go/internal/app/proposecard.go index cbbe3b6c..c1f71c53 100644 --- a/apps/tui-go/internal/app/proposecard.go +++ b/apps/tui-go/internal/app/proposecard.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -24,7 +24,7 @@ func (m *Model) proposeInitState() { // --- key handling --- -func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Propose == nil { return m, nil @@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } n := len(s.Propose.Candidates) // index n == the custom slot switch k.Type { - case tea.KeyEsc: + case keyEsc: m.proposeDismissed = true - case tea.KeyUp: + case keyUp: m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) - case tea.KeyDown: + case keyDown: m.proposeCursor = clampInt(m.proposeCursor+1, 0, n) - case tea.KeyEnter, tea.KeySpace: + case keyEnter, keySpace: return m.proposeSubmit() - case tea.KeyRunes: + case keyRunes: switch string(k.Runes) { case "k": m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) @@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.proposeTyping = false - case tea.KeyEnter: + case keyEnter: return m.proposeSubmit() // commit the custom answer and send it as chat - case tea.KeyBackspace: + case keyBackspace: if len(m.proposeText) > 0 { m.proposeText = m.proposeText[:len(m.proposeText)-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.proposeText += string(k.Runes) } return m, nil diff --git a/apps/tui-go/internal/app/proposecard_test.go b/apps/tui-go/internal/app/proposecard_test.go index cd8d9f7b..7890e415 100644 --- a/apps/tui-go/internal/app/proposecard_test.go +++ b/apps/tui-go/internal/app/proposecard_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) { func TestProposePickLaunchesChosenWorkflow(t *testing.T) { m, client := proposeModel() // move cursor to the second candidate (role_pipeline) and launch it - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown}) - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyProposeKey(m, keyMsg{Type: keyDown}) + m = applyProposeKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Propose != nil { t.Fatalf("expected proposal cleared after launch") @@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) { func TestProposeFirstCandidateIsDefault(t *testing.T) { m, client := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate + m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate wf, _ := decodeStart(t, client) if wf != "research" { t.Fatalf("expected first candidate launched by default, got %q", wf) @@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) { func TestProposeCustomAnswerContinuesChat(t *testing.T) { m, client := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.proposeTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "do it manually" { - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyProposeKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Propose != nil { t.Fatalf("expected proposal cleared after custom answer") @@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) { func TestProposeEscDismisses(t *testing.T) { m, _ := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + m = applyProposeKey(m, keyMsg{Type: keyEsc}) if m.displayState() != StateInSession { t.Fatalf("expected StateInSession after esc, got %v", m.displayState()) } @@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) { } } -func applyProposeKey(m Model, k tea.KeyMsg) Model { +func applyProposeKey(m Model, k keyMsg) Model { updated, _ := m.handleProposeKey(k) return updated.(Model) } diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 2089d57e..4cbf4dc2 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -80,7 +80,7 @@ func renderSurface(m Model, s matrixSurface) string { var raw string switch s { case surfaceView: - raw = m.View() + raw = m.render() case surfaceEvents: // Render the event-stream rows at full panel width so a row's detail isn't // clipped at the slim right-panel edge (the production builder, just wide). diff --git a/apps/tui-go/internal/app/sessions_overlay.go b/apps/tui-go/internal/app/sessions_overlay.go index bb07ece6..4963ad1b 100644 --- a/apps/tui-go/internal/app/sessions_overlay.go +++ b/apps/tui-go/internal/app/sessions_overlay.go @@ -7,8 +7,8 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // SessionSummary is one recent session from GET /sessions. Mirrors the server's @@ -107,19 +107,19 @@ func (m *Model) openSessions() tea.Cmd { // handleSessionsKey owns every key while the session browser is open: navigate // the roster, esc closes, enter resumes the selected session. -func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) { switch { - case k.Type == tea.KeyEsc || runeIs(k, "R"): + case k.Type == keyEsc || runeIs(k, "R"): m.overlay = OverlayNone - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.sessionListIndex > 0 { m.sessionListIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.sessionListIndex < len(m.sessionList)-1 { m.sessionListIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) { return m.openSelectedSession() } diff --git a/apps/tui-go/internal/app/sessions_overlay_test.go b/apps/tui-go/internal/app/sessions_overlay_test.go index 83709458..00e5023d 100644 --- a/apps/tui-go/internal/app/sessions_overlay_test.go +++ b/apps/tui-go/internal/app/sessions_overlay_test.go @@ -3,8 +3,6 @@ package app import ( "strings" "testing" - - tea "github.com/charmbracelet/bubbletea" ) func sessionsModel() Model { @@ -25,7 +23,7 @@ func sampleSummaries() []SessionSummary { func TestSessionsOverlayToggle(t *testing.T) { m := sessionsModel() // `R` opens the browser from the idle list. - updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")}) + updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("R")}) m = updated.(Model) if m.overlay != OverlaySessions { t.Fatalf("expected OverlaySessions after R, got %d", m.overlay) @@ -34,7 +32,7 @@ func TestSessionsOverlayToggle(t *testing.T) { t.Fatalf("expected loading state right after open") } // `esc` closes it (routed through the overlay key handler). - updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc}) m = updated.(Model) if m.overlay != OverlayNone { t.Fatalf("expected overlay closed after esc, got %d", m.overlay) @@ -66,25 +64,25 @@ func TestSessionsNavigationBoundsChecked(t *testing.T) { m.overlay = OverlaySessions m.sessionList = sampleSummaries() // Up at the top is a no-op (clamped at 0). - updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ := m.handleSessionsKey(keyMsg{Type: keyUp}) m = updated.(Model) if m.sessionListIndex != 0 { t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex) } // Down moves to row 1. - updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown}) m = updated.(Model) if m.sessionListIndex != 1 { t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex) } // Down again is clamped at the last row. - updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown}) m = updated.(Model) if m.sessionListIndex != 1 { t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex) } // `j` is the vim-style alias for down (still clamped here). - updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + updated, _ = m.handleSessionsKey(keyMsg{Type: keyRunes, Runes: []rune("j")}) m = updated.(Model) if m.sessionListIndex != 1 { t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex) @@ -96,7 +94,7 @@ func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) { m.overlay = OverlaySessions m.sessionList = sampleSummaries() m.sessionListIndex = 1 // the FAILED healthcheck row - updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.handleSessionsKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.overlay != OverlayNone { t.Fatalf("expected overlay closed after resume, got %d", m.overlay) diff --git a/apps/tui-go/internal/app/slash_test.go b/apps/tui-go/internal/app/slash_test.go index fa7078e1..271346ec 100644 --- a/apps/tui-go/internal/app/slash_test.go +++ b/apps/tui-go/internal/app/slash_test.go @@ -2,8 +2,6 @@ package app import ( "testing" - - tea "github.com/charmbracelet/bubbletea" ) // `/` as the first char of a chat line opens the command palette inline (opencode-style), @@ -14,7 +12,7 @@ func TestSlashOpensPaletteOnEmptyLine(t *testing.T) { m.inputMode = ModeRouter m.inputBuffer = "" - next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) nm := next.(Model) if nm.overlay != OverlayPalette { @@ -33,7 +31,7 @@ func TestSlashLiteralMidLine(t *testing.T) { m.inputBuffer = "path" m.inputCursor = len("path") - next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) nm := next.(Model) if nm.overlay != OverlayNone { diff --git a/apps/tui-go/internal/app/theme.go b/apps/tui-go/internal/app/theme.go index 914d26bd..150cdeec 100644 --- a/apps/tui-go/internal/app/theme.go +++ b/apps/tui-go/internal/app/theme.go @@ -1,36 +1,40 @@ package app -import "github.com/charmbracelet/lipgloss" +import ( + "image/color" + + "charm.land/lipgloss/v2" +) // Palette is the "Soft" chrome from docs/visual/ref with the blue accent: // rounded borders, opaque dark panels, generous spacing. type Palette struct { - Bg lipgloss.Color // panel/background fill - BgDeep lipgloss.Color // outermost backdrop (slightly darker) - BgPanel lipgloss.Color // inside-panel fill - Sel lipgloss.Color // selection row background - Border lipgloss.Color // idle panel border - BorderHi lipgloss.Color // active panel border - Fg lipgloss.Color // primary text - FgStrong lipgloss.Color // headings / emphasis - Dim lipgloss.Color // labels, secondary text - Faint lipgloss.Color // tertiary, hints - Accent lipgloss.Color // blue accent - Accent2 lipgloss.Color // secondary accent + Bg color.Color // panel/background fill + BgDeep color.Color // outermost backdrop (slightly darker) + BgPanel color.Color // inside-panel fill + Sel color.Color // selection row background + Border color.Color // idle panel border + BorderHi color.Color // active panel border + Fg color.Color // primary text + FgStrong color.Color // headings / emphasis + Dim color.Color // labels, secondary text + Faint color.Color // tertiary, hints + Accent color.Color // blue accent + Accent2 color.Color // secondary accent - OK lipgloss.Color - Warn lipgloss.Color - Bad lipgloss.Color + OK color.Color + Warn color.Color + Bad color.Color - DiffAdd lipgloss.Color // added-line cell background - DiffDel lipgloss.Color // removed-line cell background + DiffAdd color.Color // added-line cell background + DiffDel color.Color // removed-line cell background - CatLifecycle lipgloss.Color - CatContext lipgloss.Color - CatInference lipgloss.Color - CatTool lipgloss.Color - CatDomain lipgloss.Color - CatApproval lipgloss.Color + CatLifecycle color.Color + CatContext color.Color + CatInference color.Color + CatTool color.Color + CatDomain color.Color + CatApproval color.Color } // SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the @@ -91,7 +95,7 @@ type Theme struct { // NewTheme builds the style set for a palette. func NewTheme(p Palette) Theme { base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg) - roundBase := func(border lipgloss.Color) lipgloss.Style { + roundBase := func(border color.Color) lipgloss.Style { return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). @@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme { } // categoryColor maps an event category label to its accent color. -func (t Theme) categoryColor(cat string) lipgloss.Color { +func (t Theme) categoryColor(cat string) color.Color { switch cat { case "Lifecycle": return t.P.CatLifecycle diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 61d5b8af..58273bf0 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -6,8 +6,8 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" osc52 "github.com/aymanbagabas/go-osc52/v2" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -113,8 +113,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case tea.KeyMsg: - next, cmd := m.handleKey(msg) + case tea.KeyPressMsg: + next, cmd := m.handleKey(toKeyMsg(msg)) // A keypress may have entered a typing/active state (insert mode, steering, …); // kick the tick so the caret/spinner animates again. if nm, ok := next.(Model); ok { @@ -175,12 +175,12 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) { // --- key handling --- -func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) { debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s", k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay, m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID) // Ctrl+C is a universal hard-quit safety; everything else is bare-key modal. - if k.Type == tea.KeyCtrlC { + if k.Type == keyCtrlC { m.quitting = true return m, tea.Quit } @@ -198,17 +198,17 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer. if m.displayState() == StateInSession { switch k.Type { - case tea.KeyCtrlUp: + case keyCtrlUp: m.transcriptNavUser(-1) return m, nil - case tea.KeyCtrlDown: + case keyCtrlDown: m.transcriptNavUser(1) return m, nil } } // On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any // edit mode, so it works whether or not the input is focused. - if m.displayState() == StateIdle && k.Type == tea.KeyTab { + if m.displayState() == StateIdle && k.Type == keyTab { m.cycleLauncherWf() return m, nil } @@ -219,7 +219,7 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleNormalKey processes vim-style bare-key commands. -func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) { ds := m.displayState() // The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm // gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the @@ -228,15 +228,15 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleApprovalKey(k) } switch k.Type { - case tea.KeyUp: + case keyUp: m.navUp() return m, nil - case tea.KeyDown: + case keyDown: m.navDown() return m, nil - case tea.KeyEnter: + case keyEnter: return m.normalEnter() - case tea.KeyEsc: + case keyEsc: // In-session, esc first drops a transcript selection or output scroll (back to // tail-follow); otherwise it clears the session-list filter. if m.transcriptSel >= 0 { @@ -249,26 +249,26 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } m.filter = "" return m, nil - case tea.KeyPgUp: + case keyPgUp: m.scrollOutput(m.outputViewportPage()) return m, nil - case tea.KeyPgDown: + case keyPgDown: m.scrollOutput(-m.outputViewportPage()) return m, nil - case tea.KeyCtrlU: + case keyCtrlU: m.scrollOutput(m.outputViewportPage() / 2) return m, nil - case tea.KeyCtrlD: + case keyCtrlD: m.scrollOutput(-m.outputViewportPage() / 2) return m, nil - case tea.KeyCtrlX: + case keyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 } return m, nil } - if k.Type != tea.KeyRunes { + if k.Type != keyRunes { return m, nil } switch string(k.Runes) { @@ -350,18 +350,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a // single keypress; a T3+ approve arms first and only a second confirming key sends // it (any other key disarms). ↑/↓ walk the pending queue without resolving anything. -func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Pending == nil { return m, nil } // Queue navigation never resolves a gate; it just changes which one is shown. switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): s.navApproval(-1) m.approvalArmed = false return m, nil - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): s.navApproval(1) m.approvalArmed = false return m, nil @@ -369,7 +369,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of // sending; the same key pressed again (while armed) confirms. - approveKey := k.Type == tea.KeyEnter || k.Type == tea.KeyCtrlA || + approveKey := k.Type == keyEnter || k.Type == keyCtrlA || runeIs(k, "y") || runeIs(k, "a") if approveKey { if s.Pending.HighTier() && !m.approvalArmed { @@ -383,7 +383,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.approvalArmed = false switch { - case k.Type == tea.KeyCtrlR || runeIs(k, "n") || runeIs(k, "r"): + case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"): return m.decide("REJECT") case runeIs(k, "e") || runeIs(k, "s"): // Steer/edit flows into the existing steering-note input path. @@ -396,10 +396,10 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.sessionEntered = false m.approvalDismissed = false return m, nil - case k.Type == tea.KeyEsc: + case k.Type == keyEsc: m.approvalDismissed = true return m, nil - case k.Type == tea.KeyCtrlX: + case k.Type == keyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 @@ -414,26 +414,26 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleInsertKey processes typing (chat/filter/steer note). -func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) { if m.steering { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.steering = false m.editMode = ModeNormal - case tea.KeyEnter: + case keyEnter: return m.submitApproval() - case tea.KeyBackspace: + case keyBackspace: if n := len(m.steerBuffer); n > 0 { m.steerBuffer = m.steerBuffer[:n-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.steerBuffer += string(k.Runes) } return m, nil } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.editMode = ModeNormal if m.inputMode == ModeFilter { m.inputMode = ModeRouter @@ -444,45 +444,46 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.clearInput() m.inputMode = ModeRouter } - case tea.KeyEnter: - // Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not - // distinguishable from Enter in this terminal stack — see Ctrl+J below.) - if k.Alt && m.inputMode != ModeFilter { + case keyEnter: + // Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter + // is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation; + // Ctrl+J below stays as a fallback for terminals without kitty support. + if (k.Shift || k.Alt) && m.inputMode != ModeFilter { m.appendRunes("\n") return m, nil } return m.submit() - case tea.KeyCtrlJ: + case keyCtrlJ: // Reliable "newline without sending" (Ctrl+J is the literal LF key) for the // single-line filter excluded. if m.inputMode != ModeFilter { m.appendRunes("\n") } return m, nil - case tea.KeyBackspace: + case keyBackspace: m.backspace() m.syncFilter() - case tea.KeyLeft: + case keyLeft: if m.inputCursor > 0 { m.inputCursor-- } - case tea.KeyRight: + case keyRight: if m.inputCursor < len(m.inputBuffer) { m.inputCursor++ } - case tea.KeyUp: + case keyUp: if m.inputMode == ModeFilter { m.listNav(-1) } else { m.historyPrev() } - case tea.KeyDown: + case keyDown: if m.inputMode == ModeFilter { m.listNav(1) } else { m.historyNext() } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: // opencode-style slash commands: `/` as the first char of a chat line opens the // command palette inline instead of typing a literal slash. Mid-line `/` is literal. if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" { @@ -627,7 +628,7 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { // Config overlay owns all its keys (esc cancels an in-progress edit rather than closing). if m.overlay == OverlayConfig { return m.handleConfigKey(k) @@ -637,7 +638,7 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlaySessions { return m.handleSessionsKey(k) } - if k.Type == tea.KeyEsc { + if k.Type == keyEsc { // In the event inspector, esc first stops an in-progress filter edit (keeping the // query) rather than closing the overlay. if m.overlay == OverlayEventInspector && m.eventFilterTyping { @@ -656,37 +657,37 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds. page := m.diffBodyHeight() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): m.scrollDiff(-1) - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): m.scrollDiff(1) - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollDiff(-page) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollDiff(page) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollDiff(-page / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollDiff(page / 2) case runeIs(k, "g"): m.diffScrollOffset = 0 case runeIs(k, "G"): m.diffScrollOffset = m.diffMaxScroll() - case k.Type == tea.KeyCtrlX: + case k.Type == keyCtrlX: m.overlay = OverlayNone } case OverlayEventInspector: evs := m.currentEvents() if m.eventFilterTyping { switch { - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: m.eventFilterTyping = false - case k.Type == tea.KeyBackspace: + case k.Type == keyBackspace: if n := len(m.eventFilter); n > 0 { m.eventFilter = m.eventFilter[:n-1] m.overlayEventIdx = 0 } - case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + case k.Type == keyRunes || k.Type == keySpace: m.eventFilter += string(k.Runes) m.overlayEventIdx = 0 } @@ -698,11 +699,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "c"): m.eventFilter = "" m.overlayEventIdx = 0 - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.overlayEventIdx < len(evs)-1 { m.overlayEventIdx++ } @@ -712,17 +713,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // dismisses it (esc handled above), keeping the quick-glance feel. page := m.modalBodyRows() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): m.scrollModal(-1) - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): m.scrollModal(1) - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollModal(-page) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollModal(page) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollModal(-page / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollModal(page / 2) case runeIs(k, "g"): m.modalScroll = 0 @@ -737,23 +738,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayArtifacts: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.artifactsIndex > 0 { m.artifactsIndex-- m.artifactScroll = 0 } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.artifactsIndex < len(m.artifacts)-1 { m.artifactsIndex++ m.artifactScroll = 0 } - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollArtifact(-m.artifactContentBodyH()) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollArtifact(m.artifactContentBodyH()) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollArtifact(-m.artifactContentBodyH() / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollArtifact(m.artifactContentBodyH() / 2) case runeIs(k, "g"): m.artifactScroll = 0 @@ -764,15 +765,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayModels: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.modelsIndex > 0 { m.modelsIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.modelsIndex < len(m.availableModels)-1 { m.modelsIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) { m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex])) m.overlay = OverlayNone @@ -786,17 +787,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case OverlayStats: page := m.modalBodyRows() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): m.scrollModal(-1) - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): m.scrollModal(1) - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollModal(-page) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollModal(page) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollModal(-page / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollModal(page / 2) case runeIs(k, "g"): m.modalScroll = 0 @@ -807,11 +808,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayIdeas: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.ideasIndex > 0 { m.ideasIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.ideasIndex < len(m.ideas)-1 { m.ideasIndex++ } @@ -830,39 +831,39 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case OverlayFiles: files := m.filteredFiles() switch { - case k.Type == tea.KeyUp: + case k.Type == keyUp: if m.fileIndex > 0 { m.fileIndex-- } - case k.Type == tea.KeyDown: + case k.Type == keyDown: if m.fileIndex < len(files)-1 { m.fileIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.fileIndex >= 0 && m.fileIndex < len(files) { m.insertFileRef(files[m.fileIndex]) } m.overlay = OverlayNone // back to typing (still in insert mode) - case k.Type == tea.KeyBackspace: + case k.Type == keyBackspace: if n := len(m.fileFilter); n > 0 { m.fileFilter = m.fileFilter[:n-1] m.fileIndex = 0 } - case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + case k.Type == keyRunes || k.Type == keySpace: m.fileFilter += string(k.Runes) m.fileIndex = 0 } case OverlayStatusbar: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.sbIndex > 0 { m.sbIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.sbIndex < len(statusSegments)-1 { m.sbIndex++ } - case k.Type == tea.KeySpace || k.Type == tea.KeyEnter || runeIs(k, "x"): + case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"): if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) { m.toggleStatusSegment(statusSegments[m.sbIndex].id) } @@ -871,15 +872,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayGrants: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.grantIndex > 0 { m.grantIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.grantIndex < len(m.grants)-1 { m.grantIndex++ } - case k.Type == tea.KeyEnter || runeIs(k, "x"): + case k.Type == keyEnter || runeIs(k, "x"): if m.grantIndex >= 0 && m.grantIndex < len(m.grants) { g := m.grants[m.grantIndex] m.client.Send(protocol.RevokeGrant(g.GrantID)) @@ -910,14 +911,14 @@ func grantScopeOptions() []grantScopeOption { // handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter // confirms the highlighted scope. esc (handled upstream) cancels without granting. -func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) { opts := grantScopeOptions() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.grantScopeIndex > 0 { m.grantScopeIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.grantScopeIndex < len(opts)-1 { m.grantScopeIndex++ } @@ -930,7 +931,7 @@ func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "g"): m.grantScopeIndex = 2 return m.applyGrantScope() - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: return m.applyGrantScope() } return m, nil @@ -1046,31 +1047,31 @@ func (m *Model) openModelsOverlay() { } } -func runeIs(k tea.KeyMsg, s string) bool { - return k.Type == tea.KeyRunes && string(k.Runes) == s +func runeIs(k keyMsg, s string) bool { + return k.Type == keyRunes && string(k.Runes) == s } -func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) { cmds := m.filteredPalette() switch k.Type { - case tea.KeyUp: + case keyUp: if m.paletteIndex > 0 { m.paletteIndex-- } - case tea.KeyDown: + case keyDown: if m.paletteIndex < len(cmds)-1 { m.paletteIndex++ } - case tea.KeyEnter: + case keyEnter: if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { return m.execPalette(cmds[m.paletteIndex].id) } - case tea.KeyBackspace: + case keyBackspace: if n := len(m.paletteFilter); n > 0 { m.paletteFilter = m.paletteFilter[:n-1] m.paletteIndex = 0 } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.paletteFilter += string(k.Runes) m.paletteIndex = 0 } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index ff7f4a9d..6693c7d5 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -2,11 +2,13 @@ package app import ( "fmt" + "image/color" "os" "strings" "time" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -28,8 +30,15 @@ const ( // and full-width chrome. func (m Model) narrow() bool { return m.width < narrowWidth } -// View renders the whole frame purely from the model (immediate-mode). -func (m Model) View() string { +// View renders the whole frame purely from the model (immediate-mode). In +// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so +// render() builds the string and View() wraps it with the screen settings. +func (m Model) View() tea.View { + return tea.View{Content: m.render(), AltScreen: true} +} + +// render builds the frame string. +func (m Model) render() string { if m.quitting { return "" } @@ -79,7 +88,7 @@ func (m Model) View() string { func (m Model) renderStatus() string { t := m.theme bg := t.P.BgPanel - span := func(s string, fg lipgloss.Color) string { + span := func(s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s) } nrw := m.narrow() @@ -376,12 +385,12 @@ func (m Model) renderLauncher(w, h int) string { inW = 24 } left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW), - lipgloss.WithWhitespaceBackground(t.P.Bg)) + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg))) if railW == 0 { return left } rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW), - lipgloss.WithWhitespaceBackground(t.P.Bg)) + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg))) return lipgloss.JoinHorizontal(lipgloss.Top, left, rail) } @@ -738,7 +747,7 @@ func (m Model) eventStreamBadge() string { return t.span("○ idle", t.P.Faint) } if s := m.session(m.selectedID); s != nil { - badge := func(fg lipgloss.Color, text string) string { + badge := func(fg color.Color, text string) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text) } switch u := strings.ToUpper(s.Status); { @@ -764,7 +773,7 @@ func shortPath(p string) string { } // fill left-justifies a styled line and pads with bg to width w. -func (m Model) fill(s string, w int, bg lipgloss.Color) string { +func (m Model) fill(s string, w int, bg color.Color) string { return " " + padTo(s, w-1, bg) } @@ -772,7 +781,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string { // so the line never overflows w. The left segment is shrunk first (the right holds // the high-signal status — connection clock / active spinner); the right is clamped // only as a last resort. ANSI-aware so truncation can't slice through escape codes. -func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { +func (m Model) justify(left, right string, w int, bg color.Color) string { avail := w - 2 // leading + trailing space lw := lipgloss.Width(left) rw := lipgloss.Width(right) @@ -798,7 +807,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { // padTo pads (or truncates) a possibly-styled string to visible width w, filling // with the given background so the panel stays opaque. -func padTo(s string, w int, bg lipgloss.Color) string { +func padTo(s string, w int, bg color.Color) string { vw := lipgloss.Width(s) if vw == w { return s @@ -825,7 +834,7 @@ func padRaw(s string, w int) string { return s + strings.Repeat(" ", w-n) } -func statusColor(t Theme, status string) lipgloss.Color { +func statusColor(t Theme, status string) color.Color { u := strings.ToUpper(status) switch { case strings.Contains(u, "FAIL"): @@ -862,7 +871,7 @@ func statusGlyph(t Theme, status string) string { } // span renders text with foreground fg over the panel background. -func (t Theme) span(s string, fg lipgloss.Color) string { +func (t Theme) span(s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s) } diff --git a/apps/tui-go/main.go b/apps/tui-go/main.go index 9d40068b..cf18bddc 100644 --- a/apps/tui-go/main.go +++ b/apps/tui-go/main.go @@ -8,7 +8,7 @@ import ( "fmt" "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/correx/tui-go/internal/app" "github.com/correx/tui-go/internal/ws" ) @@ -33,7 +33,8 @@ func main() { defer cancel() go client.Run(ctx) - p := tea.NewProgram(app.NewModel(client), tea.WithAltScreen()) + // Alt-screen is requested per-frame via the View's AltScreen field in v2. + p := tea.NewProgram(app.NewModel(client)) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "correx tui error:", err) os.Exit(1)