feat: create bubbletea TUI with chat viewport, markdown rendering, and tool indicators

This commit is contained in:
Rahul Bansal 2026-02-21 18:35:05 +05:30
parent ec28399775
commit 530d6a1253
6 changed files with 986 additions and 0 deletions

34
go.mod
View file

@ -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
)

88
go.sum
View file

@ -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=

58
pkg/tui/messages.go Normal file
View file

@ -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)
}
}

469
pkg/tui/model.go Normal file
View file

@ -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)
})
}

294
pkg/tui/model_test.go Normal file
View file

@ -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)
}
}

43
pkg/tui/styles.go Normal file
View file

@ -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"))
)