ui: move the picker to bubbletea v2
Signed-off-by: dawn <90008@klbr.net>
15 unmodified lines1617181920202122232413 unmodified lines15 unmodified linespkgs.buildGoModule { pname = "tobi"; version = "0.1.0"; inherit src; vendorHash = "sha256-qbOGgVRsO0/F1dB/vIERm6EsUjgWGzT2z938pnKVbcI="; vendorHash = "sha256-lTajOqCGj02++YuJhNDkdGLObJbBJoLNE1Zuxft8VkE="; nativeBuildInputs = [ pkgs.zstd ]; # one build path shared with manual runs buildPhase = '' runHook preBuild13 unmodified lines1 unmodified line234567867891011123 unmodified lines161718192021222320212223252627282930313233242526272829303531323334351 unmodified line
go 1.26.7
require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.10 charm.land/lipgloss/v2 v2.0.6 github.com/creack/pty v1.1.24 github.com/gitpod-io/xterm-go v0.0.0-20260907130418-dae5128cb6b3 github.com/klauspost/compress v1.19.1 github.com/sahilm/fuzzy v0.1.33 unmodified lines)
require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect)12345678345678910111213141516910111213141516171819201920212223242122232425262728293027283132333435363738373839404142434445463940414243444950454647482 unmodified lines5152535461625556575859602 unmodified linescharm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=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/gitpod-io/xterm-go v0.0.0-20260907130418-dae5128cb6b3 h1:Y05hOetHqOEfM0yyjM9w+lMhwqAWonBIEGN7OKCE7zk=github.com/gitpod-io/xterm-go v0.0.0-20260907130418-dae5128cb6b3/go.mod h1:xCLG6CEiFPk1bB7Fa2ahEexQm4h4de04NpQrm8Qkxyw=github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=2 unmodified linesgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=2 unmodified lines7 unmodified lines891011121314121314151617181 unmodified line20212223242425262627282930313232333435361 unmodified line3839404142424344454546474849495051525354545556575831 unmodified lines7 unmodified lines "testing" "time" "unicode/utf8"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "tobi/internal/session" "tobi/internal/termcolor")
1 unmodified line
func TestViewNeverExceedsWidth(t *testing.T) { for _, width := range []int{0, 24, 30, 40, 60, 76, 100, 300} { f := textinput.New() f.Width = max(0, min(width, maxUIWidth)-8) f.SetWidth(max(0, min(width, maxUIWidth)-8)) f.SetValue(strings.Repeat("nomatch", 20)) // a filter nothing matches, to cover the empty state m := pickerModel{width: width, filter: f, spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{ m := pickerModel{width: width, filter: f, th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{ {name: "selfish", local: true, sessions: []session.Session{{ID: "a", Command: "hx", Cwd: "/tmp", StartedAt: time.Now()}}}, {name: "caladon", sessions: []session.Session{{ID: "b", Command: "nu", Cwd: "/x", StartedAt: time.Now()}}}, {name: "pupos", unreachable: true}, {name: "homura", peerOffline: true}, }} m.move(1) // the cursor has to survive a filter that left nothing to pick for n, line := range strings.Split(m.View(), "\n") { for n, line := range strings.Split(m.view(), "\n") { if got, max := utf8.RuneCountInString(ansi.ReplaceAllString(line, "")), min(width, maxUIWidth); got > max { t.Fatalf("width %d line %d: %d > %d", width, n, got, max) } }1 unmodified line}
func TestKillFlow(t *testing.T) { a := session.Session{ID: "a", Host: "caladon", Command: "hx", StartedAt: time.Now()} m := pickerModel{width: 80, filter: textinput.New(), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), m := pickerModel{width: 80, filter: textinput.New(), th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{{name: "caladon", sessions: []session.Session{a}}}} m.move(1) next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) next, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl}) if m = next.(pickerModel); cmd == nil || !m.killing[a.Canonical()] { t.Fatal("ctrl+x should fire the kill") } if _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}); cmd != nil { if _, cmd = m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl}); cmd != nil { t.Fatal("a kill in flight should not fire again") }
next, _ = m.Update(killResult{a, errors.New("remote tobi is\ntoo old")}) if m = next.(pickerModel); m.killing[a.Canonical()] || !strings.Contains(m.View(), "remote tobi is too old") { if m = next.(pickerModel); m.killing[a.Canonical()] || !strings.Contains(m.view(), "remote tobi is too old") { t.Fatal("a failed kill should clear the flight and show the error on one line") } next, _ = m.Update(killResult{a, nil}) if m = next.(pickerModel); len(m.rows[0].sessions) != 0 {31 unmodified lines1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556package app
import ( "strings" "testing" "time"
"tobi/internal/session")
// the draw window keeps the cursor's row on screen within the line budgetfunc TestTableWindowHoldsTheCursor(t *testing.T) { // rows: 3 lines, 1 line, 2 lines, 1 line, 4 lines lines := []int{3, 1, 2, 1, 4} for _, tc := range []struct { cursorRow, avail int from, to int }{ {0, 3, 0, 1}, // the cursor row fills the budget exactly {0, 4, 0, 2}, // grows down into the 1-line row {1, 3, 1, 3}, // grows down first, then up {2, 3, 2, 4}, // the 2-line row plus the 1-line row below it {4, 4, 4, 5}, // a row larger than the budget draws alone {2, 0, 0, 5}, // no known height: draw everything {2, 99, 0, 5}, // everything fits } { from, to := tableWindow(lines, tc.cursorRow, tc.avail) if from != tc.from || to != tc.to { t.Errorf("tableWindow(cursor=%d, avail=%d) = [%d,%d), want [%d,%d)", tc.cursorRow, tc.avail, from, to, tc.from, tc.to) } if tc.avail > 0 && !(from <= tc.cursorRow && tc.cursorRow < to) { t.Errorf("window [%d,%d) loses cursor row %d", from, to, tc.cursorRow) } }}
// with more hosts than the terminal is tall, the drawn table fits the budget// and still shows the cursor's row: the selection must never scroll outfunc TestViewWindowsLongTablesToTheCursor(t *testing.T) { m := pickerModel{width: 80, height: 10, th: newTheme(true)} for i := range 30 { m.rows = append(m.rows, hostRow{name: "host", probing: true, started: time.Now().Add(-time.Duration(i) * time.Minute)}) } m.rows = append(m.rows, hostRow{name: "zzz", sessions: []session.Session{{ID: "q", Command: "nu", Cwd: "/tmp", StartedAt: time.Now()}}}) m.cursor = 25 out := m.view() if table := strings.Count(out, "searching"); table != 5 { // 10 - the 5 fixed lines around the table t.Errorf("drew %d table lines into a 5-line budget", table) } if !strings.Contains(out, " › host") { t.Error("the cursor row is not drawn") } if !strings.Contains(out, "31 host") { // the header counts stay honest t.Error("the header host count is wrong") }}9 unmodified lines1011121314151617141516171819202122232425262728293031323334353637383940414243444546474849502627282930313233343536373839404142434445464748495051525354555657585960616263646515 unmodified lines8182838485868788899016 unmodified lines10710810911010311111211311411511611711811911011112012112212312412512612712812913013113213338 unmodified lines17217317417516017617716216317817918016518118216718318418517018618718817318919019119219319417919519619719819974 unmodified lines2742752762772782792622802812822832847 unmodified lines29229329429527829629729829930030128430230328628730430530630730829130931031129431231329631431531631730031831932032132225 unmodified lines3483493503513343523533543553563573583413593603613623633643653663673503683693703533543713723733563743753763593773783793803813823833843673853863873883893903703913923933943953963753973983994004014023784034044053814064074083844094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654663914674683934694704714723964734744754004764774024784794044804814824834084844854864874887 unmodified lines49649749849942450042650150250350450550650750850951051151251351451551651729 unmodified lines54754854955047155155255355455555655755847855956056156256320 unmodified lines9 unmodified lines "strings" "time" "unicode/utf8"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/sahilm/fuzzy" "github.com/samber/lo" "golang.org/x/term"
"tobi/internal/remote" "tobi/internal/session")
var ( textC = lipgloss.AdaptiveColor{Light: "#4c4f69", Dark: "#cdd6f4"} overlay = lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#6c7086"} lavender = lipgloss.AdaptiveColor{Light: "#7287fd", Dark: "#b4befe"} mauve = lipgloss.AdaptiveColor{Light: "#8839ef", Dark: "#cba6f7"} mint = lipgloss.AdaptiveColor{Light: "#40a02b", Dark: "#a6e3a1"} yellow = lipgloss.AdaptiveColor{Light: "#df8e1d", Dark: "#f9e2af"} salmon = lipgloss.AdaptiveColor{Light: "#d20f39", Dark: "#f38ba8"} base = lipgloss.AdaptiveColor{Light: "#eff1f5", Dark: "#1e1e2e"} surface = lipgloss.AdaptiveColor{Light: "#ccd0da", Dark: "#313244"}
textStyle = lipgloss.NewStyle().Foreground(textC) dimStyle = lipgloss.NewStyle().Foreground(overlay) faintStyle = lipgloss.NewStyle().Foreground(overlay).Faint(true) mintStyle = lipgloss.NewStyle().Foreground(mint) yellowStyle = lipgloss.NewStyle().Foreground(yellow) salmonStyle = lipgloss.NewStyle().Foreground(salmon) mauveStyle = lipgloss.NewStyle().Foreground(mauve) lavenderStyle = lipgloss.NewStyle().Foreground(lavender) selfPill = lipgloss.NewStyle().Background(lavender).Foreground(base).Padding(0, 1) pickStyle = lipgloss.NewStyle().Background(surface) plainStyle = lipgloss.NewStyle() borderStyle = lipgloss.NewStyle().Foreground(overlay))
const ( maxUIWidth = 76 startWidth = 80)
// the picker's palette: catppuccin latte for light terminals, frappé for dark// ones. it starts dark and is rebuilt once the terminal answers the// background query the program issues on startup.type theme struct { text, dim, faint, mint, yellow, salmon, mauve, lavender lipgloss.Style pill, pick, plain, border lipgloss.Style}
func newTheme(dark bool) theme { c := lipgloss.LightDark(dark) fg := func(pair [2]string) lipgloss.Style { return lipgloss.NewStyle().Foreground(c(lipgloss.Color(pair[0]), lipgloss.Color(pair[1]))) } t := theme{ text: fg([2]string{"#4c4f69", "#cdd6f4"}), dim: fg([2]string{"#9ca0b0", "#6c7086"}), lavender: fg([2]string{"#7287fd", "#b4befe"}), mauve: fg([2]string{"#8839ef", "#cba6f7"}), mint: fg([2]string{"#40a02b", "#a6e3a1"}), yellow: fg([2]string{"#df8e1d", "#f9e2af"}), salmon: fg([2]string{"#d20f39", "#f38ba8"}), pill: lipgloss.NewStyle(). Background(c(lipgloss.Color("#7287fd"), lipgloss.Color("#b4befe"))). Foreground(c(lipgloss.Color("#eff1f5"), lipgloss.Color("#1e1e2e"))).Padding(0, 1), pick: lipgloss.NewStyle().Background(c(lipgloss.Color("#ccd0da"), lipgloss.Color("#313244"))), plain: lipgloss.NewStyle(), } t.faint, t.border = t.dim.Faint(true), t.dim return t}
type hostRow struct { name string local bool probing bool15 unmodified lines filter textinput.Model rows []hostRow cursor int width int height int // 0 until the first resize, meaning no known limit th theme spinner spinner.Model twitch int blinking bool blinkAt int // tick the current blink started16 unmodified lines host string sess *session.Session // nil when the line is the host row itself}
func (m pickerModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, waitForProbe(m.probes)) }func (m pickerModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, waitForProbe(m.probes), tea.RequestBackgroundColor)}
func waitForProbe(probes chan probeResult) tea.Cmd { return func() tea.Msg { return <-probes } }
func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.filter.Width = min(msg.Width, maxUIWidth) - 8 m.width, m.height = msg.Width, msg.Height m.filter.SetWidth(min(msg.Width, maxUIWidth) - 8) m.filter.SetCursor(m.filter.Position()) // recompute the scroll offset for the new width
case tea.BackgroundColorMsg: m.th = newTheme(msg.IsDark()) st := textinput.DefaultStyles(msg.IsDark()) st.Focused.Placeholder, st.Blurred.Placeholder = m.th.faint, m.th.faint m.filter.SetStyles(st)
case probeResult: r := &m.rows[msg.row] r.probing = false r.unreachable = errors.Is(msg.err, remote.ErrUnreachable)38 unmodified lines } m.move(0) return m, nil
case tea.KeyMsg: case tea.KeyPressMsg: m.notice = "" switch msg.Type { case tea.KeyCtrlC: switch msg.String() { case "ctrl+c": return m, tea.Quit case tea.KeyCtrlX: case "ctrl+x": return m.kill() case tea.KeyUp: case "up": m.move(-1) return m, nil case tea.KeyDown: case "down": m.move(1) return m, nil case tea.KeyEnter: case "enter": if p := m.pick(); p != nil { m.picked = p return m, tea.Quit } return m, nil case tea.KeyEsc: case "esc": if m.filter.Value() == "" { return m, tea.Quit } m.filter.SetValue("")74 unmodified linesfunc makeKitty(dir int, char string) string { return strings.ReplaceAll(kittyFace[min(2, max(0, dir+1))], "%", char)}
func (m pickerModel) View() tea.View { return tea.NewView(m.view()) }
func (m pickerModel) View() string {func (m pickerModel) view() string { w := min(m.width, maxUIWidth) if w < 24 { return "" }7 unmodified lines if t := m.twitch - m.blinkAt; t < 10 || t >= 12 && t < 14 { // eyes open, straight kitty = makeKitty(m.lookDir, eyeOpen) } } title := mauveStyle.Render(kitty+" ") + textStyle.Bold(true).Render("tobi ") title := m.th.mauve.Render(kitty+" ") + m.th.text.Bold(true).Render("tobi ") wt := utf8.RuneCountInString(kitty) + 6 mid := strings.Repeat("─", max(1, w-5-wt)) if wt+utf8.RuneCountInString(counts) <= w-6 { mid = strings.Repeat("─", w-6-wt-utf8.RuneCountInString(counts)) + " " + counts } b.WriteString("╭─ " + title + borderStyle.Render(mid+"─╮") + "\n") b.WriteString("╭─ " + title + m.th.border.Render(mid+"─╮") + "\n") filter := m.filter.View() b.WriteString(borderStyle.Render("│") + " " + filter + strings.Repeat(" ", max(1, w-4-lipgloss.Width(filter))) + borderStyle.Render(" │") + "\n") b.WriteString(borderStyle.Render("╰"+strings.Repeat("─", w-2)+"╯") + "\n") b.WriteString(m.th.border.Render("│") + " " + filter + strings.Repeat(" ", max(1, w-4-lipgloss.Width(filter))) + m.th.border.Render(" │") + "\n") b.WriteString(m.th.border.Render("╰"+strings.Repeat("─", w-2)+"╯") + "\n")
rows := m.rowsFor() if len(rows) == 0 { b.WriteString(" " + faintStyle.MaxWidth(w-1).Render("nothing matches "+strings.TrimSpace(m.filter.Value())) + "\n") b.WriteString(" " + m.th.faint.MaxWidth(w-1).Render("nothing matches "+strings.TrimSpace(m.filter.Value())) + "\n") } m.drawHostTable(&b, w, rows) b.WriteString(borderStyle.Render(strings.Repeat("─", w)) + "\n") b.WriteString(m.th.border.Render(strings.Repeat("─", w)) + "\n") if m.notice != "" { b.WriteString(salmonStyle.MaxWidth(w).Render(m.notice)) b.WriteString(m.th.salmon.MaxWidth(w).Render(m.notice)) return b.String() } hint := func(k, act string) string { return dimStyle.Render("[") + textStyle.Render(k) + dimStyle.Render("] "+act) return m.th.dim.Render("[") + m.th.text.Render(k) + m.th.dim.Render("] "+act) } hs := m.hints() span := func(hs []pickerHint) int { return lo.SumBy(hs, pickerHint.width) + 3*(len(hs)-1) } for len(hs) > 1 && span(hs) > w {25 unmodified lines return hs}
// the cursor row is one background run: nested colour resets would punch holes in itfunc writeLine(b *strings.Builder, w int, left, right string, sel bool) {func (t theme) writeLine(b *strings.Builder, w int, left, right string, sel bool) { gap := w - lipgloss.Width(left) - lipgloss.Width(right) if gap < 0 { gap = 1 // a row longer than the width still keeps its columns apart } line := left + strings.Repeat(" ", gap) + right if sel { line = pickStyle.Render(line) line = t.pick.Render(line) } b.WriteString(line + "\n")}
func (m pickerModel) sessionStatus(s session.Session, sel bool) string { if !m.killing[s.Canonical()] { return relTime(s.StartedAt) } return lo.Ternary(sel, "killing…", salmonStyle.Render("killing…")) return lo.Ternary(sel, "killing…", m.th.salmon.Render("killing…"))}
func sessionRow(b *strings.Builder, w int, s session.Session, sel bool, right string) { pad, idSt, cmdSt, cwdSt := " ", mauveStyle, lavenderStyle, yellowStylefunc (m pickerModel) sessionRow(b *strings.Builder, w int, s session.Session, sel bool, right string) { pad, idSt, cmdSt, cwdSt := " ", m.th.mauve, m.th.lavender, m.th.yellow if sel { pad, idSt, cmdSt, cwdSt = " › ", plainStyle, plainStyle, plainStyle pad, idSt, cmdSt, cwdSt = " › ", m.th.plain, m.th.plain, m.th.plain } left := pad + idSt.Render(s.ID) + " " + cmdSt.Render(s.Command) + " " + cwdSt.Render(s.Cwd) writeLine(b, w, left, right, sel) m.th.writeLine(b, w, left, right, sel)}
type hostLine struct { glyph, sess, ping string glyphSt, sessSt, nameSt, pingSt lipgloss.Style}
func (r hostRow) line(spin string) hostLine {// only the cursor's row animates: every probing row repainting a live// spinner and tenths-of-seconds timer each frame is what stalls a slow// terminal behind the renderer, taking input with itfunc (t theme) hostLine(r hostRow, live bool, spin string) hostLine { l := hostLine{ glyph: "○", sess: "no sessions", ping: fmt.Sprintf("%.1fs", r.duration.Seconds()), glyphSt: dimStyle, sessSt: dimStyle, nameSt: textStyle, pingSt: dimStyle, glyphSt: t.dim, sessSt: t.dim, nameSt: t.text, pingSt: t.dim, } switch { case r.probing: l.sess = "searching" if live { l.ping = fmt.Sprintf("%s %.1fs", spin, probeClock(time.Since(r.started)).Seconds()) l.ping = fmt.Sprintf("%s %.1fs", spin, probeClock(time.Since(r.started)).Seconds()) } else { l.ping = fmt.Sprintf("%.0fs", probeClock(time.Since(r.started)).Seconds()) } case r.unreachable: l.glyph, l.sess = "!", "unreachable" l.glyphSt, l.sessSt, l.nameSt, l.pingSt = salmonStyle, salmonStyle.Faint(true), faintStyle, salmonStyle.Faint(true) l.glyphSt, l.sessSt, l.nameSt, l.pingSt = t.salmon, t.salmon.Faint(true), t.faint, t.salmon.Faint(true) case r.peerOffline: l.glyph, l.sess, l.ping = "×", "offline", "" l.glyphSt, l.sessSt, l.nameSt = faintStyle, faintStyle, faintStyle l.glyphSt, l.sessSt, l.nameSt = t.faint, t.faint, t.faint case len(r.sessions) > 0: l.glyph, l.sess = "●", fmt.Sprintf("%d session%s", len(r.sessions), plural(len(r.sessions))) l.glyphSt, l.sessSt = mintStyle, mintStyle.Faint(true) l.glyphSt, l.sessSt = t.mint, t.mint.Faint(true) } return l}
// tableRows measures each row's line count and finds the row holding the// cursor, for the draw windowfunc tableRows(rows []hostRow, cursor int) (lines []int, cursorRow int) { lines = make([]int, len(rows)) e, cursorRow := 0, max(0, len(rows)-1) at := func(i, entry int) { if entry == cursor { cursorRow = i } } for i, r := range rows { lines[i] = 1 + len(r.sessions) if selectableHost(r) { at(i, e) e++ } for range r.sessions { at(i, e) e++ } } return lines, cursorRow}
// tableWindow picks contiguous rows totalling at most avail lines that keep// the cursor's row on screen: it grows down from the cursor first, then upfunc tableWindow(lines []int, cursorRow, avail int) (from, to int) { if avail <= 0 || len(lines) == 0 { return 0, len(lines) } from, to, used := cursorRow, min(cursorRow+1, len(lines)), 0 if from < len(lines) { used = lines[from] } for to < len(lines) && used+lines[to] <= avail { used, to = used+lines[to], to+1 } for from > 0 && used+lines[from-1] <= avail { used, from = used+lines[from-1], from-1 } return from, to}
func (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) { // header, the filter box, the divider and the hints are fixed lines lines, cursorRow := tableRows(rows, m.cursor) from, to := tableWindow(lines, cursorRow, max(0, m.height-5)) skip := 0 // the entries index of the first drawn row, so the selection still lines up for _, r := range rows[:from] { skip += len(r.sessions) + lo.Ternary(selectableHost(r), 1, 0) } nameW := min(22, lo.Max(lo.Map(rows, func(r hostRow, _ int) int { return utf8.RuneCountInString(r.name) }))) rows = rows[from:to] cursor := 0 // the entries() index of the line being drawn cursor := skip // the entries() index of the line being drawn for _, r := range rows { l := r.line(m.spinner.View()) sel := selectableHost(r) && cursor == m.cursor l := m.th.hostLine(r, sel, m.spinner.View()) reserve := 5 + lo.Ternary(r.local, 8, 0) // the local pill and its gap sessW := min(12, max(0, w-3-2-nameW-3-reserve)) sel := selectableHost(r) && cursor == m.cursor if selectableHost(r) { cursor++ } pad, right, nameSt, sessSt := " "+l.glyphSt.Width(2).Render(l.glyph), lo.Ternary(r.local, selfPill.Render("local"), l.pingSt.Render(l.ping)), l.nameSt, l.sessSt pad, right, nameSt, sessSt := " "+l.glyphSt.Width(2).Render(l.glyph), lo.Ternary(r.local, m.th.pill.Render("local"), l.pingSt.Render(l.ping)), l.nameSt, l.sessSt if sel { pad, right, nameSt, sessSt = " › ", lo.Ternary(r.local, " local ", l.ping), plainStyle, plainStyle pad, right, nameSt, sessSt = " › ", lo.Ternary(r.local, " local ", l.ping), m.th.plain, m.th.plain } writeLine(b, w, pad+nameSt.Width(nameW).Render(r.name)+" "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel) m.th.writeLine(b, w, pad+nameSt.Width(nameW).Render(r.name)+" "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel)
for _, s := range r.sessions { sel := cursor == m.cursor sessionRow(b, w, s, sel, m.sessionStatus(s, sel)) m.sessionRow(b, w, s, sel, m.sessionStatus(s, sel)) cursor++ } }}7 unmodified lines probes := make(chan probeResult, len(peers)) filter := textinput.New() filter.Prompt = "› " filter.Placeholder = "search sessions…" filter.PlaceholderStyle = faintStyle filter.Focus() filter.Width = startWidth - 8 filter.SetWidth(startWidth - 8) th := newTheme(true) // dark until the terminal answers the background query st := textinput.DefaultStyles(true) st.Focused.Placeholder, st.Blurred.Placeholder = th.faint, th.faint filter.SetStyles(st)
if initial = strings.TrimSpace(initial); initial != "" { filter.SetValue(initial) } m := pickerModel{ filter: filter, width: startWidth, th: th, probes: probes, spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{{name: self, local: true, sessions: local}}, }29 unmodified lines model, err := tea.NewProgram(m).Run() if err != nil { return err } picked := model.(pickerModel).picked fin := model.(pickerModel) picked := fin.picked switch { case picked == nil: return nil case picked.sess != nil: return connectOrHop(*picked.sess) } fmt.Fprintln(os.Stderr, dimStyle.Render("starting a shell on "+picked.host+"…")) fmt.Fprintln(os.Stderr, fin.th.dim.Render("starting a shell on "+picked.host+"…")) return Spawn(picked.host, loginShell)}
// the target's own login shell, named so the picker need not show the wrapper20 unmodified lines