From 530d6a1253afda0b9451e037a118b9c6805f30dd Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 18:35:05 +0530 Subject: [PATCH] feat: create bubbletea TUI with chat viewport, markdown rendering, and tool indicators --- go.mod | 34 +++ go.sum | 88 ++++++++ pkg/tui/messages.go | 58 ++++++ pkg/tui/model.go | 469 ++++++++++++++++++++++++++++++++++++++++++ pkg/tui/model_test.go | 294 ++++++++++++++++++++++++++ pkg/tui/styles.go | 43 ++++ 6 files changed, 986 insertions(+) create mode 100644 pkg/tui/messages.go create mode 100644 pkg/tui/model.go create mode 100644 pkg/tui/model_test.go create mode 100644 pkg/tui/styles.go diff --git a/go.mod b/go.mod index 1f88639c8..ea91c416f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,10 @@ require ( github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.3.1 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/chzyer/readline v1.5.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -21,8 +25,38 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // 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/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/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/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0e95bf5cd..f8f70b907 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,26 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +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/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -17,12 +33,38 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -30,6 +72,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= @@ -65,12 +111,16 @@ github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -88,6 +138,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +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/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -105,6 +174,10 @@ github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbD github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -144,11 +217,18 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= @@ -161,6 +241,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -204,10 +286,12 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= @@ -217,6 +301,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -224,6 +310,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go new file mode 100644 index 000000000..8b9f410ae --- /dev/null +++ b/pkg/tui/messages.go @@ -0,0 +1,58 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package tui + +import "github.com/sipeed/picoclaw/pkg/agent" + +// ThinkingStartedMsg indicates the LLM is processing. +type ThinkingStartedMsg struct{} + +// ToolCallStartedMsg indicates a tool execution has begun. +type ToolCallStartedMsg struct { + ID string + Name string + Args string // JSON string +} + +// ToolCallCompletedMsg indicates a tool execution has finished. +type ToolCallCompletedMsg struct { + ID string + Name string + Result string + IsError bool +} + +// ResponseMsg carries the final LLM response text. +type ResponseMsg struct { + Content string +} + +// ErrorMsg carries an error from the agent loop. +type ErrorMsg struct { + Err error +} + +// SlashCommandResultMsg carries the result of a slash command. +type SlashCommandResultMsg struct { + Result string +} + +// eventBridge adapts AgentEventListener to send tea.Msg via a channel. +type eventBridge struct { + events chan agent.AgentEvent +} + +func newEventBridge() *eventBridge { + return &eventBridge{ + events: make(chan agent.AgentEvent, 50), + } +} + +func (eb *eventBridge) OnEvent(event agent.AgentEvent) { + select { + case eb.events <- event: + default: + // Drop event if channel is full (TUI not consuming fast enough) + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 000000000..8be332e92 --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,469 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/sipeed/picoclaw/pkg/agent" +) + +const ( + tickInterval = 200 * time.Millisecond + thinkingFrameCount = 4 + headerHeight = 1 + statusBarHeight = 1 + textareaHeight = 3 + // chromeHeight accounts for header, status bar, textarea, and separators + chromeHeight = headerHeight + statusBarHeight + textareaHeight + 2 + maxSessionKeyLen = 15 +) + +// thinkingFrames are the animation frames for the thinking indicator +var thinkingFrames = [thinkingFrameCount]string{"⠋", "⠙", "⠹", "⠸"} + +// tickMsg drives the thinking animation and event polling +type tickMsg time.Time + +// chatMessage represents a single message in the chat history +type chatMessage struct { + role string // "user", "assistant", "tool", "system" + content string + toolName string + toolID string + toolDone bool + toolErr bool +} + +// Model is the main bubbletea model for the PicoClaw TUI +type Model struct { + viewport viewport.Model + textarea textarea.Model + messages []chatMessage + agentLoop *agent.AgentLoop + sessionKey string + modelName string + thinking bool + thinkFrame int + width int + height int + ready bool + eventBridge *eventBridge + renderer *glamour.TermRenderer + quitting bool +} + +// NewModel creates a new TUI model wired to the given agent loop +func NewModel(agentLoop *agent.AgentLoop, sessionKey, modelName string) Model { + ta := textarea.New() + ta.Placeholder = "Type a message... (Enter to send, Alt+Enter for newline)" + ta.Prompt = "│ " + ta.CharLimit = 0 // No limit + ta.SetHeight(textareaHeight) + ta.ShowLineNumbers = false + + // Enter sends, Alt+Enter inserts newline + ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter")) + + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + + eb := newEventBridge() + agentLoop.SetEventListener(eb) + + renderer, _ := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(0), // will be updated on resize + ) + + return Model{ + textarea: ta, + agentLoop: agentLoop, + sessionKey: sessionKey, + modelName: modelName, + eventBridge: eb, + renderer: renderer, + } +} + +// Init returns the initial command for the TUI +func (m Model) Init() tea.Cmd { + return tea.Batch(textarea.Blink, tickCmd()) +} + +// Update handles incoming messages and updates the model +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyMsg(msg) + + case tea.WindowSizeMsg: + return m.handleWindowSize(msg) + + case tickMsg: + return m.handleTick() + + case ThinkingStartedMsg: + m.thinking = true + m.updateViewport() + return m, nil + + case ToolCallStartedMsg: + m.messages = append(m.messages, chatMessage{ + role: "tool", + content: fmt.Sprintf("Running %s...", msg.Name), + toolName: msg.Name, + toolID: msg.ID, + }) + m.updateViewport() + return m, nil + + case ToolCallCompletedMsg: + for i := len(m.messages) - 1; i >= 0; i-- { + if m.messages[i].role == "tool" && m.messages[i].toolID == msg.ID { + m.messages[i].toolDone = true + m.messages[i].toolErr = msg.IsError + if msg.IsError { + m.messages[i].content = fmt.Sprintf("%s failed", msg.Name) + } else { + m.messages[i].content = fmt.Sprintf("%s done", msg.Name) + } + break + } + } + m.updateViewport() + return m, nil + + case ResponseMsg: + m.messages = append(m.messages, chatMessage{ + role: "assistant", + content: msg.Content, + }) + m.thinking = false + m.updateViewport() + return m, nil + + case ErrorMsg: + m.messages = append(m.messages, chatMessage{ + role: "system", + content: fmt.Sprintf("Error: %v", msg.Err), + }) + m.thinking = false + m.updateViewport() + return m, nil + + case SlashCommandResultMsg: + m.messages = append(m.messages, chatMessage{ + role: "system", + content: msg.Result, + }) + m.thinking = false + m.updateViewport() + return m, nil + } + + // Pass remaining messages to textarea + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// View renders the TUI +func (m Model) View() string { + if m.quitting { + return "Goodbye!\n" + } + if !m.ready { + return "Initializing...\n" + } + + header := m.renderHeader() + sep := separatorStyle.Render(strings.Repeat("─", m.width)) + + return fmt.Sprintf( + "%s\n%s\n%s\n%s\n%s", + header, + m.viewport.View(), + sep, + m.textarea.View(), + m.renderStatusBar(), + ) +} + +// handleKeyMsg processes keyboard input +func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + m.quitting = true + return m, tea.Quit + + case tea.KeyPgUp: + m.viewport.PageUp() + return m, nil + + case tea.KeyPgDown: + m.viewport.PageDown() + return m, nil + + case tea.KeyEnter: + input := strings.TrimSpace(m.textarea.Value()) + if input == "" { + return m, nil + } + m.textarea.Reset() + + // Add user message to chat + m.messages = append(m.messages, chatMessage{ + role: "user", + content: input, + }) + m.thinking = true + m.updateViewport() + + return m, m.processMessage(input) + } + + // Pass to textarea for regular typing + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd +} + +// handleWindowSize initializes or resizes the viewport and textarea +func (m Model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + + vpHeight := msg.Height - chromeHeight + if vpHeight < 1 { + vpHeight = 1 + } + + if !m.ready { + m.viewport = viewport.New(msg.Width, vpHeight) + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = vpHeight + } + + m.textarea.SetWidth(msg.Width) + + // Recreate renderer with updated word wrap + if r, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(msg.Width-4), + ); err == nil { + m.renderer = r + } + + m.updateViewport() + + return m, nil +} + +// handleTick advances the thinking animation and polls agent events +func (m Model) handleTick() (tea.Model, tea.Cmd) { + cmds := []tea.Cmd{tickCmd()} + + if m.thinking { + m.thinkFrame = (m.thinkFrame + 1) % thinkingFrameCount + m.updateViewport() + } + + // Poll events from the agent bridge + eventCmds := m.pollEvents() + cmds = append(cmds, eventCmds...) + + return m, tea.Batch(cmds...) +} + +// processMessage sends user input to the agent loop in a goroutine +func (m Model) processMessage(input string) tea.Cmd { + agentLoop := m.agentLoop + sessionKey := m.sessionKey + + return func() tea.Msg { + ctx := context.Background() + response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + if err != nil { + return ErrorMsg{Err: err} + } + if strings.HasPrefix(input, "/") { + return SlashCommandResultMsg{Result: response} + } + return ResponseMsg{Content: response} + } +} + +// pollEvents drains the event bridge channel, returning tea commands +func (m Model) pollEvents() []tea.Cmd { + var cmds []tea.Cmd + + for { + select { + case event := <-m.eventBridge.events: + cmd := m.convertEvent(event) + if cmd != nil { + cmds = append(cmds, cmd) + } + default: + return cmds + } + } +} + +// convertEvent turns an agent event into a tea.Cmd that returns the matching message +func (m Model) convertEvent(event agent.AgentEvent) tea.Cmd { + switch event.Type { + case agent.EventThinkingStarted: + return func() tea.Msg { return ThinkingStartedMsg{} } + + case agent.EventToolCallStarted: + if data, ok := event.Data.(agent.ToolCallStartedData); ok { + return func() tea.Msg { + return ToolCallStartedMsg{ + ID: data.ID, + Name: data.Name, + Args: data.Args, + } + } + } + + case agent.EventToolCallCompleted: + if data, ok := event.Data.(agent.ToolCallCompletedData); ok { + return func() tea.Msg { + return ToolCallCompletedMsg{ + ID: data.ID, + Name: data.Name, + Result: data.Result, + IsError: data.IsError, + } + } + } + + case agent.EventResponseComplete: + // The response is also returned by ProcessDirect, so we don't + // duplicate it here. The tick-based polling of EventResponseComplete + // is only used for streaming scenarios in the future. + return nil + + case agent.EventError: + if data, ok := event.Data.(agent.ErrorData); ok { + return func() tea.Msg { return ErrorMsg{Err: data.Err} } + } + } + return nil +} + +// updateViewport renders all messages and sets the viewport content +func (m *Model) updateViewport() { + var sb strings.Builder + + for _, msg := range m.messages { + switch msg.role { + case "user": + label := userLabelStyle.Render("You:") + sb.WriteString(label + " " + msg.content + "\n\n") + + case "assistant": + label := assistantLabelStyle.Render("Assistant:") + rendered := msg.content + if m.renderer != nil { + if r, err := m.renderer.Render(msg.content); err == nil { + rendered = strings.TrimSpace(r) + } + } + sb.WriteString(label + "\n" + rendered + "\n\n") + + case "tool": + var styled string + switch { + case msg.toolErr: + styled = toolErrorStyle.Render("✗ " + msg.content) + case msg.toolDone: + styled = toolDoneStyle.Render("✓ " + msg.content) + default: + styled = toolRunningStyle.Render("⟳ " + msg.content) + } + sb.WriteString(" " + styled + "\n") + + case "system": + sb.WriteString(msg.content + "\n\n") + } + } + + // Append thinking indicator if active + if m.thinking { + frame := thinkingFrames[m.thinkFrame] + sb.WriteString(thinkingStyle.Render(frame+" Thinking...") + "\n") + } + + m.viewport.SetContent(sb.String()) + m.viewport.GotoBottom() +} + +// renderHeader returns the header line +func (m Model) renderHeader() string { + title := lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true). + Render("PicoClaw") + + session := truncateSessionKey(m.sessionKey) + + right := lipgloss.NewStyle(). + Foreground(secondaryColor). + Render(fmt.Sprintf("[%s] %s", session, m.modelName)) + + gap := m.width - lipgloss.Width(title) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + + return title + strings.Repeat(" ", gap) + right +} + +// renderStatusBar returns the status bar at the bottom +func (m Model) renderStatusBar() string { + left := "Ctrl+C: quit | PgUp/PgDn: scroll | Alt+Enter: newline" + pct := fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) + + gap := m.width - lipgloss.Width(left) - lipgloss.Width(pct) - 2 // padding + if gap < 1 { + gap = 1 + } + + bar := left + strings.Repeat(" ", gap) + pct + return statusBarStyle.Render(bar) +} + +// truncateSessionKey shortens the session key for display +func truncateSessionKey(key string) string { + if len(key) <= maxSessionKeyLen { + return key + } + return key[:maxSessionKeyLen] + "…" +} + +// tickCmd returns a command that sends a tickMsg after the tick interval +func tickCmd() tea.Cmd { + return tea.Tick(tickInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go new file mode 100644 index 000000000..6ed4f6d30 --- /dev/null +++ b/pkg/tui/model_test.go @@ -0,0 +1,294 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" +) + +func TestTruncateSessionKey(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "short key unchanged", + input: "abc", + want: "abc", + }, + { + name: "exact 15 chars unchanged", + input: "123456789012345", + want: "123456789012345", + }, + { + name: "long key truncated with ellipsis", + input: "1234567890123456", + want: "123456789012345…", + }, + { + name: "empty key", + input: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateSessionKey(tt.input) + if got != tt.want { + t.Errorf("truncateSessionKey(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// newTestModel creates a Model for testing without requiring a real AgentLoop. +// The model is constructed directly, bypassing NewModel. We must use +// textarea.New() because the bubbles textarea panics on SetWidth if its +// internal state is nil. +func newTestModel() Model { + ta := textarea.New() + ta.SetHeight(textareaHeight) + return Model{ + textarea: ta, + sessionKey: "test-session", + modelName: "test-model", + messages: []chatMessage{}, + eventBridge: newEventBridge(), + } +} + +// initTestModel creates a test model and sends a WindowSizeMsg to make it ready. +func initTestModel(t *testing.T) Model { + t.Helper() + m := newTestModel() + result, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + model, ok := result.(Model) + if !ok { + t.Fatal("Update did not return a Model") + } + return model +} + +func TestModel_View_NotReady(t *testing.T) { + m := newTestModel() + view := m.View() + if !strings.Contains(view, "Initializing...") { + t.Errorf("expected 'Initializing...' in view, got %q", view) + } +} + +func TestModel_View_Quitting(t *testing.T) { + m := newTestModel() + m.quitting = true + view := m.View() + if !strings.Contains(view, "Goodbye!") { + t.Errorf("expected 'Goodbye!' in view, got %q", view) + } +} + +func TestModel_Update_CtrlC_SetsQuitting(t *testing.T) { + m := initTestModel(t) + + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + model, ok := result.(Model) + if !ok { + t.Fatal("Update did not return a Model") + } + + if !model.quitting { + t.Error("expected quitting to be true after Ctrl+C") + } + if cmd == nil { + t.Error("expected quit command, got nil") + } +} + +func TestModel_Update_ResponseMsg_AddsAssistantMessage(t *testing.T) { + m := initTestModel(t) + m.thinking = true + + result, _ := m.Update(ResponseMsg{Content: "Hello, world!"}) + model, ok := result.(Model) + if !ok { + t.Fatal("Update did not return a Model") + } + + if model.thinking { + t.Error("expected thinking to be false after ResponseMsg") + } + + found := false + for _, msg := range model.messages { + if msg.role == "assistant" && msg.content == "Hello, world!" { + found = true + break + } + } + if !found { + t.Error("expected assistant message 'Hello, world!' in messages") + } +} + +func TestModel_Update_ToolCallStarted_AddsToolMessage(t *testing.T) { + m := initTestModel(t) + + result, _ := m.Update(ToolCallStartedMsg{ + ID: "tool-1", + Name: "web_search", + Args: `{"query":"test"}`, + }) + model, ok := result.(Model) + if !ok { + t.Fatal("Update did not return a Model") + } + + if len(model.messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(model.messages)) + } + + msg := model.messages[0] + if msg.role != "tool" { + t.Errorf("expected role 'tool', got %q", msg.role) + } + if msg.toolName != "web_search" { + t.Errorf("expected toolName 'web_search', got %q", msg.toolName) + } + if msg.toolID != "tool-1" { + t.Errorf("expected toolID 'tool-1', got %q", msg.toolID) + } + if msg.toolDone { + t.Error("expected toolDone to be false") + } +} + +func TestModel_Update_ToolCallCompleted_UpdatesToolMessage(t *testing.T) { + m := initTestModel(t) + + // First start a tool + result, _ := m.Update(ToolCallStartedMsg{ + ID: "tool-1", + Name: "web_search", + }) + m = result.(Model) + + // Then complete it + result, _ = m.Update(ToolCallCompletedMsg{ + ID: "tool-1", + Name: "web_search", + }) + model := result.(Model) + + if len(model.messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(model.messages)) + } + + msg := model.messages[0] + if !msg.toolDone { + t.Error("expected toolDone to be true after completion") + } + if msg.toolErr { + t.Error("expected toolErr to be false for successful completion") + } + if !strings.Contains(msg.content, "done") { + t.Errorf("expected content to contain 'done', got %q", msg.content) + } +} + +func TestModel_Update_ToolCallCompleted_Error(t *testing.T) { + m := initTestModel(t) + + result, _ := m.Update(ToolCallStartedMsg{ + ID: "tool-2", + Name: "exec", + }) + m = result.(Model) + + result, _ = m.Update(ToolCallCompletedMsg{ + ID: "tool-2", + Name: "exec", + IsError: true, + }) + model := result.(Model) + + msg := model.messages[0] + if !msg.toolErr { + t.Error("expected toolErr to be true for error completion") + } + if !strings.Contains(msg.content, "failed") { + t.Errorf("expected content to contain 'failed', got %q", msg.content) + } +} + +func TestModel_Update_ErrorMsg_AddsSystemMessage(t *testing.T) { + m := initTestModel(t) + m.thinking = true + + result, _ := m.Update(ErrorMsg{Err: fmt.Errorf("connection refused")}) + model, ok := result.(Model) + if !ok { + t.Fatal("Update did not return a Model") + } + + if model.thinking { + t.Error("expected thinking to be false after ErrorMsg") + } + + found := false + for _, msg := range model.messages { + if msg.role == "system" && strings.Contains(msg.content, "connection refused") { + found = true + break + } + } + if !found { + t.Error("expected system message containing 'connection refused'") + } +} + +func TestModel_Update_SlashCommandResult(t *testing.T) { + m := initTestModel(t) + + result, _ := m.Update(SlashCommandResultMsg{Result: "Session: default"}) + model := result.(Model) + + found := false + for _, msg := range model.messages { + if msg.role == "system" && msg.content == "Session: default" { + found = true + break + } + } + if !found { + t.Error("expected system message with slash command result") + } +} + +func TestModel_Update_WindowSizeMsg_SetsReady(t *testing.T) { + m := newTestModel() + + if m.ready { + t.Fatal("expected model to not be ready initially") + } + + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + + if !model.ready { + t.Error("expected model to be ready after WindowSizeMsg") + } + if model.width != 120 { + t.Errorf("expected width 120, got %d", model.width) + } + if model.height != 40 { + t.Errorf("expected height 40, got %d", model.height) + } +} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go new file mode 100644 index 000000000..e0d339fa6 --- /dev/null +++ b/pkg/tui/styles.go @@ -0,0 +1,43 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + primaryColor = lipgloss.Color("#FF6B35") // Lobster orange + secondaryColor = lipgloss.Color("#7B8794") + errorColor = lipgloss.Color("#E74C3C") + successColor = lipgloss.Color("#2ECC71") + warningColor = lipgloss.Color("#F39C12") + + userLabelStyle = lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true) + + assistantLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3498DB")). + Bold(true) + + toolRunningStyle = lipgloss.NewStyle(). + Foreground(warningColor) + + toolDoneStyle = lipgloss.NewStyle(). + Foreground(successColor) + + toolErrorStyle = lipgloss.NewStyle(). + Foreground(errorColor) + + statusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#2C3E50")). + Foreground(lipgloss.Color("#ECF0F1")). + Padding(0, 1) + + thinkingStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Italic(true) + + separatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#34495E")) +)