From 0697dcc1d9c7330d8c9d8a2be0bb94b3d46c9345 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 12 Apr 2025 14:49:01 +0200 Subject: [PATCH] implement nested tool calls and initial setup for result metadata --- go.mod | 21 +- go.sum | 43 ++- internal/llm/agent/agent.go | 1 + internal/llm/tools/bash.go | 13 +- internal/llm/tools/tools.go | 23 +- internal/message/content.go | 5 +- internal/message/message.go | 1 + internal/tui/components/chat/editor.go | 15 +- internal/tui/components/chat/messages.go | 458 +++++++++++++++++------ internal/tui/components/chat/sidebar.go | 11 +- internal/tui/page/chat.go | 62 ++- internal/tui/styles/background.go | 81 ++++ internal/tui/styles/markdown.go | 7 +- internal/tui/styles/styles.go | 10 + 14 files changed, 584 insertions(+), 167 deletions(-) create mode 100644 internal/tui/styles/background.go diff --git a/go.mod b/go.mod index 63df37fba20eb74d1f2f24ccccb760acf9992bad..3b8bd99b1f42db365b1c50e483e8c4ad1f559837 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/charmbracelet/glamour v0.9.1 github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.8.0 github.com/fsnotify/fsnotify v1.8.0 github.com/go-logfmt/logfmt v0.6.0 github.com/golang-migrate/migrate/v4 v4.18.2 @@ -29,11 +30,11 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/openai/openai-go v0.1.0-beta.2 - github.com/sergi/go-diff v1.3.1 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.0 github.com/stretchr/testify v1.10.0 - golang.org/x/net v0.34.0 + golang.org/x/net v0.39.0 google.golang.org/api v0.215.0 ) @@ -64,7 +65,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect @@ -76,6 +76,7 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect @@ -92,6 +93,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -115,20 +117,21 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.design/x/clipboard v0.7.0 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/image v0.14.0 // indirect golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect google.golang.org/grpc v1.67.3 // indirect google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c4b32ef32bed371a72ab4ba5f917753a77077d18..08e7e7c42e61f69fd454df0683507bb91ddd3cd9 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,8 @@ github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg= github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -139,6 +139,7 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -189,8 +190,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ 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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= @@ -199,8 +200,9 @@ github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8 github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -261,10 +263,10 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8= golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= @@ -282,15 +284,15 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -304,8 +306,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -314,8 +316,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -323,8 +325,8 @@ 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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -343,8 +345,9 @@ google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7Qf google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 998dc1551e8adc3bbc5facfb80338803a8f0afb0..b01ffec3cc312848450fec6e5c016ba377eeec7c 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -305,6 +305,7 @@ func (c *agent) generate(ctx context.Context, sessionID string, content string) assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{ Role: message.Assistant, Parts: []message.ContentPart{}, + Model: c.model.ID, }) if err != nil { return err diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go index 4e80ae60a3e4de34da6a800f339be22ab2439785..d20afb7f28629db045452a9687e6b00e8acf5931 100644 --- a/internal/llm/tools/bash.go +++ b/internal/llm/tools/bash.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/kujtimiihoxha/termai/internal/config" "github.com/kujtimiihoxha/termai/internal/llm/tools/shell" @@ -21,6 +22,9 @@ type BashPermissionsParams struct { Timeout int `json:"timeout"` } +type BashToolResponseMetadata struct { + Took int64 `json:"took"` +} type bashTool struct { permissions permission.Service } @@ -272,11 +276,13 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) return NewTextErrorResponse("permission denied"), nil } } + startTime := time.Now() shell := shell.GetPersistentShell(config.WorkingDirectory()) stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout) if err != nil { return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil } + took := time.Since(startTime).Milliseconds() stdout = truncateOutput(stdout) stderr = truncateOutput(stderr) @@ -304,10 +310,13 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) stdout += "\n" + errorMessage } + metadata := BashToolResponseMetadata{ + Took: took, + } if stdout == "" { - return NewTextResponse("no output"), nil + return WithResponseMetadata(NewTextResponse("no output"), metadata), nil } - return NewTextResponse(stdout), nil + return WithResponseMetadata(NewTextResponse(stdout), metadata), nil } func truncateOutput(content string) string { diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index e15c1c31f49484c41bf9621bebb7f4e10a466e57..6bb5286863128b26ca16a1d2965b3c4901be4471 100644 --- a/internal/llm/tools/tools.go +++ b/internal/llm/tools/tools.go @@ -1,6 +1,9 @@ package tools -import "context" +import ( + "context" + "encoding/json" +) type ToolInfo struct { Name string @@ -17,9 +20,10 @@ const ( ) type ToolResponse struct { - Type toolResponseType `json:"type"` - Content string `json:"content"` - IsError bool `json:"is_error"` + Type toolResponseType `json:"type"` + Content string `json:"content"` + Metadata string `json:"metadata,omitempty"` + IsError bool `json:"is_error"` } func NewTextResponse(content string) ToolResponse { @@ -29,6 +33,17 @@ func NewTextResponse(content string) ToolResponse { } } +func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { + if metadata != nil { + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return response + } + response.Metadata = string(metadataBytes) + } + return response +} + func NewTextErrorResponse(content string) ToolResponse { return ToolResponse{ Type: ToolResponseTypeText, diff --git a/internal/message/content.go b/internal/message/content.go index cd263798b35e8fc1df3278dca1fff288c4a2806c..422c04f52ca0e5546986ea62b610f467502b08f1 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -3,6 +3,8 @@ package message import ( "encoding/base64" "time" + + "github.com/kujtimiihoxha/termai/internal/llm/models" ) type MessageRole string @@ -65,7 +67,6 @@ type ToolCall struct { Name string `json:"name"` Input string `json:"input"` Type string `json:"type"` - Metadata any `json:"metadata"` Finished bool `json:"finished"` } @@ -75,6 +76,7 @@ type ToolResult struct { ToolCallID string `json:"tool_call_id"` Name string `json:"name"` Content string `json:"content"` + Metadata string `json:"metadata"` IsError bool `json:"is_error"` } @@ -92,6 +94,7 @@ type Message struct { Role MessageRole SessionID string Parts []ContentPart + Model models.ModelID CreatedAt int64 UpdatedAt int64 diff --git a/internal/message/message.go b/internal/message/message.go index eeeb83ed2e8cebc6f69bc569cf0f9f2784879307..06dae13a57a8ae1ca7ede1e5fd22be6bea5ee669 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -155,6 +155,7 @@ func (s *service) fromDBItem(item db.Message) (Message, error) { SessionID: item.SessionID, Role: MessageRole(item.Role), Parts: parts, + Model: models.ModelID(item.Model.String), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, }, nil diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index df336818ce1b28fe76fed100c7eeba68bc4772f8..e87f1ffae79914fcacccac5dd5bc336c31a0b980 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -77,21 +77,20 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case AgentWorkingMsg: m.agentWorking = bool(msg) case tea.KeyMsg: - if key.Matches(msg, focusedKeyMaps.Send) { + // if the key does not match any binding, return + if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) { return m, m.send() } - if key.Matches(msg, bluredKeyMaps.Send) { + if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Send) { return m, m.send() } - if key.Matches(msg, focusedKeyMaps.Blur) { + if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Blur) { m.textarea.Blur() return m, util.CmdHandler(EditorFocusMsg(false)) } - if key.Matches(msg, bluredKeyMaps.Focus) { - if !m.textarea.Focused() { - m.textarea.Focus() - return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true))) - } + if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Focus) { + m.textarea.Focus() + return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true))) } } m.textarea, cmd = m.textarea.Update(msg) diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go index 0a7e6e2a499e4d14ad19a327ba8de4dbfb1e59cd..b5a36139239b08f514a36d3a07d308f107a67520 100644 --- a/internal/tui/components/chat/messages.go +++ b/internal/tui/components/chat/messages.go @@ -1,16 +1,21 @@ package chat import ( + "encoding/json" "fmt" - "regexp" - "strconv" + "math" "strings" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kujtimiihoxha/termai/internal/app" + "github.com/kujtimiihoxha/termai/internal/llm/agent" + "github.com/kujtimiihoxha/termai/internal/llm/models" + "github.com/kujtimiihoxha/termai/internal/llm/tools" "github.com/kujtimiihoxha/termai/internal/message" "github.com/kujtimiihoxha/termai/internal/pubsub" "github.com/kujtimiihoxha/termai/internal/session" @@ -18,10 +23,20 @@ import ( "github.com/kujtimiihoxha/termai/internal/tui/util" ) +type uiMessageType int + +const ( + userMessageType uiMessageType = iota + assistantMessageType + toolMessageType +) + type uiMessage struct { - position int - height int - content string + ID string + messageType uiMessageType + position int + height int + content string } type messagesCmp struct { @@ -32,141 +47,116 @@ type messagesCmp struct { session session.Session messages []message.Message uiMessages []uiMessage - currentIndex int + currentMsgID string renderer *glamour.TermRenderer focusRenderer *glamour.TermRenderer cachedContent map[string]string + agentWorking bool + spinner spinner.Model + needsRerender bool + lastViewport string } func (m *messagesCmp) Init() tea.Cmd { - return m.viewport.Init() -} - -var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") - -func hexToBgSGR(hex string) (string, error) { - hex = strings.TrimPrefix(hex, "#") - if len(hex) != 6 { - return "", fmt.Errorf("invalid hex color: must be 6 hexadecimal digits") - } - - // Parse RGB components in one block - rgb := make([]uint64, 3) - for i := 0; i < 3; i++ { - val, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8) - if err != nil { - return "", err - } - rgb[i] = val - } - - return fmt.Sprintf("48;2;%d;%d;%d", rgb[0], rgb[1], rgb[2]), nil -} - -func forceReplaceBackgroundColors(input string, newBg string) string { - return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { - // Extract content between "\x1b[" and "m" - content := seq[2 : len(seq)-1] - tokens := strings.Split(content, ";") - var newTokens []string - - // Skip background color tokens - for i := 0; i < len(tokens); i++ { - if tokens[i] == "" { - continue - } - - val, err := strconv.Atoi(tokens[i]) - if err != nil { - newTokens = append(newTokens, tokens[i]) - continue - } - - // Skip background color tokens - if val == 48 { - // Skip "48;5;N" or "48;2;R;G;B" sequences - if i+1 < len(tokens) { - if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil { - switch nextVal { - case 5: - i += 2 // Skip "5" and color index - case 2: - i += 4 // Skip "2" and RGB components - } - } - } - } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 { - // Keep non-background tokens - newTokens = append(newTokens, tokens[i]) - } - } - - // Add new background if provided - if newBg != "" { - newTokens = append(newTokens, strings.Split(newBg, ";")...) - } - - if len(newTokens) == 0 { - return "" - } - - return "\x1b[" + strings.Join(newTokens, ";") + "m" - }) + return tea.Batch(m.viewport.Init()) } func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd switch msg := msg.(type) { + case AgentWorkingMsg: + m.agentWorking = bool(msg) + if m.agentWorking { + cmds = append(cmds, m.spinner.Tick) + } case EditorFocusMsg: m.writingMode = bool(msg) case SessionSelectedMsg: if msg.ID != m.session.ID { cmd := m.SetSession(msg) + m.needsRerender = true return m, cmd } return m, nil + case SessionClearedMsg: + m.session = session.Session{} + m.messages = make([]message.Message, 0) + m.currentMsgID = "" + m.needsRerender = true + return m, nil + + case tea.KeyMsg: + if m.writingMode { + return m, nil + } case pubsub.Event[message.Message]: if msg.Type == pubsub.CreatedEvent { if msg.Payload.SessionID == m.session.ID { // check if message exists + + messageExists := false for _, v := range m.messages { if v.ID == msg.Payload.ID { - return m, nil + messageExists = true + break } } - m.messages = append(m.messages, msg.Payload) - m.renderView() - m.viewport.GotoBottom() + if !messageExists { + m.messages = append(m.messages, msg.Payload) + delete(m.cachedContent, m.currentMsgID) + m.currentMsgID = msg.Payload.ID + m.needsRerender = true + } } for _, v := range m.messages { for _, c := range v.ToolCalls() { // the message is being added to the session of a tool called if c.ID == msg.Payload.SessionID { - m.renderView() - m.viewport.GotoBottom() + m.needsRerender = true } } } } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID { for i, v := range m.messages { if v.ID == msg.Payload.ID { + if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" { + cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false))) + } m.messages[i] = msg.Payload delete(m.cachedContent, msg.Payload.ID) - m.renderView() - if i == len(m.messages)-1 { - m.viewport.GotoBottom() - } + m.needsRerender = true break } } } } + if m.agentWorking { + u, cmd := m.spinner.Update(msg) + m.spinner = u + cmds = append(cmds, cmd) + } + oldPos := m.viewport.YPosition u, cmd := m.viewport.Update(msg) m.viewport = u - return m, cmd + m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos + cmds = append(cmds, cmd) + if m.needsRerender { + m.renderView() + if len(m.messages) > 0 { + if msg, ok := msg.(pubsub.Event[message.Message]); ok { + if (msg.Type == pubsub.CreatedEvent) || + (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { + m.viewport.GotoBottom() + } + } + } + m.needsRerender = false + } + return m, tea.Batch(cmds...) } -func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string { +func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string { if v, ok := m.cachedContent[msg.ID]; ok { return v } @@ -178,7 +168,7 @@ func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string { BorderStyle(lipgloss.ThickBorder()) renderer := m.renderer - if inx == m.currentIndex { + if msg.ID == m.currentMsgID { style = style. Foreground(styles.Forground). BorderForeground(styles.Blue). @@ -186,33 +176,269 @@ func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string { renderer = m.focusRenderer } c, _ := renderer.Render(msg.Content().String()) - col, _ := hexToBgSGR(styles.Background.Dark) - rendered := style.Render(forceReplaceBackgroundColors(c, col)) + parts := []string{ + styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background), + } + // remove newline at the end + parts[0] = strings.TrimSuffix(parts[0], "\n") + if len(info) > 0 { + parts = append(parts, info...) + } + rendered := style.Render( + lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ), + ) m.cachedContent[msg.ID] = rendered return rendered } +func formatTimeDifference(unixTime1, unixTime2 int64) string { + diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1))) + + if diffSeconds < 60 { + return fmt.Sprintf("%.1fs", diffSeconds) + } + + minutes := int(diffSeconds / 60) + seconds := int(diffSeconds) % 60 + return fmt.Sprintf("%dm%ds", minutes, seconds) +} + +func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string { + key := "" + value := "" + switch toolCall.Name { + // TODO: add result data to the tools + case agent.AgentToolName: + key = "Task" + var params agent.AgentParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.Prompt + // TODO: handle nested calls + case tools.BashToolName: + key = "Bash" + var params tools.BashParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.Command + case tools.EditToolName: + key = "Edit" + var params tools.EditParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.FilePath + case tools.FetchToolName: + key = "Fetch" + var params tools.FetchParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.URL + case tools.GlobToolName: + key = "Glob" + var params tools.GlobParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + if params.Path == "" { + params.Path = "." + } + value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path) + case tools.GrepToolName: + key = "Grep" + var params tools.GrepParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + if params.Path == "" { + params.Path = "." + } + value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path) + case tools.LSToolName: + key = "Ls" + var params tools.LSParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + if params.Path == "" { + params.Path = "." + } + value = params.Path + case tools.SourcegraphToolName: + key = "Sourcegraph" + var params tools.SourcegraphParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.Query + case tools.ViewToolName: + key = "View" + var params tools.ViewParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.FilePath + case tools.WriteToolName: + key = "Write" + var params tools.WriteParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + value = params.FilePath + default: + key = toolCall.Name + var params map[string]any + json.Unmarshal([]byte(toolCall.Input), ¶ms) + jsonData, _ := json.Marshal(params) + value = string(jsonData) + } + + style := styles.BaseStyle. + Width(m.width). + BorderLeft(true). + BorderStyle(lipgloss.ThickBorder()). + PaddingLeft(1). + BorderForeground(styles.Yellow) + + keyStyle := styles.BaseStyle. + Foreground(styles.ForgroundDim) + valyeStyle := styles.BaseStyle. + Foreground(styles.Forground) + + if isNested { + valyeStyle = valyeStyle.Foreground(styles.ForgroundMid) + } + keyValye := keyStyle.Render( + fmt.Sprintf("%s: ", key), + ) + if !isNested { + value = valyeStyle. + Width(m.width - lipgloss.Width(keyValye) - 2). + Render( + ansi.Truncate( + value, + m.width-lipgloss.Width(keyValye)-2, + "...", + ), + ) + } else { + keyValye = keyStyle.Render( + fmt.Sprintf(" └ %s: ", key), + ) + value = valyeStyle. + Width(m.width - lipgloss.Width(keyValye) - 2). + Render( + ansi.Truncate( + value, + m.width-lipgloss.Width(keyValye)-2, + "...", + ), + ) + } + + innerToolCalls := make([]string, 0) + if toolCall.Name == agent.AgentToolName { + messages, _ := m.app.Messages.List(toolCall.ID) + toolCalls := make([]message.ToolCall, 0) + for _, v := range messages { + toolCalls = append(toolCalls, v.ToolCalls()...) + } + for _, v := range toolCalls { + call := m.renderToolCall(v, true) + innerToolCalls = append(innerToolCalls, call) + } + } + + if isNested { + return lipgloss.JoinHorizontal( + lipgloss.Left, + keyValye, + value, + ) + } + callContent := lipgloss.JoinHorizontal( + lipgloss.Left, + keyValye, + value, + ) + callContent = strings.ReplaceAll(callContent, "\n", "") + if len(innerToolCalls) > 0 { + callContent = lipgloss.JoinVertical( + lipgloss.Left, + callContent, + lipgloss.JoinVertical( + lipgloss.Left, + innerToolCalls..., + ), + ) + } + return style.Render(callContent) +} + +func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage { + // find the user message that is before this assistant message + var userMsg message.Message + for i := len(m.messages) - 1; i >= 0; i-- { + if m.messages[i].Role == message.User { + userMsg = m.messages[i] + break + } + } + messages := make([]uiMessage, 0) + if msg.Content().String() != "" { + info := make([]string, 0) + if msg.IsFinished() && msg.FinishReason() == "end_turn" { + finish := msg.FinishPart() + took := formatTimeDifference(userMsg.CreatedAt, finish.Time) + + info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render( + fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took), + )) + } + content := m.renderSimpleMessage(msg, info...) + messages = append(messages, uiMessage{ + messageType: assistantMessageType, + position: 0, // gets updated in renderView + height: lipgloss.Height(content), + content: content, + }) + } + for _, v := range msg.ToolCalls() { + content := m.renderToolCall(v, false) + messages = append(messages, + uiMessage{ + messageType: toolMessageType, + position: 0, // gets updated in renderView + height: lipgloss.Height(content), + content: content, + }, + ) + } + + return messages +} + func (m *messagesCmp) renderView() { m.uiMessages = make([]uiMessage, 0) pos := 0 for _, v := range m.messages { - content := "" switch v.Role { case message.User: - content = m.renderUserMessage(pos, v) + content := m.renderSimpleMessage(v) + m.uiMessages = append(m.uiMessages, uiMessage{ + messageType: userMessageType, + position: pos, + height: lipgloss.Height(content), + content: content, + }) + pos += lipgloss.Height(content) + 1 // + 1 for spacing + case message.Assistant: + assistantMessages := m.renderAssistantMessage(v) + for _, msg := range assistantMessages { + msg.position = pos + m.uiMessages = append(m.uiMessages, msg) + pos += msg.height + 1 // + 1 for spacing + } + } - m.uiMessages = append(m.uiMessages, uiMessage{ - position: pos, - height: lipgloss.Height(content), - content: content, - }) - pos += lipgloss.Height(content) + 1 // + 1 for spacing } messages := make([]string, 0) for _, v := range m.uiMessages { - messages = append(messages, v.content) + messages = append(messages, v.content, + styles.BaseStyle. + Width(m.width). + Render( + "", + ), + ) } m.viewport.SetContent( styles.BaseStyle. @@ -246,7 +472,6 @@ func (m *messagesCmp) View() string { ) } - m.renderView() return styles.BaseStyle. Width(m.width). Render( @@ -260,15 +485,21 @@ func (m *messagesCmp) View() string { func (m *messagesCmp) help() string { text := "" + + if m.agentWorking { + text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render( + fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."), + ) + } if m.writingMode { - text = lipgloss.JoinHorizontal( + text += lipgloss.JoinHorizontal( lipgloss.Left, styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"), styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"), ) } else { - text = lipgloss.JoinHorizontal( + text += lipgloss.JoinHorizontal( lipgloss.Left, styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"), @@ -306,7 +537,15 @@ func (m *messagesCmp) SetSize(width, height int) { glamour.WithWordWrap(width-1), ) m.focusRenderer = focusRenderer + // clear the cached content + for k := range m.cachedContent { + delete(m.cachedContent, k) + } m.renderer = renderer + if len(m.messages) > 0 { + m.renderView() + m.viewport.GotoBottom() + } } func (m *messagesCmp) GetSize() (int, int) { @@ -320,7 +559,8 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { return util.ReportError(err) } m.messages = messages - m.messages = append(m.messages, m.messages[0]) + m.currentMsgID = m.messages[len(m.messages)-1].ID + m.needsRerender = true return nil } @@ -333,6 +573,9 @@ func NewMessagesCmp(app *app.App) tea.Model { glamour.WithStyles(styles.MarkdownTheme(false)), glamour.WithWordWrap(80), ) + + s := spinner.New() + s.Spinner = spinner.Pulse return &messagesCmp{ app: app, writingMode: true, @@ -340,5 +583,6 @@ func NewMessagesCmp(app *app.App) tea.Model { viewport: viewport.New(0, 0), focusRenderer: focusRenderer, renderer: renderer, + spinner: s, } } diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index 65c06f4a168e04a8bfc1bdff37368034518ea1dc..51192cf9a6e7a58c94251ae1f608d834a79a49f2 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/kujtimiihoxha/termai/internal/pubsub" "github.com/kujtimiihoxha/termai/internal/session" "github.com/kujtimiihoxha/termai/internal/tui/styles" ) @@ -19,6 +20,14 @@ func (m *sidebarCmp) Init() tea.Cmd { } func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case pubsub.Event[session.Session]: + if msg.Type == pubsub.UpdatedEvent { + if m.session.ID == msg.Payload.ID { + m.session = msg.Payload + } + } + } return m, nil } @@ -45,7 +54,7 @@ func (m *sidebarCmp) sessionSection() string { sessionValue := styles.BaseStyle. Foreground(styles.Forground). Width(m.width - lipgloss.Width(sessionKey)). - Render(": New Session") + Render(fmt.Sprintf(": %s", m.session.Title)) return lipgloss.JoinHorizontal( lipgloss.Left, sessionKey, diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 7ac0d2293f5b49bed1fee844a43319266c5c5263..a7a51bb844640ee0bd0e819d7a78531ac81fdc94 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -1,9 +1,10 @@ package page import ( + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/kujtimiihoxha/termai/internal/app" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/termai/internal/llm/agent" "github.com/kujtimiihoxha/termai/internal/session" "github.com/kujtimiihoxha/termai/internal/tui/components/chat" "github.com/kujtimiihoxha/termai/internal/tui/layout" @@ -18,8 +19,32 @@ type chatPage struct { session session.Session } +type ChatKeyMap struct { + NewSession key.Binding +} + +var keyMap = ChatKeyMap{ + NewSession: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new session"), + ), +} + func (p *chatPage) Init() tea.Cmd { - return p.layout.Init() + // TODO: remove + cmds := []tea.Cmd{ + p.layout.Init(), + } + + sessions, _ := p.app.Sessions.List() + if len(sessions) > 0 { + p.session = sessions[0] + cmd := p.setSidebar() + cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(p.session)), cmd) + } + return tea.Batch( + cmds..., + ) } func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -31,6 +56,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } + case tea.KeyMsg: + switch { + case key.Matches(msg, keyMap.NewSession): + p.session = session.Session{} + p.clearSidebar() + return p, util.CmdHandler(chat.SessionClearedMsg{}) + } } u, cmd := p.layout.Update(msg) p.layout = u.(layout.SplitPaneLayout) @@ -51,6 +83,12 @@ func (p *chatPage) setSidebar() tea.Cmd { return sidebarContainer.Init() } +func (p *chatPage) clearSidebar() { + p.layout.SetRightPanel(nil) + width, height := p.layout.GetSize() + p.layout.SetSize(width, height) +} + func (p *chatPage) sendMessage(text string) tea.Cmd { var cmds []tea.Cmd if p.session.ID == "" { @@ -66,15 +104,15 @@ func (p *chatPage) sendMessage(text string) tea.Cmd { } cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) } - // TODO: actually call agent - p.app.Messages.Create(p.session.ID, message.CreateMessageParams{ - Role: message.User, - Parts: []message.ContentPart{ - message.TextContent{ - Text: text, - }, - }, - }) + // TODO: move this to a service + a, err := agent.NewCoderAgent(p.app) + if err != nil { + return util.ReportError(err) + } + go func() { + a.Generate(p.app.Context, p.session.ID, text) + }() + return tea.Batch(cmds...) } @@ -85,7 +123,7 @@ func (p *chatPage) View() string { func NewChatPage(app *app.App) tea.Model { messagesContainer := layout.NewContainer( chat.NewMessagesCmp(app), - layout.WithPadding(1, 1, 1, 1), + layout.WithPadding(1, 1, 0, 1), ) editorContainer := layout.NewContainer( diff --git a/internal/tui/styles/background.go b/internal/tui/styles/background.go new file mode 100644 index 0000000000000000000000000000000000000000..bf6cbc1059f81d54cda3e7c3de10b925ab11a160 --- /dev/null +++ b/internal/tui/styles/background.go @@ -0,0 +1,81 @@ +package styles + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) { + r, g, b, a := c.RGBA() + + // Un-premultiply alpha if needed + if a > 0 && a < 0xffff { + r = (r * 0xffff) / a + g = (g * 0xffff) / a + b = (b * 0xffff) / a + } + + // Convert from 16-bit to 8-bit color + return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8) +} + +func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string { + r, g, b := getColorRGB(newBgColor) + + newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b) + + return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { + // Extract content between "\x1b[" and "m" + content := seq[2 : len(seq)-1] + tokens := strings.Split(content, ";") + var newTokens []string + + // Skip background color tokens + for i := 0; i < len(tokens); i++ { + if tokens[i] == "" { + continue + } + + val, err := strconv.Atoi(tokens[i]) + if err != nil { + newTokens = append(newTokens, tokens[i]) + continue + } + + // Skip background color tokens + if val == 48 { + // Skip "48;5;N" or "48;2;R;G;B" sequences + if i+1 < len(tokens) { + if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil { + switch nextVal { + case 5: + i += 2 // Skip "5" and color index + case 2: + i += 4 // Skip "2" and RGB components + } + } + } + } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 { + // Keep non-background tokens + newTokens = append(newTokens, tokens[i]) + } + } + + // Add new background if provided + if newBg != "" { + newTokens = append(newTokens, strings.Split(newBg, ";")...) + } + + if len(newTokens) == 0 { + return "" + } + + return "\x1b[" + strings.Join(newTokens, ";") + "m" + }) +} diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go index b4e71c51ef6615b9638156ac30129b1905669585..52816eab3ac0d104a0add3cf935c4327f21fc469 100644 --- a/internal/tui/styles/markdown.go +++ b/internal/tui/styles/markdown.go @@ -515,6 +515,7 @@ var ASCIIStyleConfig = ansi.StyleConfig{ Document: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BackgroundColor: stringPtr(Background.Dark), + Color: stringPtr(ForgroundDim.Dark), }, Indent: uintPtr(1), IndentToken: stringPtr(BaseStyle.Render(" ")), @@ -688,7 +689,7 @@ var DraculaStyleConfig = ansi.StyleConfig{ Heading: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockSuffix: "\n", - Color: stringPtr("#bd93f9"), + Color: stringPtr(PrimaryColor.Dark), Bold: boolPtr(true), BackgroundColor: stringPtr(Background.Dark), }, @@ -740,7 +741,7 @@ var DraculaStyleConfig = ansi.StyleConfig{ }, Strong: ansi.StylePrimitive{ Bold: boolPtr(true), - Color: stringPtr("#ffb86c"), + Color: stringPtr(Blue.Dark), BackgroundColor: stringPtr(Background.Dark), }, HorizontalRule: ansi.StylePrimitive{ @@ -796,7 +797,7 @@ var DraculaStyleConfig = ansi.StyleConfig{ CodeBlock: ansi.StyleCodeBlock{ StyleBlock: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#ffb86c"), + Color: stringPtr(Blue.Dark), BackgroundColor: stringPtr(Background.Dark), }, Margin: uintPtr(defaultMargin), diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index 41863cf1b79a4d36ce9c3d27bb87d1c2b2ddedc4..476339b57a72157de2dd2035fe89a8b11fe56860 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -34,6 +34,11 @@ var ( Light: "#d3d3d3", } + ForgroundMid = lipgloss.AdaptiveColor{ + Dark: "#a0a0a0", + Light: "#a0a0a0", + } + ForgroundDim = lipgloss.AdaptiveColor{ Dark: "#737373", Light: "#737373", @@ -159,6 +164,11 @@ var ( Light: light.Peach().Hex, } + Yellow = lipgloss.AdaptiveColor{ + Dark: dark.Yellow().Hex, + Light: light.Yellow().Hex, + } + Primary = Blue Secondary = Mauve