tui: migrate Bubble Tea v1 → v2 (charm.land), enable Shift+Enter

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:59:55 +00:00
parent 6c792b83e2
commit d247b19608
27 changed files with 432 additions and 300 deletions
+3 -4
View File
@@ -7,14 +7,13 @@ import (
"os" "os"
"strconv" "strconv"
"github.com/charmbracelet/lipgloss"
"github.com/correx/tui-go/internal/app" "github.com/correx/tui-go/internal/app"
"github.com/muesli/termenv"
) )
func main() { 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" kind := "idle"
if len(os.Args) > 1 { if len(os.Args) > 1 {
kind = os.Args[1] kind = os.Args[1]
+16 -15
View File
@@ -1,44 +1,45 @@
module github.com/correx/tui-go module github.com/correx/tui-go
go 1.24.2 go 1.25.0
require ( 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/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/ansi v0.11.6
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/muesli/termenv v0.16.0
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/aymerick/douceur v0.2.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/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // 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/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/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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // 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/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // 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/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // 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/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.30.0 // indirect
) )
+30 -29
View File
@@ -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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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= 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/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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 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/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= 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 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 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/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 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-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
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/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 h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 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/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 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
@@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
// inSessionModel builds an in-session model with deliberately long fields (model name, // 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) { func TestViewNeverOverflowsWidth(t *testing.T) {
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} { for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
m := inSessionModel(w, 30) 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.overlay = OverlayStats
m.statsFor = m.selectedID m.statsFor = m.selectedID
m.stats = sampleStats() m.stats = sampleStats()
assertNoOverflow(t, m.View(), w) assertNoOverflow(t, m.render(), w)
} }
} }
+6 -7
View File
@@ -5,7 +5,6 @@ import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws" "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. // 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) updated, _ := m.handleKey(k)
return updated.(Model) 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 // decodeApproval pulls the single ApprovalResponse frame off the client and returns
// its request id + decision. Fails if there isn't exactly one decision frame. // 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. // no decision may be emitted.
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3")) m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
m = applyKey(m, runeKey('y')) // arm req-1 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 { if m.approvalArmed {
t.Fatalf("nav key should disarm") 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) t.Fatalf("band should show queue count 1/3:\n%s", out)
} }
// ↓ advances the selection; Pending follows the cursor. // ↓ 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") s = m.session("s1")
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" { 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) 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) t.Fatalf("band should show 2/3 after ↓:\n%s", out)
} }
// ↑ wraps back. // ↑ wraps back.
m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp}) m = applyKey(m, keyMsg{Type: keyUp})
if m.session("s1").PendingIdx != 0 { if m.session("s1").PendingIdx != 0 {
t.Fatalf("↑ should return to index 0") t.Fatalf("↑ should return to index 0")
} }
@@ -238,7 +237,7 @@ func TestApprovalRejectAndSteerRoute(t *testing.T) {
for _, r := range "use sudo" { for _, r := range "use sudo" {
m2 = applyKey(m2, runeKey(r)) m2 = applyKey(m2, runeKey(r))
} }
m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter}) m2 = applyKey(m2, keyMsg{Type: keyEnter})
frames := client2.Drain() frames := client2.Drain()
var note string var note string
for _, f := range frames { for _, f := range frames {
+16 -16
View File
@@ -3,8 +3,8 @@ package app
import ( import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
) )
@@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
// --- key handling --- // --- 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) s := m.session(m.selectedID)
if s == nil || s.Clar == nil { if s == nil || s.Clar == nil {
return m, nil return m, nil
@@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleClarTyping(k), nil return m.handleClarTyping(k), nil
} }
switch k.Type { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.clarDismissed = true m.clarDismissed = true
case tea.KeyUp: case keyUp:
m.clarFocusMove(-1, qs) m.clarFocusMove(-1, qs)
case tea.KeyDown: case keyDown:
m.clarFocusMove(1, qs) m.clarFocusMove(1, qs)
case tea.KeyLeft: case keyLeft:
m.clarCursorMove(-1, qs) m.clarCursorMove(-1, qs)
case tea.KeyRight: case keyRight:
m.clarCursorMove(1, qs) m.clarCursorMove(1, qs)
case tea.KeySpace: case keySpace:
m.clarSelect(qs) m.clarSelect(qs)
case tea.KeyEnter: case keyEnter:
return m.clarSubmit() return m.clarSubmit()
case tea.KeyRunes: case keyRunes:
switch string(k.Runes) { switch string(k.Runes) {
case "k": case "k":
m.clarFocusMove(-1, qs) m.clarFocusMove(-1, qs)
@@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { if m.clarFocus >= len(m.clarText) {
return m return m
} }
switch k.Type { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.clarTyping = false m.clarTyping = false
case tea.KeyEnter: case keyEnter:
m.clarTyping = false m.clarTyping = false
// committing custom text supersedes any chosen option for this question // committing custom text supersedes any chosen option for this question
if m.clarFocus < len(m.clarChosen) { if m.clarFocus < len(m.clarChosen) {
m.clarChosen[m.clarFocus] = map[int]bool{} m.clarChosen[m.clarFocus] = map[int]bool{}
} }
case tea.KeyBackspace: case keyBackspace:
if t := m.clarText[m.clarFocus]; len(t) > 0 { if t := m.clarText[m.clarFocus]; len(t) > 0 {
m.clarText[m.clarFocus] = t[:len(t)-1] m.clarText[m.clarFocus] = t[:len(t)-1]
} }
case tea.KeyRunes, tea.KeySpace: case keyRunes, keySpace:
m.clarText[m.clarFocus] += string(k.Runes) m.clarText[m.clarFocus] += string(k.Runes)
} }
return m return m
+9 -10
View File
@@ -4,7 +4,6 @@ import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws" "github.com/correx/tui-go/internal/ws"
) )
@@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) {
func TestClarificationSelectAndSubmit(t *testing.T) { func TestClarificationSelectAndSubmit(t *testing.T) {
m := clarModel() m := clarModel()
// move the option cursor to "Vue" (index 1) and select it // move the option cursor to "Vue" (index 1) and select it
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight}) m = applyClarKey(m, keyMsg{Type: keyRight})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) m = applyClarKey(m, keyMsg{Type: keySpace})
if !m.clarChosenAt(0, 1) { if !m.clarChosenAt(0, 1) {
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen) t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
} }
// submit // 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 { if s := m.session("s1"); s == nil || s.Clar != nil {
t.Fatalf("expected clarification cleared after submit") t.Fatalf("expected clarification cleared after submit")
} }
@@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
}, },
}) })
// answer only the first question, then attempt submit // answer only the first question, then attempt submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) m = applyClarKey(m, keyMsg{Type: keySpace})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar == nil { if s := m.session("s1"); s == nil || s.Clar == nil {
t.Fatalf("expected clarification to remain with partial answers") t.Fatalf("expected clarification to remain with partial answers")
} }
@@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
func TestClarificationCustomTextAnswer(t *testing.T) { func TestClarificationCustomTextAnswer(t *testing.T) {
m := clarModel() 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 { if !m.clarTyping {
t.Fatalf("expected typing mode after 'e'") t.Fatalf("expected typing mode after 'e'")
} }
for _, r := range "Solid" { 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 { if m.clarTyping {
t.Fatalf("expected typing committed") 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) updated, _ := m.handleClarificationKey(k)
return updated.(Model) return updated.(Model)
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval // cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
+11 -11
View File
@@ -3,8 +3,8 @@ package app
import ( import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol" "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 // 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. // 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 { if m.configEditing {
switch k.Type { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.configEditing = false m.configEditing = false
m.configEditBuf = "" m.configEditBuf = ""
case tea.KeyEnter: case keyEnter:
if m.configIndex >= 0 && m.configIndex < len(m.configFields) { if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
} }
m.configEditing = false m.configEditing = false
m.configEditBuf = "" m.configEditBuf = ""
case tea.KeyBackspace: case keyBackspace:
if n := len(m.configEditBuf); n > 0 { if n := len(m.configEditBuf); n > 0 {
m.configEditBuf = m.configEditBuf[:n-1] m.configEditBuf = m.configEditBuf[:n-1]
} }
case tea.KeyRunes, tea.KeySpace: case keyRunes, keySpace:
m.configEditBuf += string(k.Runes) m.configEditBuf += string(k.Runes)
} }
return m, nil return m, nil
} }
switch { switch {
case k.Type == tea.KeyEsc || runeIs(k, "g"): case k.Type == keyEsc || runeIs(k, "g"):
m.overlay = OverlayNone m.overlay = OverlayNone
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.configIndex > 0 { if m.configIndex > 0 {
m.configIndex-- m.configIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.configIndex < len(m.configFields)-1 { if m.configIndex < len(m.configFields)-1 {
m.configIndex++ m.configIndex++
} }
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace: case k.Type == keyEnter || k.Type == keySpace:
m = m.actOnConfigField() m = m.actOnConfigField()
case runeIs(k, "s"): case runeIs(k, "s"):
if len(m.configStaged) > 0 { if len(m.configStaged) > 0 {
@@ -4,7 +4,6 @@ import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
) )
@@ -23,7 +22,7 @@ func configModel() Model {
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
m := configModel() m := configModel()
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model) m = updated.(Model)
if m.configStaged["project.enabled"] != "true" { if m.configStaged["project.enabled"] != "true" {
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"]) 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) { func TestConfigEnumCycles(t *testing.T) {
m := configModel() m := configModel()
m.configIndex = 1 // tui.theme m.configIndex = 1 // tui.theme
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model) m = updated.(Model)
if m.configStaged["tui.theme"] != "light" { if m.configStaged["tui.theme"] != "light" {
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"]) 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 := configModel()
m.configIndex = 2 // router.generation.max_tokens m.configIndex = 2 // router.generation.max_tokens
// Enter edit mode. // Enter edit mode.
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model) m = updated.(Model)
if !m.configEditing { if !m.configEditing {
t.Fatalf("expected edit mode after enter on INT field") 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. // Clear the seeded buffer and type a new value.
m.configEditBuf = "" m.configEditBuf = ""
for _, r := range "1024" { 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) m = updated.(Model)
} }
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model) m = updated.(Model)
if m.configEditing { if m.configEditing {
t.Fatalf("expected edit mode to end after commit") t.Fatalf("expected edit mode to end after commit")
+1 -1
View File
@@ -256,7 +256,7 @@ func PreviewFrame(kind string, w, h int) string {
m.grantIndex = 1 m.grantIndex = 1
m.overlay = OverlayGrants m.overlay = OverlayGrants
} }
return m.View() return m.render()
} }
func sampleStats() *protocol.StatsDto { func sampleStats() *protocol.StatsDto {
+3 -2
View File
@@ -1,10 +1,11 @@
package app package app
import ( import (
"image/color"
"strconv" "strconv"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
type diffKind int type diffKind int
@@ -196,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
if kind == diffBlank { if kind == diffBlank {
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w)) return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
} }
var bg, textFg, signFg lipgloss.Color var bg, textFg, signFg color.Color
sign := " " sign := " "
switch kind { switch kind {
case diffAdd: case diffAdd:
+1 -1
View File
@@ -3,7 +3,7 @@ package app
import ( import (
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router // ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws" "github.com/correx/tui-go/internal/ws"
) )
@@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) {
m, client := ideaModel() m, client := ideaModel()
_ = client.Drain() // drop the ListIdeas frame from openIdeas _ = 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) m = updated.(Model)
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" { if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
+123
View File
@@ -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
}
@@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
+6 -5
View File
@@ -2,10 +2,11 @@ package app
import ( import (
"fmt" "fmt"
"image/color"
"sort" "sort"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -21,8 +22,8 @@ import (
func (m Model) center(modal string) string { func (m Model) center(modal string) string {
if m.lastBase == "" { if m.lastBase == "" {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
} }
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width { if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
return modal // already a full-screen composite; don't re-dim it 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) 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) return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
} }
@@ -1217,7 +1218,7 @@ func shortID(id string) string {
return id[:13] + "…" return id[:13] + "…"
} }
func tierColor(t Theme, tier int) lipgloss.Color { func tierColor(t Theme, tier int) color.Color {
switch { switch {
case tier >= 3: case tier >= 3:
return t.P.Bad return t.P.Bad
+13 -13
View File
@@ -3,8 +3,8 @@ package app
import ( import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
) )
@@ -24,7 +24,7 @@ func (m *Model) proposeInitState() {
// --- key handling --- // --- 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) s := m.session(m.selectedID)
if s == nil || s.Propose == nil { if s == nil || s.Propose == nil {
return m, 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 n := len(s.Propose.Candidates) // index n == the custom slot
switch k.Type { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.proposeDismissed = true m.proposeDismissed = true
case tea.KeyUp: case keyUp:
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
case tea.KeyDown: case keyDown:
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n) m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
case tea.KeyEnter, tea.KeySpace: case keyEnter, keySpace:
return m.proposeSubmit() return m.proposeSubmit()
case tea.KeyRunes: case keyRunes:
switch string(k.Runes) { switch string(k.Runes) {
case "k": case "k":
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) 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 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 { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.proposeTyping = false m.proposeTyping = false
case tea.KeyEnter: case keyEnter:
return m.proposeSubmit() // commit the custom answer and send it as chat return m.proposeSubmit() // commit the custom answer and send it as chat
case tea.KeyBackspace: case keyBackspace:
if len(m.proposeText) > 0 { if len(m.proposeText) > 0 {
m.proposeText = m.proposeText[:len(m.proposeText)-1] m.proposeText = m.proposeText[:len(m.proposeText)-1]
} }
case tea.KeyRunes, tea.KeySpace: case keyRunes, keySpace:
m.proposeText += string(k.Runes) m.proposeText += string(k.Runes)
} }
return m, nil return m, nil
+8 -9
View File
@@ -5,7 +5,6 @@ import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws" "github.com/correx/tui-go/internal/ws"
) )
@@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) {
func TestProposePickLaunchesChosenWorkflow(t *testing.T) { func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
m, client := proposeModel() m, client := proposeModel()
// move cursor to the second candidate (role_pipeline) and launch it // move cursor to the second candidate (role_pipeline) and launch it
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown}) m = applyProposeKey(m, keyMsg{Type: keyDown})
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) m = applyProposeKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil { if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after launch") t.Fatalf("expected proposal cleared after launch")
@@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
func TestProposeFirstCandidateIsDefault(t *testing.T) { func TestProposeFirstCandidateIsDefault(t *testing.T) {
m, client := proposeModel() 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) wf, _ := decodeStart(t, client)
if wf != "research" { if wf != "research" {
t.Fatalf("expected first candidate launched by default, got %q", wf) 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) { func TestProposeCustomAnswerContinuesChat(t *testing.T) {
m, client := proposeModel() 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 { if !m.proposeTyping {
t.Fatalf("expected typing mode after 'e'") t.Fatalf("expected typing mode after 'e'")
} }
for _, r := range "do it manually" { 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 { if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after custom answer") t.Fatalf("expected proposal cleared after custom answer")
@@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) {
func TestProposeEscDismisses(t *testing.T) { func TestProposeEscDismisses(t *testing.T) {
m, _ := proposeModel() m, _ := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc}) m = applyProposeKey(m, keyMsg{Type: keyEsc})
if m.displayState() != StateInSession { if m.displayState() != StateInSession {
t.Fatalf("expected StateInSession after esc, got %v", m.displayState()) 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) updated, _ := m.handleProposeKey(k)
return updated.(Model) return updated.(Model)
} }
@@ -80,7 +80,7 @@ func renderSurface(m Model, s matrixSurface) string {
var raw string var raw string
switch s { switch s {
case surfaceView: case surfaceView:
raw = m.View() raw = m.render()
case surfaceEvents: case surfaceEvents:
// Render the event-stream rows at full panel width so a row's detail isn't // 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). // clipped at the slim right-panel edge (the production builder, just wide).
+7 -7
View File
@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
// SessionSummary is one recent session from GET /sessions. Mirrors the server's // 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 // handleSessionsKey owns every key while the session browser is open: navigate
// the roster, esc closes, enter resumes the selected session. // 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 { switch {
case k.Type == tea.KeyEsc || runeIs(k, "R"): case k.Type == keyEsc || runeIs(k, "R"):
m.overlay = OverlayNone m.overlay = OverlayNone
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.sessionListIndex > 0 { if m.sessionListIndex > 0 {
m.sessionListIndex-- m.sessionListIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.sessionListIndex < len(m.sessionList)-1 { if m.sessionListIndex < len(m.sessionList)-1 {
m.sessionListIndex++ m.sessionListIndex++
} }
case k.Type == tea.KeyEnter: case k.Type == keyEnter:
if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) { if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
return m.openSelectedSession() return m.openSelectedSession()
} }
@@ -3,8 +3,6 @@ package app
import ( import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
) )
func sessionsModel() Model { func sessionsModel() Model {
@@ -25,7 +23,7 @@ func sampleSummaries() []SessionSummary {
func TestSessionsOverlayToggle(t *testing.T) { func TestSessionsOverlayToggle(t *testing.T) {
m := sessionsModel() m := sessionsModel()
// `R` opens the browser from the idle list. // `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) m = updated.(Model)
if m.overlay != OverlaySessions { if m.overlay != OverlaySessions {
t.Fatalf("expected OverlaySessions after R, got %d", m.overlay) 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") t.Fatalf("expected loading state right after open")
} }
// `esc` closes it (routed through the overlay key handler). // `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) m = updated.(Model)
if m.overlay != OverlayNone { if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after esc, got %d", m.overlay) t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
@@ -66,25 +64,25 @@ func TestSessionsNavigationBoundsChecked(t *testing.T) {
m.overlay = OverlaySessions m.overlay = OverlaySessions
m.sessionList = sampleSummaries() m.sessionList = sampleSummaries()
// Up at the top is a no-op (clamped at 0). // 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) m = updated.(Model)
if m.sessionListIndex != 0 { if m.sessionListIndex != 0 {
t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex) t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex)
} }
// Down moves to row 1. // Down moves to row 1.
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
m = updated.(Model) m = updated.(Model)
if m.sessionListIndex != 1 { if m.sessionListIndex != 1 {
t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex) t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex)
} }
// Down again is clamped at the last row. // 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) m = updated.(Model)
if m.sessionListIndex != 1 { if m.sessionListIndex != 1 {
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex) t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex)
} }
// `j` is the vim-style alias for down (still clamped here). // `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) m = updated.(Model)
if m.sessionListIndex != 1 { if m.sessionListIndex != 1 {
t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex) t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex)
@@ -96,7 +94,7 @@ func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) {
m.overlay = OverlaySessions m.overlay = OverlaySessions
m.sessionList = sampleSummaries() m.sessionList = sampleSummaries()
m.sessionListIndex = 1 // the FAILED healthcheck row 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) m = updated.(Model)
if m.overlay != OverlayNone { if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after resume, got %d", m.overlay) t.Fatalf("expected overlay closed after resume, got %d", m.overlay)
+2 -4
View File
@@ -2,8 +2,6 @@ package app
import ( import (
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
) )
// `/` as the first char of a chat line opens the command palette inline (opencode-style), // `/` 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.inputMode = ModeRouter
m.inputBuffer = "" m.inputBuffer = ""
next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model) nm := next.(Model)
if nm.overlay != OverlayPalette { if nm.overlay != OverlayPalette {
@@ -33,7 +31,7 @@ func TestSlashLiteralMidLine(t *testing.T) {
m.inputBuffer = "path" m.inputBuffer = "path"
m.inputCursor = len("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) nm := next.(Model)
if nm.overlay != OverlayNone { if nm.overlay != OverlayNone {
+30 -26
View File
@@ -1,36 +1,40 @@
package app 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: // Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
// rounded borders, opaque dark panels, generous spacing. // rounded borders, opaque dark panels, generous spacing.
type Palette struct { type Palette struct {
Bg lipgloss.Color // panel/background fill Bg color.Color // panel/background fill
BgDeep lipgloss.Color // outermost backdrop (slightly darker) BgDeep color.Color // outermost backdrop (slightly darker)
BgPanel lipgloss.Color // inside-panel fill BgPanel color.Color // inside-panel fill
Sel lipgloss.Color // selection row background Sel color.Color // selection row background
Border lipgloss.Color // idle panel border Border color.Color // idle panel border
BorderHi lipgloss.Color // active panel border BorderHi color.Color // active panel border
Fg lipgloss.Color // primary text Fg color.Color // primary text
FgStrong lipgloss.Color // headings / emphasis FgStrong color.Color // headings / emphasis
Dim lipgloss.Color // labels, secondary text Dim color.Color // labels, secondary text
Faint lipgloss.Color // tertiary, hints Faint color.Color // tertiary, hints
Accent lipgloss.Color // blue accent Accent color.Color // blue accent
Accent2 lipgloss.Color // secondary accent Accent2 color.Color // secondary accent
OK lipgloss.Color OK color.Color
Warn lipgloss.Color Warn color.Color
Bad lipgloss.Color Bad color.Color
DiffAdd lipgloss.Color // added-line cell background DiffAdd color.Color // added-line cell background
DiffDel lipgloss.Color // removed-line cell background DiffDel color.Color // removed-line cell background
CatLifecycle lipgloss.Color CatLifecycle color.Color
CatContext lipgloss.Color CatContext color.Color
CatInference lipgloss.Color CatInference color.Color
CatTool lipgloss.Color CatTool color.Color
CatDomain lipgloss.Color CatDomain color.Color
CatApproval lipgloss.Color CatApproval color.Color
} }
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the // 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. // NewTheme builds the style set for a palette.
func NewTheme(p Palette) Theme { func NewTheme(p Palette) Theme {
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg) 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(). return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(border). BorderForeground(border).
@@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme {
} }
// categoryColor maps an event category label to its accent color. // 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 { switch cat {
case "Lifecycle": case "Lifecycle":
return t.P.CatLifecycle return t.P.CatLifecycle
+104 -103
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
tea "charm.land/bubbletea/v2"
osc52 "github.com/aymanbagabas/go-osc52/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/protocol"
"github.com/correx/tui-go/internal/ws" "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 return m, nil
case tea.KeyMsg: case tea.KeyPressMsg:
next, cmd := m.handleKey(msg) next, cmd := m.handleKey(toKeyMsg(msg))
// A keypress may have entered a typing/active state (insert mode, steering, …); // A keypress may have entered a typing/active state (insert mode, steering, …);
// kick the tick so the caret/spinner animates again. // kick the tick so the caret/spinner animates again.
if nm, ok := next.(Model); ok { if nm, ok := next.(Model); ok {
@@ -175,12 +175,12 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) {
// --- key handling --- // --- 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", 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, 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) m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal. // 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 m.quitting = true
return m, tea.Quit 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. // back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer.
if m.displayState() == StateInSession { if m.displayState() == StateInSession {
switch k.Type { switch k.Type {
case tea.KeyCtrlUp: case keyCtrlUp:
m.transcriptNavUser(-1) m.transcriptNavUser(-1)
return m, nil return m, nil
case tea.KeyCtrlDown: case keyCtrlDown:
m.transcriptNavUser(1) m.transcriptNavUser(1)
return m, nil return m, nil
} }
} }
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any // 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. // 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() m.cycleLauncherWf()
return m, nil 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. // 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() ds := m.displayState()
// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm // 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 // 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) return m.handleApprovalKey(k)
} }
switch k.Type { switch k.Type {
case tea.KeyUp: case keyUp:
m.navUp() m.navUp()
return m, nil return m, nil
case tea.KeyDown: case keyDown:
m.navDown() m.navDown()
return m, nil return m, nil
case tea.KeyEnter: case keyEnter:
return m.normalEnter() return m.normalEnter()
case tea.KeyEsc: case keyEsc:
// In-session, esc first drops a transcript selection or output scroll (back to // In-session, esc first drops a transcript selection or output scroll (back to
// tail-follow); otherwise it clears the session-list filter. // tail-follow); otherwise it clears the session-list filter.
if m.transcriptSel >= 0 { if m.transcriptSel >= 0 {
@@ -249,26 +249,26 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
m.filter = "" m.filter = ""
return m, nil return m, nil
case tea.KeyPgUp: case keyPgUp:
m.scrollOutput(m.outputViewportPage()) m.scrollOutput(m.outputViewportPage())
return m, nil return m, nil
case tea.KeyPgDown: case keyPgDown:
m.scrollOutput(-m.outputViewportPage()) m.scrollOutput(-m.outputViewportPage())
return m, nil return m, nil
case tea.KeyCtrlU: case keyCtrlU:
m.scrollOutput(m.outputViewportPage() / 2) m.scrollOutput(m.outputViewportPage() / 2)
return m, nil return m, nil
case tea.KeyCtrlD: case keyCtrlD:
m.scrollOutput(-m.outputViewportPage() / 2) m.scrollOutput(-m.outputViewportPage() / 2)
return m, nil return m, nil
case tea.KeyCtrlX: case keyCtrlX:
if m.currentDiff() != "" { if m.currentDiff() != "" {
m.overlay = OverlayDiff m.overlay = OverlayDiff
m.diffScrollOffset = 0 m.diffScrollOffset = 0
} }
return m, nil return m, nil
} }
if k.Type != tea.KeyRunes { if k.Type != keyRunes {
return m, nil return m, nil
} }
switch string(k.Runes) { 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 (T0T2) act on a // handleApprovalKey owns the approval band's bare keys. Low tiers (T0T2) act on a
// single keypress; a T3+ approve arms first and only a second confirming key sends // 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. // 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) s := m.session(m.selectedID)
if s == nil || s.Pending == nil { if s == nil || s.Pending == nil {
return m, nil return m, nil
} }
// Queue navigation never resolves a gate; it just changes which one is shown. // Queue navigation never resolves a gate; it just changes which one is shown.
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
s.navApproval(-1) s.navApproval(-1)
m.approvalArmed = false m.approvalArmed = false
return m, nil return m, nil
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
s.navApproval(1) s.navApproval(1)
m.approvalArmed = false m.approvalArmed = false
return m, nil 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 // Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
// sending; the same key pressed again (while armed) confirms. // 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") runeIs(k, "y") || runeIs(k, "a")
if approveKey { if approveKey {
if s.Pending.HighTier() && !m.approvalArmed { if s.Pending.HighTier() && !m.approvalArmed {
@@ -383,7 +383,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.approvalArmed = false m.approvalArmed = false
switch { 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") return m.decide("REJECT")
case runeIs(k, "e") || runeIs(k, "s"): case runeIs(k, "e") || runeIs(k, "s"):
// Steer/edit flows into the existing steering-note input path. // 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.sessionEntered = false
m.approvalDismissed = false m.approvalDismissed = false
return m, nil return m, nil
case k.Type == tea.KeyEsc: case k.Type == keyEsc:
m.approvalDismissed = true m.approvalDismissed = true
return m, nil return m, nil
case k.Type == tea.KeyCtrlX: case k.Type == keyCtrlX:
if m.currentDiff() != "" { if m.currentDiff() != "" {
m.overlay = OverlayDiff m.overlay = OverlayDiff
m.diffScrollOffset = 0 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). // 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 { if m.steering {
switch k.Type { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.steering = false m.steering = false
m.editMode = ModeNormal m.editMode = ModeNormal
case tea.KeyEnter: case keyEnter:
return m.submitApproval() return m.submitApproval()
case tea.KeyBackspace: case keyBackspace:
if n := len(m.steerBuffer); n > 0 { if n := len(m.steerBuffer); n > 0 {
m.steerBuffer = m.steerBuffer[:n-1] m.steerBuffer = m.steerBuffer[:n-1]
} }
case tea.KeyRunes, tea.KeySpace: case keyRunes, keySpace:
m.steerBuffer += string(k.Runes) m.steerBuffer += string(k.Runes)
} }
return m, nil return m, nil
} }
switch k.Type { switch k.Type {
case tea.KeyEsc: case keyEsc:
m.editMode = ModeNormal m.editMode = ModeNormal
if m.inputMode == ModeFilter { if m.inputMode == ModeFilter {
m.inputMode = ModeRouter m.inputMode = ModeRouter
@@ -444,45 +444,46 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.clearInput() m.clearInput()
m.inputMode = ModeRouter m.inputMode = ModeRouter
} }
case tea.KeyEnter: case keyEnter:
// Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not // Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter
// distinguishable from Enter in this terminal stack — see Ctrl+J below.) // is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation;
if k.Alt && m.inputMode != ModeFilter { // Ctrl+J below stays as a fallback for terminals without kitty support.
if (k.Shift || k.Alt) && m.inputMode != ModeFilter {
m.appendRunes("\n") m.appendRunes("\n")
return m, nil return m, nil
} }
return m.submit() return m.submit()
case tea.KeyCtrlJ: case keyCtrlJ:
// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the // Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
// single-line filter excluded. // single-line filter excluded.
if m.inputMode != ModeFilter { if m.inputMode != ModeFilter {
m.appendRunes("\n") m.appendRunes("\n")
} }
return m, nil return m, nil
case tea.KeyBackspace: case keyBackspace:
m.backspace() m.backspace()
m.syncFilter() m.syncFilter()
case tea.KeyLeft: case keyLeft:
if m.inputCursor > 0 { if m.inputCursor > 0 {
m.inputCursor-- m.inputCursor--
} }
case tea.KeyRight: case keyRight:
if m.inputCursor < len(m.inputBuffer) { if m.inputCursor < len(m.inputBuffer) {
m.inputCursor++ m.inputCursor++
} }
case tea.KeyUp: case keyUp:
if m.inputMode == ModeFilter { if m.inputMode == ModeFilter {
m.listNav(-1) m.listNav(-1)
} else { } else {
m.historyPrev() m.historyPrev()
} }
case tea.KeyDown: case keyDown:
if m.inputMode == ModeFilter { if m.inputMode == ModeFilter {
m.listNav(1) m.listNav(1)
} else { } else {
m.historyNext() m.historyNext()
} }
case tea.KeyRunes, tea.KeySpace: case keyRunes, keySpace:
// opencode-style slash commands: `/` as the first char of a chat line opens the // 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. // command palette inline instead of typing a literal slash. Mid-line `/` is literal.
if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" { if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" {
@@ -627,7 +628,7 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
return m, nil 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). // Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
if m.overlay == OverlayConfig { if m.overlay == OverlayConfig {
return m.handleConfigKey(k) return m.handleConfigKey(k)
@@ -637,7 +638,7 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.overlay == OverlaySessions { if m.overlay == OverlaySessions {
return m.handleSessionsKey(k) 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 // In the event inspector, esc first stops an in-progress filter edit (keeping the
// query) rather than closing the overlay. // query) rather than closing the overlay.
if m.overlay == OverlayEventInspector && m.eventFilterTyping { 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. // full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds.
page := m.diffBodyHeight() page := m.diffBodyHeight()
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
m.scrollDiff(-1) m.scrollDiff(-1)
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
m.scrollDiff(1) m.scrollDiff(1)
case k.Type == tea.KeyPgUp: case k.Type == keyPgUp:
m.scrollDiff(-page) m.scrollDiff(-page)
case k.Type == tea.KeyPgDown: case k.Type == keyPgDown:
m.scrollDiff(page) m.scrollDiff(page)
case k.Type == tea.KeyCtrlU: case k.Type == keyCtrlU:
m.scrollDiff(-page / 2) m.scrollDiff(-page / 2)
case k.Type == tea.KeyCtrlD: case k.Type == keyCtrlD:
m.scrollDiff(page / 2) m.scrollDiff(page / 2)
case runeIs(k, "g"): case runeIs(k, "g"):
m.diffScrollOffset = 0 m.diffScrollOffset = 0
case runeIs(k, "G"): case runeIs(k, "G"):
m.diffScrollOffset = m.diffMaxScroll() m.diffScrollOffset = m.diffMaxScroll()
case k.Type == tea.KeyCtrlX: case k.Type == keyCtrlX:
m.overlay = OverlayNone m.overlay = OverlayNone
} }
case OverlayEventInspector: case OverlayEventInspector:
evs := m.currentEvents() evs := m.currentEvents()
if m.eventFilterTyping { if m.eventFilterTyping {
switch { switch {
case k.Type == tea.KeyEnter: case k.Type == keyEnter:
m.eventFilterTyping = false m.eventFilterTyping = false
case k.Type == tea.KeyBackspace: case k.Type == keyBackspace:
if n := len(m.eventFilter); n > 0 { if n := len(m.eventFilter); n > 0 {
m.eventFilter = m.eventFilter[:n-1] m.eventFilter = m.eventFilter[:n-1]
m.overlayEventIdx = 0 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.eventFilter += string(k.Runes)
m.overlayEventIdx = 0 m.overlayEventIdx = 0
} }
@@ -698,11 +699,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "c"): case runeIs(k, "c"):
m.eventFilter = "" m.eventFilter = ""
m.overlayEventIdx = 0 m.overlayEventIdx = 0
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.overlayEventIdx > 0 { if m.overlayEventIdx > 0 {
m.overlayEventIdx-- m.overlayEventIdx--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.overlayEventIdx < len(evs)-1 { if m.overlayEventIdx < len(evs)-1 {
m.overlayEventIdx++ 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. // dismisses it (esc handled above), keeping the quick-glance feel.
page := m.modalBodyRows() page := m.modalBodyRows()
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
m.scrollModal(-1) m.scrollModal(-1)
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
m.scrollModal(1) m.scrollModal(1)
case k.Type == tea.KeyPgUp: case k.Type == keyPgUp:
m.scrollModal(-page) m.scrollModal(-page)
case k.Type == tea.KeyPgDown: case k.Type == keyPgDown:
m.scrollModal(page) m.scrollModal(page)
case k.Type == tea.KeyCtrlU: case k.Type == keyCtrlU:
m.scrollModal(-page / 2) m.scrollModal(-page / 2)
case k.Type == tea.KeyCtrlD: case k.Type == keyCtrlD:
m.scrollModal(page / 2) m.scrollModal(page / 2)
case runeIs(k, "g"): case runeIs(k, "g"):
m.modalScroll = 0 m.modalScroll = 0
@@ -737,23 +738,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case OverlayArtifacts: case OverlayArtifacts:
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.artifactsIndex > 0 { if m.artifactsIndex > 0 {
m.artifactsIndex-- m.artifactsIndex--
m.artifactScroll = 0 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 { if m.artifactsIndex < len(m.artifacts)-1 {
m.artifactsIndex++ m.artifactsIndex++
m.artifactScroll = 0 m.artifactScroll = 0
} }
case k.Type == tea.KeyPgUp: case k.Type == keyPgUp:
m.scrollArtifact(-m.artifactContentBodyH()) m.scrollArtifact(-m.artifactContentBodyH())
case k.Type == tea.KeyPgDown: case k.Type == keyPgDown:
m.scrollArtifact(m.artifactContentBodyH()) m.scrollArtifact(m.artifactContentBodyH())
case k.Type == tea.KeyCtrlU: case k.Type == keyCtrlU:
m.scrollArtifact(-m.artifactContentBodyH() / 2) m.scrollArtifact(-m.artifactContentBodyH() / 2)
case k.Type == tea.KeyCtrlD: case k.Type == keyCtrlD:
m.scrollArtifact(m.artifactContentBodyH() / 2) m.scrollArtifact(m.artifactContentBodyH() / 2)
case runeIs(k, "g"): case runeIs(k, "g"):
m.artifactScroll = 0 m.artifactScroll = 0
@@ -764,15 +765,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case OverlayModels: case OverlayModels:
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.modelsIndex > 0 { if m.modelsIndex > 0 {
m.modelsIndex-- m.modelsIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.modelsIndex < len(m.availableModels)-1 { if m.modelsIndex < len(m.availableModels)-1 {
m.modelsIndex++ m.modelsIndex++
} }
case k.Type == tea.KeyEnter: case k.Type == keyEnter:
if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) { if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) {
m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex])) m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex]))
m.overlay = OverlayNone m.overlay = OverlayNone
@@ -786,17 +787,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case OverlayStats: case OverlayStats:
page := m.modalBodyRows() page := m.modalBodyRows()
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
m.scrollModal(-1) m.scrollModal(-1)
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
m.scrollModal(1) m.scrollModal(1)
case k.Type == tea.KeyPgUp: case k.Type == keyPgUp:
m.scrollModal(-page) m.scrollModal(-page)
case k.Type == tea.KeyPgDown: case k.Type == keyPgDown:
m.scrollModal(page) m.scrollModal(page)
case k.Type == tea.KeyCtrlU: case k.Type == keyCtrlU:
m.scrollModal(-page / 2) m.scrollModal(-page / 2)
case k.Type == tea.KeyCtrlD: case k.Type == keyCtrlD:
m.scrollModal(page / 2) m.scrollModal(page / 2)
case runeIs(k, "g"): case runeIs(k, "g"):
m.modalScroll = 0 m.modalScroll = 0
@@ -807,11 +808,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case OverlayIdeas: case OverlayIdeas:
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.ideasIndex > 0 { if m.ideasIndex > 0 {
m.ideasIndex-- m.ideasIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.ideasIndex < len(m.ideas)-1 { if m.ideasIndex < len(m.ideas)-1 {
m.ideasIndex++ m.ideasIndex++
} }
@@ -830,39 +831,39 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case OverlayFiles: case OverlayFiles:
files := m.filteredFiles() files := m.filteredFiles()
switch { switch {
case k.Type == tea.KeyUp: case k.Type == keyUp:
if m.fileIndex > 0 { if m.fileIndex > 0 {
m.fileIndex-- m.fileIndex--
} }
case k.Type == tea.KeyDown: case k.Type == keyDown:
if m.fileIndex < len(files)-1 { if m.fileIndex < len(files)-1 {
m.fileIndex++ m.fileIndex++
} }
case k.Type == tea.KeyEnter: case k.Type == keyEnter:
if m.fileIndex >= 0 && m.fileIndex < len(files) { if m.fileIndex >= 0 && m.fileIndex < len(files) {
m.insertFileRef(files[m.fileIndex]) m.insertFileRef(files[m.fileIndex])
} }
m.overlay = OverlayNone // back to typing (still in insert mode) 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 { if n := len(m.fileFilter); n > 0 {
m.fileFilter = m.fileFilter[:n-1] m.fileFilter = m.fileFilter[:n-1]
m.fileIndex = 0 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.fileFilter += string(k.Runes)
m.fileIndex = 0 m.fileIndex = 0
} }
case OverlayStatusbar: case OverlayStatusbar:
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.sbIndex > 0 { if m.sbIndex > 0 {
m.sbIndex-- m.sbIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.sbIndex < len(statusSegments)-1 { if m.sbIndex < len(statusSegments)-1 {
m.sbIndex++ 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) { if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) {
m.toggleStatusSegment(statusSegments[m.sbIndex].id) m.toggleStatusSegment(statusSegments[m.sbIndex].id)
} }
@@ -871,15 +872,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case OverlayGrants: case OverlayGrants:
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.grantIndex > 0 { if m.grantIndex > 0 {
m.grantIndex-- m.grantIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.grantIndex < len(m.grants)-1 { if m.grantIndex < len(m.grants)-1 {
m.grantIndex++ 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) { if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
g := m.grants[m.grantIndex] g := m.grants[m.grantIndex]
m.client.Send(protocol.RevokeGrant(g.GrantID)) 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 // handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
// confirms the highlighted scope. esc (handled upstream) cancels without granting. // 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() opts := grantScopeOptions()
switch { switch {
case k.Type == tea.KeyUp || runeIs(k, "k"): case k.Type == keyUp || runeIs(k, "k"):
if m.grantScopeIndex > 0 { if m.grantScopeIndex > 0 {
m.grantScopeIndex-- m.grantScopeIndex--
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.grantScopeIndex < len(opts)-1 { if m.grantScopeIndex < len(opts)-1 {
m.grantScopeIndex++ m.grantScopeIndex++
} }
@@ -930,7 +931,7 @@ func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "g"): case runeIs(k, "g"):
m.grantScopeIndex = 2 m.grantScopeIndex = 2
return m.applyGrantScope() return m.applyGrantScope()
case k.Type == tea.KeyEnter: case k.Type == keyEnter:
return m.applyGrantScope() return m.applyGrantScope()
} }
return m, nil return m, nil
@@ -1046,31 +1047,31 @@ func (m *Model) openModelsOverlay() {
} }
} }
func runeIs(k tea.KeyMsg, s string) bool { func runeIs(k keyMsg, s string) bool {
return k.Type == tea.KeyRunes && string(k.Runes) == s 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() cmds := m.filteredPalette()
switch k.Type { switch k.Type {
case tea.KeyUp: case keyUp:
if m.paletteIndex > 0 { if m.paletteIndex > 0 {
m.paletteIndex-- m.paletteIndex--
} }
case tea.KeyDown: case keyDown:
if m.paletteIndex < len(cmds)-1 { if m.paletteIndex < len(cmds)-1 {
m.paletteIndex++ m.paletteIndex++
} }
case tea.KeyEnter: case keyEnter:
if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) {
return m.execPalette(cmds[m.paletteIndex].id) return m.execPalette(cmds[m.paletteIndex].id)
} }
case tea.KeyBackspace: case keyBackspace:
if n := len(m.paletteFilter); n > 0 { if n := len(m.paletteFilter); n > 0 {
m.paletteFilter = m.paletteFilter[:n-1] m.paletteFilter = m.paletteFilter[:n-1]
m.paletteIndex = 0 m.paletteIndex = 0
} }
case tea.KeyRunes, tea.KeySpace: case keyRunes, keySpace:
m.paletteFilter += string(k.Runes) m.paletteFilter += string(k.Runes)
m.paletteIndex = 0 m.paletteIndex = 0
} }
+21 -12
View File
@@ -2,11 +2,13 @@ package app
import ( import (
"fmt" "fmt"
"image/color"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -28,8 +30,15 @@ const (
// and full-width chrome. // and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth } func (m Model) narrow() bool { return m.width < narrowWidth }
// View renders the whole frame purely from the model (immediate-mode). // View renders the whole frame purely from the model (immediate-mode). In
func (m Model) View() string { // 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 { if m.quitting {
return "" return ""
} }
@@ -79,7 +88,7 @@ func (m Model) View() string {
func (m Model) renderStatus() string { func (m Model) renderStatus() string {
t := m.theme t := m.theme
bg := t.P.BgPanel 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) return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
} }
nrw := m.narrow() nrw := m.narrow()
@@ -376,12 +385,12 @@ func (m Model) renderLauncher(w, h int) string {
inW = 24 inW = 24
} }
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW), 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 { if railW == 0 {
return left return left
} }
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW), 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) return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
} }
@@ -738,7 +747,7 @@ func (m Model) eventStreamBadge() string {
return t.span("○ idle", t.P.Faint) return t.span("○ idle", t.P.Faint)
} }
if s := m.session(m.selectedID); s != nil { 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) return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
} }
switch u := strings.ToUpper(s.Status); { 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. // 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) 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 // 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 // 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. // 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 avail := w - 2 // leading + trailing space
lw := lipgloss.Width(left) lw := lipgloss.Width(left)
rw := lipgloss.Width(right) 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 // padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque. // 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) vw := lipgloss.Width(s)
if vw == w { if vw == w {
return s return s
@@ -825,7 +834,7 @@ func padRaw(s string, w int) string {
return s + strings.Repeat(" ", w-n) 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) u := strings.ToUpper(status)
switch { switch {
case strings.Contains(u, "FAIL"): 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. // 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) return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
} }
+3 -2
View File
@@ -8,7 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/correx/tui-go/internal/app" "github.com/correx/tui-go/internal/app"
"github.com/correx/tui-go/internal/ws" "github.com/correx/tui-go/internal/ws"
) )
@@ -33,7 +33,8 @@ func main() {
defer cancel() defer cancel()
go client.Run(ctx) 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 { if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "correx tui error:", err) fmt.Fprintln(os.Stderr, "correx tui error:", err)
os.Exit(1) os.Exit(1)