on main help sign in
implement nested tool calls and initial setup for result metadata
commit0697dcc1d9c7330d8c9d8a2be0bb94b3d46c9345 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 12 Apr 2025, 14:49 (2 years ago) parent8d874b8
14 files changed+584−167
go.mod+12−9 go.sum+23−20 internal/llm/agent/agent.go+1 internal/llm/tools/bash.go+11−2 internal/llm/tools/tools.go+19−4 internal/message/content.go+4−1 internal/message/message.go+1 internal/tui/components/chat/editor.go+7−8 internal/tui/components/chat/messages.go+351−107 internal/tui/components/chat/sidebar.go+10−1 internal/tui/page/chat.go+50−12 internal/tui/styles/background.go+81 internal/tui/styles/markdown.go+4−3 internal/tui/styles/styles.go+10
go.mod+12−9
@@ -15,6 +15,7 @@ require (
1515 github.com/charmbracelet/glamour v0.9.1
1616 github.com/charmbracelet/huh v0.6.0
1717 github.com/charmbracelet/lipgloss v1.1.0
18+ github.com/charmbracelet/x/ansi v0.8.0
1819 github.com/fsnotify/fsnotify v1.8.0
1920 github.com/go-logfmt/logfmt v0.6.0
2021 github.com/golang-migrate/migrate/v4 v4.18.2
@@ -29,11 +30,11 @@ require (
2930 github.com/muesli/reflow v0.3.0
3031 github.com/muesli/termenv v0.16.0
3132 github.com/openai/openai-go v0.1.0-beta.2
32− github.com/sergi/go-diff v1.3.1
33+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
3334 github.com/spf13/cobra v1.9.1
3435 github.com/spf13/viper v1.20.0
3536 github.com/stretchr/testify v1.10.0
36− golang.org/x/net v0.34.0
37+ golang.org/x/net v0.39.0
3738 google.golang.org/api v0.215.0
3839)
3940
@@ -64,7 +65,6 @@ require (
6465 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
6566 github.com/aymerick/douceur v0.2.0 // indirect
6667 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
67− github.com/charmbracelet/x/ansi v0.8.0 // indirect
6868 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
6969 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
7070 github.com/charmbracelet/x/term v0.2.1 // indirect
@@ -76,6 +76,7 @@ require (
7676 github.com/go-logr/logr v1.4.2 // indirect
7777 github.com/go-logr/stdr v1.2.2 // indirect
7878 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
79+ github.com/google/go-cmp v0.7.0 // indirect
7980 github.com/google/s2a-go v0.1.8 // indirect
8081 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
8182 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -92,6 +93,7 @@ require (
9293 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
9394 github.com/pmezard/go-difflib v1.0.0 // indirect
9495 github.com/rivo/uniseg v0.4.7 // indirect
96+ github.com/rogpeppe/go-internal v1.14.1 // indirect
9597 github.com/sagikazarmark/locafero v0.7.0 // indirect
9698 github.com/sahilm/fuzzy v0.1.1 // indirect
9799 github.com/sourcegraph/conc v0.3.0 // indirect
@@ -115,20 +117,21 @@ require (
115117 go.uber.org/atomic v1.9.0 // indirect
116118 go.uber.org/multierr v1.9.0 // indirect
117119 golang.design/x/clipboard v0.7.0 // indirect
118− golang.org/x/crypto v0.33.0 // indirect
119− golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
120+ golang.org/x/crypto v0.37.0 // indirect
121+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
120122 golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
121123 golang.org/x/image v0.14.0 // indirect
122124 golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
123125 golang.org/x/oauth2 v0.25.0 // indirect
124− golang.org/x/sync v0.12.0 // indirect
125− golang.org/x/sys v0.31.0 // indirect
126− golang.org/x/term v0.30.0 // indirect
127− golang.org/x/text v0.23.0 // indirect
126+ golang.org/x/sync v0.13.0 // indirect
127+ golang.org/x/sys v0.32.0 // indirect
128+ golang.org/x/term v0.31.0 // indirect
129+ golang.org/x/text v0.24.0 // indirect
128130 golang.org/x/time v0.8.0 // indirect
129131 google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
130132 google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
131133 google.golang.org/grpc v1.67.3 // indirect
132134 google.golang.org/protobuf v1.36.1 // indirect
135+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
133136 gopkg.in/yaml.v3 v3.0.1 // indirect
134137)
go.sum+23−20
@@ -117,8 +117,8 @@ github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK
117117github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
118118github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
119119github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
120−github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
121−github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
120+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
121+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
122122github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
123123github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
124124github.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
139139github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
140140github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
141141github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
142+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
142143github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
143144github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
144145github.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
189190github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
190191github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
191192github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
192−github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
193−github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
193+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
194+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
194195github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
195196github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
196197github.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
199200github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
200201github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
201202github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
202−github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
203203github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
204+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
205+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
204206github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
205207github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
206208github.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
261263golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
262264golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
263265golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
264−golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
265−golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
266−golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
267−golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
266+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
267+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
268+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
269+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
268270golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
269271golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
270272golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
@@ -282,15 +284,15 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
282284golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
283285golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
284286golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
285−golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
286−golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
287+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
288+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
287289golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
288290golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
289291golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
290292golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
291293golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
292−golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
293−golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
294+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
295+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
294296golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
295297golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
296298golang.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=
304306golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
305307golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
306308golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
307−golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
308−golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
309+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
310+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
309311golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
310312golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
311313golang.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=
314316golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
315317golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
316318golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
317−golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
318−golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
319+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
320+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
319321golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
320322golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
321323golang.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=
323325golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
324326golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
325327golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
326−golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
327−golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
328+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
329+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
328330golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
329331golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
330332golang.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
343345google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
344346google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
345347gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
346−gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
347348gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
349+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
350+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
348351gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
349352gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
350353gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
internal/llm/agent/agent.go+1
@@ -305,6 +305,7 @@ func (c *agent) generate(ctx context.Context, sessionID string, content string)
305305 assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
306306 Role: message.Assistant,
307307 Parts: []message.ContentPart{},
308+ Model: c.model.ID,
308309 })
309310 if err != nil {
310311 return err
internal/llm/tools/bash.go+11−2
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8+ "time"
89
910 "github.com/kujtimiihoxha/termai/internal/config"
1011 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
@@ -21,6 +22,9 @@ type BashPermissionsParams struct {
2122 Timeout int `json:"timeout"`
2223}
2324
25+type BashToolResponseMetadata struct {
26+ Took int64 `json:"took"`
27+}
2428type bashTool struct {
2529 permissions permission.Service
2630}
@@ -272,11 +276,13 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
272276 return NewTextErrorResponse("permission denied"), nil
273277 }
274278 }
279+ startTime := time.Now()
275280 shell := shell.GetPersistentShell(config.WorkingDirectory())
276281 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
277282 if err != nil {
278283 return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
279284 }
285+ took := time.Since(startTime).Milliseconds()
280286
281287 stdout = truncateOutput(stdout)
282288 stderr = truncateOutput(stderr)
@@ -304,10 +310,13 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
304310 stdout += "\n" + errorMessage
305311 }
306312
313+ metadata := BashToolResponseMetadata{
314+ Took: took,
315+ }
307316 if stdout == "" {
308− return NewTextResponse("no output"), nil
317+ return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
309318 }
310− return NewTextResponse(stdout), nil
319+ return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
311320}
312321
313322func truncateOutput(content string) string {
internal/llm/tools/tools.go+19−4
@@ -1,6 +1,9 @@
11package tools
22
3−import "context"
3+import (
4+ "context"
5+ "encoding/json"
6+)
47
58type ToolInfo struct {
69 Name string
@@ -17,9 +20,10 @@ const (
1720)
1821
1922type ToolResponse struct {
20− Type toolResponseType `json:"type"`
21− Content string `json:"content"`
22− IsError bool `json:"is_error"`
23+ Type toolResponseType `json:"type"`
24+ Content string `json:"content"`
25+ Metadata string `json:"metadata,omitempty"`
26+ IsError bool `json:"is_error"`
2327}
2428
2529func NewTextResponse(content string) ToolResponse {
@@ -29,6 +33,17 @@ func NewTextResponse(content string) ToolResponse {
2933 }
3034}
3135
36+func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
37+ if metadata != nil {
38+ metadataBytes, err := json.Marshal(metadata)
39+ if err != nil {
40+ return response
41+ }
42+ response.Metadata = string(metadataBytes)
43+ }
44+ return response
45+}
46+
3247func NewTextErrorResponse(content string) ToolResponse {
3348 return ToolResponse{
3449 Type: ToolResponseTypeText,
internal/message/content.go+4−1
@@ -3,6 +3,8 @@ package message
33import (
44 "encoding/base64"
55 "time"
6+
7+ "github.com/kujtimiihoxha/termai/internal/llm/models"
68)
79
810type MessageRole string
@@ -65,7 +67,6 @@ type ToolCall struct {
6567 Name string `json:"name"`
6668 Input string `json:"input"`
6769 Type string `json:"type"`
68− Metadata any `json:"metadata"`
6970 Finished bool `json:"finished"`
7071}
7172
@@ -75,6 +76,7 @@ type ToolResult struct {
7576 ToolCallID string `json:"tool_call_id"`
7677 Name string `json:"name"`
7778 Content string `json:"content"`
79+ Metadata string `json:"metadata"`
7880 IsError bool `json:"is_error"`
7981}
8082
@@ -92,6 +94,7 @@ type Message struct {
9294 Role MessageRole
9395 SessionID string
9496 Parts []ContentPart
97+ Model models.ModelID
9598
9699 CreatedAt int64
97100 UpdatedAt int64
internal/message/message.go+1
@@ -155,6 +155,7 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
155155 SessionID: item.SessionID,
156156 Role: MessageRole(item.Role),
157157 Parts: parts,
158+ Model: models.ModelID(item.Model.String),
158159 CreatedAt: item.CreatedAt,
159160 UpdatedAt: item.UpdatedAt,
160161 }, nil
internal/tui/components/chat/editor.go+7−8
@@ -77,21 +77,20 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7777 case AgentWorkingMsg:
7878 m.agentWorking = bool(msg)
7979 case tea.KeyMsg:
80− if key.Matches(msg, focusedKeyMaps.Send) {
80+ // if the key does not match any binding, return
81+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
8182 return m, m.send()
8283 }
83− if key.Matches(msg, bluredKeyMaps.Send) {
84+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Send) {
8485 return m, m.send()
8586 }
86− if key.Matches(msg, focusedKeyMaps.Blur) {
87+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Blur) {
8788 m.textarea.Blur()
8889 return m, util.CmdHandler(EditorFocusMsg(false))
8990 }
90− if key.Matches(msg, bluredKeyMaps.Focus) {
91− if !m.textarea.Focused() {
92− m.textarea.Focus()
93− return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
94− }
91+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Focus) {
92+ m.textarea.Focus()
93+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
9594 }
9695 }
9796 m.textarea, cmd = m.textarea.Update(msg)
internal/tui/components/chat/messages.go+351−107
@@ -1,16 +1,21 @@
11package chat
22
33import (
4+ "encoding/json"
45 "fmt"
5− "regexp"
6− "strconv"
6+ "math"
77 "strings"
88
9+ "github.com/charmbracelet/bubbles/spinner"
910 "github.com/charmbracelet/bubbles/viewport"
1011 tea "github.com/charmbracelet/bubbletea"
1112 "github.com/charmbracelet/glamour"
1213 "github.com/charmbracelet/lipgloss"
14+ "github.com/charmbracelet/x/ansi"
1315 "github.com/kujtimiihoxha/termai/internal/app"
16+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
17+ "github.com/kujtimiihoxha/termai/internal/llm/models"
18+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
1419 "github.com/kujtimiihoxha/termai/internal/message"
1520 "github.com/kujtimiihoxha/termai/internal/pubsub"
1621 "github.com/kujtimiihoxha/termai/internal/session"
@@ -18,10 +23,20 @@ import (
1823 "github.com/kujtimiihoxha/termai/internal/tui/util"
1924)
2025
26+type uiMessageType int
27+
28+const (
29+ userMessageType uiMessageType = iota
30+ assistantMessageType
31+ toolMessageType
32+)
33+
2134type uiMessage struct {
22− position int
23− height int
24− content string
35+ ID string
36+ messageType uiMessageType
37+ position int
38+ height int
39+ content string
2540}
2641
2742type messagesCmp struct {
@@ -32,141 +47,116 @@ type messagesCmp struct {
3247 session session.Session
3348 messages []message.Message
3449 uiMessages []uiMessage
35− currentIndex int
50+ currentMsgID string
3651 renderer *glamour.TermRenderer
3752 focusRenderer *glamour.TermRenderer
3853 cachedContent map[string]string
54+ agentWorking bool
55+ spinner spinner.Model
56+ needsRerender bool
57+ lastViewport string
3958}
4059
4160func (m *messagesCmp) Init() tea.Cmd {
42− return m.viewport.Init()
43−}
44−
45−var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
46−
47−func hexToBgSGR(hex string) (string, error) {
48− hex = strings.TrimPrefix(hex, "#")
49− if len(hex) != 6 {
50− return "", fmt.Errorf("invalid hex color: must be 6 hexadecimal digits")
51− }
52−
53− // Parse RGB components in one block
54− rgb := make([]uint64, 3)
55− for i := 0; i < 3; i++ {
56− val, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8)
57− if err != nil {
58− return "", err
59− }
60− rgb[i] = val
61− }
62−
63− return fmt.Sprintf("48;2;%d;%d;%d", rgb[0], rgb[1], rgb[2]), nil
64−}
65−
66−func forceReplaceBackgroundColors(input string, newBg string) string {
67− return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
68− // Extract content between "\x1b[" and "m"
69− content := seq[2 : len(seq)-1]
70− tokens := strings.Split(content, ";")
71− var newTokens []string
72−
73− // Skip background color tokens
74− for i := 0; i < len(tokens); i++ {
75− if tokens[i] == "" {
76− continue
77− }
78−
79− val, err := strconv.Atoi(tokens[i])
80− if err != nil {
81− newTokens = append(newTokens, tokens[i])
82− continue
83− }
84−
85− // Skip background color tokens
86− if val == 48 {
87− // Skip "48;5;N" or "48;2;R;G;B" sequences
88− if i+1 < len(tokens) {
89− if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
90− switch nextVal {
91− case 5:
92− i += 2 // Skip "5" and color index
93− case 2:
94− i += 4 // Skip "2" and RGB components
95− }
96− }
97− }
98− } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
99− // Keep non-background tokens
100− newTokens = append(newTokens, tokens[i])
101− }
102− }
103−
104− // Add new background if provided
105− if newBg != "" {
106− newTokens = append(newTokens, strings.Split(newBg, ";")...)
107− }
108−
109− if len(newTokens) == 0 {
110− return ""
111− }
112−
113− return "\x1b[" + strings.Join(newTokens, ";") + "m"
114− })
61+ return tea.Batch(m.viewport.Init())
11562}
11663
11764func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65+ var cmds []tea.Cmd
11866 switch msg := msg.(type) {
67+ case AgentWorkingMsg:
68+ m.agentWorking = bool(msg)
69+ if m.agentWorking {
70+ cmds = append(cmds, m.spinner.Tick)
71+ }
11972 case EditorFocusMsg:
12073 m.writingMode = bool(msg)
12174 case SessionSelectedMsg:
12275 if msg.ID != m.session.ID {
12376 cmd := m.SetSession(msg)
77+ m.needsRerender = true
12478 return m, cmd
12579 }
12680 return m, nil
81+ case SessionClearedMsg:
82+ m.session = session.Session{}
83+ m.messages = make([]message.Message, 0)
84+ m.currentMsgID = ""
85+ m.needsRerender = true
86+ return m, nil
87+
88+ case tea.KeyMsg:
89+ if m.writingMode {
90+ return m, nil
91+ }
12792 case pubsub.Event[message.Message]:
12893 if msg.Type == pubsub.CreatedEvent {
12994 if msg.Payload.SessionID == m.session.ID {
13095 // check if message exists
96+
97+ messageExists := false
13198 for _, v := range m.messages {
13299 if v.ID == msg.Payload.ID {
133− return m, nil
100+ messageExists = true
101+ break
134102 }
135103 }
136104
137− m.messages = append(m.messages, msg.Payload)
138− m.renderView()
139− m.viewport.GotoBottom()
105+ if !messageExists {
106+ m.messages = append(m.messages, msg.Payload)
107+ delete(m.cachedContent, m.currentMsgID)
108+ m.currentMsgID = msg.Payload.ID
109+ m.needsRerender = true
110+ }
140111 }
141112 for _, v := range m.messages {
142113 for _, c := range v.ToolCalls() {
143114 // the message is being added to the session of a tool called
144115 if c.ID == msg.Payload.SessionID {
145− m.renderView()
146− m.viewport.GotoBottom()
116+ m.needsRerender = true
147117 }
148118 }
149119 }
150120 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
151121 for i, v := range m.messages {
152122 if v.ID == msg.Payload.ID {
123+ if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
124+ cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
125+ }
153126 m.messages[i] = msg.Payload
154127 delete(m.cachedContent, msg.Payload.ID)
155− m.renderView()
156− if i == len(m.messages)-1 {
157− m.viewport.GotoBottom()
158− }
128+ m.needsRerender = true
159129 break
160130 }
161131 }
162132 }
163133 }
134+ if m.agentWorking {
135+ u, cmd := m.spinner.Update(msg)
136+ m.spinner = u
137+ cmds = append(cmds, cmd)
138+ }
139+ oldPos := m.viewport.YPosition
164140 u, cmd := m.viewport.Update(msg)
165141 m.viewport = u
166− return m, cmd
142+ m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
143+ cmds = append(cmds, cmd)
144+ if m.needsRerender {
145+ m.renderView()
146+ if len(m.messages) > 0 {
147+ if msg, ok := msg.(pubsub.Event[message.Message]); ok {
148+ if (msg.Type == pubsub.CreatedEvent) ||
149+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
150+ m.viewport.GotoBottom()
151+ }
152+ }
153+ }
154+ m.needsRerender = false
155+ }
156+ return m, tea.Batch(cmds...)
167157}
168158
169−func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
159+func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
170160 if v, ok := m.cachedContent[msg.ID]; ok {
171161 return v
172162 }
@@ -178,7 +168,7 @@ func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
178168 BorderStyle(lipgloss.ThickBorder())
179169
180170 renderer := m.renderer
181− if inx == m.currentIndex {
171+ if msg.ID == m.currentMsgID {
182172 style = style.
183173 Foreground(styles.Forground).
184174 BorderForeground(styles.Blue).
@@ -186,33 +176,269 @@ func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
186176 renderer = m.focusRenderer
187177 }
188178 c, _ := renderer.Render(msg.Content().String())
189− col, _ := hexToBgSGR(styles.Background.Dark)
190− rendered := style.Render(forceReplaceBackgroundColors(c, col))
179+ parts := []string{
180+ styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
181+ }
182+ // remove newline at the end
183+ parts[0] = strings.TrimSuffix(parts[0], "\n")
184+ if len(info) > 0 {
185+ parts = append(parts, info...)
186+ }
187+ rendered := style.Render(
188+ lipgloss.JoinVertical(
189+ lipgloss.Left,
190+ parts...,
191+ ),
192+ )
191193 m.cachedContent[msg.ID] = rendered
192194 return rendered
193195}
194196
197+func formatTimeDifference(unixTime1, unixTime2 int64) string {
198+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
199+
200+ if diffSeconds < 60 {
201+ return fmt.Sprintf("%.1fs", diffSeconds)
202+ }
203+
204+ minutes := int(diffSeconds / 60)
205+ seconds := int(diffSeconds) % 60
206+ return fmt.Sprintf("%dm%ds", minutes, seconds)
207+}
208+
209+func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
210+ key := ""
211+ value := ""
212+ switch toolCall.Name {
213+ // TODO: add result data to the tools
214+ case agent.AgentToolName:
215+ key = "Task"
216+ var params agent.AgentParams
217+ json.Unmarshal([]byte(toolCall.Input), &params)
218+ value = params.Prompt
219+ // TODO: handle nested calls
220+ case tools.BashToolName:
221+ key = "Bash"
222+ var params tools.BashParams
223+ json.Unmarshal([]byte(toolCall.Input), &params)
224+ value = params.Command
225+ case tools.EditToolName:
226+ key = "Edit"
227+ var params tools.EditParams
228+ json.Unmarshal([]byte(toolCall.Input), &params)
229+ value = params.FilePath
230+ case tools.FetchToolName:
231+ key = "Fetch"
232+ var params tools.FetchParams
233+ json.Unmarshal([]byte(toolCall.Input), &params)
234+ value = params.URL
235+ case tools.GlobToolName:
236+ key = "Glob"
237+ var params tools.GlobParams
238+ json.Unmarshal([]byte(toolCall.Input), &params)
239+ if params.Path == "" {
240+ params.Path = "."
241+ }
242+ value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
243+ case tools.GrepToolName:
244+ key = "Grep"
245+ var params tools.GrepParams
246+ json.Unmarshal([]byte(toolCall.Input), &params)
247+ if params.Path == "" {
248+ params.Path = "."
249+ }
250+ value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
251+ case tools.LSToolName:
252+ key = "Ls"
253+ var params tools.LSParams
254+ json.Unmarshal([]byte(toolCall.Input), &params)
255+ if params.Path == "" {
256+ params.Path = "."
257+ }
258+ value = params.Path
259+ case tools.SourcegraphToolName:
260+ key = "Sourcegraph"
261+ var params tools.SourcegraphParams
262+ json.Unmarshal([]byte(toolCall.Input), &params)
263+ value = params.Query
264+ case tools.ViewToolName:
265+ key = "View"
266+ var params tools.ViewParams
267+ json.Unmarshal([]byte(toolCall.Input), &params)
268+ value = params.FilePath
269+ case tools.WriteToolName:
270+ key = "Write"
271+ var params tools.WriteParams
272+ json.Unmarshal([]byte(toolCall.Input), &params)
273+ value = params.FilePath
274+ default:
275+ key = toolCall.Name
276+ var params map[string]any
277+ json.Unmarshal([]byte(toolCall.Input), &params)
278+ jsonData, _ := json.Marshal(params)
279+ value = string(jsonData)
280+ }
281+
282+ style := styles.BaseStyle.
283+ Width(m.width).
284+ BorderLeft(true).
285+ BorderStyle(lipgloss.ThickBorder()).
286+ PaddingLeft(1).
287+ BorderForeground(styles.Yellow)
288+
289+ keyStyle := styles.BaseStyle.
290+ Foreground(styles.ForgroundDim)
291+ valyeStyle := styles.BaseStyle.
292+ Foreground(styles.Forground)
293+
294+ if isNested {
295+ valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
296+ }
297+ keyValye := keyStyle.Render(
298+ fmt.Sprintf("%s: ", key),
299+ )
300+ if !isNested {
301+ value = valyeStyle.
302+ Width(m.width - lipgloss.Width(keyValye) - 2).
303+ Render(
304+ ansi.Truncate(
305+ value,
306+ m.width-lipgloss.Width(keyValye)-2,
307+ "...",
308+ ),
309+ )
310+ } else {
311+ keyValye = keyStyle.Render(
312+ fmt.Sprintf(" └ %s: ", key),
313+ )
314+ value = valyeStyle.
315+ Width(m.width - lipgloss.Width(keyValye) - 2).
316+ Render(
317+ ansi.Truncate(
318+ value,
319+ m.width-lipgloss.Width(keyValye)-2,
320+ "...",
321+ ),
322+ )
323+ }
324+
325+ innerToolCalls := make([]string, 0)
326+ if toolCall.Name == agent.AgentToolName {
327+ messages, _ := m.app.Messages.List(toolCall.ID)
328+ toolCalls := make([]message.ToolCall, 0)
329+ for _, v := range messages {
330+ toolCalls = append(toolCalls, v.ToolCalls()...)
331+ }
332+ for _, v := range toolCalls {
333+ call := m.renderToolCall(v, true)
334+ innerToolCalls = append(innerToolCalls, call)
335+ }
336+ }
337+
338+ if isNested {
339+ return lipgloss.JoinHorizontal(
340+ lipgloss.Left,
341+ keyValye,
342+ value,
343+ )
344+ }
345+ callContent := lipgloss.JoinHorizontal(
346+ lipgloss.Left,
347+ keyValye,
348+ value,
349+ )
350+ callContent = strings.ReplaceAll(callContent, "\n", "")
351+ if len(innerToolCalls) > 0 {
352+ callContent = lipgloss.JoinVertical(
353+ lipgloss.Left,
354+ callContent,
355+ lipgloss.JoinVertical(
356+ lipgloss.Left,
357+ innerToolCalls...,
358+ ),
359+ )
360+ }
361+ return style.Render(callContent)
362+}
363+
364+func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage {
365+ // find the user message that is before this assistant message
366+ var userMsg message.Message
367+ for i := len(m.messages) - 1; i >= 0; i-- {
368+ if m.messages[i].Role == message.User {
369+ userMsg = m.messages[i]
370+ break
371+ }
372+ }
373+ messages := make([]uiMessage, 0)
374+ if msg.Content().String() != "" {
375+ info := make([]string, 0)
376+ if msg.IsFinished() && msg.FinishReason() == "end_turn" {
377+ finish := msg.FinishPart()
378+ took := formatTimeDifference(userMsg.CreatedAt, finish.Time)
379+
380+ info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render(
381+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
382+ ))
383+ }
384+ content := m.renderSimpleMessage(msg, info...)
385+ messages = append(messages, uiMessage{
386+ messageType: assistantMessageType,
387+ position: 0, // gets updated in renderView
388+ height: lipgloss.Height(content),
389+ content: content,
390+ })
391+ }
392+ for _, v := range msg.ToolCalls() {
393+ content := m.renderToolCall(v, false)
394+ messages = append(messages,
395+ uiMessage{
396+ messageType: toolMessageType,
397+ position: 0, // gets updated in renderView
398+ height: lipgloss.Height(content),
399+ content: content,
400+ },
401+ )
402+ }
403+
404+ return messages
405+}
406+
195407func (m *messagesCmp) renderView() {
196408 m.uiMessages = make([]uiMessage, 0)
197409 pos := 0
198410
199411 for _, v := range m.messages {
200− content := ""
201412 switch v.Role {
202413 case message.User:
203− content = m.renderUserMessage(pos, v)
414+ content := m.renderSimpleMessage(v)
415+ m.uiMessages = append(m.uiMessages, uiMessage{
416+ messageType: userMessageType,
417+ position: pos,
418+ height: lipgloss.Height(content),
419+ content: content,
420+ })
421+ pos += lipgloss.Height(content) + 1 // + 1 for spacing
422+ case message.Assistant:
423+ assistantMessages := m.renderAssistantMessage(v)
424+ for _, msg := range assistantMessages {
425+ msg.position = pos
426+ m.uiMessages = append(m.uiMessages, msg)
427+ pos += msg.height + 1 // + 1 for spacing
428+ }
429+
204430 }
205− m.uiMessages = append(m.uiMessages, uiMessage{
206− position: pos,
207− height: lipgloss.Height(content),
208− content: content,
209− })
210− pos += lipgloss.Height(content) + 1 // + 1 for spacing
211431 }
212432
213433 messages := make([]string, 0)
214434 for _, v := range m.uiMessages {
215− messages = append(messages, v.content)
435+ messages = append(messages, v.content,
436+ styles.BaseStyle.
437+ Width(m.width).
438+ Render(
439+ "",
440+ ),
441+ )
216442 }
217443 m.viewport.SetContent(
218444 styles.BaseStyle.
@@ -246,7 +472,6 @@ func (m *messagesCmp) View() string {
246472 )
247473 }
248474
249− m.renderView()
250475 return styles.BaseStyle.
251476 Width(m.width).
252477 Render(
@@ -260,15 +485,21 @@ func (m *messagesCmp) View() string {
260485
261486func (m *messagesCmp) help() string {
262487 text := ""
488+
489+ if m.agentWorking {
490+ text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
491+ fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
492+ )
493+ }
263494 if m.writingMode {
264− text = lipgloss.JoinHorizontal(
495+ text += lipgloss.JoinHorizontal(
265496 lipgloss.Left,
266497 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
267498 styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
268499 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
269500 )
270501 } else {
271− text = lipgloss.JoinHorizontal(
502+ text += lipgloss.JoinHorizontal(
272503 lipgloss.Left,
273504 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
274505 styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
@@ -306,7 +537,15 @@ func (m *messagesCmp) SetSize(width, height int) {
306537 glamour.WithWordWrap(width-1),
307538 )
308539 m.focusRenderer = focusRenderer
540+ // clear the cached content
541+ for k := range m.cachedContent {
542+ delete(m.cachedContent, k)
543+ }
309544 m.renderer = renderer
545+ if len(m.messages) > 0 {
546+ m.renderView()
547+ m.viewport.GotoBottom()
548+ }
310549}
311550
312551func (m *messagesCmp) GetSize() (int, int) {
@@ -320,7 +559,8 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
320559 return util.ReportError(err)
321560 }
322561 m.messages = messages
323− m.messages = append(m.messages, m.messages[0])
562+ m.currentMsgID = m.messages[len(m.messages)-1].ID
563+ m.needsRerender = true
324564 return nil
325565}
326566
@@ -333,6 +573,9 @@ func NewMessagesCmp(app *app.App) tea.Model {
333573 glamour.WithStyles(styles.MarkdownTheme(false)),
334574 glamour.WithWordWrap(80),
335575 )
576+
577+ s := spinner.New()
578+ s.Spinner = spinner.Pulse
336579 return &messagesCmp{
337580 app: app,
338581 writingMode: true,
@@ -340,5 +583,6 @@ func NewMessagesCmp(app *app.App) tea.Model {
340583 viewport: viewport.New(0, 0),
341584 focusRenderer: focusRenderer,
342585 renderer: renderer,
586+ spinner: s,
343587 }
344588}
internal/tui/components/chat/sidebar.go+10−1
@@ -5,6 +5,7 @@ import (
55
66 tea "github.com/charmbracelet/bubbletea"
77 "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/pubsub"
89 "github.com/kujtimiihoxha/termai/internal/session"
910 "github.com/kujtimiihoxha/termai/internal/tui/styles"
1011)
@@ -19,6 +20,14 @@ func (m *sidebarCmp) Init() tea.Cmd {
1920}
2021
2122func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
23+ switch msg := msg.(type) {
24+ case pubsub.Event[session.Session]:
25+ if msg.Type == pubsub.UpdatedEvent {
26+ if m.session.ID == msg.Payload.ID {
27+ m.session = msg.Payload
28+ }
29+ }
30+ }
2231 return m, nil
2332}
2433
@@ -45,7 +54,7 @@ func (m *sidebarCmp) sessionSection() string {
4554 sessionValue := styles.BaseStyle.
4655 Foreground(styles.Forground).
4756 Width(m.width - lipgloss.Width(sessionKey)).
48− Render(": New Session")
57+ Render(fmt.Sprintf(": %s", m.session.Title))
4958 return lipgloss.JoinHorizontal(
5059 lipgloss.Left,
5160 sessionKey,
internal/tui/page/chat.go+50−12
@@ -1,9 +1,10 @@
11package page
22
33import (
4+ "github.com/charmbracelet/bubbles/key"
45 tea "github.com/charmbracelet/bubbletea"
56 "github.com/kujtimiihoxha/termai/internal/app"
6− "github.com/kujtimiihoxha/termai/internal/message"
7+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
78 "github.com/kujtimiihoxha/termai/internal/session"
89 "github.com/kujtimiihoxha/termai/internal/tui/components/chat"
910 "github.com/kujtimiihoxha/termai/internal/tui/layout"
@@ -18,8 +19,32 @@ type chatPage struct {
1819 session session.Session
1920}
2021
22+type ChatKeyMap struct {
23+ NewSession key.Binding
24+}
25+
26+var keyMap = ChatKeyMap{
27+ NewSession: key.NewBinding(
28+ key.WithKeys("ctrl+n"),
29+ key.WithHelp("ctrl+n", "new session"),
30+ ),
31+}
32+
2133func (p *chatPage) Init() tea.Cmd {
22− return p.layout.Init()
34+ // TODO: remove
35+ cmds := []tea.Cmd{
36+ p.layout.Init(),
37+ }
38+
39+ sessions, _ := p.app.Sessions.List()
40+ if len(sessions) > 0 {
41+ p.session = sessions[0]
42+ cmd := p.setSidebar()
43+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(p.session)), cmd)
44+ }
45+ return tea.Batch(
46+ cmds...,
47+ )
2348}
2449
2550func (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) {
3156 if cmd != nil {
3257 return p, cmd
3358 }
59+ case tea.KeyMsg:
60+ switch {
61+ case key.Matches(msg, keyMap.NewSession):
62+ p.session = session.Session{}
63+ p.clearSidebar()
64+ return p, util.CmdHandler(chat.SessionClearedMsg{})
65+ }
3466 }
3567 u, cmd := p.layout.Update(msg)
3668 p.layout = u.(layout.SplitPaneLayout)
@@ -51,6 +83,12 @@ func (p *chatPage) setSidebar() tea.Cmd {
5183 return sidebarContainer.Init()
5284}
5385
86+func (p *chatPage) clearSidebar() {
87+ p.layout.SetRightPanel(nil)
88+ width, height := p.layout.GetSize()
89+ p.layout.SetSize(width, height)
90+}
91+
5492func (p *chatPage) sendMessage(text string) tea.Cmd {
5593 var cmds []tea.Cmd
5694 if p.session.ID == "" {
@@ -66,15 +104,15 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
66104 }
67105 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
68106 }
69− // TODO: actually call agent
70− p.app.Messages.Create(p.session.ID, message.CreateMessageParams{
71− Role: message.User,
72− Parts: []message.ContentPart{
73− message.TextContent{
74− Text: text,
75− },
76− },
77− })
107+ // TODO: move this to a service
108+ a, err := agent.NewCoderAgent(p.app)
109+ if err != nil {
110+ return util.ReportError(err)
111+ }
112+ go func() {
113+ a.Generate(p.app.Context, p.session.ID, text)
114+ }()
115+
78116 return tea.Batch(cmds...)
79117}
80118
@@ -85,7 +123,7 @@ func (p *chatPage) View() string {
85123func NewChatPage(app *app.App) tea.Model {
86124 messagesContainer := layout.NewContainer(
87125 chat.NewMessagesCmp(app),
88− layout.WithPadding(1, 1, 1, 1),
126+ layout.WithPadding(1, 1, 0, 1),
89127 )
90128
91129 editorContainer := layout.NewContainer(
internal/tui/styles/background.gonew file+81
@@ -0,0 +1,81 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/charmbracelet/lipgloss"
10+)
11+
12+var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
13+
14+func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
15+ r, g, b, a := c.RGBA()
16+
17+ // Un-premultiply alpha if needed
18+ if a > 0 && a < 0xffff {
19+ r = (r * 0xffff) / a
20+ g = (g * 0xffff) / a
21+ b = (b * 0xffff) / a
22+ }
23+
24+ // Convert from 16-bit to 8-bit color
25+ return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
26+}
27+
28+func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
29+ r, g, b := getColorRGB(newBgColor)
30+
31+ newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
32+
33+ return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
34+ // Extract content between "\x1b[" and "m"
35+ content := seq[2 : len(seq)-1]
36+ tokens := strings.Split(content, ";")
37+ var newTokens []string
38+
39+ // Skip background color tokens
40+ for i := 0; i < len(tokens); i++ {
41+ if tokens[i] == "" {
42+ continue
43+ }
44+
45+ val, err := strconv.Atoi(tokens[i])
46+ if err != nil {
47+ newTokens = append(newTokens, tokens[i])
48+ continue
49+ }
50+
51+ // Skip background color tokens
52+ if val == 48 {
53+ // Skip "48;5;N" or "48;2;R;G;B" sequences
54+ if i+1 < len(tokens) {
55+ if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
56+ switch nextVal {
57+ case 5:
58+ i += 2 // Skip "5" and color index
59+ case 2:
60+ i += 4 // Skip "2" and RGB components
61+ }
62+ }
63+ }
64+ } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
65+ // Keep non-background tokens
66+ newTokens = append(newTokens, tokens[i])
67+ }
68+ }
69+
70+ // Add new background if provided
71+ if newBg != "" {
72+ newTokens = append(newTokens, strings.Split(newBg, ";")...)
73+ }
74+
75+ if len(newTokens) == 0 {
76+ return ""
77+ }
78+
79+ return "\x1b[" + strings.Join(newTokens, ";") + "m"
80+ })
81+}
internal/tui/styles/markdown.go+4−3
@@ -515,6 +515,7 @@ var ASCIIStyleConfig = ansi.StyleConfig{
515515 Document: ansi.StyleBlock{
516516 StylePrimitive: ansi.StylePrimitive{
517517 BackgroundColor: stringPtr(Background.Dark),
518+ Color: stringPtr(ForgroundDim.Dark),
518519 },
519520 Indent: uintPtr(1),
520521 IndentToken: stringPtr(BaseStyle.Render(" ")),
@@ -688,7 +689,7 @@ var DraculaStyleConfig = ansi.StyleConfig{
688689 Heading: ansi.StyleBlock{
689690 StylePrimitive: ansi.StylePrimitive{
690691 BlockSuffix: "\n",
691− Color: stringPtr("#bd93f9"),
692+ Color: stringPtr(PrimaryColor.Dark),
692693 Bold: boolPtr(true),
693694 BackgroundColor: stringPtr(Background.Dark),
694695 },
@@ -740,7 +741,7 @@ var DraculaStyleConfig = ansi.StyleConfig{
740741 },
741742 Strong: ansi.StylePrimitive{
742743 Bold: boolPtr(true),
743− Color: stringPtr("#ffb86c"),
744+ Color: stringPtr(Blue.Dark),
744745 BackgroundColor: stringPtr(Background.Dark),
745746 },
746747 HorizontalRule: ansi.StylePrimitive{
@@ -796,7 +797,7 @@ var DraculaStyleConfig = ansi.StyleConfig{
796797 CodeBlock: ansi.StyleCodeBlock{
797798 StyleBlock: ansi.StyleBlock{
798799 StylePrimitive: ansi.StylePrimitive{
799− Color: stringPtr("#ffb86c"),
800+ Color: stringPtr(Blue.Dark),
800801 BackgroundColor: stringPtr(Background.Dark),
801802 },
802803 Margin: uintPtr(defaultMargin),
internal/tui/styles/styles.go+10
@@ -34,6 +34,11 @@ var (
3434 Light: "#d3d3d3",
3535 }
3636
37+ ForgroundMid = lipgloss.AdaptiveColor{
38+ Dark: "#a0a0a0",
39+ Light: "#a0a0a0",
40+ }
41+
3742 ForgroundDim = lipgloss.AdaptiveColor{
3843 Dark: "#737373",
3944 Light: "#737373",
@@ -159,6 +164,11 @@ var (
159164 Light: light.Peach().Hex,
160165 }
161166
167+ Yellow = lipgloss.AdaptiveColor{
168+ Dark: dark.Yellow().Hex,
169+ Light: light.Yellow().Hex,
170+ }
171+
162172 Primary = Blue
163173 Secondary = Mauve
164174