implement nested tool calls and initial setup for result metadata

Kujtim Hoxha created

Change summary

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

Detailed changes

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
 )

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=

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

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 {

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,

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

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

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)

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), &params)
+		value = params.Prompt
+	// TODO: handle nested calls
+	case tools.BashToolName:
+		key = "Bash"
+		var params tools.BashParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		value = params.Command
+	case tools.EditToolName:
+		key = "Edit"
+		var params tools.EditParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		value = params.FilePath
+	case tools.FetchToolName:
+		key = "Fetch"
+		var params tools.FetchParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		value = params.URL
+	case tools.GlobToolName:
+		key = "Glob"
+		var params tools.GlobParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		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), &params)
+		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), &params)
+		if params.Path == "" {
+			params.Path = "."
+		}
+		value = params.Path
+	case tools.SourcegraphToolName:
+		key = "Sourcegraph"
+		var params tools.SourcegraphParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		value = params.Query
+	case tools.ViewToolName:
+		key = "View"
+		var params tools.ViewParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		value = params.FilePath
+	case tools.WriteToolName:
+		key = "Write"
+		var params tools.WriteParams
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		value = params.FilePath
+	default:
+		key = toolCall.Name
+		var params map[string]any
+		json.Unmarshal([]byte(toolCall.Input), &params)
+		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,
 	}
 }

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,

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(

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"
+	})
+}

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

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