on main help sign in
reimplement agent,provider and add file history
bbfa60cKujtim Hoxha
.opencode.json−4
@@ -1,8 +1,4 @@
1{
1{
2− "model": {
3− "coder": "claude-3.7-sonnet",
4− "coderMaxTokens": 20000
5− },
6 "lsp": {
2 "lsp": {
7 "gopls": {
3 "gopls": {
8 "command": "gopls"
4 "command": "gopls"
README.md+17−17
@@ -1,14 +1,14 @@
1−# TermAI
1+# OpenCode
2
2
3> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
3> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
4
4
5A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
5A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
6
6
7−[![TermAI Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
7+[![OpenCode Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
8
8
9## Overview
9## Overview
10
10
11−TermAI is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
11+OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
12
12
13## Features
13## Features
14
14
@@ -23,16 +23,16 @@ TermAI is a Go-based CLI application that brings AI assistance to your terminal.
23
23
24```bash
24```bash
25# Coming soon
25# Coming soon
26−go install github.com/kujtimiihoxha/termai@latest
26+go install github.com/kujtimiihoxha/opencode@latest
27```
27```
28
28
29## Configuration
29## Configuration
30
30
31−TermAI looks for configuration in the following locations:
31+OpenCode looks for configuration in the following locations:
32
32
33−- `$HOME/.termai.json`
33+- `$HOME/.opencode.json`
34−- `$XDG_CONFIG_HOME/termai/.termai.json`
34+- `$XDG_CONFIG_HOME/opencode/.opencode.json`
35−- `./.termai.json` (local directory)
35+- `./.opencode.json` (local directory)
36
36
37You can also use environment variables:
37You can also use environment variables:
38
38
@@ -43,11 +43,11 @@ You can also use environment variables:
43## Usage
43## Usage
44
44
45```bash
45```bash
46−# Start TermAI
46+# Start OpenCode
47−termai
47+opencode
48
48
49# Start with debug logging
49# Start with debug logging
50−termai -d
50+opencode -d
51```
51```
52
52
53### Keyboard Shortcuts
53### Keyboard Shortcuts
@@ -81,7 +81,7 @@ termai -d
81
81
82## Architecture
82## Architecture
83
83
84−TermAI is built with a modular architecture:
84+OpenCode is built with a modular architecture:
85
85
86- **cmd**: Command-line interface using Cobra
86- **cmd**: Command-line interface using Cobra
87- **internal/app**: Core application services
87- **internal/app**: Core application services
@@ -103,22 +103,22 @@ TermAI is built with a modular architecture:
103
103
104```bash
104```bash
105# Clone the repository
105# Clone the repository
106−git clone https://github.com/kujtimiihoxha/termai.git
106+git clone https://github.com/kujtimiihoxha/opencode.git
107−cd termai
107+cd opencode
108
108
109# Build the diff script first
109# Build the diff script first
110go run cmd/diff/main.go
110go run cmd/diff/main.go
111
111
112# Build
112# Build
113−go build -o termai
113+go build -o opencode
114
114
115# Run
115# Run
116−./termai
116+./opencode
117```
117```
118
118
119## Acknowledgments
119## Acknowledgments
120
120
121−TermAI builds upon the work of several open source projects and developers:
121+OpenCode builds upon the work of several open source projects and developers:
122
122
123- [@isaacphi](https://github.com/isaacphi) - LSP client implementation
123- [@isaacphi](https://github.com/isaacphi) - LSP client implementation
124
124
cmd/root.go+7−17
@@ -20,7 +20,7 @@ import (
20)
20)
21
21
22var rootCmd = &cobra.Command{
22var rootCmd = &cobra.Command{
23− Use: "termai",
23+ Use: "OpenCode",
24 Short: "A terminal ai assistant",
24 Short: "A terminal ai assistant",
25 Long: `A terminal ai assistant`,
25 Long: `A terminal ai assistant`,
26 RunE: func(cmd *cobra.Command, args []string) error {
26 RunE: func(cmd *cobra.Command, args []string) error {
@@ -89,12 +89,9 @@ var rootCmd = &cobra.Command{
89 // Set up message handling for the TUI
89 // Set up message handling for the TUI
90 go func() {
90 go func() {
91 defer tuiWg.Done()
91 defer tuiWg.Done()
92− defer func() {
93− if r := recover(); r != nil {
94− logging.Error("Panic in TUI message handling: %v", r)
92+ defer logging.RecoverPanic("TUI-message-handler", func() {
95− attemptTUIRecovery(program)
93+ attemptTUIRecovery(program)
96− }
97− }()
94+ })
98
95
99 for {
96 for {
100 select {
97 select {
@@ -153,11 +150,7 @@ func attemptTUIRecovery(program *tea.Program) {
153
150
154func initMCPTools(ctx context.Context, app *app.App) {
151func initMCPTools(ctx context.Context, app *app.App) {
155 go func() {
152 go func() {
156− defer func() {
157− if r := recover(); r != nil {
158− logging.Error("Panic in MCP goroutine: %v", r)
153+ defer logging.RecoverPanic("MCP-goroutine", nil)
159− }
160− }()
161
154
162 // Create a context with timeout for the initial MCP tools fetch
155 // Create a context with timeout for the initial MCP tools fetch
163 ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
156 ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
@@ -179,11 +172,7 @@ func setupSubscriber[T any](
179 wg.Add(1)
172 wg.Add(1)
180 go func() {
173 go func() {
181 defer wg.Done()
174 defer wg.Done()
182− defer func() {
183− if r := recover(); r != nil {
184− logging.Error("Panic in %s subscription goroutine: %v", name, r)
175+ defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
185− }
186− }()
187
176
188 for {
177 for {
189 select {
178 select {
@@ -232,6 +221,7 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
232 // Wait with a timeout for all goroutines to complete
221 // Wait with a timeout for all goroutines to complete
233 waitCh := make(chan struct{})
222 waitCh := make(chan struct{})
234 go func() {
223 go func() {
224+ defer logging.RecoverPanic("subscription-cleanup", nil)
235 wg.Wait()
225 wg.Wait()
236 close(waitCh)
226 close(waitCh)
237 }()
227 }()
go.mod+1−7
@@ -23,7 +23,6 @@ require (
23 github.com/golang-migrate/migrate/v4 v4.18.2
23 github.com/golang-migrate/migrate/v4 v4.18.2
24 github.com/google/generative-ai-go v0.19.0
24 github.com/google/generative-ai-go v0.19.0
25 github.com/google/uuid v1.6.0
25 github.com/google/uuid v1.6.0
26− github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9
27 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
26 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
28 github.com/mark3labs/mcp-go v0.17.0
27 github.com/mark3labs/mcp-go v0.17.0
29 github.com/mattn/go-runewidth v0.0.16
28 github.com/mattn/go-runewidth v0.0.16
@@ -36,7 +35,6 @@ require (
36 github.com/spf13/cobra v1.9.1
35 github.com/spf13/cobra v1.9.1
37 github.com/spf13/viper v1.20.0
36 github.com/spf13/viper v1.20.0
38 github.com/stretchr/testify v1.10.0
37 github.com/stretchr/testify v1.10.0
39− golang.org/x/net v0.39.0
40 google.golang.org/api v0.215.0
38 google.golang.org/api v0.215.0
41)
39)
42
40
@@ -106,7 +104,6 @@ require (
106 github.com/pmezard/go-difflib v1.0.0 // indirect
104 github.com/pmezard/go-difflib v1.0.0 // indirect
107 github.com/rivo/uniseg v0.4.7 // indirect
105 github.com/rivo/uniseg v0.4.7 // indirect
108 github.com/sagikazarmark/locafero v0.7.0 // indirect
106 github.com/sagikazarmark/locafero v0.7.0 // indirect
109− github.com/sahilm/fuzzy v0.1.1 // indirect
110 github.com/skeema/knownhosts v1.3.1 // indirect
107 github.com/skeema/knownhosts v1.3.1 // indirect
111 github.com/sourcegraph/conc v0.3.0 // indirect
108 github.com/sourcegraph/conc v0.3.0 // indirect
112 github.com/spf13/afero v1.12.0 // indirect
109 github.com/spf13/afero v1.12.0 // indirect
@@ -129,11 +126,8 @@ require (
129 go.opentelemetry.io/otel/trace v1.29.0 // indirect
126 go.opentelemetry.io/otel/trace v1.29.0 // indirect
130 go.uber.org/atomic v1.9.0 // indirect
127 go.uber.org/atomic v1.9.0 // indirect
131 go.uber.org/multierr v1.9.0 // indirect
128 go.uber.org/multierr v1.9.0 // indirect
132− golang.design/x/clipboard v0.7.0 // indirect
133 golang.org/x/crypto v0.37.0 // indirect
129 golang.org/x/crypto v0.37.0 // indirect
134− golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
130+ golang.org/x/net v0.39.0 // indirect
135− golang.org/x/image v0.14.0 // indirect
136− golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
137 golang.org/x/oauth2 v0.25.0 // indirect
131 golang.org/x/oauth2 v0.25.0 // indirect
138 golang.org/x/sync v0.13.0 // indirect
132 golang.org/x/sync v0.13.0 // indirect
139 golang.org/x/sys v0.32.0 // indirect
133 golang.org/x/sys v0.32.0 // indirect
go.sum−14
@@ -180,10 +180,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
180github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
180github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
181github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
181github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
182github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
182github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
183−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9 h1:xYfCLI8KUwmXDFp1pOpNX+XsQczQw9VbEuju1pQF5/A=
184−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9/go.mod h1:Ye+kIkTmPO5xuqCQ+PPHDTGIViRRoSpSIlcYgma8YlA=
185−github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
186−github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
187github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
183github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
188github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
184github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
189github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
185github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
@@ -235,8 +231,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
235github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
231github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
236github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
232github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
237github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
233github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
238−github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
239−github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
240github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
234github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
241github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
235github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
242github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
236github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -302,8 +296,6 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
302go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
296go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
303go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
297go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
304go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
298go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
305−golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
306−golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
307golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
299golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
308golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
300golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
309golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
301golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -314,12 +306,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
314golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
306golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
315golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
307golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
316golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
308golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
317−golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
318−golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
319−golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
320−golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
321−golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
322−golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
323golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
309golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
324golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
310golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
325golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
311golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
internal/app/app.go+12−5
@@ -7,6 +7,7 @@ import (
7 "sync"
7 "sync"
8 "time"
8 "time"
9
9
10+ "github.com/kujtimiihoxha/termai/internal/config"
10 "github.com/kujtimiihoxha/termai/internal/db"
11 "github.com/kujtimiihoxha/termai/internal/db"
11 "github.com/kujtimiihoxha/termai/internal/history"
12 "github.com/kujtimiihoxha/termai/internal/history"
12 "github.com/kujtimiihoxha/termai/internal/llm/agent"
13 "github.com/kujtimiihoxha/termai/internal/llm/agent"
@@ -20,7 +21,7 @@ import (
20type App struct {
21type App struct {
21 Sessions session.Service
22 Sessions session.Service
22 Messages message.Service
23 Messages message.Service
23− Files history.Service
24+ History history.Service
24 Permissions permission.Service
25 Permissions permission.Service
25
26
26 CoderAgent agent.Service
27 CoderAgent agent.Service
@@ -43,7 +44,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
43 app := &App{
44 app := &App{
44 Sessions: sessions,
45 Sessions: sessions,
45 Messages: messages,
46 Messages: messages,
46− Files: files,
47+ History: files,
47 Permissions: permission.NewPermissionService(),
48 Permissions: permission.NewPermissionService(),
48 LSPClients: make(map[string]*lsp.Client),
49 LSPClients: make(map[string]*lsp.Client),
49 }
50 }
@@ -51,11 +52,17 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
51 app.initLSPClients(ctx)
52 app.initLSPClients(ctx)
52
53
53 var err error
54 var err error
54− app.CoderAgent, err = agent.NewCoderAgent(
55+ app.CoderAgent, err = agent.NewAgent(
55− app.Permissions,
56+ config.AgentCoder,
56 app.Sessions,
57 app.Sessions,
57 app.Messages,
58 app.Messages,
59+ agent.CoderAgentTools(
60+ app.Permissions,
61+ app.Sessions,
62+ app.Messages,
63+ app.History,
58− app.LSPClients,
64+ app.LSPClients,
65+ ),
59 )
66 )
60 if err != nil {
67 if err != nil {
61 logging.Error("Failed to create coder agent", err)
68 logging.Error("Failed to create coder agent", err)
internal/app/lsp.go+8−11
@@ -22,16 +22,17 @@ func (app *App) initLSPClients(ctx context.Context) {
22// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
22// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
23func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
23func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
24 // Create a specific context for initialization with a timeout
24 // Create a specific context for initialization with a timeout
25− initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second)
26− defer initCancel()
27
25
28 // Create the LSP client
26 // Create the LSP client
29− lspClient, err := lsp.NewClient(initCtx, command, args...)
27+ lspClient, err := lsp.NewClient(ctx, command, args...)
30 if err != nil {
28 if err != nil {
31 logging.Error("Failed to create LSP client for", name, err)
29 logging.Error("Failed to create LSP client for", name, err)
32 return
30 return
31+
33 }
32 }
34
33
34+ initCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
35+ defer cancel()
35 // Initialize with the initialization context
36 // Initialize with the initialization context
36 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
37 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
37 if err != nil {
38 if err != nil {
@@ -64,14 +65,10 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
64// runWorkspaceWatcher executes the workspace watcher for an LSP client
65// runWorkspaceWatcher executes the workspace watcher for an LSP client
65func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
66func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
66 defer app.watcherWG.Done()
67 defer app.watcherWG.Done()
67− defer func() {
68+ defer logging.RecoverPanic("LSP-"+name, func() {
68− if r := recover(); r != nil {
69− logging.Error("LSP client crashed", "client", name, "panic", r)
70−
71− // Try to restart the client
69+ // Try to restart the client
72− app.restartLSPClient(ctx, name)
70+ app.restartLSPClient(ctx, name)
73− }
74− }()
71+ })
75
72
76 workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
73 workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
77 logging.Info("Workspace watcher stopped", "client", name)
74 logging.Info("Workspace watcher stopped", "client", name)
internal/config/config.go+76−32
@@ -31,12 +31,18 @@ type MCPServer struct {
31 Headers map[string]string `json:"headers"`
31 Headers map[string]string `json:"headers"`
32}
32}
33
33
34+type AgentName string
35+
36+const (
37+ AgentCoder AgentName = "coder"
38+ AgentTask AgentName = "task"
39+ AgentTitle AgentName = "title"
40+)
41+
34−// Model defines configuration for different LLM models and their token limits.
42+// Agent defines configuration for different LLM models and their token limits.
35−type Model struct {
43+type Agent struct {
36− Coder models.ModelID `json:"coder"`
44+ Model models.ModelID `json:"model"`
37− CoderMaxTokens int64 `json:"coderMaxTokens"`
45+ MaxTokens int64 `json:"maxTokens"`
38− Task models.ModelID `json:"task"`
39− TaskMaxTokens int64 `json:"taskMaxTokens"`
40}
46}
41
47
42// Provider defines configuration for an LLM provider.
48// Provider defines configuration for an LLM provider.
@@ -65,8 +71,9 @@ type Config struct {
65 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
71 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
66 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
72 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
67 LSP map[string]LSPConfig `json:"lsp,omitempty"`
73 LSP map[string]LSPConfig `json:"lsp,omitempty"`
68− Model Model `json:"model"`
74+ Agents map[AgentName]Agent `json:"agents"`
69 Debug bool `json:"debug,omitempty"`
75 Debug bool `json:"debug,omitempty"`
76+ DebugLSP bool `json:"debugLSP,omitempty"`
70}
77}
71
78
72// Application constants
79// Application constants
@@ -118,11 +125,42 @@ func Load(workingDir string, debug bool) (*Config, error) {
118 if cfg.Debug {
125 if cfg.Debug {
119 defaultLevel = slog.LevelDebug
126 defaultLevel = slog.LevelDebug
120 }
127 }
128+ // if we are in debug mode make the writer a file
129+ if cfg.Debug {
130+ loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
131+
132+ // if file does not exist create it
133+ if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
134+ if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
135+ return cfg, fmt.Errorf("failed to create directory: %w", err)
136+ }
137+ if _, err := os.Create(loggingFile); err != nil {
138+ return cfg, fmt.Errorf("failed to create log file: %w", err)
139+ }
140+ }
141+
142+ sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
143+ if err != nil {
144+ return cfg, fmt.Errorf("failed to open log file: %w", err)
145+ }
121− // Configure logger
146+ // Configure logger
147+ logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
148+ Level: defaultLevel,
149+ }))
150+ slog.SetDefault(logger)
151+ } else {
152+ // Configure logger
122− logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
153+ logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
123− Level: defaultLevel,
154+ Level: defaultLevel,
124− }))
155+ }))
125− slog.SetDefault(logger)
156+ slog.SetDefault(logger)
157+ }
158+
159+ // Override the max tokens for title agent
160+ cfg.Agents[AgentTitle] = Agent{
161+ Model: cfg.Agents[AgentTitle].Model,
162+ MaxTokens: 80,
163+ }
126 return cfg, nil
164 return cfg, nil
127}
165}
128
166
@@ -159,44 +197,50 @@ func setProviderDefaults() {
159 // Groq configuration
197 // Groq configuration
160 if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
198 if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
161 viper.SetDefault("providers.groq.apiKey", apiKey)
199 viper.SetDefault("providers.groq.apiKey", apiKey)
162− viper.SetDefault("model.coder", models.QWENQwq)
200+ viper.SetDefault("agents.coder.model", models.QWENQwq)
163− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
201+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
164− viper.SetDefault("model.task", models.QWENQwq)
202+ viper.SetDefault("agents.task.model", models.QWENQwq)
165− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
203+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
204+ viper.SetDefault("agents.title.model", models.QWENQwq)
166 }
205 }
167
206
168 // Google Gemini configuration
207 // Google Gemini configuration
169 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
208 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
170 viper.SetDefault("providers.gemini.apiKey", apiKey)
209 viper.SetDefault("providers.gemini.apiKey", apiKey)
171− viper.SetDefault("model.coder", models.GRMINI20Flash)
210+ viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
172− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
211+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
173− viper.SetDefault("model.task", models.GRMINI20Flash)
212+ viper.SetDefault("agents.task.model", models.GRMINI20Flash)
174− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
213+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
214+ viper.SetDefault("agents.title.model", models.GRMINI20Flash)
175 }
215 }
176
216
177 // OpenAI configuration
217 // OpenAI configuration
178 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
179 viper.SetDefault("providers.openai.apiKey", apiKey)
219 viper.SetDefault("providers.openai.apiKey", apiKey)
180− viper.SetDefault("model.coder", models.GPT4o)
220+ viper.SetDefault("agents.coder.model", models.GPT4o)
181− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
221+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
182− viper.SetDefault("model.task", models.GPT4o)
222+ viper.SetDefault("agents.task.model", models.GPT4o)
183− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
223+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
224+ viper.SetDefault("agents.title.model", models.GPT4o)
225+
184 }
226 }
185
227
186 // Anthropic configuration
228 // Anthropic configuration
187 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
229 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
188 viper.SetDefault("providers.anthropic.apiKey", apiKey)
230 viper.SetDefault("providers.anthropic.apiKey", apiKey)
189− viper.SetDefault("model.coder", models.Claude37Sonnet)
231+ viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
190− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
232+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
191− viper.SetDefault("model.task", models.Claude37Sonnet)
233+ viper.SetDefault("agents.task.model", models.Claude37Sonnet)
192− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
234+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
235+ viper.SetDefault("agents.title.model", models.Claude37Sonnet)
193 }
236 }
194
237
195 if hasAWSCredentials() {
238 if hasAWSCredentials() {
196− viper.SetDefault("model.coder", models.BedrockClaude37Sonnet)
239+ viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
197− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
240+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
198− viper.SetDefault("model.task", models.BedrockClaude37Sonnet)
241+ viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
199− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
242+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
243+ viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
200 }
244 }
201}
245}
202
246
internal/db/files.sql.go+3−1
@@ -97,7 +97,9 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
97const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
97const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
98SELECT id, session_id, path, content, version, created_at, updated_at
98SELECT id, session_id, path, content, version, created_at, updated_at
99FROM files
99FROM files
100−WHERE path = ? AND session_id = ? LIMIT 1
100+WHERE path = ? AND session_id = ?
101+ORDER BY created_at DESC
102+LIMIT 1
101`
103`
102
104
103type GetFileByPathAndSessionParams struct {
105type GetFileByPathAndSessionParams struct {
internal/db/sql/files.sql+3−1
@@ -6,7 +6,9 @@ WHERE id = ? LIMIT 1;
6-- name: GetFileByPathAndSession :one
6-- name: GetFileByPathAndSession :one
7SELECT *
7SELECT *
8FROM files
8FROM files
9−WHERE path = ? AND session_id = ? LIMIT 1;
9+WHERE path = ? AND session_id = ?
10+ORDER BY created_at DESC
11+LIMIT 1;
10
12
11-- name: ListFilesBySession :many
13-- name: ListFilesBySession :many
12SELECT *
14SELECT *
internal/diff/diff.go+83−16
@@ -19,6 +19,8 @@ import (
19 "github.com/charmbracelet/x/ansi"
19 "github.com/charmbracelet/x/ansi"
20 "github.com/go-git/go-git/v5"
20 "github.com/go-git/go-git/v5"
21 "github.com/go-git/go-git/v5/plumbing/object"
21 "github.com/go-git/go-git/v5/plumbing/object"
22+ "github.com/kujtimiihoxha/termai/internal/config"
23+ "github.com/kujtimiihoxha/termai/internal/logging"
22 "github.com/sergi/go-diff/diffmatchpatch"
24 "github.com/sergi/go-diff/diffmatchpatch"
23)
25)
24
26
@@ -77,6 +79,8 @@ type linePair struct {
77
79
78// StyleConfig defines styling for diff rendering
80// StyleConfig defines styling for diff rendering
79type StyleConfig struct {
81type StyleConfig struct {
82+ ShowHeader bool
83+ FileNameFg lipgloss.Color
80 // Background colors
84 // Background colors
81 RemovedLineBg lipgloss.Color
85 RemovedLineBg lipgloss.Color
82 AddedLineBg lipgloss.Color
86 AddedLineBg lipgloss.Color
@@ -106,11 +110,13 @@ type StyleOption func(*StyleConfig)
106func NewStyleConfig(opts ...StyleOption) StyleConfig {
110func NewStyleConfig(opts ...StyleOption) StyleConfig {
107 // Default color scheme
111 // Default color scheme
108 config := StyleConfig{
112 config := StyleConfig{
113+ ShowHeader: true,
114+ FileNameFg: lipgloss.Color("#fab283"),
109 RemovedLineBg: lipgloss.Color("#3A3030"),
115 RemovedLineBg: lipgloss.Color("#3A3030"),
110 AddedLineBg: lipgloss.Color("#303A30"),
116 AddedLineBg: lipgloss.Color("#303A30"),
111 ContextLineBg: lipgloss.Color("#212121"),
117 ContextLineBg: lipgloss.Color("#212121"),
112− HunkLineBg: lipgloss.Color("#23252D"),
118+ HunkLineBg: lipgloss.Color("#212121"),
113− HunkLineFg: lipgloss.Color("#8CA3B4"),
119+ HunkLineFg: lipgloss.Color("#a0a0a0"),
114 RemovedFg: lipgloss.Color("#7C4444"),
120 RemovedFg: lipgloss.Color("#7C4444"),
115 AddedFg: lipgloss.Color("#478247"),
121 AddedFg: lipgloss.Color("#478247"),
116 LineNumberFg: lipgloss.Color("#888888"),
122 LineNumberFg: lipgloss.Color("#888888"),
@@ -132,6 +138,10 @@ func NewStyleConfig(opts ...StyleOption) StyleConfig {
132}
138}
133
139
134// Style option functions
140// Style option functions
141+func WithFileNameFg(color lipgloss.Color) StyleOption {
142+ return func(s *StyleConfig) { s.FileNameFg = color }
143+}
144+
135func WithRemovedLineBg(color lipgloss.Color) StyleOption {
145func WithRemovedLineBg(color lipgloss.Color) StyleOption {
136 return func(s *StyleConfig) { s.RemovedLineBg = color }
146 return func(s *StyleConfig) { s.RemovedLineBg = color }
137}
147}
@@ -190,6 +200,10 @@ func WithHunkLineFg(color lipgloss.Color) StyleOption {
190 return func(s *StyleConfig) { s.HunkLineFg = color }
200 return func(s *StyleConfig) { s.HunkLineFg = color }
191}
201}
192
202
203+func WithShowHeader(show bool) StyleOption {
204+ return func(s *StyleConfig) { s.ShowHeader = show }
205+}
206+
193// -------------------------------------------------------------------------
207// -------------------------------------------------------------------------
194// Parse Configuration
208// Parse Configuration
195// -------------------------------------------------------------------------
209// -------------------------------------------------------------------------
@@ -841,10 +855,12 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
841 // Calculate column width
855 // Calculate column width
842 colWidth := config.TotalWidth / 2
856 colWidth := config.TotalWidth / 2
843
857
858+ leftWidth := colWidth
859+ rightWidth := config.TotalWidth - colWidth
844 var sb strings.Builder
860 var sb strings.Builder
845 for _, p := range pairs {
861 for _, p := range pairs {
846− leftStr := renderLeftColumn(fileName, p.left, colWidth, config.Style)
862+ leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
847− rightStr := renderRightColumn(fileName, p.right, colWidth, config.Style)
863+ rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
848 sb.WriteString(leftStr + rightStr + "\n")
864 sb.WriteString(leftStr + rightStr + "\n")
849 }
865 }
850
866
@@ -861,17 +877,50 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
861 var sb strings.Builder
877 var sb strings.Builder
862 config := NewSideBySideConfig(opts...)
878 config := NewSideBySideConfig(opts...)
863
879
880+ if config.Style.ShowHeader {
881+ removeIcon := lipgloss.NewStyle().
882+ Background(config.Style.RemovedLineBg).
883+ Foreground(config.Style.RemovedFg).
884+ Render("⏹")
885+ addIcon := lipgloss.NewStyle().
886+ Background(config.Style.AddedLineBg).
887+ Foreground(config.Style.AddedFg).
888+ Render("⏹")
889+
890+ fileName := lipgloss.NewStyle().
891+ Background(config.Style.ContextLineBg).
892+ Foreground(config.Style.FileNameFg).
893+ Render(" " + diffResult.OldFile)
894+ sb.WriteString(
895+ lipgloss.NewStyle().
896+ Background(config.Style.ContextLineBg).
897+ Padding(0, 1, 0, 1).
898+ Foreground(config.Style.FileNameFg).
899+ BorderStyle(lipgloss.NormalBorder()).
900+ BorderTop(true).
901+ BorderBottom(true).
902+ BorderForeground(config.Style.FileNameFg).
903+ BorderBackground(config.Style.ContextLineBg).
904+ Width(config.TotalWidth).
905+ Render(
906+ lipgloss.JoinHorizontal(lipgloss.Top,
907+ removeIcon,
908+ addIcon,
909+ fileName,
910+ ),
911+ ) + "\n",
912+ )
913+ }
914+
864− for i, h := range diffResult.Hunks {
915+ for _, h := range diffResult.Hunks {
865− if i > 0 {
866− // Render hunk header
916+ // Render hunk header
867− sb.WriteString(
917+ sb.WriteString(
868− lipgloss.NewStyle().
918+ lipgloss.NewStyle().
869− Background(config.Style.HunkLineBg).
919+ Background(config.Style.HunkLineBg).
870− Foreground(config.Style.HunkLineFg).
920+ Foreground(config.Style.HunkLineFg).
871− Width(config.TotalWidth).
921+ Width(config.TotalWidth).
872− Render(h.Header) + "\n",
922+ Render(h.Header) + "\n",
873− )
923+ )
874− }
875 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
924 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
876 }
925 }
877
926
@@ -880,9 +929,15 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
880
929
881// GenerateDiff creates a unified diff from two file contents
930// GenerateDiff creates a unified diff from two file contents
882func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
931func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
932+ // remove the cwd prefix and ensure consistent path format
933+ // this prevents issues with absolute paths in different environments
934+ cwd := config.WorkingDirectory()
935+ fileName = strings.TrimPrefix(fileName, cwd)
936+ fileName = strings.TrimPrefix(fileName, "/")
883 // Create temporary directory for git operations
937 // Create temporary directory for git operations
884− tempDir, err := os.MkdirTemp("", "git-diff-temp")
938+ tempDir, err := os.MkdirTemp("", fmt.Sprintf("git-diff-%d", time.Now().UnixNano()))
885 if err != nil {
939 if err != nil {
940+ logging.Error("Failed to create temp directory for git diff", "error", err)
886 return "", 0, 0
941 return "", 0, 0
887 }
942 }
888 defer os.RemoveAll(tempDir)
943 defer os.RemoveAll(tempDir)
@@ -890,25 +945,30 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
890 // Initialize git repo
945 // Initialize git repo
891 repo, err := git.PlainInit(tempDir, false)
946 repo, err := git.PlainInit(tempDir, false)
892 if err != nil {
947 if err != nil {
948+ logging.Error("Failed to initialize git repository", "error", err)
893 return "", 0, 0
949 return "", 0, 0
894 }
950 }
895
951
896 wt, err := repo.Worktree()
952 wt, err := repo.Worktree()
897 if err != nil {
953 if err != nil {
954+ logging.Error("Failed to get git worktree", "error", err)
898 return "", 0, 0
955 return "", 0, 0
899 }
956 }
900
957
901 // Write the "before" content and commit it
958 // Write the "before" content and commit it
902 fullPath := filepath.Join(tempDir, fileName)
959 fullPath := filepath.Join(tempDir, fileName)
903 if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
960 if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
961+ logging.Error("Failed to create directory for file", "error", err)
904 return "", 0, 0
962 return "", 0, 0
905 }
963 }
906 if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
964 if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
965+ logging.Error("Failed to write before content to file", "error", err)
907 return "", 0, 0
966 return "", 0, 0
908 }
967 }
909
968
910 _, err = wt.Add(fileName)
969 _, err = wt.Add(fileName)
911 if err != nil {
970 if err != nil {
971+ logging.Error("Failed to add file to git", "error", err)
912 return "", 0, 0
972 return "", 0, 0
913 }
973 }
914
974
@@ -920,16 +980,19 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
920 },
980 },
921 })
981 })
922 if err != nil {
982 if err != nil {
983+ logging.Error("Failed to commit before content", "error", err)
923 return "", 0, 0
984 return "", 0, 0
924 }
985 }
925
986
926 // Write the "after" content and commit it
987 // Write the "after" content and commit it
927 if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
988 if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
989+ logging.Error("Failed to write after content to file", "error", err)
928 return "", 0, 0
990 return "", 0, 0
929 }
991 }
930
992
931 _, err = wt.Add(fileName)
993 _, err = wt.Add(fileName)
932 if err != nil {
994 if err != nil {
995+ logging.Error("Failed to add file to git", "error", err)
933 return "", 0, 0
996 return "", 0, 0
934 }
997 }
935
998
@@ -941,22 +1004,26 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
941 },
1004 },
942 })
1005 })
943 if err != nil {
1006 if err != nil {
1007+ logging.Error("Failed to commit after content", "error", err)
944 return "", 0, 0
1008 return "", 0, 0
945 }
1009 }
946
1010
947 // Get the diff between the two commits
1011 // Get the diff between the two commits
948 beforeCommitObj, err := repo.CommitObject(beforeCommit)
1012 beforeCommitObj, err := repo.CommitObject(beforeCommit)
949 if err != nil {
1013 if err != nil {
1014+ logging.Error("Failed to get before commit object", "error", err)
950 return "", 0, 0
1015 return "", 0, 0
951 }
1016 }
952
1017
953 afterCommitObj, err := repo.CommitObject(afterCommit)
1018 afterCommitObj, err := repo.CommitObject(afterCommit)
954 if err != nil {
1019 if err != nil {
1020+ logging.Error("Failed to get after commit object", "error", err)
955 return "", 0, 0
1021 return "", 0, 0
956 }
1022 }
957
1023
958 patch, err := beforeCommitObj.Patch(afterCommitObj)
1024 patch, err := beforeCommitObj.Patch(afterCommitObj)
959 if err != nil {
1025 if err != nil {
1026+ logging.Error("Failed to create git diff patch", "error", err)
960 return "", 0, 0
1027 return "", 0, 0
961 }
1028 }
962
1029
internal/llm/agent/agent-tool.go+7−11
@@ -5,6 +5,7 @@ import (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7
7
8+ "github.com/kujtimiihoxha/termai/internal/config"
8 "github.com/kujtimiihoxha/termai/internal/llm/tools"
9 "github.com/kujtimiihoxha/termai/internal/llm/tools"
9 "github.com/kujtimiihoxha/termai/internal/lsp"
10 "github.com/kujtimiihoxha/termai/internal/lsp"
10 "github.com/kujtimiihoxha/termai/internal/message"
11 "github.com/kujtimiihoxha/termai/internal/message"
@@ -53,7 +54,7 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
53 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
54 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
54 }
55 }
55
56
56− agent, err := NewTaskAgent(b.messages, b.sessions, b.lspClients)
57+ agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
57 if err != nil {
58 if err != nil {
58 return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
59 return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
59 }
60 }
@@ -63,21 +64,16 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
63 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
64 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
64 }
65 }
65
66
66− err = agent.Generate(ctx, session.ID, params.Prompt)
67+ done, err := agent.Run(ctx, session.ID, params.Prompt)
67 if err != nil {
68 if err != nil {
68 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
69 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
69 }
70 }
70−
71+ result := <-done
71− messages, err := b.messages.List(ctx, session.ID)
72− if err != nil {
72+ if result.Err() != nil {
73− return tools.ToolResponse{}, fmt.Errorf("error listing messages: %s", err)
73+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
74− }
75−
76− if len(messages) == 0 {
77− return tools.NewTextErrorResponse("no response"), nil
78 }
74 }
79
75
80− response := messages[len(messages)-1]
76+ response := result.Response()
81 if response.Role != message.Assistant {
77 if response.Role != message.Assistant {
82 return tools.NewTextErrorResponse("no response"), nil
78 return tools.NewTextErrorResponse("no response"), nil
83 }
79 }
internal/llm/agent/agent.go+313−548
@@ -4,8 +4,6 @@ import (
4 "context"
4 "context"
5 "errors"
5 "errors"
6 "fmt"
6 "fmt"
7− "os"
8− "runtime/debug"
9 "strings"
7 "strings"
10 "sync"
8 "sync"
11
9
@@ -16,133 +14,101 @@ import (
16 "github.com/kujtimiihoxha/termai/internal/llm/tools"
14 "github.com/kujtimiihoxha/termai/internal/llm/tools"
17 "github.com/kujtimiihoxha/termai/internal/logging"
15 "github.com/kujtimiihoxha/termai/internal/logging"
18 "github.com/kujtimiihoxha/termai/internal/message"
16 "github.com/kujtimiihoxha/termai/internal/message"
17+ "github.com/kujtimiihoxha/termai/internal/permission"
19 "github.com/kujtimiihoxha/termai/internal/session"
18 "github.com/kujtimiihoxha/termai/internal/session"
20)
19)
21
20
22// Common errors
21// Common errors
23var (
22var (
24− ErrProviderNotEnabled = errors.New("provider is not enabled")
25− ErrRequestCancelled = errors.New("request cancelled by user")
23+ ErrRequestCancelled = errors.New("request cancelled by user")
26− ErrSessionBusy = errors.New("session is currently processing another request")
24+ ErrSessionBusy = errors.New("session is currently processing another request")
27)
25)
28
26
29−// Service defines the interface for generating responses
27+type AgentEvent struct {
28+ message message.Message
29+ err error
30+}
31+
32+func (e *AgentEvent) Err() error {
33+ return e.err
34+}
35+
36+func (e *AgentEvent) Response() message.Message {
37+ return e.message
38+}
39+
30type Service interface {
40type Service interface {
31− Generate(ctx context.Context, sessionID string, content string) error
41+ Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
32− Cancel(sessionID string) error
42+ Cancel(sessionID string)
43+ IsSessionBusy(sessionID string) bool
33}
44}
34
45
35type agent struct {
46type agent struct {
36− sessions session.Service
47+ sessions session.Service
37− messages message.Service
48+ messages message.Service
38− model models.Model
49+
39− tools []tools.BaseTool
50+ tools []tools.BaseTool
40− agent provider.Provider
51+ provider provider.Provider
52+
41− titleGenerator provider.Provider
53+ titleProvider provider.Provider
54+
42− activeRequests sync.Map // map[sessionID]context.CancelFunc
55+ activeRequests sync.Map
43}
56}
44
57
45−// NewAgent creates a new agent instance with the given model and tools
58+func NewAgent(
46−func NewAgent(ctx context.Context, sessions session.Service, messages message.Service, model models.Model, tools []tools.BaseTool) (Service, error) {
59+ agentName config.AgentName,
47− agentProvider, titleGenerator, err := getAgentProviders(ctx, model)
60+ sessions session.Service,
61+ messages message.Service,
62+ agentTools []tools.BaseTool,
63+) (Service, error) {
64+ agentProvider, err := createAgentProvider(agentName)
48 if err != nil {
65 if err != nil {
49− return nil, fmt.Errorf("failed to initialize providers: %w", err)
66+ return nil, err
67+ }
68+ var titleProvider provider.Provider
69+ // Only generate titles for the coder agent
70+ if agentName == config.AgentCoder {
71+ titleProvider, err = createAgentProvider(config.AgentTitle)
72+ if err != nil {
73+ return nil, err
74+ }
50 }
75 }
51
76
52− return &agent{
77+ agent := &agent{
53− model: model,
78+ provider: agentProvider,
54− tools: tools,
55− sessions: sessions,
56 messages: messages,
79 messages: messages,
57− agent: agentProvider,
80+ sessions: sessions,
58− titleGenerator: titleGenerator,
81+ tools: agentTools,
82+ titleProvider: titleProvider,
59 activeRequests: sync.Map{},
83 activeRequests: sync.Map{},
60− }, nil
84+ }
85+
86+ return agent, nil
61}
87}
62
88
63−// Cancel cancels an active request by session ID
64−func (a *agent) Cancel(sessionID string) error {
89+func (a *agent) Cancel(sessionID string) {
65 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
90 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
66 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
91 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
67 logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
92 logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
68 cancel()
93 cancel()
69− return nil
70 }
94 }
71 }
95 }
72− return errors.New("no active request found for this session")
73}
96}
74
97
75−// Generate starts the generation process
76−func (a *agent) Generate(ctx context.Context, sessionID string, content string) error {
77− // Check if this session already has an active request
78− if _, busy := a.activeRequests.Load(sessionID); busy {
79− return ErrSessionBusy
80− }
81−
82− // Create a cancellable context
83− genCtx, cancel := context.WithCancel(ctx)
84−
85− // Store cancel function to allow user cancellation
86− a.activeRequests.Store(sessionID, cancel)
87−
88− // Launch the generation in a goroutine
89− go func() {
90− defer func() {
91− if r := recover(); r != nil {
92− logging.ErrorPersist(fmt.Sprintf("Panic in Generate: %v", r))
93−
94− // dump stack trace into a file
95− file, err := os.Create("panic.log")
96− if err != nil {
97− logging.ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
98− return
99− }
100−
101− defer file.Close()
102−
103− stackTrace := debug.Stack()
104− if _, err := file.Write(stackTrace); err != nil {
105− logging.ErrorPersist(fmt.Sprintf("Failed to write panic log: %v", err))
106− }
107−
108− }
109− }()
110− defer a.activeRequests.Delete(sessionID)
111− defer cancel()
112−
113− if err := a.generate(genCtx, sessionID, content); err != nil {
114− if !errors.Is(err, ErrRequestCancelled) && !errors.Is(err, context.Canceled) {
115− // Log the error (avoid logging cancellations as they're expected)
116− logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, err))
117−
118− // You may want to create an error message in the chat
119− bgCtx := context.Background()
120− errorMsg := fmt.Sprintf("Sorry, an error occurred: %v", err)
121− _, createErr := a.messages.Create(bgCtx, sessionID, message.CreateMessageParams{
122− Role: message.System,
123− Parts: []message.ContentPart{
124− message.TextContent{
125− Text: errorMsg,
126− },
127− },
128− })
129− if createErr != nil {
130− logging.ErrorPersist(fmt.Sprintf("Failed to create error message: %v", createErr))
131− }
132− }
133− }
134− }()
135−
136− return nil
137−}
138−
139−// IsSessionBusy checks if a session currently has an active request
140func (a *agent) IsSessionBusy(sessionID string) bool {
98func (a *agent) IsSessionBusy(sessionID string) bool {
141 _, busy := a.activeRequests.Load(sessionID)
99 _, busy := a.activeRequests.Load(sessionID)
142 return busy
100 return busy
143−} // handleTitleGeneration asynchronously generates a title for new sessions
101+}
102+
144−func (a *agent) handleTitleGeneration(ctx context.Context, sessionID, content string) {
103+func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
104+ if a.titleProvider == nil {
105+ return nil
106+ }
107+ session, err := a.sessions.Get(ctx, sessionID)
108+ if err != nil {
109+ return err
110+ }
145− response, err := a.titleGenerator.SendMessages(
111+ response, err := a.titleProvider.SendMessages(
146 ctx,
112 ctx,
147 []message.Message{
113 []message.Message{
148 {
114 {
@@ -154,121 +120,152 @@ func (a *agent) handleTitleGeneration(ctx context.Context, sessionID, content st
154 },
120 },
155 },
121 },
156 },
122 },
157− nil,
123+ make([]tools.BaseTool, 0),
158 )
124 )
159 if err != nil {
125 if err != nil {
160− logging.ErrorPersist(fmt.Sprintf("Failed to generate title: %v", err))
126+ return err
161− return
162 }
127 }
163
128
164− session, err := a.sessions.Get(ctx, sessionID)
129+ title := strings.TrimSpace(strings.ReplaceAll(response.Content, "\n", " "))
165− if err != nil {
130+ if title == "" {
166− logging.ErrorPersist(fmt.Sprintf("Failed to get session: %v", err))
167− return
131+ return nil
168 }
132 }
169
133
170− if response.Content != "" {
171− session.Title = strings.TrimSpace(response.Content)
134+ session.Title = title
172− session.Title = strings.ReplaceAll(session.Title, "\n", " ")
173− if _, err := a.sessions.Save(ctx, session); err != nil {
135+ _, err = a.sessions.Save(ctx, session)
174− logging.ErrorPersist(fmt.Sprintf("Failed to save session title: %v", err))
136+ return err
175− }
137+}
138+
139+func (a *agent) err(err error) AgentEvent {
140+ return AgentEvent{
141+ err: err,
176 }
142 }
177}
143}
178
144
179−// TrackUsage updates token usage statistics for the session
180−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
145+func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
181− session, err := a.sessions.Get(ctx, sessionID)
146+ events := make(chan AgentEvent)
182− if err != nil {
147+ if a.IsSessionBusy(sessionID) {
183− return fmt.Errorf("failed to get session: %w", err)
148+ return nil, ErrSessionBusy
184 }
149 }
185
150
186− cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
151+ genCtx, cancel := context.WithCancel(ctx)
187− model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
152+
188− model.CostPer1MIn/1e6*float64(usage.InputTokens) +
153+ a.activeRequests.Store(sessionID, cancel)
189− model.CostPer1MOut/1e6*float64(usage.OutputTokens)
154+ go func() {
155+ logging.Debug("Request started", "sessionID", sessionID)
156+ defer logging.RecoverPanic("agent.Run", func() {
157+ events <- a.err(fmt.Errorf("panic while running the agent"))
158+ })
190
159
191− session.Cost += cost
160+ result := a.processGeneration(genCtx, sessionID, content)
192− session.CompletionTokens += usage.OutputTokens
161+ if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
193− session.PromptTokens += usage.InputTokens
162+ logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result))
163+ }
164+ logging.Debug("Request completed", "sessionID", sessionID)
165+ a.activeRequests.Delete(sessionID)
166+ cancel()
167+ events <- result
168+ close(events)
169+ }()
170+ return events, nil
171+}
194
172
195− _, err = a.sessions.Save(ctx, session)
173+func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
174+ // List existing messages; if none, start title generation asynchronously.
175+ msgs, err := a.messages.List(ctx, sessionID)
196 if err != nil {
176 if err != nil {
197− return fmt.Errorf("failed to save session: %w", err)
177+ return a.err(fmt.Errorf("failed to list messages: %w", err))
178+ }
179+ if len(msgs) == 0 {
180+ go func() {
181+ defer logging.RecoverPanic("agent.Run", func() {
182+ logging.ErrorPersist("panic while generating title")
183+ })
184+ titleErr := a.generateTitle(context.Background(), sessionID, content)
185+ if titleErr != nil {
186+ logging.ErrorPersist(fmt.Sprintf("failed to generate title: %v", titleErr))
187+ }
188+ }()
198 }
189 }
199− return nil
200−}
201
190
202−// processEvent handles different types of events during generation
191+ userMsg, err := a.createUserMessage(ctx, sessionID, content)
203−func (a *agent) processEvent(
192+ if err != nil {
204− ctx context.Context,
193+ return a.err(fmt.Errorf("failed to create user message: %w", err))
205− sessionID string,
206− assistantMsg *message.Message,
207− event provider.ProviderEvent,
208−) error {
209− select {
210− case <-ctx.Done():
211− return ctx.Err()
212− default:
213− // Continue processing
214 }
194 }
215
195
216− switch event.Type {
196+ // Append the new user message to the conversation history.
217− case provider.EventThinkingDelta:
197+ msgHistory := append(msgs, userMsg)
218− assistantMsg.AppendReasoningContent(event.Content)
198+ for {
219− return a.messages.Update(ctx, *assistantMsg)
199+ // Check for cancellation before each iteration
220− case provider.EventContentDelta:
200+ select {
221− assistantMsg.AppendContent(event.Content)
201+ case <-ctx.Done():
222− return a.messages.Update(ctx, *assistantMsg)
202+ return a.err(ctx.Err())
223− case provider.EventError:
203+ default:
224− if errors.Is(event.Error, context.Canceled) {
204+ // Continue processing
225− logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
226− return context.Canceled
227 }
205 }
228− logging.ErrorPersist(event.Error.Error())
206+ agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
229− return event.Error
207+ if err != nil {
230− case provider.EventWarning:
208+ if errors.Is(err, context.Canceled) {
231− logging.WarnPersist(event.Info)
209+ return a.err(ErrRequestCancelled)
232− case provider.EventInfo:
210+ }
233− logging.InfoPersist(event.Info)
234− case provider.EventComplete:
235− assistantMsg.SetToolCalls(event.Response.ToolCalls)
236− assistantMsg.AddFinish(event.Response.FinishReason)
237− if err := a.messages.Update(ctx, *assistantMsg); err != nil {
238− return fmt.Errorf("failed to update message: %w", err)
211+ return a.err(fmt.Errorf("failed to process events: %w", err))
212+ }
213+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
214+ if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
215+ // We are not done, we need to respond with the tool response
216+ msgHistory = append(msgHistory, agentMessage, *toolResults)
217+ continue
218+ }
219+ return AgentEvent{
220+ message: agentMessage,
239 }
221 }
240− return a.TrackUsage(ctx, sessionID, a.model, event.Response.Usage)
241 }
222 }
223+}
242
224
243− return nil
225+func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
226+ return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
227+ Role: message.User,
228+ Parts: []message.ContentPart{
229+ message.TextContent{Text: content},
230+ },
231+ })
244}
232}
245
233
246−// ExecuteTools runs all tool calls sequentially and returns the results
234+func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
247−func (a *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall, tls []tools.BaseTool) ([]message.ToolResult, error) {
235+ eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
248− toolResults := make([]message.ToolResult, len(toolCalls))
236+
237+ assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
238+ Role: message.Assistant,
239+ Parts: []message.ContentPart{},
240+ Model: a.provider.Model().ID,
241+ })
242+ if err != nil {
243+ return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
244+ }
249
245
250− // Create a child context that can be canceled
246+ // Add the session and message ID into the context if needed by tools.
251− ctx, cancel := context.WithCancel(ctx)
247+ ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
252− defer cancel()
248+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
253
249
254− // Check if already canceled before starting any execution
250+ // Process each event in the stream.
255− if ctx.Err() != nil {
251+ for event := range eventChan {
256− // Mark all tools as canceled
252+ if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
257− for i, toolCall := range toolCalls {
253+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
258− toolResults[i] = message.ToolResult{
254+ return assistantMsg, nil, processErr
259− ToolCallID: toolCall.ID,
260− Content: "Tool execution canceled by user",
261− IsError: true,
262− }
255+ }
256+ if ctx.Err() != nil {
257+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
258+ return assistantMsg, nil, ctx.Err()
263 }
259 }
264− return toolResults, ctx.Err()
265 }
260 }
266
261
262+ toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
263+ toolCalls := assistantMsg.ToolCalls()
267 for i, toolCall := range toolCalls {
264 for i, toolCall := range toolCalls {
268− // Check for cancellation before executing each tool
269 select {
265 select {
270 case <-ctx.Done():
266 case <-ctx.Done():
271− // Mark this and all remaining tools as canceled
267+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
268+ // Make all future tool calls cancelled
272 for j := i; j < len(toolCalls); j++ {
269 for j := i; j < len(toolCalls); j++ {
273 toolResults[j] = message.ToolResult{
270 toolResults[j] = message.ToolResult{
274 ToolCallID: toolCalls[j].ID,
271 ToolCallID: toolCalls[j].ID,
@@ -276,412 +273,180 @@ func (a *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall,
276 IsError: true,
273 IsError: true,
277 }
274 }
278 }
275 }
279− return toolResults, ctx.Err()
276+ goto out
280 default:
277 default:
281 // Continue processing
278 // Continue processing
282− }
279+ var tool tools.BaseTool
283−
284− response := ""
285− isError := false
286− found := false
287−
288− // Find and execute the appropriate tool
289− for _, tool := range tls {
280+ for _, availableTools := range a.tools {
290− if tool.Info().Name == toolCall.Name {
281+ if availableTools.Info().Name == toolCall.Name {
291− found = true
282+ tool = availableTools
292− toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
293− ID: toolCall.ID,
294− Name: toolCall.Name,
295− Input: toolCall.Input,
296− })
297−
298− if toolErr != nil {
299− if errors.Is(toolErr, context.Canceled) {
300− response = "Tool execution canceled by user"
301− } else {
302− response = fmt.Sprintf("Error running tool: %s", toolErr)
303− }
304− isError = true
305− } else {
306− response = toolResult.Content
307− isError = toolResult.IsError
308 }
283 }
309− break
310 }
284 }
311− }
312−
313− if !found {
314− response = fmt.Sprintf("Tool not found: %s", toolCall.Name)
315− isError = true
316− }
317−
318− toolResults[i] = message.ToolResult{
319− ToolCallID: toolCall.ID,
320− Content: response,
321− IsError: isError,
322− }
323− }
324
285
325− return toolResults, nil
286+ // Tool not found
326−}
287+ if tool == nil {
327−
328−// handleToolExecution processes tool calls and creates tool result messages
329−func (a *agent) handleToolExecution(
330− ctx context.Context,
331− assistantMsg message.Message,
332−) (*message.Message, error) {
333− select {
334− case <-ctx.Done():
335− // If cancelled, create tool results that indicate cancellation
336− if len(assistantMsg.ToolCalls()) > 0 {
337− toolResults := make([]message.ToolResult, 0, len(assistantMsg.ToolCalls()))
338− for _, tc := range assistantMsg.ToolCalls() {
339− toolResults = append(toolResults, message.ToolResult{
288+ toolResults[i] = message.ToolResult{
340− ToolCallID: tc.ID,
289+ ToolCallID: toolCall.ID,
341− Content: "Tool execution canceled by user",
290+ Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
342 IsError: true,
291 IsError: true,
343− })
292+ }
293+ continue
344 }
294 }
345
295
346− // Use background context to ensure the message is created even if original context is cancelled
296+ toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
347− bgCtx := context.Background()
297+ ID: toolCall.ID,
348− parts := make([]message.ContentPart, 0)
298+ Name: toolCall.Name,
349− for _, toolResult := range toolResults {
299+ Input: toolCall.Input,
350− parts = append(parts, toolResult)
351− }
352− msg, err := a.messages.Create(bgCtx, assistantMsg.SessionID, message.CreateMessageParams{
353− Role: message.Tool,
354− Parts: parts,
355 })
300 })
356− if err != nil {
301+ if toolErr != nil {
357− return nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
302+ if errors.Is(toolErr, permission.ErrorPermissionDenied) {
358− }
303+ toolResults[i] = message.ToolResult{
359− return &msg, ctx.Err()
304+ ToolCallID: toolCall.ID,
305+ Content: "Permission denied",
306+ IsError: true,
307+ }
308+ for j := i + 1; j < len(toolCalls); j++ {
309+ toolResults[j] = message.ToolResult{
310+ ToolCallID: toolCalls[j].ID,
311+ Content: "Tool execution canceled by user",
312+ IsError: true,
313+ }
314+ }
315+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
316+ } else {
317+ toolResults[i] = message.ToolResult{
318+ ToolCallID: toolCall.ID,
319+ Content: toolErr.Error(),
320+ IsError: true,
321+ }
322+ for j := i; j < len(toolCalls); j++ {
323+ toolResults[j] = message.ToolResult{
324+ ToolCallID: toolCalls[j].ID,
325+ Content: "Previous tool failed",
326+ IsError: true,
360− }
327+ }
361− return nil, ctx.Err()
362− default:
363− // Continue processing
364− }
328+ }
365−
329+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonError)
366− if len(assistantMsg.ToolCalls()) == 0 {
330+ }
367− return nil, nil
331+ // If permission is denied or an error happens we cancel all the following tools
368− }
332+ break
369−
370− toolResults, err := a.ExecuteTools(ctx, assistantMsg.ToolCalls(), a.tools)
371− if err != nil {
372− // If error is from cancellation, still return the partial results we have
373− if errors.Is(err, context.Canceled) {
374− // Use background context to ensure the message is created even if original context is cancelled
375− bgCtx := context.Background()
376− parts := make([]message.ContentPart, 0)
377− for _, toolResult := range toolResults {
378− parts = append(parts, toolResult)
379 }
333 }
380−
334+ toolResults[i] = message.ToolResult{
381− msg, createErr := a.messages.Create(bgCtx, assistantMsg.SessionID, message.CreateMessageParams{
335+ ToolCallID: toolCall.ID,
382− Role: message.Tool,
336+ Content: toolResult.Content,
383− Parts: parts,
337+ Metadata: toolResult.Metadata,
384− })
338+ IsError: toolResult.IsError,
385− if createErr != nil {
386− logging.ErrorPersist(fmt.Sprintf("Failed to create tool message after cancellation: %v", createErr))
387− return nil, err
388 }
339 }
389− return &msg, err
390 }
340 }
391− return nil, err
392 }
341 }
393−
342+out:
394− parts := make([]message.ContentPart, 0, len(toolResults))
343+ if len(toolResults) == 0 {
395− for _, toolResult := range toolResults {
344+ return assistantMsg, nil, nil
396− parts = append(parts, toolResult)
397 }
345 }
398−
346+ parts := make([]message.ContentPart, 0)
347+ for _, tr := range toolResults {
348+ parts = append(parts, tr)
349+ }
399− msg, err := a.messages.Create(ctx, assistantMsg.SessionID, message.CreateMessageParams{
350+ msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
400 Role: message.Tool,
351 Role: message.Tool,
401 Parts: parts,
352 Parts: parts,
402 })
353 })
403 if err != nil {
354 if err != nil {
404− return nil, fmt.Errorf("failed to create tool message: %w", err)
355+ return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
405 }
356 }
406
357
407− return &msg, nil
358+ return assistantMsg, &msg, err
408}
359}
409
360
410−// generate handles the main generation workflow
361+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
411−func (a *agent) generate(ctx context.Context, sessionID string, content string) error {
362+ msg.AddFinish(finishReson)
412− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
363+ _ = a.messages.Update(ctx, *msg)
364+}
413
365
414− // Handle context cancellation at any point
366+func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
367+ select {
368+ case <-ctx.Done():
415− if err := ctx.Err(); err != nil {
369+ return ctx.Err()
416− return ErrRequestCancelled
370+ default:
371+ // Continue processing.
417 }
372 }
418
373
419− messages, err := a.messages.List(ctx, sessionID)
374+ switch event.Type {
420− if err != nil {
375+ case provider.EventThinkingDelta:
376+ assistantMsg.AppendReasoningContent(event.Content)
377+ return a.messages.Update(ctx, *assistantMsg)
378+ case provider.EventContentDelta:
379+ assistantMsg.AppendContent(event.Content)
380+ return a.messages.Update(ctx, *assistantMsg)
381+ case provider.EventError:
382+ if errors.Is(event.Error, context.Canceled) {
383+ logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
384+ return context.Canceled
385+ }
386+ logging.ErrorPersist(event.Error.Error())
387+ return event.Error
388+ case provider.EventComplete:
389+ assistantMsg.SetToolCalls(event.Response.ToolCalls)
390+ assistantMsg.AddFinish(event.Response.FinishReason)
391+ if err := a.messages.Update(ctx, *assistantMsg); err != nil {
421− return fmt.Errorf("failed to list messages: %w", err)
392+ return fmt.Errorf("failed to update message: %w", err)
393+ }
394+ return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
422 }
395 }
423
396
424− if len(messages) == 0 {
397+ return nil
425− titleCtx := context.Background()
426− go a.handleTitleGeneration(titleCtx, sessionID, content)
427− }
398+}
428
399
429− userMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
400+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
430− Role: message.User,
401+ sess, err := a.sessions.Get(ctx, sessionID)
431− Parts: []message.ContentPart{
432− message.TextContent{
433− Text: content,
434− },
435− },
436− })
437 if err != nil {
402 if err != nil {
438− return fmt.Errorf("failed to create user message: %w", err)
403+ return fmt.Errorf("failed to get session: %w", err)
439 }
404 }
440
405
441− messages = append(messages, userMsg)
406+ cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
442−
407+ model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
443− for {
408+ model.CostPer1MIn/1e6*float64(usage.InputTokens) +
444− // Check for cancellation before each iteration
409+ model.CostPer1MOut/1e6*float64(usage.OutputTokens)
445− select {
446− case <-ctx.Done():
447− return ErrRequestCancelled
448− default:
449− // Continue processing
450− }
451−
452− eventChan, err := a.agent.StreamResponse(ctx, messages, a.tools)
453− if err != nil {
454− if errors.Is(err, context.Canceled) {
455− return ErrRequestCancelled
456− }
457− return fmt.Errorf("failed to stream response: %w", err)
458− }
459−
460− assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
461− Role: message.Assistant,
462− Parts: []message.ContentPart{},
463− Model: a.model.ID,
464− })
465− if err != nil {
466− return fmt.Errorf("failed to create assistant message: %w", err)
467− }
468−
469− ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
470−
471− // Process events from the LLM provider
472− for event := range eventChan {
473− if err := a.processEvent(ctx, sessionID, &assistantMsg, event); err != nil {
474− if errors.Is(err, context.Canceled) {
475− // Mark as canceled but don't create separate message
476− assistantMsg.AddFinish("canceled")
477− _ = a.messages.Update(context.Background(), assistantMsg)
478− return ErrRequestCancelled
479− }
480− assistantMsg.AddFinish("error:" + err.Error())
481− _ = a.messages.Update(ctx, assistantMsg)
482− return fmt.Errorf("event processing error: %w", err)
483− }
484−
485− // Check for cancellation during event processing
486− select {
487− case <-ctx.Done():
488− // Mark as canceled
489− assistantMsg.AddFinish("canceled")
490− _ = a.messages.Update(context.Background(), assistantMsg)
491− return ErrRequestCancelled
492− default:
493− }
494− }
495−
496− // Check for cancellation before tool execution
497− select {
498− case <-ctx.Done():
499− assistantMsg.AddFinish("canceled_by_user")
500− _ = a.messages.Update(context.Background(), assistantMsg)
501− return ErrRequestCancelled
502− default:
503− }
504−
505− // Execute any tool calls
506− toolMsg, err := a.handleToolExecution(ctx, assistantMsg)
507− if err != nil {
508− if errors.Is(err, context.Canceled) {
509− assistantMsg.AddFinish("canceled_by_user")
510− _ = a.messages.Update(context.Background(), assistantMsg)
511− return ErrRequestCancelled
512− }
513− return fmt.Errorf("tool execution error: %w", err)
514− }
515−
516− if err := a.messages.Update(ctx, assistantMsg); err != nil {
517− return fmt.Errorf("failed to update assistant message: %w", err)
518− }
519−
520− // If no tool calls, we're done
521− if len(assistantMsg.ToolCalls()) == 0 {
522− break
523− }
524
410
525− // Add messages for next iteration
411+ sess.Cost += cost
526− messages = append(messages, assistantMsg)
412+ sess.CompletionTokens += usage.OutputTokens
527− if toolMsg != nil {
413+ sess.PromptTokens += usage.InputTokens
528− messages = append(messages, *toolMsg)
529− }
530
414
531− // Check for cancellation after tool execution
415+ _, err = a.sessions.Save(ctx, sess)
532− select {
416+ if err != nil {
533− case <-ctx.Done():
417+ return fmt.Errorf("failed to save session: %w", err)
534− return ErrRequestCancelled
535− default:
536− }
537 }
418 }
538−
539 return nil
419 return nil
540}
420}
541
421
542−// getAgentProviders initializes the LLM providers based on the chosen model
422+func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
543−func getAgentProviders(ctx context.Context, model models.Model) (provider.Provider, provider.Provider, error) {
423+ cfg := config.Get()
544− maxTokens := config.Get().Model.CoderMaxTokens
424+ agentConfig, ok := cfg.Agents[agentName]
545−
425+ if !ok {
546− providerConfig, ok := config.Get().Providers[model.Provider]
426+ return nil, fmt.Errorf("agent %s not found", agentName)
547− if !ok || providerConfig.Disabled {
427+ }
548− return nil, nil, ErrProviderNotEnabled
428+ model, ok := models.SupportedModels[agentConfig.Model]
429+ if !ok {
430+ return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
549 }
431 }
550
432
551− var agentProvider provider.Provider
433+ providerCfg, ok := cfg.Providers[model.Provider]
552− var titleGenerator provider.Provider
434+ if !ok {
553− var err error
435+ return nil, fmt.Errorf("provider %s not supported", model.Provider)
554−
436+ }
555− switch model.Provider {
437+ if providerCfg.Disabled {
556− case models.ProviderOpenAI:
438+ return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
557− agentProvider, err = provider.NewOpenAIProvider(
558− provider.WithOpenAISystemMessage(
559− prompt.CoderOpenAISystemPrompt(),
560− ),
561− provider.WithOpenAIMaxTokens(maxTokens),
562− provider.WithOpenAIModel(model),
563− provider.WithOpenAIKey(providerConfig.APIKey),
564− )
565− if err != nil {
566− return nil, nil, fmt.Errorf("failed to create OpenAI agent provider: %w", err)
567− }
439+ }
568−
440+ agentProvider, err := provider.NewProvider(
569− titleGenerator, err = provider.NewOpenAIProvider(
441+ model.Provider,
570− provider.WithOpenAISystemMessage(
442+ provider.WithAPIKey(providerCfg.APIKey),
571− prompt.TitlePrompt(),
443+ provider.WithModel(model),
572− ),
444+ provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
573− provider.WithOpenAIMaxTokens(80),
445+ provider.WithMaxTokens(agentConfig.MaxTokens),
574− provider.WithOpenAIModel(model),
575− provider.WithOpenAIKey(providerConfig.APIKey),
576− )
446+ )
577− if err != nil {
447+ if err != nil {
578− return nil, nil, fmt.Errorf("failed to create OpenAI title generator: %w", err)
448+ return nil, fmt.Errorf("could not create provider: %v", err)
579− }
580−
581− case models.ProviderAnthropic:
582− agentProvider, err = provider.NewAnthropicProvider(
583− provider.WithAnthropicSystemMessage(
584− prompt.CoderAnthropicSystemPrompt(),
585− ),
586− provider.WithAnthropicMaxTokens(maxTokens),
587− provider.WithAnthropicKey(providerConfig.APIKey),
588− provider.WithAnthropicModel(model),
589− )
590− if err != nil {
591− return nil, nil, fmt.Errorf("failed to create Anthropic agent provider: %w", err)
592− }
593−
594− titleGenerator, err = provider.NewAnthropicProvider(
595− provider.WithAnthropicSystemMessage(
596− prompt.TitlePrompt(),
597− ),
598− provider.WithAnthropicMaxTokens(80),
599− provider.WithAnthropicKey(providerConfig.APIKey),
600− provider.WithAnthropicModel(model),
601− )
602− if err != nil {
603− return nil, nil, fmt.Errorf("failed to create Anthropic title generator: %w", err)
604− }
605−
606− case models.ProviderGemini:
607− agentProvider, err = provider.NewGeminiProvider(
608− ctx,
609− provider.WithGeminiSystemMessage(
610− prompt.CoderOpenAISystemPrompt(),
611− ),
612− provider.WithGeminiMaxTokens(int32(maxTokens)),
613− provider.WithGeminiKey(providerConfig.APIKey),
614− provider.WithGeminiModel(model),
615− )
616− if err != nil {
617− return nil, nil, fmt.Errorf("failed to create Gemini agent provider: %w", err)
618− }
619−
620− titleGenerator, err = provider.NewGeminiProvider(
621− ctx,
622− provider.WithGeminiSystemMessage(
623− prompt.TitlePrompt(),
624− ),
625− provider.WithGeminiMaxTokens(80),
626− provider.WithGeminiKey(providerConfig.APIKey),
627− provider.WithGeminiModel(model),
628− )
629− if err != nil {
630− return nil, nil, fmt.Errorf("failed to create Gemini title generator: %w", err)
631− }
632−
633− case models.ProviderGROQ:
634− agentProvider, err = provider.NewOpenAIProvider(
635− provider.WithOpenAISystemMessage(
636− prompt.CoderAnthropicSystemPrompt(),
637− ),
638− provider.WithOpenAIMaxTokens(maxTokens),
639− provider.WithOpenAIModel(model),
640− provider.WithOpenAIKey(providerConfig.APIKey),
641− provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
642− )
643− if err != nil {
644− return nil, nil, fmt.Errorf("failed to create GROQ agent provider: %w", err)
645− }
646−
647− titleGenerator, err = provider.NewOpenAIProvider(
648− provider.WithOpenAISystemMessage(
649− prompt.TitlePrompt(),
650− ),
651− provider.WithOpenAIMaxTokens(80),
652− provider.WithOpenAIModel(model),
653− provider.WithOpenAIKey(providerConfig.APIKey),
654− provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
655− )
656− if err != nil {
657− return nil, nil, fmt.Errorf("failed to create GROQ title generator: %w", err)
658− }
659−
660− case models.ProviderBedrock:
661− agentProvider, err = provider.NewBedrockProvider(
662− provider.WithBedrockSystemMessage(
663− prompt.CoderAnthropicSystemPrompt(),
664− ),
665− provider.WithBedrockMaxTokens(maxTokens),
666− provider.WithBedrockModel(model),
667− )
668− if err != nil {
669− return nil, nil, fmt.Errorf("failed to create Bedrock agent provider: %w", err)
670− }
671−
672− titleGenerator, err = provider.NewBedrockProvider(
673− provider.WithBedrockSystemMessage(
674− prompt.TitlePrompt(),
675− ),
676− provider.WithBedrockMaxTokens(80),
677− provider.WithBedrockModel(model),
678− )
679− if err != nil {
680− return nil, nil, fmt.Errorf("failed to create Bedrock title generator: %w", err)
681− }
682− default:
683− return nil, nil, fmt.Errorf("unsupported provider: %s", model.Provider)
684 }
449 }
685
450
686− return agentProvider, titleGenerator, nil
451+ return agentProvider, nil
687}
452}
internal/llm/agent/coder.godeleted−63
@@ -1,63 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
8− "github.com/kujtimiihoxha/termai/internal/llm/models"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/lsp"
11− "github.com/kujtimiihoxha/termai/internal/message"
12− "github.com/kujtimiihoxha/termai/internal/permission"
13− "github.com/kujtimiihoxha/termai/internal/session"
14−)
15−
16−type coderAgent struct {
17− Service
18−}
19−
20−func NewCoderAgent(
21− permissions permission.Service,
22− sessions session.Service,
23− messages message.Service,
24− lspClients map[string]*lsp.Client,
25−) (Service, error) {
26− model, ok := models.SupportedModels[config.Get().Model.Coder]
27− if !ok {
28− return nil, errors.New("model not supported")
29− }
30−
31− ctx := context.Background()
32− otherTools := GetMcpTools(ctx, permissions)
33− if len(lspClients) > 0 {
34− otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
35− }
36− agent, err := NewAgent(
37− ctx,
38− sessions,
39− messages,
40− model,
41− append(
42− []tools.BaseTool{
43− tools.NewBashTool(permissions),
44− tools.NewEditTool(lspClients, permissions),
45− tools.NewFetchTool(permissions),
46− tools.NewGlobTool(),
47− tools.NewGrepTool(),
48− tools.NewLsTool(),
49− tools.NewSourcegraphTool(),
50− tools.NewViewTool(lspClients),
51− tools.NewWriteTool(lspClients, permissions),
52− NewAgentTool(sessions, messages, lspClients),
53− }, otherTools...,
54− ),
55− )
56− if err != nil {
57− return nil, err
58− }
59−
60− return &coderAgent{
61− agent,
62− }, nil
63−}
internal/llm/agent/mcp-tools.go+2−2
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
46 initRequest := mcp.InitializeRequest{}
46 initRequest := mcp.InitializeRequest{}
47 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
47 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
48 initRequest.Params.ClientInfo = mcp.Implementation{
48 initRequest.Params.ClientInfo = mcp.Implementation{
49− Name: "termai",
49+ Name: "OpenCode",
50 Version: version.Version,
50 Version: version.Version,
51 }
51 }
52
52
@@ -135,7 +135,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
135 initRequest := mcp.InitializeRequest{}
135 initRequest := mcp.InitializeRequest{}
136 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
136 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
137 initRequest.Params.ClientInfo = mcp.Implementation{
137 initRequest.Params.ClientInfo = mcp.Implementation{
138− Name: "termai",
138+ Name: "OpenCode",
139 Version: version.Version,
139 Version: version.Version,
140 }
140 }
141
141
internal/llm/agent/task.godeleted−47
@@ -1,47 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
8− "github.com/kujtimiihoxha/termai/internal/llm/models"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/lsp"
11− "github.com/kujtimiihoxha/termai/internal/message"
12− "github.com/kujtimiihoxha/termai/internal/session"
13−)
14−
15−type taskAgent struct {
16− Service
17−}
18−
19−func NewTaskAgent(messages message.Service, sessions session.Service, lspClients map[string]*lsp.Client) (Service, error) {
20− model, ok := models.SupportedModels[config.Get().Model.Coder]
21− if !ok {
22− return nil, errors.New("model not supported")
23− }
24−
25− ctx := context.Background()
26−
27− agent, err := NewAgent(
28− ctx,
29− sessions,
30− messages,
31− model,
32− []tools.BaseTool{
33− tools.NewGlobTool(),
34− tools.NewGrepTool(),
35− tools.NewLsTool(),
36− tools.NewSourcegraphTool(),
37− tools.NewViewTool(lspClients),
38− },
39− )
40− if err != nil {
41− return nil, err
42− }
43−
44− return &taskAgent{
45− agent,
46− }, nil
47−}
internal/llm/agent/tools.gonew file+50
@@ -0,0 +1,50 @@
1+package agent
2+
3+import (
4+ "context"
5+
6+ "github.com/kujtimiihoxha/termai/internal/history"
7+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
8+ "github.com/kujtimiihoxha/termai/internal/lsp"
9+ "github.com/kujtimiihoxha/termai/internal/message"
10+ "github.com/kujtimiihoxha/termai/internal/permission"
11+ "github.com/kujtimiihoxha/termai/internal/session"
12+)
13+
14+func CoderAgentTools(
15+ permissions permission.Service,
16+ sessions session.Service,
17+ messages message.Service,
18+ history history.Service,
19+ lspClients map[string]*lsp.Client,
20+) []tools.BaseTool {
21+ ctx := context.Background()
22+ otherTools := GetMcpTools(ctx, permissions)
23+ if len(lspClients) > 0 {
24+ otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
25+ }
26+ return append(
27+ []tools.BaseTool{
28+ tools.NewBashTool(permissions),
29+ tools.NewEditTool(lspClients, permissions, history),
30+ tools.NewFetchTool(permissions),
31+ tools.NewGlobTool(),
32+ tools.NewGrepTool(),
33+ tools.NewLsTool(),
34+ tools.NewSourcegraphTool(),
35+ tools.NewViewTool(lspClients),
36+ tools.NewWriteTool(lspClients, permissions, history),
37+ NewAgentTool(sessions, messages, lspClients),
38+ }, otherTools...,
39+ )
40+}
41+
42+func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
43+ return []tools.BaseTool{
44+ tools.NewGlobTool(),
45+ tools.NewGrepTool(),
46+ tools.NewLsTool(),
47+ tools.NewSourcegraphTool(),
48+ tools.NewViewTool(lspClients),
49+ }
50+}
internal/llm/models/anthropic.gonew file+71
@@ -0,0 +1,71 @@
1+package models
2+
3+const (
4+ ProviderAnthropic ModelProvider = "anthropic"
5+
6+ // Models
7+ Claude35Sonnet ModelID = "claude-3.5-sonnet"
8+ Claude3Haiku ModelID = "claude-3-haiku"
9+ Claude37Sonnet ModelID = "claude-3.7-sonnet"
10+ Claude35Haiku ModelID = "claude-3.5-haiku"
11+ Claude3Opus ModelID = "claude-3-opus"
12+)
13+
14+var AnthropicModels = map[ModelID]Model{
15+ // Anthropic
16+ Claude35Sonnet: {
17+ ID: Claude35Sonnet,
18+ Name: "Claude 3.5 Sonnet",
19+ Provider: ProviderAnthropic,
20+ APIModel: "claude-3-5-sonnet-latest",
21+ CostPer1MIn: 3.0,
22+ CostPer1MInCached: 3.75,
23+ CostPer1MOutCached: 0.30,
24+ CostPer1MOut: 15.0,
25+ ContextWindow: 200000,
26+ },
27+ Claude3Haiku: {
28+ ID: Claude3Haiku,
29+ Name: "Claude 3 Haiku",
30+ Provider: ProviderAnthropic,
31+ APIModel: "claude-3-haiku-latest",
32+ CostPer1MIn: 0.25,
33+ CostPer1MInCached: 0.30,
34+ CostPer1MOutCached: 0.03,
35+ CostPer1MOut: 1.25,
36+ ContextWindow: 200000,
37+ },
38+ Claude37Sonnet: {
39+ ID: Claude37Sonnet,
40+ Name: "Claude 3.7 Sonnet",
41+ Provider: ProviderAnthropic,
42+ APIModel: "claude-3-7-sonnet-latest",
43+ CostPer1MIn: 3.0,
44+ CostPer1MInCached: 3.75,
45+ CostPer1MOutCached: 0.30,
46+ CostPer1MOut: 15.0,
47+ ContextWindow: 200000,
48+ },
49+ Claude35Haiku: {
50+ ID: Claude35Haiku,
51+ Name: "Claude 3.5 Haiku",
52+ Provider: ProviderAnthropic,
53+ APIModel: "claude-3-5-haiku-latest",
54+ CostPer1MIn: 0.80,
55+ CostPer1MInCached: 1.0,
56+ CostPer1MOutCached: 0.08,
57+ CostPer1MOut: 4.0,
58+ ContextWindow: 200000,
59+ },
60+ Claude3Opus: {
61+ ID: Claude3Opus,
62+ Name: "Claude 3 Opus",
63+ Provider: ProviderAnthropic,
64+ APIModel: "claude-3-opus-latest",
65+ CostPer1MIn: 15.0,
66+ CostPer1MInCached: 18.75,
67+ CostPer1MOutCached: 1.50,
68+ CostPer1MOut: 75.0,
69+ ContextWindow: 200000,
70+ },
71+}
internal/llm/models/models.go+103−87
@@ -1,5 +1,7 @@
1package models
1package models
2
2
3+import "maps"
4+
3type (
5type (
4 ModelID string
6 ModelID string
5 ModelProvider string
7 ModelProvider string
@@ -14,15 +16,13 @@ type Model struct {
14 CostPer1MOut float64 `json:"cost_per_1m_out"`
16 CostPer1MOut float64 `json:"cost_per_1m_out"`
15 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
17 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
16 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
18 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19+ ContextWindow int64 `json:"context_window"`
17}
20}
18
21
19// Model IDs
22// Model IDs
20const (
23const (
21− // Anthropic
22− Claude35Sonnet ModelID = "claude-3.5-sonnet"
23− Claude3Haiku ModelID = "claude-3-haiku"
24− Claude37Sonnet ModelID = "claude-3.7-sonnet"
25 // OpenAI
24 // OpenAI
25+ GPT4o ModelID = "gpt-4o"
26 GPT41 ModelID = "gpt-4.1"
26 GPT41 ModelID = "gpt-4.1"
27
27
28 // GEMINI
28 // GEMINI
@@ -37,47 +37,59 @@ const (
37)
37)
38
38
39const (
39const (
40− ProviderOpenAI ModelProvider = "openai"
40+ ProviderOpenAI ModelProvider = "openai"
41− ProviderAnthropic ModelProvider = "anthropic"
42− ProviderBedrock ModelProvider = "bedrock"
41+ ProviderBedrock ModelProvider = "bedrock"
43− ProviderGemini ModelProvider = "gemini"
42+ ProviderGemini ModelProvider = "gemini"
44− ProviderGROQ ModelProvider = "groq"
43+ ProviderGROQ ModelProvider = "groq"
44+
45+ // ForTests
46+ ProviderMock ModelProvider = "__mock"
45)
47)
46
48
47var SupportedModels = map[ModelID]Model{
49var SupportedModels = map[ModelID]Model{
48− // Anthropic
50+ // // Anthropic
49− Claude35Sonnet: {
51+ // Claude35Sonnet: {
50− ID: Claude35Sonnet,
52+ // ID: Claude35Sonnet,
51− Name: "Claude 3.5 Sonnet",
53+ // Name: "Claude 3.5 Sonnet",
52− Provider: ProviderAnthropic,
54+ // Provider: ProviderAnthropic,
53− APIModel: "claude-3-5-sonnet-latest",
55+ // APIModel: "claude-3-5-sonnet-latest",
54− CostPer1MIn: 3.0,
56+ // CostPer1MIn: 3.0,
55− CostPer1MInCached: 3.75,
57+ // CostPer1MInCached: 3.75,
56− CostPer1MOutCached: 0.30,
58+ // CostPer1MOutCached: 0.30,
57− CostPer1MOut: 15.0,
59+ // CostPer1MOut: 15.0,
58− },
60+ // },
59− Claude3Haiku: {
61+ // Claude3Haiku: {
60− ID: Claude3Haiku,
62+ // ID: Claude3Haiku,
61− Name: "Claude 3 Haiku",
63+ // Name: "Claude 3 Haiku",
62− Provider: ProviderAnthropic,
64+ // Provider: ProviderAnthropic,
63− APIModel: "claude-3-haiku-latest",
65+ // APIModel: "claude-3-haiku-latest",
64− CostPer1MIn: 0.80,
66+ // CostPer1MIn: 0.80,
65− CostPer1MInCached: 1,
67+ // CostPer1MInCached: 1,
66− CostPer1MOutCached: 0.08,
68+ // CostPer1MOutCached: 0.08,
67− CostPer1MOut: 4,
69+ // CostPer1MOut: 4,
68− },
70+ // },
69− Claude37Sonnet: {
71+ // Claude37Sonnet: {
70− ID: Claude37Sonnet,
72+ // ID: Claude37Sonnet,
71− Name: "Claude 3.7 Sonnet",
73+ // Name: "Claude 3.7 Sonnet",
72− Provider: ProviderAnthropic,
74+ // Provider: ProviderAnthropic,
73− APIModel: "claude-3-7-sonnet-latest",
75+ // APIModel: "claude-3-7-sonnet-latest",
74− CostPer1MIn: 3.0,
76+ // CostPer1MIn: 3.0,
75− CostPer1MInCached: 3.75,
77+ // CostPer1MInCached: 3.75,
76− CostPer1MOutCached: 0.30,
78+ // CostPer1MOutCached: 0.30,
77− CostPer1MOut: 15.0,
79+ // CostPer1MOut: 15.0,
80+ // },
81+ //
82+ // // OpenAI
83+ GPT4o: {
84+ ID: GPT4o,
85+ Name: "GPT-4o",
86+ Provider: ProviderOpenAI,
87+ APIModel: "gpt-4.1",
88+ CostPer1MIn: 2.00,
89+ CostPer1MInCached: 0.50,
90+ CostPer1MOutCached: 0,
91+ CostPer1MOut: 8.00,
78 },
92 },
79−
80− // OpenAI
81 GPT41: {
93 GPT41: {
82 ID: GPT41,
94 ID: GPT41,
83 Name: "GPT-4.1",
95 Name: "GPT-4.1",
@@ -88,51 +100,55 @@ var SupportedModels = map[ModelID]Model{
88 CostPer1MOutCached: 0,
100 CostPer1MOutCached: 0,
89 CostPer1MOut: 8.00,
101 CostPer1MOut: 8.00,
90 },
102 },
103+ //
104+ // // GEMINI
105+ // GEMINI25: {
106+ // ID: GEMINI25,
107+ // Name: "Gemini 2.5 Pro",
108+ // Provider: ProviderGemini,
109+ // APIModel: "gemini-2.5-pro-exp-03-25",
110+ // CostPer1MIn: 0,
111+ // CostPer1MInCached: 0,
112+ // CostPer1MOutCached: 0,
113+ // CostPer1MOut: 0,
114+ // },
115+ //
116+ // GRMINI20Flash: {
117+ // ID: GRMINI20Flash,
118+ // Name: "Gemini 2.0 Flash",
119+ // Provider: ProviderGemini,
120+ // APIModel: "gemini-2.0-flash",
121+ // CostPer1MIn: 0.1,
122+ // CostPer1MInCached: 0,
123+ // CostPer1MOutCached: 0.025,
124+ // CostPer1MOut: 0.4,
125+ // },
126+ //
127+ // // GROQ
128+ // QWENQwq: {
129+ // ID: QWENQwq,
130+ // Name: "Qwen Qwq",
131+ // Provider: ProviderGROQ,
132+ // APIModel: "qwen-qwq-32b",
133+ // CostPer1MIn: 0,
134+ // CostPer1MInCached: 0,
135+ // CostPer1MOutCached: 0,
136+ // CostPer1MOut: 0,
137+ // },
138+ //
139+ // // Bedrock
140+ // BedrockClaude37Sonnet: {
141+ // ID: BedrockClaude37Sonnet,
142+ // Name: "Bedrock: Claude 3.7 Sonnet",
143+ // Provider: ProviderBedrock,
144+ // APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
145+ // CostPer1MIn: 3.0,
146+ // CostPer1MInCached: 3.75,
147+ // CostPer1MOutCached: 0.30,
148+ // CostPer1MOut: 15.0,
149+ // },
150+}
91
151
92− // GEMINI
152+func init() {
93− GEMINI25: {
153+ maps.Copy(SupportedModels, AnthropicModels)
94− ID: GEMINI25,
95− Name: "Gemini 2.5 Pro",
96− Provider: ProviderGemini,
97− APIModel: "gemini-2.5-pro-exp-03-25",
98− CostPer1MIn: 0,
99− CostPer1MInCached: 0,
100− CostPer1MOutCached: 0,
101− CostPer1MOut: 0,
102− },
103−
104− GRMINI20Flash: {
105− ID: GRMINI20Flash,
106− Name: "Gemini 2.0 Flash",
107− Provider: ProviderGemini,
108− APIModel: "gemini-2.0-flash",
109− CostPer1MIn: 0.1,
110− CostPer1MInCached: 0,
111− CostPer1MOutCached: 0.025,
112− CostPer1MOut: 0.4,
113− },
114−
115− // GROQ
116− QWENQwq: {
117− ID: QWENQwq,
118− Name: "Qwen Qwq",
119− Provider: ProviderGROQ,
120− APIModel: "qwen-qwq-32b",
121− CostPer1MIn: 0,
122− CostPer1MInCached: 0,
123− CostPer1MOutCached: 0,
124− CostPer1MOut: 0,
125− },
126−
127− // Bedrock
128− BedrockClaude37Sonnet: {
129− ID: BedrockClaude37Sonnet,
130− Name: "Bedrock: Claude 3.7 Sonnet",
131− Provider: ProviderBedrock,
132− APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
133− CostPer1MIn: 3.0,
134− CostPer1MInCached: 3.75,
135− CostPer1MOutCached: 0.30,
136− CostPer1MOut: 15.0,
137− },
138}
154}
internal/llm/prompt/coder.go+14−14
@@ -9,11 +9,22 @@ import (
9 "time"
9 "time"
10
10
11 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/termai/internal/llm/models"
12 "github.com/kujtimiihoxha/termai/internal/llm/tools"
13 "github.com/kujtimiihoxha/termai/internal/llm/tools"
13)
14)
14
15
15−func CoderOpenAISystemPrompt() string {
16+func CoderPrompt(provider models.ModelProvider) string {
17+ basePrompt := baseAnthropicCoderPrompt
18+ switch provider {
19+ case models.ProviderOpenAI:
20+ basePrompt = baseOpenAICoderPrompt
21+ }
22+ envInfo := getEnvironmentInfo()
23+
24+ return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
25+}
26+
16− basePrompt := `You are termAI, an autonomous CLI-based software engineer. Your job is to reduce user effort by proactively reasoning, inferring context, and solving software engineering tasks end-to-end with minimal prompting.
27+const baseOpenAICoderPrompt = `You are termAI, an autonomous CLI-based software engineer. Your job is to reduce user effort by proactively reasoning, inferring context, and solving software engineering tasks end-to-end with minimal prompting.
17
28
18# Your mindset
29# Your mindset
19Act like a competent, efficient software engineer who is familiar with large codebases. You should:
30Act like a competent, efficient software engineer who is familiar with large codebases. You should:
@@ -65,13 +76,7 @@ assistant: [searches repo for references, returns file paths and lines]
65
76
66Never commit changes unless the user explicitly asks you to.`
77Never commit changes unless the user explicitly asks you to.`
67
78
68− envInfo := getEnvironmentInfo()
69−
70− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
71−}
72−
73−func CoderAnthropicSystemPrompt() string {
74− basePrompt := `You are termAI, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
79+const baseAnthropicCoderPrompt = `You are termAI, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
75
80
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
81IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
77
82
@@ -166,11 +171,6 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
166
171
167You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
172You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168
173
169− envInfo := getEnvironmentInfo()
170−
171− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
172−}
173−
174func getEnvironmentInfo() string {
174func getEnvironmentInfo() string {
175 cwd := config.WorkingDirectory()
175 cwd := config.WorkingDirectory()
176 isGit := isGitRepo(cwd)
176 isGit := isGitRepo(cwd)
internal/llm/prompt/prompt.gonew file+19
@@ -0,0 +1,19 @@
1+package prompt
2+
3+import (
4+ "github.com/kujtimiihoxha/termai/internal/config"
5+ "github.com/kujtimiihoxha/termai/internal/llm/models"
6+)
7+
8+func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
9+ switch agentName {
10+ case config.AgentCoder:
11+ return CoderPrompt(provider)
12+ case config.AgentTitle:
13+ return TitlePrompt(provider)
14+ case config.AgentTask:
15+ return TaskPrompt(provider)
16+ default:
17+ return "You are a helpful assistant"
18+ }
19+}
internal/llm/prompt/task.go+3−2
@@ -2,11 +2,12 @@ package prompt
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+
6+ "github.com/kujtimiihoxha/termai/internal/llm/models"
5)
7)
6
8
7−func TaskAgentSystemPrompt() string {
9+func TaskPrompt(_ models.ModelProvider) string {
8 agentPrompt := `You are an agent for termAI. Given the user's prompt, you should use the tools available to you to answer the user's question.
10 agentPrompt := `You are an agent for termAI. Given the user's prompt, you should use the tools available to you to answer the user's question.
9−
10Notes:
11Notes:
111. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
121. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
122. When relevant, share file names and code snippets relevant to the query
132. When relevant, share file names and code snippets relevant to the query
internal/llm/prompt/title.go+3−1
@@ -1,6 +1,8 @@
1package prompt
1package prompt
2
2
3+import "github.com/kujtimiihoxha/termai/internal/llm/models"
4+
3−func TitlePrompt() string {
5+func TitlePrompt(_ models.ModelProvider) string {
4 return `you will generate a short title based on the first message a user begins a conversation with
6 return `you will generate a short title based on the first message a user begins a conversation with
5- ensure it is not more than 50 characters long
7- ensure it is not more than 50 characters long
6- the title should be a summary of the user's message
8- the title should be a summary of the user's message
internal/llm/provider/anthropic.go+274−257
@@ -12,187 +12,257 @@ import (
12 "github.com/anthropics/anthropic-sdk-go"
12 "github.com/anthropics/anthropic-sdk-go"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
14 "github.com/anthropics/anthropic-sdk-go/option"
14 "github.com/anthropics/anthropic-sdk-go/option"
15− "github.com/kujtimiihoxha/termai/internal/llm/models"
15+ "github.com/kujtimiihoxha/termai/internal/config"
16 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16 "github.com/kujtimiihoxha/termai/internal/llm/tools"
17+ "github.com/kujtimiihoxha/termai/internal/logging"
17 "github.com/kujtimiihoxha/termai/internal/message"
18 "github.com/kujtimiihoxha/termai/internal/message"
18)
19)
19
20
20−type anthropicProvider struct {
21+type anthropicOptions struct {
21− client anthropic.Client
22− model models.Model
23− maxTokens int64
24− apiKey string
25− systemMessage string
26− useBedrock bool
22+ useBedrock bool
27− disableCache bool
23+ disableCache bool
24+ shouldThink func(userMessage string) bool
28}
25}
29
26
30−type AnthropicOption func(*anthropicProvider)
27+type AnthropicOption func(*anthropicOptions)
31
28
32−func WithAnthropicSystemMessage(message string) AnthropicOption {
29+type anthropicClient struct {
33− return func(a *anthropicProvider) {
30+ providerOptions providerClientOptions
34− a.systemMessage = message
31+ options anthropicOptions
35− }
32+ client anthropic.Client
36}
33}
37
34
38−func WithAnthropicMaxTokens(maxTokens int64) AnthropicOption {
35+type AnthropicClient ProviderClient
39− return func(a *anthropicProvider) {
40− a.maxTokens = maxTokens
41− }
42−}
43
36
44−func WithAnthropicModel(model models.Model) AnthropicOption {
37+func newAnthropicClient(opts providerClientOptions) AnthropicClient {
45− return func(a *anthropicProvider) {
38+ anthropicOpts := anthropicOptions{}
46− a.model = model
39+ for _, o := range opts.anthropicOptions {
40+ o(&anthropicOpts)
47 }
41 }
48−}
49
42
50−func WithAnthropicKey(apiKey string) AnthropicOption {
43+ anthropicClientOptions := []option.RequestOption{}
51− return func(a *anthropicProvider) {
44+ if opts.apiKey != "" {
52− a.apiKey = apiKey
45+ anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
53 }
46 }
54−}
47+ if anthropicOpts.useBedrock {
55−
48+ anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
56−func WithAnthropicBedrock() AnthropicOption {
57− return func(a *anthropicProvider) {
58− a.useBedrock = true
59 }
49 }
60−}
61
50
62−func WithAnthropicDisableCache() AnthropicOption {
51+ client := anthropic.NewClient(anthropicClientOptions...)
63− return func(a *anthropicProvider) {
52+ return &anthropicClient{
64− a.disableCache = true
53+ providerOptions: opts,
54+ options: anthropicOpts,
55+ client: client,
65 }
56 }
66}
57}
67
58
68−func NewAnthropicProvider(opts ...AnthropicOption) (Provider, error) {
59+func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
69− provider := &anthropicProvider{
60+ cachedBlocks := 0
70− maxTokens: 1024,
61+ for _, msg := range messages {
62+ switch msg.Role {
63+ case message.User:
64+ content := anthropic.NewTextBlock(msg.Content().String())
65+ if cachedBlocks < 2 && !a.options.disableCache {
66+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
67+ Type: "ephemeral",
68+ }
69+ cachedBlocks++
71− }
70+ }
71+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
72
72
73− for _, opt := range opts {
73+ case message.Assistant:
74− opt(provider)
74+ blocks := []anthropic.ContentBlockParamUnion{}
75+ if msg.Content().String() != "" {
76+ content := anthropic.NewTextBlock(msg.Content().String())
77+ if cachedBlocks < 2 && !a.options.disableCache {
78+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
79+ Type: "ephemeral",
80+ }
81+ cachedBlocks++
82+ }
83+ blocks = append(blocks, content)
75− }
84+ }
76
85
77− if provider.systemMessage == "" {
86+ for _, toolCall := range msg.ToolCalls() {
78− return nil, errors.New("system message is required")
87+ var inputMap map[string]any
88+ err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
89+ if err != nil {
90+ continue
91+ }
92+ blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
79− }
93+ }
80
94
81− anthropicOptions := []option.RequestOption{}
95+ if len(blocks) == 0 {
96+ logging.Warn("There is a message without content, investigate")
97+ // This should never happend but we log this because we might have a bug in our cleanup method
98+ continue
99+ }
100+ anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
82
101
83− if provider.apiKey != "" {
102+ case message.Tool:
84− anthropicOptions = append(anthropicOptions, option.WithAPIKey(provider.apiKey))
103+ results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
104+ for i, toolResult := range msg.ToolResults() {
105+ results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
85− }
106+ }
86− if provider.useBedrock {
107+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
87− anthropicOptions = append(anthropicOptions, bedrock.WithLoadDefaultConfig(context.Background()))
108+ }
88 }
109 }
89−
90− provider.client = anthropic.NewClient(anthropicOptions...)
91− return provider, nil
110+ return
92}
111}
93
112
94−func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
113+func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
95− messages = cleanupMessages(messages)
114+ anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
96− anthropicMessages := a.convertToAnthropicMessages(messages)
115+
97− anthropicTools := a.convertToAnthropicTools(tools)
116+ for i, tool := range tools {
98−
117+ info := tool.Info()
99− response, err := a.client.Messages.New(
118+ toolParam := anthropic.ToolParam{
100− ctx,
119+ Name: info.Name,
101− anthropic.MessageNewParams{
120+ Description: anthropic.String(info.Description),
102− Model: anthropic.Model(a.model.APIModel),
121+ InputSchema: anthropic.ToolInputSchemaParam{
103− MaxTokens: a.maxTokens,
122+ Properties: info.Parameters,
104− Temperature: anthropic.Float(0),
123+ // TODO: figure out how we can tell claude the required fields?
105− Messages: anthropicMessages,
106− Tools: anthropicTools,
107− System: []anthropic.TextBlockParam{
108− {
109− Text: a.systemMessage,
110− CacheControl: anthropic.CacheControlEphemeralParam{
111− Type: "ephemeral",
112− },
113− },
114 },
124 },
115− },
125+ }
116− )
117− if err != nil {
118− return nil, err
119− }
120
126
121− content := ""
127+ if i == len(tools)-1 && !a.options.disableCache {
122− for _, block := range response.Content {
128+ toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
123− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
129+ Type: "ephemeral",
124− content += text.Text
130+ }
125 }
131 }
126− }
127
132
128− toolCalls := a.extractToolCalls(response.Content)
133+ anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
129− tokenUsage := a.extractTokenUsage(response.Usage)
134+ }
130
135
131− return &ProviderResponse{
136+ return anthropicTools
132− Content: content,
133− ToolCalls: toolCalls,
134− Usage: tokenUsage,
135− }, nil
136}
137}
137
138
138−func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
139+func (a *anthropicClient) finishReason(reason string) message.FinishReason {
139− messages = cleanupMessages(messages)
140+ switch reason {
140− anthropicMessages := a.convertToAnthropicMessages(messages)
141+ case "end_turn":
141− anthropicTools := a.convertToAnthropicTools(tools)
142+ return message.FinishReasonEndTurn
143+ case "max_tokens":
144+ return message.FinishReasonMaxTokens
145+ case "tool_use":
146+ return message.FinishReasonToolUse
147+ case "stop_sequence":
148+ return message.FinishReasonEndTurn
149+ default:
150+ return message.FinishReasonUnknown
151+ }
152+}
142
153
154+func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
143 var thinkingParam anthropic.ThinkingConfigParamUnion
155 var thinkingParam anthropic.ThinkingConfigParamUnion
144 lastMessage := messages[len(messages)-1]
156 lastMessage := messages[len(messages)-1]
157+ isUser := lastMessage.Role == anthropic.MessageParamRoleUser
158+ messageContent := ""
145 temperature := anthropic.Float(0)
159 temperature := anthropic.Float(0)
146− if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content().String()), "think") {
160+ if isUser {
161+ for _, m := range lastMessage.Content {
162+ if m.OfRequestTextBlock != nil && m.OfRequestTextBlock.Text != "" {
163+ messageContent = m.OfRequestTextBlock.Text
164+ }
165+ }
166+ if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
147− thinkingParam = anthropic.ThinkingConfigParamUnion{
167+ thinkingParam = anthropic.ThinkingConfigParamUnion{
148− OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
168+ OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
149− BudgetTokens: int64(float64(a.maxTokens) * 0.8),
169+ BudgetTokens: int64(float64(a.providerOptions.maxTokens) * 0.8),
150− Type: "enabled",
170+ Type: "enabled",
151− },
171+ },
172+ }
173+ temperature = anthropic.Float(1)
152 }
174 }
153− temperature = anthropic.Float(1)
154 }
175 }
155
176
156− eventChan := make(chan ProviderEvent)
177+ return anthropic.MessageNewParams{
178+ Model: anthropic.Model(a.providerOptions.model.APIModel),
179+ MaxTokens: a.providerOptions.maxTokens,
180+ Temperature: temperature,
181+ Messages: messages,
182+ Tools: tools,
183+ Thinking: thinkingParam,
184+ System: []anthropic.TextBlockParam{
185+ {
186+ Text: a.providerOptions.systemMessage,
187+ CacheControl: anthropic.CacheControlEphemeralParam{
188+ Type: "ephemeral",
189+ },
190+ },
191+ },
192+ }
193+}
157
194
158− go func() {
195+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
159− defer close(eventChan)
196+ preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
197+ cfg := config.Get()
198+ if cfg.Debug {
199+ jsonData, _ := json.Marshal(preparedMessages)
200+ logging.Debug("Prepared messages", "messages", string(jsonData))
201+ }
202+ attempts := 0
203+ for {
204+ attempts++
205+ anthropicResponse, err := a.client.Messages.New(
206+ ctx,
207+ preparedMessages,
208+ )
209+ // If there is an error we are going to see if we can retry the call
210+ if err != nil {
211+ retry, after, retryErr := a.shouldRetry(attempts, err)
212+ if retryErr != nil {
213+ return nil, retryErr
214+ }
215+ if retry {
216+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
217+ select {
218+ case <-ctx.Done():
219+ return nil, ctx.Err()
220+ case <-time.After(time.Duration(after) * time.Millisecond):
221+ continue
222+ }
223+ }
224+ return nil, retryErr
225+ }
160
226
161− const maxRetries = 8
227+ content := ""
162− attempts := 0
228+ for _, block := range anthropicResponse.Content {
229+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
230+ content += text.Text
231+ }
232+ }
163
233
164− for {
234+ return &ProviderResponse{
235+ Content: content,
236+ ToolCalls: a.toolCalls(*anthropicResponse),
237+ Usage: a.usage(*anthropicResponse),
238+ }, nil
239+ }
240+}
165
241
242+func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
243+ preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
244+ cfg := config.Get()
245+ if cfg.Debug {
246+ jsonData, _ := json.Marshal(preparedMessages)
247+ logging.Debug("Prepared messages", "messages", string(jsonData))
248+ }
249+ attempts := 0
250+ eventChan := make(chan ProviderEvent)
251+ go func() {
252+ for {
166 attempts++
253 attempts++
167−
168− stream := a.client.Messages.NewStreaming(
254+ anthropicStream := a.client.Messages.NewStreaming(
169 ctx,
255 ctx,
170− anthropic.MessageNewParams{
256+ preparedMessages,
171− Model: anthropic.Model(a.model.APIModel),
172− MaxTokens: a.maxTokens,
173− Temperature: temperature,
174− Messages: anthropicMessages,
175− Tools: anthropicTools,
176− Thinking: thinkingParam,
177− System: []anthropic.TextBlockParam{
178− {
179− Text: a.systemMessage,
180− CacheControl: anthropic.CacheControlEphemeralParam{
181− Type: "ephemeral",
182− },
183− },
184− },
185− },
186 )
257 )
187−
188 accumulatedMessage := anthropic.Message{}
258 accumulatedMessage := anthropic.Message{}
189
259
190− for stream.Next() {
260+ for anthropicStream.Next() {
191− event := stream.Current()
261+ event := anthropicStream.Current()
192 err := accumulatedMessage.Accumulate(event)
262 err := accumulatedMessage.Accumulate(event)
193 if err != nil {
263 if err != nil {
194 eventChan <- ProviderEvent{Type: EventError, Error: err}
264 eventChan <- ProviderEvent{Type: EventError, Error: err}
195− return // Don't retry on accumulation errors
265+ continue
196 }
266 }
197
267
198 switch event := event.AsAny().(type) {
268 switch event := event.AsAny().(type) {
@@ -211,6 +281,7 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
211 Content: event.Delta.Text,
281 Content: event.Delta.Text,
212 }
282 }
213 }
283 }
284+ // TODO: check if we can somehow stream tool calls
214
285
215 case anthropic.ContentBlockStopEvent:
286 case anthropic.ContentBlockStopEvent:
216 eventChan <- ProviderEvent{Type: EventContentStop}
287 eventChan <- ProviderEvent{Type: EventContentStop}
@@ -223,84 +294,87 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
223 }
294 }
224 }
295 }
225
296
226− toolCalls := a.extractToolCalls(accumulatedMessage.Content)
227− tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
228−
229 eventChan <- ProviderEvent{
297 eventChan <- ProviderEvent{
230 Type: EventComplete,
298 Type: EventComplete,
231 Response: &ProviderResponse{
299 Response: &ProviderResponse{
232 Content: content,
300 Content: content,
233− ToolCalls: toolCalls,
301+ ToolCalls: a.toolCalls(accumulatedMessage),
234− Usage: tokenUsage,
302+ Usage: a.usage(accumulatedMessage),
235− FinishReason: string(accumulatedMessage.StopReason),
303+ FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
236 },
304 },
237 }
305 }
238 }
306 }
239 }
307 }
240
308
241− err := stream.Err()
309+ err := anthropicStream.Err()
242 if err == nil || errors.Is(err, io.EOF) {
310 if err == nil || errors.Is(err, io.EOF) {
311+ close(eventChan)
243 return
312 return
244 }
313 }
245−
314+ // If there is an error we are going to see if we can retry the call
246− var apierr *anthropic.Error
315+ retry, after, retryErr := a.shouldRetry(attempts, err)
247− if !errors.As(err, &apierr) {
316+ if retryErr != nil {
248− eventChan <- ProviderEvent{Type: EventError, Error: err}
249− return
250− }
251−
252− if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
253− eventChan <- ProviderEvent{Type: EventError, Error: err}
317+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
318+ close(eventChan)
254 return
319 return
255 }
320 }
256−
321+ if retry {
257− if attempts > maxRetries {
322+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
258− eventChan <- ProviderEvent{
323+ select {
259− Type: EventError,
324+ case <-ctx.Done():
260− Error: errors.New("maximum retry attempts reached for rate limit (429)"),
325+ // context cancelled
261− }
326+ if ctx.Err() != nil {
262− return
263− }
264−
265− retryMs := 0
266− retryAfterValues := apierr.Response.Header.Values("Retry-After")
267− if len(retryAfterValues) > 0 {
268− var retryAfterSec int
269− if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
270− retryMs = retryAfterSec * 1000
271− eventChan <- ProviderEvent{
327+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
272− Type: EventWarning,
273− Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
274 }
328 }
329+ close(eventChan)
330+ return
331+ case <-time.After(time.Duration(after) * time.Millisecond):
332+ continue
275 }
333 }
276− } else {
277− eventChan <- ProviderEvent{
278− Type: EventWarning,
279− Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
280− }
281−
282− backoffMs := 2000 * (1 << (attempts - 1))
283− jitterMs := int(float64(backoffMs) * 0.2)
284− retryMs = backoffMs + jitterMs
285 }
334 }
286− select {
335+ if ctx.Err() != nil {
287− case <-ctx.Done():
288 eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
336 eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
289− return
290− case <-time.After(time.Duration(retryMs) * time.Millisecond):
291− continue
292 }
337 }
293
338
339+ close(eventChan)
340+ return
294 }
341 }
295 }()
342 }()
343+ return eventChan
344+}
296
345
346+func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
347+ var apierr *anthropic.Error
348+ if !errors.As(err, &apierr) {
349+ return false, 0, err
350+ }
351+
352+ if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
353+ return false, 0, err
354+ }
355+
356+ if attempts > maxRetries {
357+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
358+ }
359+
360+ retryMs := 0
361+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
362+
363+ backoffMs := 2000 * (1 << (attempts - 1))
364+ jitterMs := int(float64(backoffMs) * 0.2)
365+ retryMs = backoffMs + jitterMs
366+ if len(retryAfterValues) > 0 {
367+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
368+ retryMs = retryMs * 1000
369+ }
370+ }
297− return eventChan, nil
371+ return true, int64(retryMs), nil
298}
372}
299
373
300−func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUnion) []message.ToolCall {
374+func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
301 var toolCalls []message.ToolCall
375 var toolCalls []message.ToolCall
302
376
303− for _, block := range content {
377+ for _, block := range msg.Content {
304 switch variant := block.AsAny().(type) {
378 switch variant := block.AsAny().(type) {
305 case anthropic.ToolUseBlock:
379 case anthropic.ToolUseBlock:
306 toolCall := message.ToolCall{
380 toolCall := message.ToolCall{
@@ -316,90 +390,33 @@ func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUni
316 return toolCalls
390 return toolCalls
317}
391}
318
392
319−func (a *anthropicProvider) extractTokenUsage(usage anthropic.Usage) TokenUsage {
393+func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
320 return TokenUsage{
394 return TokenUsage{
321− InputTokens: usage.InputTokens,
395+ InputTokens: msg.Usage.InputTokens,
322− OutputTokens: usage.OutputTokens,
396+ OutputTokens: msg.Usage.OutputTokens,
323− CacheCreationTokens: usage.CacheCreationInputTokens,
397+ CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
324− CacheReadTokens: usage.CacheReadInputTokens,
398+ CacheReadTokens: msg.Usage.CacheReadInputTokens,
325 }
399 }
326}
400}
327
401
328−func (a *anthropicProvider) convertToAnthropicTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
402+func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
329− anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
403+ return func(options *anthropicOptions) {
330−
404+ options.useBedrock = useBedrock
331− for i, tool := range tools {
332− info := tool.Info()
333− toolParam := anthropic.ToolParam{
334− Name: info.Name,
335− Description: anthropic.String(info.Description),
336− InputSchema: anthropic.ToolInputSchemaParam{
337− Properties: info.Parameters,
338− },
339− }
340−
341− if i == len(tools)-1 && !a.disableCache {
342− toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
343− Type: "ephemeral",
344− }
345− }
346−
347− anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
348 }
405 }
349−
350− return anthropicTools
351}
406}
352
407
353−func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Message) []anthropic.MessageParam {
408+func WithAnthropicDisableCache() AnthropicOption {
354− anthropicMessages := make([]anthropic.MessageParam, 0, len(messages))
409+ return func(options *anthropicOptions) {
355− cachedBlocks := 0
356−
357− for _, msg := range messages {
358− switch msg.Role {
359− case message.User:
360− content := anthropic.NewTextBlock(msg.Content().String())
361− if cachedBlocks < 2 && !a.disableCache {
410+ options.disableCache = true
362− content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
363− Type: "ephemeral",
364− }
365− cachedBlocks++
366− }
367− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
368−
369− case message.Assistant:
370− blocks := []anthropic.ContentBlockParamUnion{}
371− if msg.Content().String() != "" {
372− content := anthropic.NewTextBlock(msg.Content().String())
373− if cachedBlocks < 2 && !a.disableCache {
374− content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
375− Type: "ephemeral",
376− }
377− cachedBlocks++
378− }
379− blocks = append(blocks, content)
380− }
381−
382− for _, toolCall := range msg.ToolCalls() {
383− var inputMap map[string]any
384− err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
385− if err != nil {
386− continue
387− }
411+ }
388− blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
389− }
412+}
390
413
391− if len(blocks) > 0 {
414+func DefaultShouldThinkFn(s string) bool {
392− anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
415+ return strings.Contains(strings.ToLower(s), "think")
393− }
416+}
394
417
395− case message.Tool:
418+func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
396− results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
419+ return func(options *anthropicOptions) {
397− for i, toolResult := range msg.ToolResults() {
420+ options.shouldThink = fn
398− results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
399− }
400− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
401− }
402 }
421 }
403−
404− return anthropicMessages
405}
422}
internal/llm/provider/bedrock.go+57−44
@@ -7,33 +7,29 @@ import (
7 "os"
7 "os"
8 "strings"
8 "strings"
9
9
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
11 "github.com/kujtimiihoxha/termai/internal/llm/tools"
10 "github.com/kujtimiihoxha/termai/internal/llm/tools"
12 "github.com/kujtimiihoxha/termai/internal/message"
11 "github.com/kujtimiihoxha/termai/internal/message"
13)
12)
14
13
15−type bedrockProvider struct {
14+type bedrockOptions struct {
16− childProvider Provider
15+ // Bedrock specific options can be added here
17− model models.Model
18− maxTokens int64
19− systemMessage string
20}
16}
21
17
22−func (b *bedrockProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
18+type BedrockOption func(*bedrockOptions)
23− return b.childProvider.SendMessages(ctx, messages, tools)
24−}
25
19
26−func (b *bedrockProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
20+type bedrockClient struct {
27− return b.childProvider.StreamResponse(ctx, messages, tools)
21+ providerOptions providerClientOptions
22+ options bedrockOptions
23+ childProvider ProviderClient
28}
24}
29
25
30−func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
26+type BedrockClient ProviderClient
31− provider := &bedrockProvider{}
27+
32− for _, opt := range opts {
28+func newBedrockClient(opts providerClientOptions) BedrockClient {
33− opt(provider)
29+ bedrockOpts := bedrockOptions{}
34− }
30+ // Apply bedrock specific options if they are added in the future
35
31
36− // based on the AWS region prefix the model name with, us, eu, ap, sa, etc.
32+ // Get AWS region from environment
37 region := os.Getenv("AWS_REGION")
33 region := os.Getenv("AWS_REGION")
38 if region == "" {
34 if region == "" {
39 region = os.Getenv("AWS_DEFAULT_REGION")
35 region = os.Getenv("AWS_DEFAULT_REGION")
@@ -43,45 +39,62 @@ func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
43 region = "us-east-1" // default region
39 region = "us-east-1" // default region
44 }
40 }
45 if len(region) < 2 {
41 if len(region) < 2 {
46− return nil, errors.New("AWS_REGION or AWS_DEFAULT_REGION environment variable is invalid")
42+ return &bedrockClient{
43+ providerOptions: opts,
44+ options: bedrockOpts,
45+ childProvider: nil, // Will cause an error when used
46+ }
47 }
47 }
48+
49+ // Prefix the model name with region
48 regionPrefix := region[:2]
50 regionPrefix := region[:2]
51+ modelName := opts.model.APIModel
49− provider.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, provider.model.APIModel)
52+ opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
50
53
54+ // Determine which provider to use based on the model
51− if strings.Contains(string(provider.model.APIModel), "anthropic") {
55+ if strings.Contains(string(opts.model.APIModel), "anthropic") {
52− anthropic, err := NewAnthropicProvider(
56+ // Create Anthropic client with Bedrock configuration
53− WithAnthropicModel(provider.model),
57+ anthropicOpts := opts
54− WithAnthropicMaxTokens(provider.maxTokens),
58+ anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
55− WithAnthropicSystemMessage(provider.systemMessage),
56− WithAnthropicBedrock(),
59+ WithAnthropicBedrock(true),
57 WithAnthropicDisableCache(),
60 WithAnthropicDisableCache(),
58 )
61 )
59− provider.childProvider = anthropic
62+ return &bedrockClient{
60− if err != nil {
63+ providerOptions: opts,
61− return nil, err
64+ options: bedrockOpts,
65+ childProvider: newAnthropicClient(anthropicOpts),
62 }
66 }
63− } else {
64− return nil, errors.New("unsupported model for bedrock provider")
65 }
67 }
66− return provider, nil
67−}
68−
69−type BedrockOption func(*bedrockProvider)
70
68
71−func WithBedrockSystemMessage(message string) BedrockOption {
69+ // Return client with nil childProvider if model is not supported
72− return func(a *bedrockProvider) {
70+ // This will cause an error when used
73− a.systemMessage = message
71+ return &bedrockClient{
72+ providerOptions: opts,
73+ options: bedrockOpts,
74+ childProvider: nil,
74 }
75 }
75}
76}
76
77
77−func WithBedrockMaxTokens(maxTokens int64) BedrockOption {
78+func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
78− return func(a *bedrockProvider) {
79+ if b.childProvider == nil {
79− a.maxTokens = maxTokens
80+ return nil, errors.New("unsupported model for bedrock provider")
80 }
81 }
82+ return b.childProvider.send(ctx, messages, tools)
81}
83}
82
84
83−func WithBedrockModel(model models.Model) BedrockOption {
85+func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
84− return func(a *bedrockProvider) {
86+ eventChan := make(chan ProviderEvent)
85− a.model = model
87+
88+ if b.childProvider == nil {
89+ go func() {
90+ eventChan <- ProviderEvent{
91+ Type: EventError,
92+ Error: errors.New("unsupported model for bedrock provider"),
93+ }
94+ close(eventChan)
95+ }()
96+ return eventChan
86 }
97 }
98+
99+ return b.childProvider.stream(ctx, messages, tools)
87−}
100+}
internal/llm/provider/gemini.go+350−183
@@ -4,80 +4,68 @@ import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6 "errors"
6 "errors"
7+ "fmt"
8+ "io"
9+ "strings"
10+ "time"
7
11
8 "github.com/google/generative-ai-go/genai"
12 "github.com/google/generative-ai-go/genai"
9 "github.com/google/uuid"
13 "github.com/google/uuid"
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
14+ "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/llm/tools"
15 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16+ "github.com/kujtimiihoxha/termai/internal/logging"
12 "github.com/kujtimiihoxha/termai/internal/message"
17 "github.com/kujtimiihoxha/termai/internal/message"
13 "google.golang.org/api/iterator"
18 "google.golang.org/api/iterator"
14 "google.golang.org/api/option"
19 "google.golang.org/api/option"
15)
20)
16
21
17−type geminiProvider struct {
22+type geminiOptions struct {
18− client *genai.Client
23+ disableCache bool
19− model models.Model
20− maxTokens int32
21− apiKey string
22− systemMessage string
23}
24}
24
25
25−type GeminiOption func(*geminiProvider)
26+type GeminiOption func(*geminiOptions)
26
27
27−func NewGeminiProvider(ctx context.Context, opts ...GeminiOption) (Provider, error) {
28+type geminiClient struct {
28− provider := &geminiProvider{
29+ providerOptions providerClientOptions
29− maxTokens: 5000,
30+ options geminiOptions
31+ client *genai.Client
30− }
32+}
31
33
32− for _, opt := range opts {
34+type GeminiClient ProviderClient
33− opt(provider)
34− }
35
35
36− if provider.systemMessage == "" {
36+func newGeminiClient(opts providerClientOptions) GeminiClient {
37− return nil, errors.New("system message is required")
37+ geminiOpts := geminiOptions{}
38+ for _, o := range opts.geminiOptions {
39+ o(&geminiOpts)
38 }
40 }
39
41
40− client, err := genai.NewClient(ctx, option.WithAPIKey(provider.apiKey))
42+ client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey))
41 if err != nil {
43 if err != nil {
42− return nil, err
44+ logging.Error("Failed to create Gemini client", "error", err)
43− }
45+ return nil
44− provider.client = client
45−
46− return provider, nil
47−}
48−
49−func WithGeminiSystemMessage(message string) GeminiOption {
50− return func(p *geminiProvider) {
51− p.systemMessage = message
52 }
46 }
53−}
54
47
55−func WithGeminiMaxTokens(maxTokens int32) GeminiOption {
48+ return &geminiClient{
56− return func(p *geminiProvider) {
49+ providerOptions: opts,
57− p.maxTokens = maxTokens
50+ options: geminiOpts,
51+ client: client,
58 }
52 }
59}
53}
60
54
61−func WithGeminiModel(model models.Model) GeminiOption {
55+func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
62− return func(p *geminiProvider) {
56+ var history []*genai.Content
63− p.model = model
64− }
65−}
66−
67−func WithGeminiKey(apiKey string) GeminiOption {
68− return func(p *geminiProvider) {
69− p.apiKey = apiKey
70− }
71−}
72
57
73−func (p *geminiProvider) Close() {
58+ // Add system message first
74− if p.client != nil {
59+ history = append(history, &genai.Content{
75− p.client.Close()
60+ Parts: []genai.Part{genai.Text(g.providerOptions.systemMessage)},
61+ Role: "user",
76− }
62+ })
77−}
78
63
79−func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content {
64+ // Add a system response to acknowledge the system message
80− var history []*genai.Content
65+ history = append(history, &genai.Content{
66+ Parts: []genai.Part{genai.Text("I'll help you with that.")},
67+ Role: "model",
68+ })
81
69
82 for _, msg := range messages {
70 for _, msg := range messages {
83 switch msg.Role {
71 switch msg.Role {
@@ -86,6 +74,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
86 Parts: []genai.Part{genai.Text(msg.Content().String())},
74 Parts: []genai.Part{genai.Text(msg.Content().String())},
87 Role: "user",
75 Role: "user",
88 })
76 })
77+
89 case message.Assistant:
78 case message.Assistant:
90 content := &genai.Content{
79 content := &genai.Content{
91 Role: "model",
80 Role: "model",
@@ -107,6 +96,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
107 }
96 }
108
97
109 history = append(history, content)
98 history = append(history, content)
99+
110 case message.Tool:
100 case message.Tool:
111 for _, result := range msg.ToolResults() {
101 for _, result := range msg.ToolResults() {
112 response := map[string]interface{}{"result": result.Content}
102 response := map[string]interface{}{"result": result.Content}
@@ -114,10 +104,11 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
114 if err == nil {
104 if err == nil {
115 response = parsed
105 response = parsed
116 }
106 }
107+
117 var toolCall message.ToolCall
108 var toolCall message.ToolCall
118− for _, msg := range messages {
109+ for _, m := range messages {
119− if msg.Role == message.Assistant {
110+ if m.Role == message.Assistant {
120− for _, call := range msg.ToolCalls() {
111+ for _, call := range m.ToolCalls() {
121 if call.ID == result.ToolCallID {
112 if call.ID == result.ToolCallID {
122 toolCall = call
113 toolCall = call
123 break
114 break
@@ -140,186 +131,358 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
140 return history
131 return history
141}
132}
142
133
143−func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage {
134+func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
144− if resp == nil || resp.UsageMetadata == nil {
135+ geminiTools := make([]*genai.Tool, 0, len(tools))
145− return TokenUsage{}
146− }
147
136
148− return TokenUsage{
137+ for _, tool := range tools {
149− InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
138+ info := tool.Info()
150− OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
139+ declaration := &genai.FunctionDeclaration{
151− CacheCreationTokens: 0, // Not directly provided by Gemini
140+ Name: info.Name,
152− CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
141+ Description: info.Description,
142+ Parameters: &genai.Schema{
143+ Type: genai.TypeObject,
144+ Properties: convertSchemaProperties(info.Parameters),
145+ Required: info.Required,
146+ },
147+ }
148+
149+ geminiTools = append(geminiTools, &genai.Tool{
150+ FunctionDeclarations: []*genai.FunctionDeclaration{declaration},
151+ })
153 }
152 }
153+
154+ return geminiTools
154}
155}
155
156
156−func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
157+func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
157− messages = cleanupMessages(messages)
158+ reasonStr := reason.String()
158− model := p.client.GenerativeModel(p.model.APIModel)
159+ switch {
159− model.SetMaxOutputTokens(p.maxTokens)
160+ case reasonStr == "STOP":
161+ return message.FinishReasonEndTurn
162+ case reasonStr == "MAX_TOKENS":
163+ return message.FinishReasonMaxTokens
164+ case strings.Contains(reasonStr, "FUNCTION") || strings.Contains(reasonStr, "TOOL"):
165+ return message.FinishReasonToolUse
166+ default:
167+ return message.FinishReasonUnknown
168+ }
169+}
160
170
161− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
171+func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
172+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
173+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
162
174
175+ // Convert tools
163 if len(tools) > 0 {
176 if len(tools) > 0 {
164− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
177+ model.Tools = g.convertTools(tools)
165− for _, declaration := range declarations {
166− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
167− }
168 }
178 }
169
179
170− chat := model.StartChat()
180+ // Convert messages
171− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
181+ geminiMessages := g.convertMessages(messages)
172
182
173− lastUserMsg := messages[len(messages)-1]
183+ cfg := config.Get()
174− resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content().String()))
184+ if cfg.Debug {
175− if err != nil {
185+ jsonData, _ := json.Marshal(geminiMessages)
176− return nil, err
186+ logging.Debug("Prepared messages", "messages", string(jsonData))
177 }
187 }
178
188
179− var content string
189+ attempts := 0
180− var toolCalls []message.ToolCall
190+ for {
191+ attempts++
192+ chat := model.StartChat()
193+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
194+
195+ lastMsg := geminiMessages[len(geminiMessages)-1]
196+ var lastText string
197+ for _, part := range lastMsg.Parts {
198+ if text, ok := part.(genai.Text); ok {
199+ lastText = string(text)
200+ break
201+ }
202+ }
181
203
182− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
204+ resp, err := chat.SendMessage(ctx, genai.Text(lastText))
183− for _, part := range resp.Candidates[0].Content.Parts {
205+ // If there is an error we are going to see if we can retry the call
184− switch p := part.(type) {
206+ if err != nil {
185− case genai.Text:
207+ retry, after, retryErr := g.shouldRetry(attempts, err)
186− content = string(p)
208+ if retryErr != nil {
187− case genai.FunctionCall:
209+ return nil, retryErr
188− id := "call_" + uuid.New().String()
189− args, _ := json.Marshal(p.Args)
190− toolCalls = append(toolCalls, message.ToolCall{
191− ID: id,
192− Name: p.Name,
193− Input: string(args),
194− Type: "function",
195− })
196 }
210 }
211+ if retry {
212+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
213+ select {
214+ case <-ctx.Done():
215+ return nil, ctx.Err()
216+ case <-time.After(time.Duration(after) * time.Millisecond):
217+ continue
218+ }
219+ }
220+ return nil, retryErr
197 }
221 }
198− }
199
222
200− tokenUsage := p.extractTokenUsage(resp)
223+ content := ""
224+ var toolCalls []message.ToolCall
225+
226+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
227+ for _, part := range resp.Candidates[0].Content.Parts {
228+ switch p := part.(type) {
229+ case genai.Text:
230+ content = string(p)
231+ case genai.FunctionCall:
232+ id := "call_" + uuid.New().String()
233+ args, _ := json.Marshal(p.Args)
234+ toolCalls = append(toolCalls, message.ToolCall{
235+ ID: id,
236+ Name: p.Name,
237+ Input: string(args),
238+ Type: "function",
239+ })
240+ }
241+ }
242+ }
201
243
202− return &ProviderResponse{
244+ return &ProviderResponse{
203− Content: content,
245+ Content: content,
204− ToolCalls: toolCalls,
246+ ToolCalls: toolCalls,
205− Usage: tokenUsage,
247+ Usage: g.usage(resp),
248+ FinishReason: g.finishReason(resp.Candidates[0].FinishReason),
206− }, nil
249+ }, nil
250+ }
207}
251}
208
252
209−func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
253+func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
210− messages = cleanupMessages(messages)
211− model := p.client.GenerativeModel(p.model.APIModel)
254+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
212− model.SetMaxOutputTokens(p.maxTokens)
255+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
213−
214− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
215
256
257+ // Convert tools
216 if len(tools) > 0 {
258 if len(tools) > 0 {
217− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
259+ model.Tools = g.convertTools(tools)
218− for _, declaration := range declarations {
219− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
220− }
221 }
260 }
222
261
223− chat := model.StartChat()
262+ // Convert messages
224− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
263+ geminiMessages := g.convertMessages(messages)
225
264
226− lastUserMsg := messages[len(messages)-1]
265+ cfg := config.Get()
227−
266+ if cfg.Debug {
228− iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content().String()))
267+ jsonData, _ := json.Marshal(geminiMessages)
268+ logging.Debug("Prepared messages", "messages", string(jsonData))
269+ }
229
270
271+ attempts := 0
230 eventChan := make(chan ProviderEvent)
272 eventChan := make(chan ProviderEvent)
231
273
232 go func() {
274 go func() {
233 defer close(eventChan)
275 defer close(eventChan)
234
276
235− var finalResp *genai.GenerateContentResponse
236− currentContent := ""
237− toolCalls := []message.ToolCall{}
238−
239 for {
277 for {
240− resp, err := iter.Next()
278+ attempts++
241− if err == iterator.Done {
279+ chat := model.StartChat()
280+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
281+
282+ lastMsg := geminiMessages[len(geminiMessages)-1]
283+ var lastText string
284+ for _, part := range lastMsg.Parts {
285+ if text, ok := part.(genai.Text); ok {
286+ lastText = string(text)
242− break
287+ break
243− }
244− if err != nil {
245− eventChan <- ProviderEvent{
246− Type: EventError,
247− Error: err,
248 }
288 }
249− return
250 }
289 }
251
290
252− finalResp = resp
291+ iter := chat.SendMessageStream(ctx, genai.Text(lastText))
253
292
254− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
255− for _, part := range resp.Candidates[0].Content.Parts {
256− switch p := part.(type) {
257− case genai.Text:
258− newText := string(p)
259− eventChan <- ProviderEvent{
260− Type: EventContentDelta,
261− Content: newText,
262− }
263− currentContent += newText
293+ currentContent := ""
264− case genai.FunctionCall:
265− id := "call_" + uuid.New().String()
266− args, _ := json.Marshal(p.Args)
267− newCall := message.ToolCall{
294+ toolCalls := []message.ToolCall{}
268− ID: id,
295+ var finalResp *genai.GenerateContentResponse
269− Name: p.Name,
270− Input: string(args),
271− Type: "function",
272− }
273
296
274− isNew := true
297+ eventChan <- ProviderEvent{Type: EventContentStart}
275− for _, existing := range toolCalls {
298+
276− if existing.Name == newCall.Name && existing.Input == newCall.Input {
299+ for {
277− isNew = false
300+ resp, err := iter.Next()
278− break
301+ if err == iterator.Done {
302+ break
303+ }
304+ if err != nil {
305+ retry, after, retryErr := g.shouldRetry(attempts, err)
306+ if retryErr != nil {
307+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
308+ return
309+ }
310+ if retry {
311+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
312+ select {
313+ case <-ctx.Done():
314+ if ctx.Err() != nil {
315+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
279 }
316 }
317+
318+ return
319+ case <-time.After(time.Duration(after) * time.Millisecond):
320+ break
280 }
321 }
322+ } else {
323+ eventChan <- ProviderEvent{Type: EventError, Error: err}
324+ return
325+ }
326+ }
327+
328+ finalResp = resp
329+
330+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
331+ for _, part := range resp.Candidates[0].Content.Parts {
332+ switch p := part.(type) {
333+ case genai.Text:
334+ newText := string(p)
335+ delta := newText[len(currentContent):]
336+ if delta != "" {
337+ eventChan <- ProviderEvent{
338+ Type: EventContentDelta,
339+ Content: delta,
340+ }
341+ currentContent = newText
342+ }
343+ case genai.FunctionCall:
344+ id := "call_" + uuid.New().String()
345+ args, _ := json.Marshal(p.Args)
346+ newCall := message.ToolCall{
347+ ID: id,
348+ Name: p.Name,
349+ Input: string(args),
350+ Type: "function",
351+ }
281
352
282− if isNew {
353+ isNew := true
354+ for _, existing := range toolCalls {
355+ if existing.Name == newCall.Name && existing.Input == newCall.Input {
356+ isNew = false
357+ break
358+ }
359+ }
360+
361+ if isNew {
283− toolCalls = append(toolCalls, newCall)
362+ toolCalls = append(toolCalls, newCall)
363+ }
284 }
364 }
285 }
365 }
286 }
366 }
287 }
367 }
288− }
289
368
290− tokenUsage := p.extractTokenUsage(finalResp)
369+ eventChan <- ProviderEvent{Type: EventContentStop}
291
370
371+ if finalResp != nil {
292− eventChan <- ProviderEvent{
372+ eventChan <- ProviderEvent{
293− Type: EventComplete,
373+ Type: EventComplete,
294− Response: &ProviderResponse{
374+ Response: &ProviderResponse{
295− Content: currentContent,
375+ Content: currentContent,
296− ToolCalls: toolCalls,
376+ ToolCalls: toolCalls,
297− Usage: tokenUsage,
377+ Usage: g.usage(finalResp),
298− FinishReason: string(finalResp.Candidates[0].FinishReason.String()),
378+ FinishReason: g.finishReason(finalResp.Candidates[0].FinishReason),
379+ },
380+ }
381+ return
382+ }
383+
384+ // If we get here, we need to retry
385+ if attempts > maxRetries {
386+ eventChan <- ProviderEvent{
387+ Type: EventError,
388+ Error: fmt.Errorf("maximum retry attempts reached: %d retries", maxRetries),
389+ }
390+ return
299− },
391+ }
392+
393+ // Wait before retrying
394+ select {
395+ case <-ctx.Done():
396+ if ctx.Err() != nil {
397+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
398+ }
399+ return
400+ case <-time.After(time.Duration(2000*(1<<(attempts-1))) * time.Millisecond):
401+ continue
402+ }
300 }
403 }
301 }()
404 }()
302
405
303− return eventChan, nil
406+ return eventChan
304}
407}
305
408
306−func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
409+func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
307− declarations := make([]*genai.FunctionDeclaration, len(tools))
410+ // Check if error is a rate limit error
411+ if attempts > maxRetries {
412+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
413+ }
308
414
309− for i, tool := range tools {
415+ // Gemini doesn't have a standard error type we can check against
310− info := tool.Info()
416+ // So we'll check the error message for rate limit indicators
311− declarations[i] = &genai.FunctionDeclaration{
417+ if errors.Is(err, io.EOF) {
312− Name: info.Name,
418+ return false, 0, err
313− Description: info.Description,
419+ }
314− Parameters: &genai.Schema{
420+
315− Type: genai.TypeObject,
421+ errMsg := err.Error()
316− Properties: convertSchemaProperties(info.Parameters),
422+ isRateLimit := false
317− Required: info.Required,
423+
424+ // Check for common rate limit error messages
425+ if contains(errMsg, "rate limit", "quota exceeded", "too many requests") {
426+ isRateLimit = true
427+ }
428+
429+ if !isRateLimit {
430+ return false, 0, err
431+ }
432+
433+ // Calculate backoff with jitter
434+ backoffMs := 2000 * (1 << (attempts - 1))
435+ jitterMs := int(float64(backoffMs) * 0.2)
436+ retryMs := backoffMs + jitterMs
437+
438+ return true, int64(retryMs), nil
439+}
440+
441+func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.ToolCall {
442+ var toolCalls []message.ToolCall
443+
444+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
445+ for _, part := range resp.Candidates[0].Content.Parts {
446+ if funcCall, ok := part.(genai.FunctionCall); ok {
447+ id := "call_" + uuid.New().String()
448+ args, _ := json.Marshal(funcCall.Args)
449+ toolCalls = append(toolCalls, message.ToolCall{
450+ ID: id,
451+ Name: funcCall.Name,
452+ Input: string(args),
453+ Type: "function",
454+ })
318− },
455+ }
319 }
456 }
320 }
457 }
321
458
322− return declarations
459+ return toolCalls
460+}
461+
462+func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
463+ if resp == nil || resp.UsageMetadata == nil {
464+ return TokenUsage{}
465+ }
466+
467+ return TokenUsage{
468+ InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
469+ OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
470+ CacheCreationTokens: 0, // Not directly provided by Gemini
471+ CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
472+ }
473+}
474+
475+func WithGeminiDisableCache() GeminiOption {
476+ return func(options *geminiOptions) {
477+ options.disableCache = true
478+ }
479+}
480+
481+// Helper functions
482+func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
483+ var result map[string]interface{}
484+ err := json.Unmarshal([]byte(jsonStr), &result)
485+ return result, err
323}
486}
324
487
325func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
488func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
@@ -396,8 +559,12 @@ func mapJSONTypeToGenAI(jsonType string) genai.Type {
396 }
559 }
397}
560}
398
561
399−func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
562+func contains(s string, substrs ...string) bool {
400− var result map[string]interface{}
563+ for _, substr := range substrs {
401− err := json.Unmarshal([]byte(jsonStr), &result)
564+ if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
402− return result, err
565+ return true
566+ }
567+ }
568+ return false
403}
569}
570+
internal/llm/provider/openai.go+241−160
@@ -2,89 +2,65 @@ package provider
2
2
3import (
3import (
4 "context"
4 "context"
5+ "encoding/json"
5 "errors"
6 "errors"
7+ "fmt"
8+ "io"
9+ "time"
6
10
7− "github.com/kujtimiihoxha/termai/internal/llm/models"
11+ "github.com/kujtimiihoxha/termai/internal/config"
8 "github.com/kujtimiihoxha/termai/internal/llm/tools"
12 "github.com/kujtimiihoxha/termai/internal/llm/tools"
13+ "github.com/kujtimiihoxha/termai/internal/logging"
9 "github.com/kujtimiihoxha/termai/internal/message"
14 "github.com/kujtimiihoxha/termai/internal/message"
10 "github.com/openai/openai-go"
15 "github.com/openai/openai-go"
11 "github.com/openai/openai-go/option"
16 "github.com/openai/openai-go/option"
12)
17)
13
18
14−type openaiProvider struct {
19+type openaiOptions struct {
15− client openai.Client
16− model models.Model
17− maxTokens int64
18− baseURL string
20+ baseURL string
19− apiKey string
21+ disableCache bool
20− systemMessage string
21}
22}
22
23
23−type OpenAIOption func(*openaiProvider)
24+type OpenAIOption func(*openaiOptions)
24
25
25−func NewOpenAIProvider(opts ...OpenAIOption) (Provider, error) {
26+type openaiClient struct {
26− provider := &openaiProvider{
27+ providerOptions providerClientOptions
27− maxTokens: 5000,
28+ options openaiOptions
28− }
29−
30− for _, opt := range opts {
31− opt(provider)
32− }
33−
34− clientOpts := []option.RequestOption{
35− option.WithAPIKey(provider.apiKey),
36− }
37− if provider.baseURL != "" {
38− clientOpts = append(clientOpts, option.WithBaseURL(provider.baseURL))
39− }
40−
41− provider.client = openai.NewClient(clientOpts...)
29+ client openai.Client
42− if provider.systemMessage == "" {
43− return nil, errors.New("system message is required")
44− }
45−
46− return provider, nil
47}
30}
48
31
49−func WithOpenAISystemMessage(message string) OpenAIOption {
32+type OpenAIClient ProviderClient
50− return func(p *openaiProvider) {
51− p.systemMessage = message
52− }
53−}
54
33
55−func WithOpenAIMaxTokens(maxTokens int64) OpenAIOption {
34+func newOpenAIClient(opts providerClientOptions) OpenAIClient {
56− return func(p *openaiProvider) {
35+ openaiOpts := openaiOptions{}
57− p.maxTokens = maxTokens
36+ for _, o := range opts.openaiOptions {
37+ o(&openaiOpts)
58 }
38 }
59−}
60
39
61−func WithOpenAIModel(model models.Model) OpenAIOption {
40+ openaiClientOptions := []option.RequestOption{}
62− return func(p *openaiProvider) {
41+ if opts.apiKey != "" {
63− p.model = model
42+ openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
64 }
43 }
65−}
44+ if openaiOpts.baseURL != "" {
66−
45+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
67−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
68− return func(p *openaiProvider) {
69− p.baseURL = baseURL
70 }
46 }
71−}
72
47
73−func WithOpenAIKey(apiKey string) OpenAIOption {
48+ client := openai.NewClient(openaiClientOptions...)
74− return func(p *openaiProvider) {
49+ return &openaiClient{
75− p.apiKey = apiKey
50+ providerOptions: opts,
51+ options: openaiOpts,
52+ client: client,
76 }
53 }
77}
54}
78
55
79−func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []openai.ChatCompletionMessageParamUnion {
56+func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
80− var chatMessages []openai.ChatCompletionMessageParamUnion
57+ // Add system message first
81−
82− chatMessages = append(chatMessages, openai.SystemMessage(p.systemMessage))
58+ openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
83
59
84 for _, msg := range messages {
60 for _, msg := range messages {
85 switch msg.Role {
61 switch msg.Role {
86 case message.User:
62 case message.User:
87− chatMessages = append(chatMessages, openai.UserMessage(msg.Content().String()))
63+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
88
64
89 case message.Assistant:
65 case message.Assistant:
90 assistantMsg := openai.ChatCompletionAssistantMessageParam{
66 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -111,23 +87,23 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o
111 }
87 }
112 }
88 }
113
89
114− chatMessages = append(chatMessages, openai.ChatCompletionMessageParamUnion{
90+ openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
115 OfAssistant: &assistantMsg,
91 OfAssistant: &assistantMsg,
116 })
92 })
117
93
118 case message.Tool:
94 case message.Tool:
119 for _, result := range msg.ToolResults() {
95 for _, result := range msg.ToolResults() {
120− chatMessages = append(chatMessages,
96+ openaiMessages = append(openaiMessages,
121 openai.ToolMessage(result.Content, result.ToolCallID),
97 openai.ToolMessage(result.Content, result.ToolCallID),
122 )
98 )
123 }
99 }
124 }
100 }
125 }
101 }
126
102
127− return chatMessages
103+ return
128}
104}
129
105
130−func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
106+func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
131 openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
107 openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
132
108
133 for i, tool := range tools {
109 for i, tool := range tools {
@@ -148,133 +124,238 @@ func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.C
148 return openaiTools
124 return openaiTools
149}
125}
150
126
151−func (p *openaiProvider) extractTokenUsage(usage openai.CompletionUsage) TokenUsage {
127+func (o *openaiClient) finishReason(reason string) message.FinishReason {
152− cachedTokens := int64(0)
128+ switch reason {
153−
129+ case "stop":
154− cachedTokens = usage.PromptTokensDetails.CachedTokens
130+ return message.FinishReasonEndTurn
155− inputTokens := usage.PromptTokens - cachedTokens
131+ case "length":
156−
132+ return message.FinishReasonMaxTokens
157− return TokenUsage{
133+ case "tool_calls":
158− InputTokens: inputTokens,
134+ return message.FinishReasonToolUse
159− OutputTokens: usage.CompletionTokens,
135+ default:
160− CacheCreationTokens: 0, // OpenAI doesn't provide this directly
136+ return message.FinishReasonUnknown
161− CacheReadTokens: cachedTokens,
162 }
137 }
163}
138}
164
139
165−func (p *openaiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
140+func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
166− messages = cleanupMessages(messages)
167− chatMessages := p.convertToOpenAIMessages(messages)
168− openaiTools := p.convertToOpenAITools(tools)
169−
170− params := openai.ChatCompletionNewParams{
141+ return openai.ChatCompletionNewParams{
171− Model: openai.ChatModel(p.model.APIModel),
142+ Model: openai.ChatModel(o.providerOptions.model.APIModel),
172− Messages: chatMessages,
143+ Messages: messages,
173− MaxTokens: openai.Int(p.maxTokens),
144+ MaxTokens: openai.Int(o.providerOptions.maxTokens),
174− Tools: openaiTools,
145+ Tools: tools,
175− }
176−
177− response, err := p.client.Chat.Completions.New(ctx, params)
178− if err != nil {
179− return nil, err
180 }
146 }
147+}
181
148
182− content := ""
149+func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
183− if response.Choices[0].Message.Content != "" {
150+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
184− content = response.Choices[0].Message.Content
151+ cfg := config.Get()
152+ if cfg.Debug {
153+ jsonData, _ := json.Marshal(params)
154+ logging.Debug("Prepared messages", "messages", string(jsonData))
185 }
155 }
186−
156+ attempts := 0
187− var toolCalls []message.ToolCall
157+ for {
188− if len(response.Choices[0].Message.ToolCalls) > 0 {
158+ attempts++
189− toolCalls = make([]message.ToolCall, len(response.Choices[0].Message.ToolCalls))
159+ openaiResponse, err := o.client.Chat.Completions.New(
190− for i, call := range response.Choices[0].Message.ToolCalls {
160+ ctx,
191− toolCalls[i] = message.ToolCall{
161+ params,
192− ID: call.ID,
162+ )
193− Name: call.Function.Name,
163+ // If there is an error we are going to see if we can retry the call
194− Input: call.Function.Arguments,
164+ if err != nil {
195− Type: "function",
165+ retry, after, retryErr := o.shouldRetry(attempts, err)
166+ if retryErr != nil {
167+ return nil, retryErr
196 }
168 }
169+ if retry {
170+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
171+ select {
172+ case <-ctx.Done():
173+ return nil, ctx.Err()
174+ case <-time.After(time.Duration(after) * time.Millisecond):
175+ continue
176+ }
177+ }
178+ return nil, retryErr
197 }
179 }
198− }
199
180
200− tokenUsage := p.extractTokenUsage(response.Usage)
181+ content := ""
182+ if openaiResponse.Choices[0].Message.Content != "" {
183+ content = openaiResponse.Choices[0].Message.Content
184+ }
201
185
202− return &ProviderResponse{
186+ return &ProviderResponse{
203− Content: content,
187+ Content: content,
204− ToolCalls: toolCalls,
188+ ToolCalls: o.toolCalls(*openaiResponse),
205− Usage: tokenUsage,
189+ Usage: o.usage(*openaiResponse),
190+ FinishReason: o.finishReason(string(openaiResponse.Choices[0].FinishReason)),
206− }, nil
191+ }, nil
192+ }
207}
193}
208
194
209−func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
195+func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
210− messages = cleanupMessages(messages)
196+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
211− chatMessages := p.convertToOpenAIMessages(messages)
212− openaiTools := p.convertToOpenAITools(tools)
213−
214− params := openai.ChatCompletionNewParams{
215− Model: openai.ChatModel(p.model.APIModel),
216− Messages: chatMessages,
217− MaxTokens: openai.Int(p.maxTokens),
218− Tools: openaiTools,
219− StreamOptions: openai.ChatCompletionStreamOptionsParam{
197+ params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
220− IncludeUsage: openai.Bool(true),
198+ IncludeUsage: openai.Bool(true),
221− },
222 }
199 }
223
200
224− stream := p.client.Chat.Completions.NewStreaming(ctx, params)
201+ cfg := config.Get()
202+ if cfg.Debug {
203+ jsonData, _ := json.Marshal(params)
204+ logging.Debug("Prepared messages", "messages", string(jsonData))
205+ }
225
206
207+ attempts := 0
226 eventChan := make(chan ProviderEvent)
208 eventChan := make(chan ProviderEvent)
227
209
228− toolCalls := make([]message.ToolCall, 0)
229 go func() {
210 go func() {
230− defer close(eventChan)
211+ for {
231−
212+ attempts++
213+ openaiStream := o.client.Chat.Completions.NewStreaming(
214+ ctx,
215+ params,
216+ )
217+
232− acc := openai.ChatCompletionAccumulator{}
218+ acc := openai.ChatCompletionAccumulator{}
233− currentContent := ""
219+ currentContent := ""
234−
220+ toolCalls := make([]message.ToolCall, 0)
221+
235− for stream.Next() {
222+ for openaiStream.Next() {
236− chunk := stream.Current()
223+ chunk := openaiStream.Current()
237− acc.AddChunk(chunk)
224+ acc.AddChunk(chunk)
238−
225+
239− if tool, ok := acc.JustFinishedToolCall(); ok {
226+ if tool, ok := acc.JustFinishedToolCall(); ok {
240− toolCalls = append(toolCalls, message.ToolCall{
227+ toolCalls = append(toolCalls, message.ToolCall{
241− ID: tool.Id,
228+ ID: tool.Id,
242− Name: tool.Name,
229+ Name: tool.Name,
243− Input: tool.Arguments,
230+ Input: tool.Arguments,
244− Type: "function",
231+ Type: "function",
245− })
232+ })
246− }
233+ }
247
234
248− for _, choice := range chunk.Choices {
235+ for _, choice := range chunk.Choices {
249− if choice.Delta.Content != "" {
236+ if choice.Delta.Content != "" {
250− eventChan <- ProviderEvent{
237+ eventChan <- ProviderEvent{
251− Type: EventContentDelta,
238+ Type: EventContentDelta,
252− Content: choice.Delta.Content,
239+ Content: choice.Delta.Content,
240+ }
241+ currentContent += choice.Delta.Content
253 }
242 }
254− currentContent += choice.Delta.Content
255 }
243 }
256 }
244 }
257− }
258
245
259− if err := stream.Err(); err != nil {
246+ err := openaiStream.Err()
247+ if err == nil || errors.Is(err, io.EOF) {
248+ // Stream completed successfully
260− eventChan <- ProviderEvent{
249+ eventChan <- ProviderEvent{
261− Type: EventError,
250+ Type: EventComplete,
262− Error: err,
251+ Response: &ProviderResponse{
252+ Content: currentContent,
253+ ToolCalls: toolCalls,
254+ Usage: o.usage(acc.ChatCompletion),
255+ FinishReason: o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)),
256+ },
257+ }
258+ close(eventChan)
259+ return
263 }
260 }
261+
262+ // If there is an error we are going to see if we can retry the call
263+ retry, after, retryErr := o.shouldRetry(attempts, err)
264+ if retryErr != nil {
265+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
266+ close(eventChan)
267+ return
268+ }
269+ if retry {
270+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
271+ select {
272+ case <-ctx.Done():
273+ // context cancelled
274+ if ctx.Err() == nil {
275+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
276+ }
277+ close(eventChan)
278+ return
279+ case <-time.After(time.Duration(after) * time.Millisecond):
280+ continue
281+ }
282+ }
283+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
284+ close(eventChan)
264 return
285 return
265 }
286 }
287+ }()
266
288
267− tokenUsage := p.extractTokenUsage(acc.Usage)
289+ return eventChan
290+}
268
291
269− eventChan <- ProviderEvent{
292+func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
270− Type: EventComplete,
293+ var apierr *openai.Error
271− Response: &ProviderResponse{
294+ if !errors.As(err, &apierr) {
272− Content: currentContent,
295+ return false, 0, err
273− ToolCalls: toolCalls,
296+ }
274− Usage: tokenUsage,
297+
275− },
298+ if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
299+ return false, 0, err
300+ }
301+
302+ if attempts > maxRetries {
303+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
304+ }
305+
306+ retryMs := 0
307+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
308+
309+ backoffMs := 2000 * (1 << (attempts - 1))
310+ jitterMs := int(float64(backoffMs) * 0.2)
311+ retryMs = backoffMs + jitterMs
312+ if len(retryAfterValues) > 0 {
313+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
314+ retryMs = retryMs * 1000
276 }
315 }
277− }()
316+ }
317+ return true, int64(retryMs), nil
318+}
278
319
279− return eventChan, nil
320+func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
321+ var toolCalls []message.ToolCall
322+
323+ if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
324+ for _, call := range completion.Choices[0].Message.ToolCalls {
325+ toolCall := message.ToolCall{
326+ ID: call.ID,
327+ Name: call.Function.Name,
328+ Input: call.Function.Arguments,
329+ Type: "function",
330+ }
331+ toolCalls = append(toolCalls, toolCall)
332+ }
333+ }
334+
335+ return toolCalls
280}
336}
337+
338+func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
339+ cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
340+ inputTokens := completion.Usage.PromptTokens - cachedTokens
341+
342+ return TokenUsage{
343+ InputTokens: inputTokens,
344+ OutputTokens: completion.Usage.CompletionTokens,
345+ CacheCreationTokens: 0, // OpenAI doesn't provide this directly
346+ CacheReadTokens: cachedTokens,
347+ }
348+}
349+
350+func WithOpenAIBaseURL(baseURL string) OpenAIOption {
351+ return func(options *openaiOptions) {
352+ options.baseURL = baseURL
353+ }
354+}
355+
356+func WithOpenAIDisableCache() OpenAIOption {
357+ return func(options *openaiOptions) {
358+ options.disableCache = true
359+ }
360+}
361+
internal/llm/provider/provider.go+131−38
@@ -2,14 +2,17 @@ package provider
2
2
3import (
3import (
4 "context"
4 "context"
5+ "fmt"
5
6
7+ "github.com/kujtimiihoxha/termai/internal/llm/models"
6 "github.com/kujtimiihoxha/termai/internal/llm/tools"
8 "github.com/kujtimiihoxha/termai/internal/llm/tools"
7 "github.com/kujtimiihoxha/termai/internal/message"
9 "github.com/kujtimiihoxha/termai/internal/message"
8)
10)
9
11
10−// EventType represents the type of streaming event
11type EventType string
12type EventType string
12
13
14+const maxRetries = 8
15+
13const (
16const (
14 EventContentStart EventType = "content_start"
17 EventContentStart EventType = "content_start"
15 EventContentDelta EventType = "content_delta"
18 EventContentDelta EventType = "content_delta"
@@ -18,7 +21,6 @@ const (
18 EventComplete EventType = "complete"
21 EventComplete EventType = "complete"
19 EventError EventType = "error"
22 EventError EventType = "error"
20 EventWarning EventType = "warning"
23 EventWarning EventType = "warning"
21− EventInfo EventType = "info"
22)
24)
23
25
24type TokenUsage struct {
26type TokenUsage struct {
@@ -32,61 +34,152 @@ type ProviderResponse struct {
32 Content string
34 Content string
33 ToolCalls []message.ToolCall
35 ToolCalls []message.ToolCall
34 Usage TokenUsage
36 Usage TokenUsage
35− FinishReason string
37+ FinishReason message.FinishReason
36}
38}
37
39
38type ProviderEvent struct {
40type ProviderEvent struct {
39− Type EventType
41+ Type EventType
42+
40 Content string
43 Content string
41 Thinking string
44 Thinking string
42− ToolCall *message.ToolCall
43− Error error
44 Response *ProviderResponse
45 Response *ProviderResponse
45
46
46− // Used for giving users info on e.x retry
47+ Error error
47− Info string
48}
48}
49−
50type Provider interface {
49type Provider interface {
51 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
50 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
52
51
53− StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error)
52+ StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
53+
54+ Model() models.Model
55+}
56+
57+type providerClientOptions struct {
58+ apiKey string
59+ model models.Model
60+ maxTokens int64
61+ systemMessage string
62+
63+ anthropicOptions []AnthropicOption
64+ openaiOptions []OpenAIOption
65+ geminiOptions []GeminiOption
66+ bedrockOptions []BedrockOption
67+}
68+
69+type ProviderClientOption func(*providerClientOptions)
70+
71+type ProviderClient interface {
72+ send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
73+ stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
74+}
75+
76+type baseProvider[C ProviderClient] struct {
77+ options providerClientOptions
78+ client C
79+}
80+
81+func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) {
82+ clientOptions := providerClientOptions{}
83+ for _, o := range opts {
84+ o(&clientOptions)
85+ }
86+ switch providerName {
87+ case models.ProviderAnthropic:
88+ return &baseProvider[AnthropicClient]{
89+ options: clientOptions,
90+ client: newAnthropicClient(clientOptions),
91+ }, nil
92+ case models.ProviderOpenAI:
93+ return &baseProvider[OpenAIClient]{
94+ options: clientOptions,
95+ client: newOpenAIClient(clientOptions),
96+ }, nil
97+ case models.ProviderGemini:
98+ return &baseProvider[GeminiClient]{
99+ options: clientOptions,
100+ client: newGeminiClient(clientOptions),
101+ }, nil
102+ case models.ProviderBedrock:
103+ return &baseProvider[BedrockClient]{
104+ options: clientOptions,
105+ client: newBedrockClient(clientOptions),
106+ }, nil
107+ case models.ProviderMock:
108+ // TODO: implement mock client for test
109+ panic("not implemented")
110+ }
111+ return nil, fmt.Errorf("provider not supported: %s", providerName)
54}
112}
55
113
56−func cleanupMessages(messages []message.Message) []message.Message {
114+func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
57− // First pass: filter out canceled messages
58− var cleanedMessages []message.Message
59 for _, msg := range messages {
115 for _, msg := range messages {
60− if msg.FinishReason() != "canceled" || len(msg.ToolCalls()) > 0 {
116+ // The message has no content
61− // if there are toolCalls this means we want to return it to the LLM telling it that those tools have been
117+ if len(msg.Parts) == 0 {
62− // cancelled
118+ continue
63− cleanedMessages = append(cleanedMessages, msg)
64 }
119 }
120+ cleaned = append(cleaned, msg)
65 }
121 }
122+ return
123+}
66
124
67− // Second pass: filter out tool messages without a corresponding tool call
125+func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
68− var result []message.Message
126+ messages = p.cleanMessages(messages)
69− toolMessageIDs := make(map[string]bool)
127+ return p.client.send(ctx, messages, tools)
128+}
70
129
71− for _, msg := range cleanedMessages {
130+func (p *baseProvider[C]) Model() models.Model {
72− if msg.Role == message.Assistant {
131+ return p.options.model
73− for _, toolCall := range msg.ToolCalls() {
132+}
74− toolMessageIDs[toolCall.ID] = true // Mark as referenced
133+
75− }
134+func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
76− }
135+ messages = p.cleanMessages(messages)
136+ return p.client.stream(ctx, messages, tools)
137+}
138+
139+func WithAPIKey(apiKey string) ProviderClientOption {
140+ return func(options *providerClientOptions) {
141+ options.apiKey = apiKey
77 }
142 }
143+}
78
144
79− // Keep only messages that aren't unreferenced tool messages
145+func WithModel(model models.Model) ProviderClientOption {
80− for _, msg := range cleanedMessages {
146+ return func(options *providerClientOptions) {
81− if msg.Role == message.Tool {
147+ options.model = model
82− for _, toolCall := range msg.ToolResults() {
148+ }
83− if referenced, exists := toolMessageIDs[toolCall.ToolCallID]; exists && referenced {
149+}
84− result = append(result, msg)
150+
151+func WithMaxTokens(maxTokens int64) ProviderClientOption {
152+ return func(options *providerClientOptions) {
153+ options.maxTokens = maxTokens
154+ }
155+}
156+
157+func WithSystemMessage(systemMessage string) ProviderClientOption {
158+ return func(options *providerClientOptions) {
159+ options.systemMessage = systemMessage
160+ }
161+}
162+
163+func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
164+ return func(options *providerClientOptions) {
165+ options.anthropicOptions = anthropicOptions
166+ }
167+}
168+
169+func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
170+ return func(options *providerClientOptions) {
171+ options.openaiOptions = openaiOptions
172+ }
173+}
174+
175+func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
176+ return func(options *providerClientOptions) {
177+ options.geminiOptions = geminiOptions
85− }
178+ }
86− }
179+}
87− } else {
180+
88− result = append(result, msg)
181+func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
89− }
182+ return func(options *providerClientOptions) {
183+ options.bedrockOptions = bedrockOptions
90 }
184 }
91− return result
92}
185}
internal/llm/tools/bash.go+4−3
@@ -23,7 +23,8 @@ type BashPermissionsParams struct {
23}
23}
24
24
25type BashResponseMetadata struct {
25type BashResponseMetadata struct {
26− Took int64 `json:"took"`
26+ StartTime int64 `json:"start_time"`
27+ EndTime int64 `json:"end_time"`
27}
28}
28type bashTool struct {
29type bashTool struct {
29 permissions permission.Service
30 permissions permission.Service
@@ -282,7 +283,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
282 if err != nil {
283 if err != nil {
283 return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
284 return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
284 }
285 }
285− took := time.Since(startTime).Milliseconds()
286
286
287 stdout = truncateOutput(stdout)
287 stdout = truncateOutput(stdout)
288 stderr = truncateOutput(stderr)
288 stderr = truncateOutput(stderr)
@@ -311,7 +311,8 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
311 }
311 }
312
312
313 metadata := BashResponseMetadata{
313 metadata := BashResponseMetadata{
314− Took: took,
314+ StartTime: startTime.UnixMilli(),
315+ EndTime: time.Now().UnixMilli(),
315 }
316 }
316 if stdout == "" {
317 if stdout == "" {
317 return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
318 return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
internal/llm/tools/bash_test.go−31
@@ -8,8 +8,6 @@ import (
8 "testing"
8 "testing"
9 "time"
9 "time"
10
10
11− "github.com/kujtimiihoxha/termai/internal/permission"
12− "github.com/kujtimiihoxha/termai/internal/pubsub"
13 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
12 "github.com/stretchr/testify/require"
15)
13)
@@ -340,32 +338,3 @@ func TestCountLines(t *testing.T) {
340 })
338 })
341 }
339 }
342}
340}
343−
344−// Mock permission service for testing
345−type mockPermissionService struct {
346− *pubsub.Broker[permission.PermissionRequest]
347− allow bool
348−}
349−
350−func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
351− // Not needed for tests
352−}
353−
354−func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
355− // Not needed for tests
356−}
357−
358−func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
359− // Not needed for tests
360−}
361−
362−func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
363− return m.allow
364−}
365−
366−func newMockPermissionService(allow bool) permission.Service {
367− return &mockPermissionService{
368− Broker: pubsub.NewBroker[permission.PermissionRequest](),
369− allow: allow,
370− }
371−}
internal/llm/tools/edit.go+72−3
@@ -11,6 +11,7 @@ import (
11
11
12 "github.com/kujtimiihoxha/termai/internal/config"
12 "github.com/kujtimiihoxha/termai/internal/config"
13 "github.com/kujtimiihoxha/termai/internal/diff"
13 "github.com/kujtimiihoxha/termai/internal/diff"
14+ "github.com/kujtimiihoxha/termai/internal/history"
14 "github.com/kujtimiihoxha/termai/internal/lsp"
15 "github.com/kujtimiihoxha/termai/internal/lsp"
15 "github.com/kujtimiihoxha/termai/internal/permission"
16 "github.com/kujtimiihoxha/termai/internal/permission"
16)
17)
@@ -35,6 +36,7 @@ type EditResponseMetadata struct {
35type editTool struct {
36type editTool struct {
36 lspClients map[string]*lsp.Client
37 lspClients map[string]*lsp.Client
37 permissions permission.Service
38 permissions permission.Service
39+ files history.Service
38}
40}
39
41
40const (
42const (
@@ -88,10 +90,11 @@ When making edits:
88Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
90Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
89)
91)
90
92
91−func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
93+func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
92 return &editTool{
94 return &editTool{
93 lspClients: lspClients,
95 lspClients: lspClients,
94 permissions: permissions,
96 permissions: permissions,
97+ files: files,
95 }
98 }
96}
99}
97
100
@@ -153,6 +156,11 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
153 if err != nil {
156 if err != nil {
154 return response, nil
157 return response, nil
155 }
158 }
159+ if response.IsError {
160+ // Return early if there was an error during content replacement
161+ // This prevents unnecessary LSP diagnostics processing
162+ return response, nil
163+ }
156
164
157 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
165 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
158 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
166 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
@@ -208,6 +216,20 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
208 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
216 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
209 }
217 }
210
218
219+ // File can't be in the history so we create a new file history
220+ _, err = e.files.Create(ctx, sessionID, filePath, "")
221+ if err != nil {
222+ // Log error but don't fail the operation
223+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
224+ }
225+
226+ // Add the new content to the file history
227+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
228+ if err != nil {
229+ // Log error but don't fail the operation
230+ fmt.Printf("Error creating file history version: %v\n", err)
231+ }
232+
211 recordFileWrite(filePath)
233 recordFileWrite(filePath)
212 recordFileRead(filePath)
234 recordFileRead(filePath)
213
235
@@ -298,6 +320,29 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
298 if err != nil {
320 if err != nil {
299 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
321 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
300 }
322 }
323+
324+ // Check if file exists in history
325+ file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
326+ if err != nil {
327+ _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
328+ if err != nil {
329+ // Log error but don't fail the operation
330+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
331+ }
332+ }
333+ if file.Content != oldContent {
334+ // User Manually changed the content store an intermediate version
335+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
336+ if err != nil {
337+ fmt.Printf("Error creating file history version: %v\n", err)
338+ }
339+ }
340+ // Store the new version
341+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
342+ if err != nil {
343+ fmt.Printf("Error creating file history version: %v\n", err)
344+ }
345+
301 recordFileWrite(filePath)
346 recordFileWrite(filePath)
302 recordFileRead(filePath)
347 recordFileRead(filePath)
303
348
@@ -356,6 +401,9 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
356
401
357 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
402 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
358
403
404+ if oldContent == newContent {
405+ return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
406+ }
359 sessionID, messageID := GetContextValues(ctx)
407 sessionID, messageID := GetContextValues(ctx)
360
408
361 if sessionID == "" || messageID == "" {
409 if sessionID == "" || messageID == "" {
@@ -374,8 +422,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
374 Description: fmt.Sprintf("Replace content in file %s", filePath),
422 Description: fmt.Sprintf("Replace content in file %s", filePath),
375 Params: EditPermissionsParams{
423 Params: EditPermissionsParams{
376 FilePath: filePath,
424 FilePath: filePath,
377−
378− Diff: diff,
425+ Diff: diff,
379 },
426 },
380 },
427 },
381 )
428 )
@@ -388,6 +435,28 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
388 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
435 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
389 }
436 }
390
437
438+ // Check if file exists in history
439+ file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
440+ if err != nil {
441+ _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
442+ if err != nil {
443+ // Log error but don't fail the operation
444+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
445+ }
446+ }
447+ if file.Content != oldContent {
448+ // User Manually changed the content store an intermediate version
449+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
450+ if err != nil {
451+ fmt.Printf("Error creating file history version: %v\n", err)
452+ }
453+ }
454+ // Store the new version
455+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
456+ if err != nil {
457+ fmt.Printf("Error creating file history version: %v\n", err)
458+ }
459+
391 recordFileWrite(filePath)
460 recordFileWrite(filePath)
392 recordFileRead(filePath)
461 recordFileRead(filePath)
393
462
internal/llm/tools/edit_test.go+15−15
@@ -14,7 +14,7 @@ import (
14)
14)
15
15
16func TestEditTool_Info(t *testing.T) {
16func TestEditTool_Info(t *testing.T) {
17− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
17+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
18 info := tool.Info()
18 info := tool.Info()
19
19
20 assert.Equal(t, EditToolName, info.Name)
20 assert.Equal(t, EditToolName, info.Name)
@@ -34,7 +34,7 @@ func TestEditTool_Run(t *testing.T) {
34 defer os.RemoveAll(tempDir)
34 defer os.RemoveAll(tempDir)
35
35
36 t.Run("creates a new file successfully", func(t *testing.T) {
36 t.Run("creates a new file successfully", func(t *testing.T) {
37− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
37+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
38
38
39 filePath := filepath.Join(tempDir, "new_file.txt")
39 filePath := filepath.Join(tempDir, "new_file.txt")
40 content := "This is a test content"
40 content := "This is a test content"
@@ -64,7 +64,7 @@ func TestEditTool_Run(t *testing.T) {
64 })
64 })
65
65
66 t.Run("creates file with nested directories", func(t *testing.T) {
66 t.Run("creates file with nested directories", func(t *testing.T) {
67− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
67+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
68
68
69 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
69 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
70 content := "Content in nested directory"
70 content := "Content in nested directory"
@@ -94,7 +94,7 @@ func TestEditTool_Run(t *testing.T) {
94 })
94 })
95
95
96 t.Run("fails to create file that already exists", func(t *testing.T) {
96 t.Run("fails to create file that already exists", func(t *testing.T) {
97− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
97+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
98
98
99 // Create a file first
99 // Create a file first
100 filePath := filepath.Join(tempDir, "existing_file.txt")
100 filePath := filepath.Join(tempDir, "existing_file.txt")
@@ -123,7 +123,7 @@ func TestEditTool_Run(t *testing.T) {
123 })
123 })
124
124
125 t.Run("fails to create file when path is a directory", func(t *testing.T) {
125 t.Run("fails to create file when path is a directory", func(t *testing.T) {
126− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
126+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
127
127
128 // Create a directory
128 // Create a directory
129 dirPath := filepath.Join(tempDir, "test_dir")
129 dirPath := filepath.Join(tempDir, "test_dir")
@@ -151,7 +151,7 @@ func TestEditTool_Run(t *testing.T) {
151 })
151 })
152
152
153 t.Run("replaces content successfully", func(t *testing.T) {
153 t.Run("replaces content successfully", func(t *testing.T) {
154− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
154+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
155
155
156 // Create a file first
156 // Create a file first
157 filePath := filepath.Join(tempDir, "replace_content.txt")
157 filePath := filepath.Join(tempDir, "replace_content.txt")
@@ -191,7 +191,7 @@ func TestEditTool_Run(t *testing.T) {
191 })
191 })
192
192
193 t.Run("deletes content successfully", func(t *testing.T) {
193 t.Run("deletes content successfully", func(t *testing.T) {
194− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
194+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
195
195
196 // Create a file first
196 // Create a file first
197 filePath := filepath.Join(tempDir, "delete_content.txt")
197 filePath := filepath.Join(tempDir, "delete_content.txt")
@@ -230,7 +230,7 @@ func TestEditTool_Run(t *testing.T) {
230 })
230 })
231
231
232 t.Run("handles invalid parameters", func(t *testing.T) {
232 t.Run("handles invalid parameters", func(t *testing.T) {
233− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
233+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
234
234
235 call := ToolCall{
235 call := ToolCall{
236 Name: EditToolName,
236 Name: EditToolName,
@@ -243,7 +243,7 @@ func TestEditTool_Run(t *testing.T) {
243 })
243 })
244
244
245 t.Run("handles missing file_path", func(t *testing.T) {
245 t.Run("handles missing file_path", func(t *testing.T) {
246− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
246+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
247
247
248 params := EditParams{
248 params := EditParams{
249 FilePath: "",
249 FilePath: "",
@@ -265,7 +265,7 @@ func TestEditTool_Run(t *testing.T) {
265 })
265 })
266
266
267 t.Run("handles file not found", func(t *testing.T) {
267 t.Run("handles file not found", func(t *testing.T) {
268− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
268+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
269
269
270 filePath := filepath.Join(tempDir, "non_existent_file.txt")
270 filePath := filepath.Join(tempDir, "non_existent_file.txt")
271 params := EditParams{
271 params := EditParams{
@@ -288,7 +288,7 @@ func TestEditTool_Run(t *testing.T) {
288 })
288 })
289
289
290 t.Run("handles old_string not found in file", func(t *testing.T) {
290 t.Run("handles old_string not found in file", func(t *testing.T) {
291− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
291+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
292
292
293 // Create a file first
293 // Create a file first
294 filePath := filepath.Join(tempDir, "content_not_found.txt")
294 filePath := filepath.Join(tempDir, "content_not_found.txt")
@@ -320,7 +320,7 @@ func TestEditTool_Run(t *testing.T) {
320 })
320 })
321
321
322 t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
322 t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
323− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
323+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
324
324
325 // Create a file with duplicate content
325 // Create a file with duplicate content
326 filePath := filepath.Join(tempDir, "duplicate_content.txt")
326 filePath := filepath.Join(tempDir, "duplicate_content.txt")
@@ -352,7 +352,7 @@ func TestEditTool_Run(t *testing.T) {
352 })
352 })
353
353
354 t.Run("handles file modified since last read", func(t *testing.T) {
354 t.Run("handles file modified since last read", func(t *testing.T) {
355− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
355+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
356
356
357 // Create a file
357 // Create a file
358 filePath := filepath.Join(tempDir, "modified_file.txt")
358 filePath := filepath.Join(tempDir, "modified_file.txt")
@@ -394,7 +394,7 @@ func TestEditTool_Run(t *testing.T) {
394 })
394 })
395
395
396 t.Run("handles file not read before editing", func(t *testing.T) {
396 t.Run("handles file not read before editing", func(t *testing.T) {
397− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
397+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
398
398
399 // Create a file
399 // Create a file
400 filePath := filepath.Join(tempDir, "not_read_file.txt")
400 filePath := filepath.Join(tempDir, "not_read_file.txt")
@@ -423,7 +423,7 @@ func TestEditTool_Run(t *testing.T) {
423 })
423 })
424
424
425 t.Run("handles permission denied", func(t *testing.T) {
425 t.Run("handles permission denied", func(t *testing.T) {
426− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false))
426+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
427
427
428 // Create a file
428 // Create a file
429 filePath := filepath.Join(tempDir, "permission_denied.txt")
429 filePath := filepath.Join(tempDir, "permission_denied.txt")
internal/llm/tools/file.go−10
@@ -3,8 +3,6 @@ package tools
3import (
3import (
4 "sync"
4 "sync"
5 "time"
5 "time"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
8)
6)
9
7
10// File record to track when files were read/written
8// File record to track when files were read/written
@@ -19,14 +17,6 @@ var (
19 fileRecordMutex sync.RWMutex
17 fileRecordMutex sync.RWMutex
20)
18)
21
19
22−func removeWorkingDirectoryPrefix(path string) string {
23− wd := config.WorkingDirectory()
24− if len(path) > len(wd) && path[:len(wd)] == wd {
25− return path[len(wd)+1:]
26− }
27− return path
28−}
29−
30func recordFileRead(path string) {
20func recordFileRead(path string) {
31 fileRecordMutex.Lock()
21 fileRecordMutex.Lock()
32 defer fileRecordMutex.Unlock()
22 defer fileRecordMutex.Unlock()
internal/llm/tools/glob.go+2−2
@@ -63,7 +63,7 @@ type GlobParams struct {
63 Path string `json:"path"`
63 Path string `json:"path"`
64}
64}
65
65
66−type GlobMetadata struct {
66+type GlobResponseMetadata struct {
67 NumberOfFiles int `json:"number_of_files"`
67 NumberOfFiles int `json:"number_of_files"`
68 Truncated bool `json:"truncated"`
68 Truncated bool `json:"truncated"`
69}
69}
@@ -124,7 +124,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
124
124
125 return WithResponseMetadata(
125 return WithResponseMetadata(
126 NewTextResponse(output),
126 NewTextResponse(output),
127− GlobMetadata{
127+ GlobResponseMetadata{
128 NumberOfFiles: len(files),
128 NumberOfFiles: len(files),
129 Truncated: truncated,
129 Truncated: truncated,
130 },
130 },
internal/llm/tools/grep.go+2−2
@@ -27,7 +27,7 @@ type grepMatch struct {
27 modTime time.Time
27 modTime time.Time
28}
28}
29
29
30−type GrepMetadata struct {
30+type GrepResponseMetadata struct {
31 NumberOfMatches int `json:"number_of_matches"`
31 NumberOfMatches int `json:"number_of_matches"`
32 Truncated bool `json:"truncated"`
32 Truncated bool `json:"truncated"`
33}
33}
@@ -134,7 +134,7 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
134
134
135 return WithResponseMetadata(
135 return WithResponseMetadata(
136 NewTextResponse(output),
136 NewTextResponse(output),
137− GrepMetadata{
137+ GrepResponseMetadata{
138 NumberOfMatches: len(matches),
138 NumberOfMatches: len(matches),
139 Truncated: truncated,
139 Truncated: truncated,
140 },
140 },
internal/llm/tools/ls.go+2−2
@@ -23,7 +23,7 @@ type TreeNode struct {
23 Children []*TreeNode `json:"children,omitempty"`
23 Children []*TreeNode `json:"children,omitempty"`
24}
24}
25
25
26−type LSMetadata struct {
26+type LSResponseMetadata struct {
27 NumberOfFiles int `json:"number_of_files"`
27 NumberOfFiles int `json:"number_of_files"`
28 Truncated bool `json:"truncated"`
28 Truncated bool `json:"truncated"`
29}
29}
@@ -121,7 +121,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
121
121
122 return WithResponseMetadata(
122 return WithResponseMetadata(
123 NewTextResponse(output),
123 NewTextResponse(output),
124− LSMetadata{
124+ LSResponseMetadata{
125 NumberOfFiles: len(files),
125 NumberOfFiles: len(files),
126 Truncated: truncated,
126 Truncated: truncated,
127 },
127 },
internal/llm/tools/mocks_test.gonew file+246
@@ -0,0 +1,246 @@
1+package tools
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/google/uuid"
12+ "github.com/kujtimiihoxha/termai/internal/history"
13+ "github.com/kujtimiihoxha/termai/internal/permission"
14+ "github.com/kujtimiihoxha/termai/internal/pubsub"
15+)
16+
17+// Mock permission service for testing
18+type mockPermissionService struct {
19+ *pubsub.Broker[permission.PermissionRequest]
20+ allow bool
21+}
22+
23+func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
24+ // Not needed for tests
25+}
26+
27+func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
28+ // Not needed for tests
29+}
30+
31+func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
32+ // Not needed for tests
33+}
34+
35+func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
36+ return m.allow
37+}
38+
39+func newMockPermissionService(allow bool) permission.Service {
40+ return &mockPermissionService{
41+ Broker: pubsub.NewBroker[permission.PermissionRequest](),
42+ allow: allow,
43+ }
44+}
45+
46+type mockFileHistoryService struct {
47+ *pubsub.Broker[history.File]
48+ files map[string]history.File // ID -> File
49+ timeNow func() int64
50+}
51+
52+// Create implements history.Service.
53+func (m *mockFileHistoryService) Create(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
54+ return m.createWithVersion(ctx, sessionID, path, content, history.InitialVersion)
55+}
56+
57+// CreateVersion implements history.Service.
58+func (m *mockFileHistoryService) CreateVersion(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
59+ var files []history.File
60+ for _, file := range m.files {
61+ if file.Path == path {
62+ files = append(files, file)
63+ }
64+ }
65+
66+ if len(files) == 0 {
67+ // No previous versions, create initial
68+ return m.Create(ctx, sessionID, path, content)
69+ }
70+
71+ // Sort files by CreatedAt in descending order
72+ sort.Slice(files, func(i, j int) bool {
73+ return files[i].CreatedAt > files[j].CreatedAt
74+ })
75+
76+ // Get the latest version
77+ latestFile := files[0]
78+ latestVersion := latestFile.Version
79+
80+ // Generate the next version
81+ var nextVersion string
82+ if latestVersion == history.InitialVersion {
83+ nextVersion = "v1"
84+ } else if strings.HasPrefix(latestVersion, "v") {
85+ versionNum, err := strconv.Atoi(latestVersion[1:])
86+ if err != nil {
87+ // If we can't parse the version, just use a timestamp-based version
88+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
89+ } else {
90+ nextVersion = fmt.Sprintf("v%d", versionNum+1)
91+ }
92+ } else {
93+ // If the version format is unexpected, use a timestamp-based version
94+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
95+ }
96+
97+ return m.createWithVersion(ctx, sessionID, path, content, nextVersion)
98+}
99+
100+func (m *mockFileHistoryService) createWithVersion(_ context.Context, sessionID, path, content, version string) (history.File, error) {
101+ now := m.timeNow()
102+ file := history.File{
103+ ID: uuid.New().String(),
104+ SessionID: sessionID,
105+ Path: path,
106+ Content: content,
107+ Version: version,
108+ CreatedAt: now,
109+ UpdatedAt: now,
110+ }
111+
112+ m.files[file.ID] = file
113+ m.Publish(pubsub.CreatedEvent, file)
114+ return file, nil
115+}
116+
117+// Delete implements history.Service.
118+func (m *mockFileHistoryService) Delete(ctx context.Context, id string) error {
119+ file, ok := m.files[id]
120+ if !ok {
121+ return fmt.Errorf("file not found: %s", id)
122+ }
123+
124+ delete(m.files, id)
125+ m.Publish(pubsub.DeletedEvent, file)
126+ return nil
127+}
128+
129+// DeleteSessionFiles implements history.Service.
130+func (m *mockFileHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
131+ files, err := m.ListBySession(ctx, sessionID)
132+ if err != nil {
133+ return err
134+ }
135+
136+ for _, file := range files {
137+ err = m.Delete(ctx, file.ID)
138+ if err != nil {
139+ return err
140+ }
141+ }
142+
143+ return nil
144+}
145+
146+// Get implements history.Service.
147+func (m *mockFileHistoryService) Get(ctx context.Context, id string) (history.File, error) {
148+ file, ok := m.files[id]
149+ if !ok {
150+ return history.File{}, fmt.Errorf("file not found: %s", id)
151+ }
152+ return file, nil
153+}
154+
155+// GetByPathAndSession implements history.Service.
156+func (m *mockFileHistoryService) GetByPathAndSession(ctx context.Context, path string, sessionID string) (history.File, error) {
157+ var latestFile history.File
158+ var found bool
159+ var latestTime int64
160+
161+ for _, file := range m.files {
162+ if file.Path == path && file.SessionID == sessionID {
163+ if !found || file.CreatedAt > latestTime {
164+ latestFile = file
165+ latestTime = file.CreatedAt
166+ found = true
167+ }
168+ }
169+ }
170+
171+ if !found {
172+ return history.File{}, fmt.Errorf("file not found: %s for session %s", path, sessionID)
173+ }
174+ return latestFile, nil
175+}
176+
177+// ListBySession implements history.Service.
178+func (m *mockFileHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
179+ var files []history.File
180+ for _, file := range m.files {
181+ if file.SessionID == sessionID {
182+ files = append(files, file)
183+ }
184+ }
185+
186+ // Sort by CreatedAt in descending order
187+ sort.Slice(files, func(i, j int) bool {
188+ return files[i].CreatedAt > files[j].CreatedAt
189+ })
190+
191+ return files, nil
192+}
193+
194+// ListLatestSessionFiles implements history.Service.
195+func (m *mockFileHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
196+ // Map to track the latest file for each path
197+ latestFiles := make(map[string]history.File)
198+
199+ for _, file := range m.files {
200+ if file.SessionID == sessionID {
201+ existing, ok := latestFiles[file.Path]
202+ if !ok || file.CreatedAt > existing.CreatedAt {
203+ latestFiles[file.Path] = file
204+ }
205+ }
206+ }
207+
208+ // Convert map to slice
209+ var result []history.File
210+ for _, file := range latestFiles {
211+ result = append(result, file)
212+ }
213+
214+ // Sort by CreatedAt in descending order
215+ sort.Slice(result, func(i, j int) bool {
216+ return result[i].CreatedAt > result[j].CreatedAt
217+ })
218+
219+ return result, nil
220+}
221+
222+// Subscribe implements history.Service.
223+func (m *mockFileHistoryService) Subscribe(ctx context.Context) <-chan pubsub.Event[history.File] {
224+ return m.Broker.Subscribe(ctx)
225+}
226+
227+// Update implements history.Service.
228+func (m *mockFileHistoryService) Update(ctx context.Context, file history.File) (history.File, error) {
229+ _, ok := m.files[file.ID]
230+ if !ok {
231+ return history.File{}, fmt.Errorf("file not found: %s", file.ID)
232+ }
233+
234+ file.UpdatedAt = m.timeNow()
235+ m.files[file.ID] = file
236+ m.Publish(pubsub.UpdatedEvent, file)
237+ return file, nil
238+}
239+
240+func newMockFileHistoryService() history.Service {
241+ return &mockFileHistoryService{
242+ Broker: pubsub.NewBroker[history.File](),
243+ files: make(map[string]history.File),
244+ timeNow: func() int64 { return time.Now().Unix() },
245+ }
246+}
internal/llm/tools/shell/shell.go+11−1
@@ -83,11 +83,21 @@ func newPersistentShell(cwd string) *PersistentShell {
83 commandQueue: make(chan *commandExecution, 10),
83 commandQueue: make(chan *commandExecution, 10),
84 }
84 }
85
85
86+ go func() {
87+ defer func() {
88+ if r := recover(); r != nil {
89+ fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
90+ shell.isAlive = false
91+ close(shell.commandQueue)
92+ }
93+ }()
86− go shell.processCommands()
94+ shell.processCommands()
95+ }()
87
96
88 go func() {
97 go func() {
89 err := cmd.Wait()
98 err := cmd.Wait()
90 if err != nil {
99 if err != nil {
100+ // Log the error if needed
91 }
101 }
92 shell.isAlive = false
102 shell.isAlive = false
93 close(shell.commandQueue)
103 close(shell.commandQueue)
internal/llm/tools/sourcegraph.go+1−1
@@ -18,7 +18,7 @@ type SourcegraphParams struct {
18 Timeout int `json:"timeout,omitempty"`
18 Timeout int `json:"timeout,omitempty"`
19}
19}
20
20
21−type SourcegraphMetadata struct {
21+type SourcegraphResponseMetadata struct {
22 NumberOfMatches int `json:"number_of_matches"`
22 NumberOfMatches int `json:"number_of_matches"`
23 Truncated bool `json:"truncated"`
23 Truncated bool `json:"truncated"`
24}
24}
internal/llm/tools/tools.go+7−2
@@ -14,12 +14,17 @@ type ToolInfo struct {
14
14
15type toolResponseType string
15type toolResponseType string
16
16
17+type (
18+ sessionIDContextKey string
19+ messageIDContextKey string
20+)
21+
17const (
22const (
18 ToolResponseTypeText toolResponseType = "text"
23 ToolResponseTypeText toolResponseType = "text"
19 ToolResponseTypeImage toolResponseType = "image"
24 ToolResponseTypeImage toolResponseType = "image"
20
25
21− SessionIDContextKey = "session_id"
26+ SessionIDContextKey sessionIDContextKey = "session_id"
22− MessageIDContextKey = "message_id"
27+ MessageIDContextKey messageIDContextKey = "message_id"
23)
28)
24
29
25type ToolResponse struct {
30type ToolResponse struct {
internal/llm/tools/write.go+26−1
@@ -10,6 +10,7 @@ import (
10
10
11 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/config"
12 "github.com/kujtimiihoxha/termai/internal/diff"
12 "github.com/kujtimiihoxha/termai/internal/diff"
13+ "github.com/kujtimiihoxha/termai/internal/history"
13 "github.com/kujtimiihoxha/termai/internal/lsp"
14 "github.com/kujtimiihoxha/termai/internal/lsp"
14 "github.com/kujtimiihoxha/termai/internal/permission"
15 "github.com/kujtimiihoxha/termai/internal/permission"
15)
16)
@@ -27,6 +28,7 @@ type WritePermissionsParams struct {
27type writeTool struct {
28type writeTool struct {
28 lspClients map[string]*lsp.Client
29 lspClients map[string]*lsp.Client
29 permissions permission.Service
30 permissions permission.Service
31+ files history.Service
30}
32}
31
33
32type WriteResponseMetadata struct {
34type WriteResponseMetadata struct {
@@ -67,10 +69,11 @@ TIPS:
67- Always include descriptive comments when making changes to existing code`
69- Always include descriptive comments when making changes to existing code`
68)
70)
69
71
70−func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
72+func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
71 return &writeTool{
73 return &writeTool{
72 lspClients: lspClients,
74 lspClients: lspClients,
73 permissions: permissions,
75 permissions: permissions,
76+ files: files,
74 }
77 }
75}
78}
76
79
@@ -176,6 +179,28 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
176 return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
179 return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
177 }
180 }
178
181
182+ // Check if file exists in history
183+ file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
184+ if err != nil {
185+ _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
186+ if err != nil {
187+ // Log error but don't fail the operation
188+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
189+ }
190+ }
191+ if file.Content != oldContent {
192+ // User Manually changed the content store an intermediate version
193+ _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
194+ if err != nil {
195+ fmt.Printf("Error creating file history version: %v\n", err)
196+ }
197+ }
198+ // Store the new version
199+ _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
200+ if err != nil {
201+ fmt.Printf("Error creating file history version: %v\n", err)
202+ }
203+
179 recordFileWrite(filePath)
204 recordFileWrite(filePath)
180 recordFileRead(filePath)
205 recordFileRead(filePath)
181 waitForLspDiagnostics(ctx, filePath, w.lspClients)
206 waitForLspDiagnostics(ctx, filePath, w.lspClients)
internal/llm/tools/write_test.go+11−11
@@ -14,7 +14,7 @@ import (
14)
14)
15
15
16func TestWriteTool_Info(t *testing.T) {
16func TestWriteTool_Info(t *testing.T) {
17− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
17+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
18 info := tool.Info()
18 info := tool.Info()
19
19
20 assert.Equal(t, WriteToolName, info.Name)
20 assert.Equal(t, WriteToolName, info.Name)
@@ -32,7 +32,7 @@ func TestWriteTool_Run(t *testing.T) {
32 defer os.RemoveAll(tempDir)
32 defer os.RemoveAll(tempDir)
33
33
34 t.Run("creates a new file successfully", func(t *testing.T) {
34 t.Run("creates a new file successfully", func(t *testing.T) {
35− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
35+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
36
36
37 filePath := filepath.Join(tempDir, "new_file.txt")
37 filePath := filepath.Join(tempDir, "new_file.txt")
38 content := "This is a test content"
38 content := "This is a test content"
@@ -61,7 +61,7 @@ func TestWriteTool_Run(t *testing.T) {
61 })
61 })
62
62
63 t.Run("creates file with nested directories", func(t *testing.T) {
63 t.Run("creates file with nested directories", func(t *testing.T) {
64− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
64+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
65
65
66 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
66 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
67 content := "Content in nested directory"
67 content := "Content in nested directory"
@@ -90,7 +90,7 @@ func TestWriteTool_Run(t *testing.T) {
90 })
90 })
91
91
92 t.Run("updates existing file", func(t *testing.T) {
92 t.Run("updates existing file", func(t *testing.T) {
93− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
93+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
94
94
95 // Create a file first
95 // Create a file first
96 filePath := filepath.Join(tempDir, "existing_file.txt")
96 filePath := filepath.Join(tempDir, "existing_file.txt")
@@ -127,7 +127,7 @@ func TestWriteTool_Run(t *testing.T) {
127 })
127 })
128
128
129 t.Run("handles invalid parameters", func(t *testing.T) {
129 t.Run("handles invalid parameters", func(t *testing.T) {
130− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
130+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
131
131
132 call := ToolCall{
132 call := ToolCall{
133 Name: WriteToolName,
133 Name: WriteToolName,
@@ -140,7 +140,7 @@ func TestWriteTool_Run(t *testing.T) {
140 })
140 })
141
141
142 t.Run("handles missing file_path", func(t *testing.T) {
142 t.Run("handles missing file_path", func(t *testing.T) {
143− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
143+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
144
144
145 params := WriteParams{
145 params := WriteParams{
146 FilePath: "",
146 FilePath: "",
@@ -161,7 +161,7 @@ func TestWriteTool_Run(t *testing.T) {
161 })
161 })
162
162
163 t.Run("handles missing content", func(t *testing.T) {
163 t.Run("handles missing content", func(t *testing.T) {
164− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
164+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
165
165
166 params := WriteParams{
166 params := WriteParams{
167 FilePath: filepath.Join(tempDir, "file.txt"),
167 FilePath: filepath.Join(tempDir, "file.txt"),
@@ -182,7 +182,7 @@ func TestWriteTool_Run(t *testing.T) {
182 })
182 })
183
183
184 t.Run("handles writing to a directory path", func(t *testing.T) {
184 t.Run("handles writing to a directory path", func(t *testing.T) {
185− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
185+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
186
186
187 // Create a directory
187 // Create a directory
188 dirPath := filepath.Join(tempDir, "test_dir")
188 dirPath := filepath.Join(tempDir, "test_dir")
@@ -208,7 +208,7 @@ func TestWriteTool_Run(t *testing.T) {
208 })
208 })
209
209
210 t.Run("handles permission denied", func(t *testing.T) {
210 t.Run("handles permission denied", func(t *testing.T) {
211− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false))
211+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
212
212
213 filePath := filepath.Join(tempDir, "permission_denied.txt")
213 filePath := filepath.Join(tempDir, "permission_denied.txt")
214 params := WriteParams{
214 params := WriteParams{
@@ -234,7 +234,7 @@ func TestWriteTool_Run(t *testing.T) {
234 })
234 })
235
235
236 t.Run("detects file modified since last read", func(t *testing.T) {
236 t.Run("detects file modified since last read", func(t *testing.T) {
237− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
237+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
238
238
239 // Create a file
239 // Create a file
240 filePath := filepath.Join(tempDir, "modified_file.txt")
240 filePath := filepath.Join(tempDir, "modified_file.txt")
@@ -275,7 +275,7 @@ func TestWriteTool_Run(t *testing.T) {
275 })
275 })
276
276
277 t.Run("skips writing when content is identical", func(t *testing.T) {
277 t.Run("skips writing when content is identical", func(t *testing.T) {
278− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
278+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
279
279
280 // Create a file
280 // Create a file
281 filePath := filepath.Join(tempDir, "identical_content.txt")
281 filePath := filepath.Join(tempDir, "identical_content.txt")
internal/logging/logger.go+40−1
@@ -1,6 +1,12 @@
1package logging
1package logging
2
2
3+import (
4+ "fmt"
3−import "log/slog"
5+ "log/slog"
6+ "os"
7+ "runtime/debug"
8+ "time"
9+)
4
10
5func Info(msg string, args ...any) {
11func Info(msg string, args ...any) {
6 slog.Info(msg, args...)
12 slog.Info(msg, args...)
@@ -37,3 +43,36 @@ func ErrorPersist(msg string, args ...any) {
37 args = append(args, persistKeyArg, true)
43 args = append(args, persistKeyArg, true)
38 slog.Error(msg, args...)
44 slog.Error(msg, args...)
39}
45}
46+
47+// RecoverPanic is a common function to handle panics gracefully.
48+// It logs the error, creates a panic log file with stack trace,
49+// and executes an optional cleanup function before returning.
50+func RecoverPanic(name string, cleanup func()) {
51+ if r := recover(); r != nil {
52+ // Log the panic
53+ ErrorPersist(fmt.Sprintf("Panic in %s: %v", name, r))
54+
55+ // Create a timestamped panic log file
56+ timestamp := time.Now().Format("20060102-150405")
57+ filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
58+
59+ file, err := os.Create(filename)
60+ if err != nil {
61+ ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
62+ } else {
63+ defer file.Close()
64+
65+ // Write panic information and stack trace
66+ fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
67+ fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
68+ fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack())
69+
70+ InfoPersist(fmt.Sprintf("Panic details written to %s", filename))
71+ }
72+
73+ // Execute cleanup function if provided
74+ if cleanup != nil {
75+ cleanup()
76+ }
77+ }
78+}
internal/lsp/client.go+9−4
@@ -97,7 +97,12 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
97 }()
97 }()
98
98
99 // Start message handling loop
99 // Start message handling loop
100+ go func() {
101+ defer logging.RecoverPanic("LSP-message-handler", func() {
102+ logging.ErrorPersist("LSP message handler crashed, LSP functionality may be impaired")
103+ })
100− go client.handleMessages()
104+ client.handleMessages()
105+ }()
101
106
102 return client, nil
107 return client, nil
103}
108}
@@ -374,7 +379,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
374 },
379 },
375 }
380 }
376
381
377− if cnf.Debug {
382+ if cnf.DebugLSP {
378 logging.Debug("Closing file", "file", filepath)
383 logging.Debug("Closing file", "file", filepath)
379 }
384 }
380 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
385 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
@@ -413,12 +418,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
413 // Then close them all
418 // Then close them all
414 for _, filePath := range filesToClose {
419 for _, filePath := range filesToClose {
415 err := c.CloseFile(ctx, filePath)
420 err := c.CloseFile(ctx, filePath)
416− if err != nil && cnf.Debug {
421+ if err != nil && cnf.DebugLSP {
417 logging.Warn("Error closing file", "file", filePath, "error", err)
422 logging.Warn("Error closing file", "file", filePath, "error", err)
418 }
423 }
419 }
424 }
420
425
421− if cnf.Debug {
426+ if cnf.DebugLSP {
422 logging.Debug("Closed all files", "files", filesToClose)
427 logging.Debug("Closed all files", "files", filesToClose)
423 }
428 }
424}
429}
internal/lsp/handlers.go+1−1
@@ -88,7 +88,7 @@ func HandleServerMessage(params json.RawMessage) {
88 Message string `json:"message"`
88 Message string `json:"message"`
89 }
89 }
90 if err := json.Unmarshal(params, &msg); err == nil {
90 if err := json.Unmarshal(params, &msg); err == nil {
91− if cnf.Debug {
91+ if cnf.DebugLSP {
92 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
92 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
93 }
93 }
94 }
94 }
internal/lsp/transport.go+14−14
@@ -20,7 +20,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
20 }
20 }
21 cnf := config.Get()
21 cnf := config.Get()
22
22
23− if cnf.Debug {
23+ if cnf.DebugLSP {
24 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
24 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
25 }
25 }
26
26
@@ -49,7 +49,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
49 }
49 }
50 line = strings.TrimSpace(line)
50 line = strings.TrimSpace(line)
51
51
52− if cnf.Debug {
52+ if cnf.DebugLSP {
53 logging.Debug("Received header", "line", line)
53 logging.Debug("Received header", "line", line)
54 }
54 }
55
55
@@ -65,7 +65,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
65 }
65 }
66 }
66 }
67
67
68− if cnf.Debug {
68+ if cnf.DebugLSP {
69 logging.Debug("Content-Length", "length", contentLength)
69 logging.Debug("Content-Length", "length", contentLength)
70 }
70 }
71
71
@@ -76,7 +76,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
76 return nil, fmt.Errorf("failed to read content: %w", err)
76 return nil, fmt.Errorf("failed to read content: %w", err)
77 }
77 }
78
78
79− if cnf.Debug {
79+ if cnf.DebugLSP {
80 logging.Debug("Received content", "content", string(content))
80 logging.Debug("Received content", "content", string(content))
81 }
81 }
82
82
@@ -95,7 +95,7 @@ func (c *Client) handleMessages() {
95 for {
95 for {
96 msg, err := ReadMessage(c.stdout)
96 msg, err := ReadMessage(c.stdout)
97 if err != nil {
97 if err != nil {
98− if cnf.Debug {
98+ if cnf.DebugLSP {
99 logging.Error("Error reading message", "error", err)
99 logging.Error("Error reading message", "error", err)
100 }
100 }
101 return
101 return
@@ -103,7 +103,7 @@ func (c *Client) handleMessages() {
103
103
104 // Handle server->client request (has both Method and ID)
104 // Handle server->client request (has both Method and ID)
105 if msg.Method != "" && msg.ID != 0 {
105 if msg.Method != "" && msg.ID != 0 {
106− if cnf.Debug {
106+ if cnf.DebugLSP {
107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
108 }
108 }
109
109
@@ -157,11 +157,11 @@ func (c *Client) handleMessages() {
157 c.notificationMu.RUnlock()
157 c.notificationMu.RUnlock()
158
158
159 if ok {
159 if ok {
160− if cnf.Debug {
160+ if cnf.DebugLSP {
161 logging.Debug("Handling notification", "method", msg.Method)
161 logging.Debug("Handling notification", "method", msg.Method)
162 }
162 }
163 go handler(msg.Params)
163 go handler(msg.Params)
164− } else if cnf.Debug {
164+ } else if cnf.DebugLSP {
165 logging.Debug("No handler for notification", "method", msg.Method)
165 logging.Debug("No handler for notification", "method", msg.Method)
166 }
166 }
167 continue
167 continue
@@ -174,12 +174,12 @@ func (c *Client) handleMessages() {
174 c.handlersMu.RUnlock()
174 c.handlersMu.RUnlock()
175
175
176 if ok {
176 if ok {
177− if cnf.Debug {
177+ if cnf.DebugLSP {
178 logging.Debug("Received response for request", "id", msg.ID)
178 logging.Debug("Received response for request", "id", msg.ID)
179 }
179 }
180 ch <- msg
180 ch <- msg
181 close(ch)
181 close(ch)
182− } else if cnf.Debug {
182+ } else if cnf.DebugLSP {
183 logging.Debug("No handler for response", "id", msg.ID)
183 logging.Debug("No handler for response", "id", msg.ID)
184 }
184 }
185 }
185 }
@@ -191,7 +191,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
191 cnf := config.Get()
191 cnf := config.Get()
192 id := c.nextID.Add(1)
192 id := c.nextID.Add(1)
193
193
194− if cnf.Debug {
194+ if cnf.DebugLSP {
195 logging.Debug("Making call", "method", method, "id", id)
195 logging.Debug("Making call", "method", method, "id", id)
196 }
196 }
197
197
@@ -217,14 +217,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217 return fmt.Errorf("failed to send request: %w", err)
217 return fmt.Errorf("failed to send request: %w", err)
218 }
218 }
219
219
220− if cnf.Debug {
220+ if cnf.DebugLSP {
221 logging.Debug("Request sent", "method", method, "id", id)
221 logging.Debug("Request sent", "method", method, "id", id)
222 }
222 }
223
223
224 // Wait for response
224 // Wait for response
225 resp := <-ch
225 resp := <-ch
226
226
227− if cnf.Debug {
227+ if cnf.DebugLSP {
228 logging.Debug("Received response", "id", id)
228 logging.Debug("Received response", "id", id)
229 }
229 }
230
230
@@ -250,7 +250,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
250// Notify sends a notification (a request without an ID that doesn't expect a response)
250// Notify sends a notification (a request without an ID that doesn't expect a response)
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
252 cnf := config.Get()
252 cnf := config.Get()
253− if cnf.Debug {
253+ if cnf.DebugLSP {
254 logging.Debug("Sending notification", "method", method)
254 logging.Debug("Sending notification", "method", method)
255 }
255 }
256
256
internal/lsp/watcher/watcher.go+9−9
@@ -50,7 +50,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
50 w.registrations = append(w.registrations, watchers...)
50 w.registrations = append(w.registrations, watchers...)
51
51
52 // Print detailed registration information for debugging
52 // Print detailed registration information for debugging
53− if cnf.Debug {
53+ if cnf.DebugLSP {
54 logging.Debug("Adding file watcher registrations",
54 logging.Debug("Adding file watcher registrations",
55 "id", id,
55 "id", id,
56 "watchers", len(watchers),
56 "watchers", len(watchers),
@@ -116,7 +116,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
116 // Skip directories that should be excluded
116 // Skip directories that should be excluded
117 if d.IsDir() {
117 if d.IsDir() {
118 if path != w.workspacePath && shouldExcludeDir(path) {
118 if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.Debug {
119+ if cnf.DebugLSP {
120 logging.Debug("Skipping excluded directory", "path", path)
120 logging.Debug("Skipping excluded directory", "path", path)
121 }
121 }
122 return filepath.SkipDir
122 return filepath.SkipDir
@@ -136,7 +136,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
136 })
136 })
137
137
138 elapsedTime := time.Since(startTime)
138 elapsedTime := time.Since(startTime)
139− if cnf.Debug {
139+ if cnf.DebugLSP {
140 logging.Debug("Workspace scan complete",
140 logging.Debug("Workspace scan complete",
141 "filesOpened", filesOpened,
141 "filesOpened", filesOpened,
142 "elapsedTime", elapsedTime.Seconds(),
142 "elapsedTime", elapsedTime.Seconds(),
@@ -144,7 +144,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
144 )
144 )
145 }
145 }
146
146
147− if err != nil && cnf.Debug {
147+ if err != nil && cnf.DebugLSP {
148 logging.Debug("Error scanning workspace for files to open", "error", err)
148 logging.Debug("Error scanning workspace for files to open", "error", err)
149 }
149 }
150 }()
150 }()
@@ -175,7 +175,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
175 // Skip excluded directories (except workspace root)
175 // Skip excluded directories (except workspace root)
176 if d.IsDir() && path != workspacePath {
176 if d.IsDir() && path != workspacePath {
177 if shouldExcludeDir(path) {
177 if shouldExcludeDir(path) {
178− if cnf.Debug {
178+ if cnf.DebugLSP {
179 logging.Debug("Skipping excluded directory", "path", path)
179 logging.Debug("Skipping excluded directory", "path", path)
180 }
180 }
181 return filepath.SkipDir
181 return filepath.SkipDir
@@ -228,7 +228,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
228 }
228 }
229
229
230 // Debug logging
230 // Debug logging
231− if cnf.Debug {
231+ if cnf.DebugLSP {
232 matched, kind := w.isPathWatched(event.Name)
232 matched, kind := w.isPathWatched(event.Name)
233 logging.Debug("File event",
233 logging.Debug("File event",
234 "path", event.Name,
234 "path", event.Name,
@@ -491,7 +491,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
491// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
491// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
492func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
492func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
493 cnf := config.Get()
493 cnf := config.Get()
494− if cnf.Debug {
494+ if cnf.DebugLSP {
495 logging.Debug("Notifying file event",
495 logging.Debug("Notifying file event",
496 "uri", uri,
496 "uri", uri,
497 "changeType", changeType,
497 "changeType", changeType,
@@ -615,7 +615,7 @@ func shouldExcludeFile(filePath string) bool {
615
615
616 // Skip large files
616 // Skip large files
617 if info.Size() > maxFileSize {
617 if info.Size() > maxFileSize {
618− if cnf.Debug {
618+ if cnf.DebugLSP {
619 logging.Debug("Skipping large file",
619 logging.Debug("Skipping large file",
620 "path", filePath,
620 "path", filePath,
621 "size", info.Size(),
621 "size", info.Size(),
@@ -648,7 +648,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
648 // Check if this path should be watched according to server registrations
648 // Check if this path should be watched according to server registrations
649 if watched, _ := w.isPathWatched(path); watched {
649 if watched, _ := w.isPathWatched(path); watched {
650 // Don't need to check if it's already open - the client.OpenFile handles that
650 // Don't need to check if it's already open - the client.OpenFile handles that
651− if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
651+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
652 logging.Error("Error opening file", "path", path, "error", err)
652 logging.Error("Error opening file", "path", path, "error", err)
653 }
653 }
654 }
654 }
internal/message/content.go+26−4
@@ -2,6 +2,7 @@ package message
2
2
3import (
3import (
4 "encoding/base64"
4 "encoding/base64"
5+ "slices"
5 "time"
6 "time"
6
7
7 "github.com/kujtimiihoxha/termai/internal/llm/models"
8 "github.com/kujtimiihoxha/termai/internal/llm/models"
@@ -16,6 +17,20 @@ const (
16 Tool MessageRole = "tool"
17 Tool MessageRole = "tool"
17)
18)
18
19
20+type FinishReason string
21+
22+const (
23+ FinishReasonEndTurn FinishReason = "end_turn"
24+ FinishReasonMaxTokens FinishReason = "max_tokens"
25+ FinishReasonToolUse FinishReason = "tool_use"
26+ FinishReasonCanceled FinishReason = "canceled"
27+ FinishReasonError FinishReason = "error"
28+ FinishReasonPermissionDenied FinishReason = "permission_denied"
29+
30+ // Should never happen
31+ FinishReasonUnknown FinishReason = "unknown"
32+)
33+
19type ContentPart interface {
34type ContentPart interface {
20 isPart()
35 isPart()
21}
36}
@@ -83,8 +98,8 @@ type ToolResult struct {
83func (ToolResult) isPart() {}
98func (ToolResult) isPart() {}
84
99
85type Finish struct {
100type Finish struct {
86− Reason string `json:"reason"`
101+ Reason FinishReason `json:"reason"`
87− Time int64 `json:"time"`
102+ Time int64 `json:"time"`
88}
103}
89
104
90func (Finish) isPart() {}
105func (Finish) isPart() {}
@@ -176,7 +191,7 @@ func (m *Message) FinishPart() *Finish {
176 return nil
191 return nil
177}
192}
178
193
179−func (m *Message) FinishReason() string {
194+func (m *Message) FinishReason() FinishReason {
180 for _, part := range m.Parts {
195 for _, part := range m.Parts {
181 if c, ok := part.(Finish); ok {
196 if c, ok := part.(Finish); ok {
182 return c.Reason
197 return c.Reason
@@ -246,7 +261,14 @@ func (m *Message) SetToolResults(tr []ToolResult) {
246 }
261 }
247}
262}
248
263
249−func (m *Message) AddFinish(reason string) {
264+func (m *Message) AddFinish(reason FinishReason) {
265+ // remove any existing finish part
266+ for i, part := range m.Parts {
267+ if _, ok := part.(Finish); ok {
268+ m.Parts = slices.Delete(m.Parts, i, i+1)
269+ break
270+ }
271+ }
250 m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
272 m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
251}
273}
252
274
internal/pubsub/broker.go+1−1
@@ -5,7 +5,7 @@ import (
5 "sync"
5 "sync"
6)
6)
7
7
8−const bufferSize = 1024 * 1024
8+const bufferSize = 1024
9
9
10type Logger interface {
10type Logger interface {
11 Debug(msg string, args ...any)
11 Debug(msg string, args ...any)
internal/session/session.go+15
@@ -24,6 +24,7 @@ type Session struct {
24type Service interface {
24type Service interface {
25 pubsub.Suscriber[Session]
25 pubsub.Suscriber[Session]
26 Create(ctx context.Context, title string) (Session, error)
26 Create(ctx context.Context, title string) (Session, error)
27+ CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
27 CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
28 CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
28 Get(ctx context.Context, id string) (Session, error)
29 Get(ctx context.Context, id string) (Session, error)
29 List(ctx context.Context) ([]Session, error)
30 List(ctx context.Context) ([]Session, error)
@@ -63,6 +64,20 @@ func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessi
63 return session, nil
64 return session, nil
64}
65}
65
66
67+func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
68+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
69+ ID: "title-" + parentSessionID,
70+ ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
71+ Title: "Generate a title",
72+ })
73+ if err != nil {
74+ return Session{}, err
75+ }
76+ session := s.fromDBItem(dbSession)
77+ s.Publish(pubsub.CreatedEvent, session)
78+ return session, nil
79+}
80+
66func (s *service) Delete(ctx context.Context, id string) error {
81func (s *service) Delete(ctx context.Context, id string) error {
67 session, err := s.Get(ctx, id)
82 session, err := s.Get(ctx, id)
68 if err != nil {
83 if err != nil {
internal/tui/components/chat/chat.go−2
@@ -19,8 +19,6 @@ type SessionSelectedMsg = session.Session
19
19
20type SessionClearedMsg struct{}
20type SessionClearedMsg struct{}
21
21
22−type AgentWorkingMsg bool
23−
24type EditorFocusMsg bool
22type EditorFocusMsg bool
25
23
26func lspsConfigured(width int) string {
24func lspsConfigured(width int) string {
internal/tui/components/chat/editor.go+14−8
@@ -5,14 +5,17 @@ import (
5 "github.com/charmbracelet/bubbles/textarea"
5 "github.com/charmbracelet/bubbles/textarea"
6 tea "github.com/charmbracelet/bubbletea"
6 tea "github.com/charmbracelet/bubbletea"
7 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/app"
9+ "github.com/kujtimiihoxha/termai/internal/session"
8 "github.com/kujtimiihoxha/termai/internal/tui/layout"
10 "github.com/kujtimiihoxha/termai/internal/tui/layout"
9 "github.com/kujtimiihoxha/termai/internal/tui/styles"
11 "github.com/kujtimiihoxha/termai/internal/tui/styles"
10 "github.com/kujtimiihoxha/termai/internal/tui/util"
12 "github.com/kujtimiihoxha/termai/internal/tui/util"
11)
13)
12
14
13type editorCmp struct {
15type editorCmp struct {
16+ app *app.App
17+ session session.Session
14− textarea textarea.Model
18+ textarea textarea.Model
15− agentWorking bool
16}
19}
17
20
18type focusedEditorKeyMaps struct {
21type focusedEditorKeyMaps struct {
@@ -32,7 +35,7 @@ var focusedKeyMaps = focusedEditorKeyMaps{
32 ),
35 ),
33 Blur: key.NewBinding(
36 Blur: key.NewBinding(
34 key.WithKeys("esc"),
37 key.WithKeys("esc"),
35− key.WithHelp("esc", "blur editor"),
38+ key.WithHelp("esc", "focus messages"),
36 ),
39 ),
37}
40}
38
41
@@ -52,7 +55,7 @@ func (m *editorCmp) Init() tea.Cmd {
52}
55}
53
56
54func (m *editorCmp) send() tea.Cmd {
57func (m *editorCmp) send() tea.Cmd {
55− if m.agentWorking {
58+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
56 return util.ReportWarn("Agent is working, please wait...")
59 return util.ReportWarn("Agent is working, please wait...")
57 }
60 }
58
61
@@ -66,7 +69,6 @@ func (m *editorCmp) send() tea.Cmd {
66 util.CmdHandler(SendMsg{
69 util.CmdHandler(SendMsg{
67 Text: value,
70 Text: value,
68 }),
71 }),
69− util.CmdHandler(AgentWorkingMsg(true)),
70 util.CmdHandler(EditorFocusMsg(false)),
72 util.CmdHandler(EditorFocusMsg(false)),
71 )
73 )
72}
74}
@@ -74,8 +76,11 @@ func (m *editorCmp) send() tea.Cmd {
74func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
75 var cmd tea.Cmd
77 var cmd tea.Cmd
76 switch msg := msg.(type) {
78 switch msg := msg.(type) {
77− case AgentWorkingMsg:
79+ case SessionSelectedMsg:
78− m.agentWorking = bool(msg)
80+ if msg.ID != m.session.ID {
81+ m.session = msg
82+ }
83+ return m, nil
79 case tea.KeyMsg:
84 case tea.KeyMsg:
80 // if the key does not match any binding, return
85 // if the key does not match any binding, return
81 if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
86 if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
@@ -122,7 +127,7 @@ func (m *editorCmp) BindingKeys() []key.Binding {
122 return bindings
127 return bindings
123}
128}
124
129
125−func NewEditorCmp() tea.Model {
130+func NewEditorCmp(app *app.App) tea.Model {
126 ti := textarea.New()
131 ti := textarea.New()
127 ti.Prompt = " "
132 ti.Prompt = " "
128 ti.ShowLineNumbers = false
133 ti.ShowLineNumbers = false
@@ -138,6 +143,7 @@ func NewEditorCmp() tea.Model {
138 ti.CharLimit = -1
143 ti.CharLimit = -1
139 ti.Focus()
144 ti.Focus()
140 return &editorCmp{
145 return &editorCmp{
146+ app: app,
141 textarea: ti,
147 textarea: ti,
142 }
148 }
143}
149}
internal/tui/components/chat/messages.go+179−26
@@ -6,7 +6,9 @@ import (
6 "fmt"
6 "fmt"
7 "math"
7 "math"
8 "strings"
8 "strings"
9+ "time"
9
10
11+ "github.com/charmbracelet/bubbles/key"
10 "github.com/charmbracelet/bubbles/spinner"
12 "github.com/charmbracelet/bubbles/spinner"
11 "github.com/charmbracelet/bubbles/viewport"
13 "github.com/charmbracelet/bubbles/viewport"
12 tea "github.com/charmbracelet/bubbletea"
14 tea "github.com/charmbracelet/bubbletea"
@@ -17,9 +19,11 @@ import (
17 "github.com/kujtimiihoxha/termai/internal/llm/agent"
19 "github.com/kujtimiihoxha/termai/internal/llm/agent"
18 "github.com/kujtimiihoxha/termai/internal/llm/models"
20 "github.com/kujtimiihoxha/termai/internal/llm/models"
19 "github.com/kujtimiihoxha/termai/internal/llm/tools"
21 "github.com/kujtimiihoxha/termai/internal/llm/tools"
22+ "github.com/kujtimiihoxha/termai/internal/logging"
20 "github.com/kujtimiihoxha/termai/internal/message"
23 "github.com/kujtimiihoxha/termai/internal/message"
21 "github.com/kujtimiihoxha/termai/internal/pubsub"
24 "github.com/kujtimiihoxha/termai/internal/pubsub"
22 "github.com/kujtimiihoxha/termai/internal/session"
25 "github.com/kujtimiihoxha/termai/internal/session"
26+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
23 "github.com/kujtimiihoxha/termai/internal/tui/styles"
27 "github.com/kujtimiihoxha/termai/internal/tui/styles"
24 "github.com/kujtimiihoxha/termai/internal/tui/util"
28 "github.com/kujtimiihoxha/termai/internal/tui/util"
25)
29)
@@ -32,6 +36,9 @@ const (
32 toolMessageType
36 toolMessageType
33)
37)
34
38
39+// messagesTickMsg is a message sent by the timer to refresh messages
40+type messagesTickMsg time.Time
41+
35type uiMessage struct {
42type uiMessage struct {
36 ID string
43 ID string
37 messageType uiMessageType
44 messageType uiMessageType
@@ -52,24 +59,34 @@ type messagesCmp struct {
52 renderer *glamour.TermRenderer
59 renderer *glamour.TermRenderer
53 focusRenderer *glamour.TermRenderer
60 focusRenderer *glamour.TermRenderer
54 cachedContent map[string]string
61 cachedContent map[string]string
55− agentWorking bool
56 spinner spinner.Model
62 spinner spinner.Model
57 needsRerender bool
63 needsRerender bool
58− lastViewport string
59}
64}
60
65
61func (m *messagesCmp) Init() tea.Cmd {
66func (m *messagesCmp) Init() tea.Cmd {
62− return tea.Batch(m.viewport.Init())
67+ return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
68+}
69+
70+func (m *messagesCmp) tickMessages() tea.Cmd {
71+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
72+ return messagesTickMsg(t)
73+ })
63}
74}
64
75
65func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66 var cmds []tea.Cmd
77 var cmds []tea.Cmd
67 switch msg := msg.(type) {
78 switch msg := msg.(type) {
68− case AgentWorkingMsg:
79+ case messagesTickMsg:
69− m.agentWorking = bool(msg)
80+ // Refresh messages if we have an active session
70− if m.agentWorking {
81+ if m.session.ID != "" {
71− cmds = append(cmds, m.spinner.Tick)
82+ messages, err := m.app.Messages.List(context.Background(), m.session.ID)
83+ if err == nil {
84+ m.messages = messages
85+ m.needsRerender = true
86+ }
72 }
87 }
88+ // Continue ticking
89+ cmds = append(cmds, m.tickMessages())
73 case EditorFocusMsg:
90 case EditorFocusMsg:
74 m.writingMode = bool(msg)
91 m.writingMode = bool(msg)
75 case SessionSelectedMsg:
92 case SessionSelectedMsg:
@@ -84,6 +101,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 m.messages = make([]message.Message, 0)
101 m.messages = make([]message.Message, 0)
85 m.currentMsgID = ""
102 m.currentMsgID = ""
86 m.needsRerender = true
103 m.needsRerender = true
104+ m.cachedContent = make(map[string]string)
87 return m, nil
105 return m, nil
88
106
89 case tea.KeyMsg:
107 case tea.KeyMsg:
@@ -104,6 +122,12 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104 }
122 }
105
123
106 if !messageExists {
124 if !messageExists {
125+ // If we have messages, ensure the previous last message is not cached
126+ if len(m.messages) > 0 {
127+ lastMsgID := m.messages[len(m.messages)-1].ID
128+ delete(m.cachedContent, lastMsgID)
129+ }
130+
107 m.messages = append(m.messages, msg.Payload)
131 m.messages = append(m.messages, msg.Payload)
108 delete(m.cachedContent, m.currentMsgID)
132 delete(m.cachedContent, m.currentMsgID)
109 m.currentMsgID = msg.Payload.ID
133 m.currentMsgID = msg.Payload.ID
@@ -112,36 +136,40 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112 }
136 }
113 for _, v := range m.messages {
137 for _, v := range m.messages {
114 for _, c := range v.ToolCalls() {
138 for _, c := range v.ToolCalls() {
115− // the message is being added to the session of a tool called
116 if c.ID == msg.Payload.SessionID {
139 if c.ID == msg.Payload.SessionID {
117 m.needsRerender = true
140 m.needsRerender = true
118 }
141 }
119 }
142 }
120 }
143 }
121 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
144 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
145+ logging.Debug("Message", "finish", msg.Payload.FinishReason())
122 for i, v := range m.messages {
146 for i, v := range m.messages {
123 if v.ID == msg.Payload.ID {
147 if v.ID == msg.Payload.ID {
124− if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
125− cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
126− }
127 m.messages[i] = msg.Payload
148 m.messages[i] = msg.Payload
128 delete(m.cachedContent, msg.Payload.ID)
149 delete(m.cachedContent, msg.Payload.ID)
150+
151+ // If this is the last message, ensure it's not cached
152+ if i == len(m.messages)-1 {
153+ delete(m.cachedContent, msg.Payload.ID)
154+ }
155+
129 m.needsRerender = true
156 m.needsRerender = true
130 break
157 break
131 }
158 }
132 }
159 }
133 }
160 }
134 }
161 }
135− if m.agentWorking {
162+
136− u, cmd := m.spinner.Update(msg)
137− m.spinner = u
138− cmds = append(cmds, cmd)
139− }
140 oldPos := m.viewport.YPosition
163 oldPos := m.viewport.YPosition
141 u, cmd := m.viewport.Update(msg)
164 u, cmd := m.viewport.Update(msg)
142 m.viewport = u
165 m.viewport = u
143 m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
166 m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
144 cmds = append(cmds, cmd)
167 cmds = append(cmds, cmd)
168+
169+ spinner, cmd := m.spinner.Update(msg)
170+ m.spinner = spinner
171+ cmds = append(cmds, cmd)
172+
145 if m.needsRerender {
173 if m.needsRerender {
146 m.renderView()
174 m.renderView()
147 if len(m.messages) > 0 {
175 if len(m.messages) > 0 {
@@ -157,10 +185,21 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157 return m, tea.Batch(cmds...)
185 return m, tea.Batch(cmds...)
158}
186}
159
187
188+func (m *messagesCmp) IsAgentWorking() bool {
189+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
190+}
191+
160func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
192func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
193+ // Check if this is the last message in the list
194+ isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
195+
196+ // Only use cache for non-last messages
197+ if !isLastMessage {
161− if v, ok := m.cachedContent[msg.ID]; ok {
198+ if v, ok := m.cachedContent[msg.ID]; ok {
162− return v
199+ return v
200+ }
163 }
201 }
202+
164 style := styles.BaseStyle.
203 style := styles.BaseStyle.
165 Width(m.width).
204 Width(m.width).
166 BorderLeft(true).
205 BorderLeft(true).
@@ -191,7 +230,12 @@ func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) s
191 parts...,
230 parts...,
192 ),
231 ),
193 )
232 )
233+
234+ // Only cache if it's not the last message
235+ if !isLastMessage {
194− m.cachedContent[msg.ID] = rendered
236+ m.cachedContent[msg.ID] = rendered
237+ }
238+
195 return rendered
239 return rendered
196}
240}
197
241
@@ -207,32 +251,71 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string {
207 return fmt.Sprintf("%dm%ds", minutes, seconds)
251 return fmt.Sprintf("%dm%ds", minutes, seconds)
208}
252}
209
253
254+func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
255+ for _, v := range m.messages {
256+ for _, c := range v.ToolResults() {
257+ if c.ToolCallID == callID {
258+ return &c
259+ }
260+ }
261+ }
262+ return nil
263+}
264+
210func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
265func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
211 key := ""
266 key := ""
212 value := ""
267 value := ""
268+ result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
269+
270+ response := m.findToolResponse(toolCall.ID)
271+ if response != nil && response.IsError {
272+ // Clean up error message for display by removing newlines
273+ // This ensures error messages display properly in the UI
274+ errMsg := strings.ReplaceAll(response.Content, "\n", " ")
275+ result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
276+ } else if response != nil {
277+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
278+ }
213 switch toolCall.Name {
279 switch toolCall.Name {
214 // TODO: add result data to the tools
280 // TODO: add result data to the tools
215 case agent.AgentToolName:
281 case agent.AgentToolName:
216 key = "Task"
282 key = "Task"
217 var params agent.AgentParams
283 var params agent.AgentParams
218 json.Unmarshal([]byte(toolCall.Input), &params)
284 json.Unmarshal([]byte(toolCall.Input), &params)
219− value = params.Prompt
285+ value = strings.ReplaceAll(params.Prompt, "\n", " ")
220− // TODO: handle nested calls
286+ if response != nil && !response.IsError {
287+ firstRow := strings.ReplaceAll(response.Content, "\n", " ")
288+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
289+ }
221 case tools.BashToolName:
290 case tools.BashToolName:
222 key = "Bash"
291 key = "Bash"
223 var params tools.BashParams
292 var params tools.BashParams
224 json.Unmarshal([]byte(toolCall.Input), &params)
293 json.Unmarshal([]byte(toolCall.Input), &params)
225 value = params.Command
294 value = params.Command
295+ if response != nil && !response.IsError {
296+ metadata := tools.BashResponseMetadata{}
297+ json.Unmarshal([]byte(response.Metadata), &metadata)
298+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
299+ }
300+
226 case tools.EditToolName:
301 case tools.EditToolName:
227 key = "Edit"
302 key = "Edit"
228 var params tools.EditParams
303 var params tools.EditParams
229 json.Unmarshal([]byte(toolCall.Input), &params)
304 json.Unmarshal([]byte(toolCall.Input), &params)
230 value = params.FilePath
305 value = params.FilePath
306+ if response != nil && !response.IsError {
307+ metadata := tools.EditResponseMetadata{}
308+ json.Unmarshal([]byte(response.Metadata), &metadata)
309+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
310+ }
231 case tools.FetchToolName:
311 case tools.FetchToolName:
232 key = "Fetch"
312 key = "Fetch"
233 var params tools.FetchParams
313 var params tools.FetchParams
234 json.Unmarshal([]byte(toolCall.Input), &params)
314 json.Unmarshal([]byte(toolCall.Input), &params)
235 value = params.URL
315 value = params.URL
316+ if response != nil && !response.IsError {
317+ result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
318+ }
236 case tools.GlobToolName:
319 case tools.GlobToolName:
237 key = "Glob"
320 key = "Glob"
238 var params tools.GlobParams
321 var params tools.GlobParams
@@ -241,6 +324,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
241 params.Path = "."
324 params.Path = "."
242 }
325 }
243 value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
326 value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
327+ if response != nil && !response.IsError {
328+ metadata := tools.GlobResponseMetadata{}
329+ json.Unmarshal([]byte(response.Metadata), &metadata)
330+ if metadata.Truncated {
331+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
332+ } else {
333+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
334+ }
335+ }
244 case tools.GrepToolName:
336 case tools.GrepToolName:
245 key = "Grep"
337 key = "Grep"
246 var params tools.GrepParams
338 var params tools.GrepParams
@@ -249,19 +341,46 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
249 params.Path = "."
341 params.Path = "."
250 }
342 }
251 value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
343 value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
344+ if response != nil && !response.IsError {
345+ metadata := tools.GrepResponseMetadata{}
346+ json.Unmarshal([]byte(response.Metadata), &metadata)
347+ if metadata.Truncated {
348+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
349+ } else {
350+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
351+ }
352+ }
252 case tools.LSToolName:
353 case tools.LSToolName:
253− key = "Ls"
354+ key = "ls"
254 var params tools.LSParams
355 var params tools.LSParams
255 json.Unmarshal([]byte(toolCall.Input), &params)
356 json.Unmarshal([]byte(toolCall.Input), &params)
256 if params.Path == "" {
357 if params.Path == "" {
257 params.Path = "."
358 params.Path = "."
258 }
359 }
259 value = params.Path
360 value = params.Path
361+ if response != nil && !response.IsError {
362+ metadata := tools.LSResponseMetadata{}
363+ json.Unmarshal([]byte(response.Metadata), &metadata)
364+ if metadata.Truncated {
365+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
366+ } else {
367+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
368+ }
369+ }
260 case tools.SourcegraphToolName:
370 case tools.SourcegraphToolName:
261 key = "Sourcegraph"
371 key = "Sourcegraph"
262 var params tools.SourcegraphParams
372 var params tools.SourcegraphParams
263 json.Unmarshal([]byte(toolCall.Input), &params)
373 json.Unmarshal([]byte(toolCall.Input), &params)
264 value = params.Query
374 value = params.Query
375+ if response != nil && !response.IsError {
376+ metadata := tools.SourcegraphResponseMetadata{}
377+ json.Unmarshal([]byte(response.Metadata), &metadata)
378+ if metadata.Truncated {
379+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
380+ } else {
381+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
382+ }
383+ }
265 case tools.ViewToolName:
384 case tools.ViewToolName:
266 key = "View"
385 key = "View"
267 var params tools.ViewParams
386 var params tools.ViewParams
@@ -272,6 +391,12 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
272 var params tools.WriteParams
391 var params tools.WriteParams
273 json.Unmarshal([]byte(toolCall.Input), &params)
392 json.Unmarshal([]byte(toolCall.Input), &params)
274 value = params.FilePath
393 value = params.FilePath
394+ if response != nil && !response.IsError {
395+ metadata := tools.WriteResponseMetadata{}
396+ json.Unmarshal([]byte(response.Metadata), &metadata)
397+
398+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
399+ }
275 default:
400 default:
276 key = toolCall.Name
401 key = toolCall.Name
277 var params map[string]any
402 var params map[string]any
@@ -300,14 +425,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
300 )
425 )
301 if !isNested {
426 if !isNested {
302 value = valyeStyle.
427 value = valyeStyle.
303− Width(m.width - lipgloss.Width(keyValye) - 2).
304 Render(
428 Render(
305 ansi.Truncate(
429 ansi.Truncate(
306− value,
430+ value+" ",
307− m.width-lipgloss.Width(keyValye)-2,
431+ m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
308 "...",
432 "...",
309 ),
433 ),
310 )
434 )
435+ value += result
436+
311 } else {
437 } else {
312 keyValye = keyStyle.Render(
438 keyValye = keyStyle.Render(
313 fmt.Sprintf(" └ %s: ", key),
439 fmt.Sprintf(" └ %s: ", key),
@@ -409,6 +535,27 @@ func (m *messagesCmp) renderView() {
409 m.uiMessages = make([]uiMessage, 0)
535 m.uiMessages = make([]uiMessage, 0)
410 pos := 0
536 pos := 0
411
537
538+ // If we have messages, ensure the last message is not cached
539+ // This ensures we always render the latest content for the most recent message
540+ // which may be actively updating (e.g., during generation)
541+ if len(m.messages) > 0 {
542+ lastMsgID := m.messages[len(m.messages)-1].ID
543+ delete(m.cachedContent, lastMsgID)
544+ }
545+
546+ // Limit cache to 10 messages
547+ if len(m.cachedContent) > 15 {
548+ // Create a list of keys to delete (oldest messages first)
549+ keys := make([]string, 0, len(m.cachedContent))
550+ for k := range m.cachedContent {
551+ keys = append(keys, k)
552+ }
553+ // Delete oldest messages until we have 10 or fewer
554+ for i := 0; i < len(keys)-15; i++ {
555+ delete(m.cachedContent, keys[i])
556+ }
557+ }
558+
412 for _, v := range m.messages {
559 for _, v := range m.messages {
413 switch v.Role {
560 switch v.Role {
414 case message.User:
561 case message.User:
@@ -487,7 +634,7 @@ func (m *messagesCmp) View() string {
487func (m *messagesCmp) help() string {
634func (m *messagesCmp) help() string {
488 text := ""
635 text := ""
489
636
490− if m.agentWorking {
637+ if m.IsAgentWorking() {
491 text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
638 text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
492 fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
639 fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
493 )
640 )
@@ -562,9 +709,15 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
562 m.messages = messages
709 m.messages = messages
563 m.currentMsgID = m.messages[len(m.messages)-1].ID
710 m.currentMsgID = m.messages[len(m.messages)-1].ID
564 m.needsRerender = true
711 m.needsRerender = true
712+ m.cachedContent = make(map[string]string)
565 return nil
713 return nil
566}
714}
567
715
716+func (m *messagesCmp) BindingKeys() []key.Binding {
717+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
718+ return bindings
719+}
720+
568func NewMessagesCmp(app *app.App) tea.Model {
721func NewMessagesCmp(app *app.App) tea.Model {
569 focusRenderer, _ := glamour.NewTermRenderer(
722 focusRenderer, _ := glamour.NewTermRenderer(
570 glamour.WithStyles(styles.MarkdownTheme(true)),
723 glamour.WithStyles(styles.MarkdownTheme(true)),
internal/tui/components/chat/sidebar.go+165−11
@@ -1,10 +1,15 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "context"
4 "fmt"
5 "fmt"
6+ "strings"
5
7
6 tea "github.com/charmbracelet/bubbletea"
8 tea "github.com/charmbracelet/bubbletea"
7 "github.com/charmbracelet/lipgloss"
9 "github.com/charmbracelet/lipgloss"
10+ "github.com/kujtimiihoxha/termai/internal/config"
11+ "github.com/kujtimiihoxha/termai/internal/diff"
12+ "github.com/kujtimiihoxha/termai/internal/history"
8 "github.com/kujtimiihoxha/termai/internal/pubsub"
13 "github.com/kujtimiihoxha/termai/internal/pubsub"
9 "github.com/kujtimiihoxha/termai/internal/session"
14 "github.com/kujtimiihoxha/termai/internal/session"
10 "github.com/kujtimiihoxha/termai/internal/tui/styles"
15 "github.com/kujtimiihoxha/termai/internal/tui/styles"
@@ -13,9 +18,33 @@ import (
13type sidebarCmp struct {
18type sidebarCmp struct {
14 width, height int
19 width, height int
15 session session.Session
20 session session.Session
21+ history history.Service
22+ modFiles map[string]struct {
23+ additions int
24+ removals int
25+ }
16}
26}
17
27
18func (m *sidebarCmp) Init() tea.Cmd {
28func (m *sidebarCmp) Init() tea.Cmd {
29+ if m.history != nil {
30+ ctx := context.Background()
31+ // Subscribe to file events
32+ filesCh := m.history.Subscribe(ctx)
33+
34+ // Initialize the modified files map
35+ m.modFiles = make(map[string]struct {
36+ additions int
37+ removals int
38+ })
39+
40+ // Load initial files and calculate diffs
41+ m.loadModifiedFiles(ctx)
42+
43+ // Return a command that will send file events to the Update method
44+ return func() tea.Msg {
45+ return <-filesCh
46+ }
47+ }
19 return nil
48 return nil
20}
49}
21
50
@@ -27,6 +56,13 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
27 m.session = msg.Payload
56 m.session = msg.Payload
28 }
57 }
29 }
58 }
59+ case pubsub.Event[history.File]:
60+ if msg.Payload.SessionID == m.session.ID {
61+ // When a file changes, reload all modified files
62+ // This ensures we have the complete and accurate list
63+ ctx := context.Background()
64+ m.loadModifiedFiles(ctx)
65+ }
30 }
66 }
31 return m, nil
67 return m, nil
32}
68}
@@ -86,18 +122,28 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
86
122
87func (m *sidebarCmp) modifiedFiles() string {
123func (m *sidebarCmp) modifiedFiles() string {
88 modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
124 modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
89− files := []struct {
125+
90− path string
126+ // If no modified files, show a placeholder message
91− additions int
127+ if m.modFiles == nil || len(m.modFiles) == 0 {
92− removals int
128+ message := "No modified files"
93− }{
129+ remainingWidth := m.width - lipgloss.Width(modifiedFiles)
94− {"file1.txt", 10, 5},
130+ if remainingWidth > 0 {
95− {"file2.txt", 20, 0},
131+ message += strings.Repeat(" ", remainingWidth)
96− {"file3.txt", 0, 15},
132+ }
133+ return styles.BaseStyle.
134+ Width(m.width).
135+ Render(
136+ lipgloss.JoinVertical(
137+ lipgloss.Top,
138+ modifiedFiles,
139+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
140+ ),
141+ )
97 }
142 }
143+
98 var fileViews []string
144 var fileViews []string
99− for _, file := range files {
145+ for path, stats := range m.modFiles {
100− fileViews = append(fileViews, m.modifiedFile(file.path, file.additions, file.removals))
146+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
101 }
147 }
102
148
103 return styles.BaseStyle.
149 return styles.BaseStyle.
@@ -123,8 +169,116 @@ func (m *sidebarCmp) GetSize() (int, int) {
123 return m.width, m.height
169 return m.width, m.height
124}
170}
125
171
126−func NewSidebarCmp(session session.Session) tea.Model {
172+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
127 return &sidebarCmp{
173 return &sidebarCmp{
128 session: session,
174 session: session,
175+ history: history,
176+ }
177+}
178+
179+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
180+ if m.history == nil || m.session.ID == "" {
181+ return
182+ }
183+
184+ // Get all latest files for this session
185+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
186+ if err != nil {
187+ return
188+ }
189+
190+ // Get all files for this session (to find initial versions)
191+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
192+ if err != nil {
193+ return
194+ }
195+
196+ // Process each latest file
197+ for _, file := range latestFiles {
198+ // Skip if this is the initial version (no changes to show)
199+ if file.Version == history.InitialVersion {
200+ continue
201+ }
202+
203+ // Find the initial version for this specific file
204+ var initialVersion history.File
205+ for _, v := range allFiles {
206+ if v.Path == file.Path && v.Version == history.InitialVersion {
207+ initialVersion = v
208+ break
209+ }
210+ }
211+
212+ // Skip if we can't find the initial version
213+ if initialVersion.ID == "" {
214+ continue
215+ }
216+
217+ // Calculate diff between initial and latest version
218+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
219+
220+ // Only add to modified files if there are changes
221+ if additions > 0 || removals > 0 {
222+ // Remove working directory prefix from file path
223+ displayPath := file.Path
224+ workingDir := config.WorkingDirectory()
225+ displayPath = strings.TrimPrefix(displayPath, workingDir)
226+ displayPath = strings.TrimPrefix(displayPath, "/")
227+
228+ m.modFiles[displayPath] = struct {
229+ additions int
230+ removals int
231+ }{
232+ additions: additions,
233+ removals: removals,
234+ }
235+ }
236+ }
237+}
238+
239+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
240+ // Skip if not the latest version
241+ if file.Version == history.InitialVersion {
242+ return
243+ }
244+
245+ // Get all versions of this file
246+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
247+ if err != nil {
248+ return
249+ }
250+
251+ // Find the initial version
252+ var initialVersion history.File
253+ for _, v := range fileVersions {
254+ if v.Path == file.Path && v.Version == history.InitialVersion {
255+ initialVersion = v
256+ break
257+ }
258+ }
259+
260+ // Skip if we can't find the initial version
261+ if initialVersion.ID == "" {
262+ return
263+ }
264+
265+ // Calculate diff between initial and latest version
266+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
267+
268+ // Only add to modified files if there are changes
269+ if additions > 0 || removals > 0 {
270+ // Remove working directory prefix from file path
271+ displayPath := file.Path
272+ workingDir := config.WorkingDirectory()
273+ displayPath = strings.TrimPrefix(displayPath, workingDir)
274+ displayPath = strings.TrimPrefix(displayPath, "/")
275+
276+ m.modFiles[displayPath] = struct {
277+ additions int
278+ removals int
279+ }{
280+ additions: additions,
281+ removals: removals,
282+ }
129 }
283 }
130}
284}
internal/tui/components/core/dialog.godeleted−117
@@ -1,117 +0,0 @@
1−package core
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/kujtimiihoxha/termai/internal/tui/layout"
8− "github.com/kujtimiihoxha/termai/internal/tui/util"
9−)
10−
11−type SizeableModel interface {
12− tea.Model
13− layout.Sizeable
14−}
15−
16−type DialogMsg struct {
17− Content SizeableModel
18− WidthRatio float64
19− HeightRatio float64
20−
21− MinWidth int
22− MinHeight int
23−}
24−
25−type DialogCloseMsg struct{}
26−
27−type KeyBindings struct {
28− Return key.Binding
29−}
30−
31−var keys = KeyBindings{
32− Return: key.NewBinding(
33− key.WithKeys("esc"),
34− key.WithHelp("esc", "close"),
35− ),
36−}
37−
38−type DialogCmp interface {
39− tea.Model
40− layout.Bindings
41−}
42−
43−type dialogCmp struct {
44− content SizeableModel
45− screenWidth int
46− screenHeight int
47−
48− widthRatio float64
49− heightRatio float64
50−
51− minWidth int
52− minHeight int
53−
54− width int
55− height int
56−}
57−
58−func (d *dialogCmp) Init() tea.Cmd {
59− return nil
60−}
61−
62−func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
63− switch msg := msg.(type) {
64− case tea.WindowSizeMsg:
65− d.screenWidth = msg.Width
66− d.screenHeight = msg.Height
67− d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
68− d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
69− if d.content != nil {
70− d.content.SetSize(d.width, d.height)
71− }
72− return d, nil
73− case DialogMsg:
74− d.content = msg.Content
75− d.widthRatio = msg.WidthRatio
76− d.heightRatio = msg.HeightRatio
77− d.minWidth = msg.MinWidth
78− d.minHeight = msg.MinHeight
79− d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
80− d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
81− if d.content != nil {
82− d.content.SetSize(d.width, d.height)
83− }
84− case DialogCloseMsg:
85− d.content = nil
86− return d, nil
87− case tea.KeyMsg:
88− if key.Matches(msg, keys.Return) {
89− return d, util.CmdHandler(DialogCloseMsg{})
90− }
91− }
92− if d.content != nil {
93− u, cmd := d.content.Update(msg)
94− d.content = u.(SizeableModel)
95− return d, cmd
96− }
97− return d, nil
98−}
99−
100−func (d *dialogCmp) BindingKeys() []key.Binding {
101− bindings := []key.Binding{keys.Return}
102− if d.content == nil {
103− return bindings
104− }
105− if c, ok := d.content.(layout.Bindings); ok {
106− return append(bindings, c.BindingKeys()...)
107− }
108− return bindings
109−}
110−
111−func (d *dialogCmp) View() string {
112− return lipgloss.NewStyle().Width(d.width).Height(d.height).Render(d.content.View())
113−}
114−
115−func NewDialogCmp() DialogCmp {
116− return &dialogCmp{}
117−}
internal/tui/components/core/help.godeleted−119
@@ -1,119 +0,0 @@
1−package core
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9− "github.com/kujtimiihoxha/termai/internal/tui/styles"
10−)
11−
12−type HelpCmp interface {
13− tea.Model
14− SetBindings(bindings []key.Binding)
15− Height() int
16−}
17−
18−const (
19− helpWidgetHeight = 12
20−)
21−
22−type helpCmp struct {
23− width int
24− bindings []key.Binding
25−}
26−
27−func (h *helpCmp) Init() tea.Cmd {
28− return nil
29−}
30−
31−func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
32− switch msg := msg.(type) {
33− case tea.WindowSizeMsg:
34− h.width = msg.Width
35− }
36− return h, nil
37−}
38−
39−func (h *helpCmp) View() string {
40− helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
41− helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
42− // Compile list of bindings to render
43− bindings := removeDuplicateBindings(h.bindings)
44− // Enumerate through each group of bindings, populating a series of
45− // pairs of columns, one for keys, one for descriptions
46− var (
47− pairs []string
48− width int
49− rows = helpWidgetHeight - 2
50− )
51− for i := 0; i < len(bindings); i += rows {
52− var (
53− keys []string
54− descs []string
55− )
56− for j := i; j < min(i+rows, len(bindings)); j++ {
57− keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
58− descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
59− }
60− // Render pair of columns; beyond the first pair, render a three space
61− // left margin, in order to visually separate the pairs.
62− var cols []string
63− if len(pairs) > 0 {
64− cols = []string{" "}
65− }
66− cols = append(cols,
67− strings.Join(keys, "\n"),
68− strings.Join(descs, "\n"),
69− )
70−
71− pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
72− // check whether it exceeds the maximum width avail (the width of the
73− // terminal, subtracting 2 for the borders).
74− width += lipgloss.Width(pair)
75− if width > h.width-2 {
76− break
77− }
78− pairs = append(pairs, pair)
79− }
80−
81− // Join pairs of columns and enclose in a border
82− content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
83− return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content)
84−}
85−
86−func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
87− seen := make(map[string]struct{})
88− result := make([]key.Binding, 0, len(bindings))
89−
90− // Process bindings in reverse order
91− for i := len(bindings) - 1; i >= 0; i-- {
92− b := bindings[i]
93− k := strings.Join(b.Keys(), " ")
94− if _, ok := seen[k]; ok {
95− // duplicate, skip
96− continue
97− }
98− seen[k] = struct{}{}
99− // Add to the beginning of result to maintain original order
100− result = append([]key.Binding{b}, result...)
101− }
102−
103− return result
104−}
105−
106−func (h *helpCmp) SetBindings(bindings []key.Binding) {
107− h.bindings = bindings
108−}
109−
110−func (h helpCmp) Height() int {
111− return helpWidgetHeight
112−}
113−
114−func NewHelpCmp() HelpCmp {
115− return &helpCmp{
116− width: 0,
117− bindings: make([]key.Binding, 0),
118− }
119−}
internal/tui/components/core/status.go+73−17
@@ -1,21 +1,25 @@
1package core
1package core
2
2
3import (
3import (
4+ "fmt"
5+ "strings"
4 "time"
6 "time"
5
7
6 tea "github.com/charmbracelet/bubbletea"
8 tea "github.com/charmbracelet/bubbletea"
7 "github.com/charmbracelet/lipgloss"
9 "github.com/charmbracelet/lipgloss"
8 "github.com/kujtimiihoxha/termai/internal/config"
10 "github.com/kujtimiihoxha/termai/internal/config"
9 "github.com/kujtimiihoxha/termai/internal/llm/models"
11 "github.com/kujtimiihoxha/termai/internal/llm/models"
12+ "github.com/kujtimiihoxha/termai/internal/lsp"
13+ "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
10 "github.com/kujtimiihoxha/termai/internal/tui/styles"
14 "github.com/kujtimiihoxha/termai/internal/tui/styles"
11 "github.com/kujtimiihoxha/termai/internal/tui/util"
15 "github.com/kujtimiihoxha/termai/internal/tui/util"
12− "github.com/kujtimiihoxha/termai/internal/version"
13)
16)
14
17
15type statusCmp struct {
18type statusCmp struct {
16 info util.InfoMsg
19 info util.InfoMsg
17 width int
20 width int
18 messageTTL time.Duration
21 messageTTL time.Duration
22+ lspClients map[string]*lsp.Client
19}
23}
20
24
21// clearMessageCmd is a command that clears status messages after a timeout
25// clearMessageCmd is a command that clears status messages after a timeout
@@ -47,20 +51,18 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 return m, nil
51 return m, nil
48}
52}
49
53
50−var (
51− versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
52− helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
54+var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
53−)
54
55
55func (m statusCmp) View() string {
56func (m statusCmp) View() string {
57+ status := helpWidget
56− status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
58+ diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
57 if m.info.Msg != "" {
59 if m.info.Msg != "" {
58 infoStyle := styles.Padded.
60 infoStyle := styles.Padded.
59 Foreground(styles.Base).
61 Foreground(styles.Base).
60− Width(m.availableFooterMsgWidth())
62+ Width(m.availableFooterMsgWidth(diagnostics))
61 switch m.info.Type {
63 switch m.info.Type {
62 case util.InfoTypeInfo:
64 case util.InfoTypeInfo:
63− infoStyle = infoStyle.Background(styles.Blue)
65+ infoStyle = infoStyle.Background(styles.BorderColor)
64 case util.InfoTypeWarn:
66 case util.InfoTypeWarn:
65 infoStyle = infoStyle.Background(styles.Peach)
67 infoStyle = infoStyle.Background(styles.Peach)
66 case util.InfoTypeError:
68 case util.InfoTypeError:
@@ -68,7 +70,7 @@ func (m statusCmp) View() string {
68 }
70 }
69 // Truncate message if it's longer than available width
71 // Truncate message if it's longer than available width
70 msg := m.info.Msg
72 msg := m.info.Msg
71− availWidth := m.availableFooterMsgWidth() - 10
73+ availWidth := m.availableFooterMsgWidth(diagnostics) - 10
72 if len(msg) > availWidth && availWidth > 0 {
74 if len(msg) > availWidth && availWidth > 0 {
73 msg = msg[:availWidth] + "..."
75 msg = msg[:availWidth] + "..."
74 }
76 }
@@ -76,27 +78,81 @@ func (m statusCmp) View() string {
76 } else {
78 } else {
77 status += styles.Padded.
79 status += styles.Padded.
78 Foreground(styles.Base).
80 Foreground(styles.Base).
79− Background(styles.LightGrey).
81+ Background(styles.BackgroundDim).
80− Width(m.availableFooterMsgWidth()).
82+ Width(m.availableFooterMsgWidth(diagnostics)).
81 Render("")
83 Render("")
82 }
84 }
85+ status += diagnostics
83 status += m.model()
86 status += m.model()
84− status += versionWidget
85 return status
87 return status
86}
88}
87
89
90+func (m *statusCmp) projectDiagnostics() string {
91+ errorDiagnostics := []protocol.Diagnostic{}
92+ warnDiagnostics := []protocol.Diagnostic{}
93+ hintDiagnostics := []protocol.Diagnostic{}
94+ infoDiagnostics := []protocol.Diagnostic{}
95+ for _, client := range m.lspClients {
96+ for _, d := range client.GetDiagnostics() {
97+ for _, diag := range d {
98+ switch diag.Severity {
99+ case protocol.SeverityError:
100+ errorDiagnostics = append(errorDiagnostics, diag)
101+ case protocol.SeverityWarning:
102+ warnDiagnostics = append(warnDiagnostics, diag)
103+ case protocol.SeverityHint:
104+ hintDiagnostics = append(hintDiagnostics, diag)
105+ case protocol.SeverityInformation:
106+ infoDiagnostics = append(infoDiagnostics, diag)
107+ }
108+ }
109+ }
110+ }
111+
112+ if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
113+ return "No diagnostics"
114+ }
115+
116+ diagnostics := []string{}
117+
118+ if len(errorDiagnostics) > 0 {
119+ errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
120+ diagnostics = append(diagnostics, errStr)
121+ }
122+ if len(warnDiagnostics) > 0 {
123+ warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
124+ diagnostics = append(diagnostics, warnStr)
125+ }
126+ if len(hintDiagnostics) > 0 {
127+ hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
128+ diagnostics = append(diagnostics, hintStr)
129+ }
130+ if len(infoDiagnostics) > 0 {
131+ infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
132+ diagnostics = append(diagnostics, infoStr)
133+ }
134+
135+ return strings.Join(diagnostics, " ")
136+}
137+
88−func (m statusCmp) availableFooterMsgWidth() int {
138+func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
89− // -2 to accommodate padding
90− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
139+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics))
91}
140}
92
141
93func (m statusCmp) model() string {
142func (m statusCmp) model() string {
143+ cfg := config.Get()
144+
145+ coder, ok := cfg.Agents[config.AgentCoder]
146+ if !ok {
147+ return "Unknown"
148+ }
94− model := models.SupportedModels[config.Get().Model.Coder]
149+ model := models.SupportedModels[coder.Model]
95 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
150 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
96}
151}
97
152
98−func NewStatusCmp() tea.Model {
153+func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model {
99 return &statusCmp{
154 return &statusCmp{
100 messageTTL: 10 * time.Second,
155 messageTTL: 10 * time.Second,
156+ lspClients: lspClients,
101 }
157 }
102}
158}
internal/tui/components/dialog/help.gonew file+182
@@ -0,0 +1,182 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
10+)
11+
12+type helpCmp struct {
13+ width int
14+ height int
15+ keys []key.Binding
16+}
17+
18+func (h *helpCmp) Init() tea.Cmd {
19+ return nil
20+}
21+
22+func (h *helpCmp) SetBindings(k []key.Binding) {
23+ h.keys = k
24+}
25+
26+func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
27+ switch msg := msg.(type) {
28+ case tea.WindowSizeMsg:
29+ h.width = 80
30+ h.height = msg.Height
31+ }
32+ return h, nil
33+}
34+
35+func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
36+ seen := make(map[string]struct{})
37+ result := make([]key.Binding, 0, len(bindings))
38+
39+ // Process bindings in reverse order
40+ for i := len(bindings) - 1; i >= 0; i-- {
41+ b := bindings[i]
42+ k := strings.Join(b.Keys(), " ")
43+ if _, ok := seen[k]; ok {
44+ // duplicate, skip
45+ continue
46+ }
47+ seen[k] = struct{}{}
48+ // Add to the beginning of result to maintain original order
49+ result = append([]key.Binding{b}, result...)
50+ }
51+
52+ return result
53+}
54+
55+func (h *helpCmp) render() string {
56+ helpKeyStyle := styles.Bold.Background(styles.Background).Foreground(styles.Forground).Padding(0, 1, 0, 0)
57+ helpDescStyle := styles.Regular.Background(styles.Background).Foreground(styles.ForgroundMid)
58+ // Compile list of bindings to render
59+ bindings := removeDuplicateBindings(h.keys)
60+ // Enumerate through each group of bindings, populating a series of
61+ // pairs of columns, one for keys, one for descriptions
62+ var (
63+ pairs []string
64+ width int
65+ rows = 12 - 2
66+ )
67+ for i := 0; i < len(bindings); i += rows {
68+ var (
69+ keys []string
70+ descs []string
71+ )
72+ for j := i; j < min(i+rows, len(bindings)); j++ {
73+ keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
74+ descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
75+ }
76+ // Render pair of columns; beyond the first pair, render a three space
77+ // left margin, in order to visually separate the pairs.
78+ var cols []string
79+ if len(pairs) > 0 {
80+ cols = []string{styles.BaseStyle.Render(" ")}
81+ }
82+
83+ maxDescWidth := 0
84+ for _, desc := range descs {
85+ if maxDescWidth < lipgloss.Width(desc) {
86+ maxDescWidth = lipgloss.Width(desc)
87+ }
88+ }
89+ for i := range descs {
90+ remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
91+ if remainingWidth > 0 {
92+ descs[i] = descs[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
93+ }
94+ }
95+ maxKeyWidth := 0
96+ for _, key := range keys {
97+ if maxKeyWidth < lipgloss.Width(key) {
98+ maxKeyWidth = lipgloss.Width(key)
99+ }
100+ }
101+ for i := range keys {
102+ remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
103+ if remainingWidth > 0 {
104+ keys[i] = keys[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
105+ }
106+ }
107+
108+ cols = append(cols,
109+ strings.Join(keys, "\n"),
110+ strings.Join(descs, "\n"),
111+ )
112+
113+ pair := styles.BaseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
114+ // check whether it exceeds the maximum width avail (the width of the
115+ // terminal, subtracting 2 for the borders).
116+ width += lipgloss.Width(pair)
117+ if width > h.width-2 {
118+ break
119+ }
120+ pairs = append(pairs, pair)
121+ }
122+
123+ // https://github.com/charmbracelet/lipgloss/issues/209
124+ if len(pairs) > 1 {
125+ prefix := pairs[:len(pairs)-1]
126+ lastPair := pairs[len(pairs)-1]
127+ prefix = append(prefix, lipgloss.Place(
128+ lipgloss.Width(lastPair), // width
129+ lipgloss.Height(prefix[0]), // height
130+ lipgloss.Left, // x
131+ lipgloss.Top, // y
132+ lastPair, // content
133+ lipgloss.WithWhitespaceBackground(styles.Background), // background
134+ ))
135+ content := styles.BaseStyle.Width(h.width).Render(
136+ lipgloss.JoinHorizontal(
137+ lipgloss.Top,
138+ prefix...,
139+ ),
140+ )
141+ return content
142+ }
143+ // Join pairs of columns and enclose in a border
144+ content := styles.BaseStyle.Width(h.width).Render(
145+ lipgloss.JoinHorizontal(
146+ lipgloss.Top,
147+ pairs...,
148+ ),
149+ )
150+ return content
151+}
152+
153+func (h *helpCmp) View() string {
154+ content := h.render()
155+ header := styles.BaseStyle.
156+ Bold(true).
157+ Width(lipgloss.Width(content)).
158+ Foreground(styles.PrimaryColor).
159+ Render("Keyboard Shortcuts")
160+
161+ return styles.BaseStyle.Padding(1).
162+ Border(lipgloss.RoundedBorder()).
163+ BorderForeground(styles.ForgroundDim).
164+ Width(h.width).
165+ BorderBackground(styles.Background).
166+ Render(
167+ lipgloss.JoinVertical(lipgloss.Center,
168+ header,
169+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
170+ content,
171+ ),
172+ )
173+}
174+
175+type HelpCmp interface {
176+ tea.Model
177+ SetBindings([]key.Binding)
178+}
179+
180+func NewHelpCmp() HelpCmp {
181+ return &helpCmp{}
182+}
internal/tui/components/dialog/permission.go+332−350
@@ -12,12 +12,9 @@ import (
12 "github.com/kujtimiihoxha/termai/internal/diff"
12 "github.com/kujtimiihoxha/termai/internal/diff"
13 "github.com/kujtimiihoxha/termai/internal/llm/tools"
13 "github.com/kujtimiihoxha/termai/internal/llm/tools"
14 "github.com/kujtimiihoxha/termai/internal/permission"
14 "github.com/kujtimiihoxha/termai/internal/permission"
15− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
16 "github.com/kujtimiihoxha/termai/internal/tui/layout"
15 "github.com/kujtimiihoxha/termai/internal/tui/layout"
17 "github.com/kujtimiihoxha/termai/internal/tui/styles"
16 "github.com/kujtimiihoxha/termai/internal/tui/styles"
18 "github.com/kujtimiihoxha/termai/internal/tui/util"
17 "github.com/kujtimiihoxha/termai/internal/tui/util"
19−
20− "github.com/charmbracelet/huh"
21)
18)
22
19
23type PermissionAction string
20type PermissionAction string
@@ -35,69 +32,64 @@ type PermissionResponseMsg struct {
35 Action PermissionAction
32 Action PermissionAction
36}
33}
37
34
38−// PermissionDialog interface for permission dialog component
35+// PermissionDialogCmp interface for permission dialog component
39−type PermissionDialog interface {
36+type PermissionDialogCmp interface {
40 tea.Model
37 tea.Model
41− layout.Sizeable
42 layout.Bindings
38 layout.Bindings
39+ SetPermissions(permission permission.PermissionRequest)
43}
40}
44
41
45−type keyMap struct {
42+type permissionsMapping struct {
43+ LeftRight key.Binding
44+ EnterSpace key.Binding
45+ Allow key.Binding
46− ChangeFocus key.Binding
46+ AllowSession key.Binding
47+ Deny key.Binding
48+ Tab key.Binding
47}
49}
48
50
49−var keyMapValue = keyMap{
51+var permissionsKeys = permissionsMapping{
52+ LeftRight: key.NewBinding(
53+ key.WithKeys("left", "right"),
54+ key.WithHelp("←/→", "switch options"),
55+ ),
56+ EnterSpace: key.NewBinding(
57+ key.WithKeys("enter", " "),
58+ key.WithHelp("enter/space", "confirm"),
59+ ),
60+ Allow: key.NewBinding(
61+ key.WithKeys("a"),
62+ key.WithHelp("a", "allow"),
63+ ),
64+ AllowSession: key.NewBinding(
65+ key.WithKeys("A"),
66+ key.WithHelp("A", "allow for session"),
67+ ),
68+ Deny: key.NewBinding(
69+ key.WithKeys("d"),
70+ key.WithHelp("d", "deny"),
71+ ),
50− ChangeFocus: key.NewBinding(
72+ Tab: key.NewBinding(
51 key.WithKeys("tab"),
73 key.WithKeys("tab"),
52− key.WithHelp("tab", "change focus"),
74+ key.WithHelp("tab", "switch options"),
53 ),
75 ),
54}
76}
55
77
56// permissionDialogCmp is the implementation of PermissionDialog
78// permissionDialogCmp is the implementation of PermissionDialog
57type permissionDialogCmp struct {
79type permissionDialogCmp struct {
58− form *huh.Form
59 width int
80 width int
60 height int
81 height int
61 permission permission.PermissionRequest
82 permission permission.PermissionRequest
62 windowSize tea.WindowSizeMsg
83 windowSize tea.WindowSizeMsg
63− r *glamour.TermRenderer
64 contentViewPort viewport.Model
84 contentViewPort viewport.Model
65− isViewportFocus bool
85+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
66− selectOption *huh.Select[string]
67−}
68
86
69−// formatDiff formats a diff string with colors for additions and deletions
70−func formatDiff(diffText string) string {
87+ diffCache map[string]string
71− lines := strings.Split(diffText, "\n")
88+ markdownCache map[string]string
72− var formattedLines []string
73−
74− // Define styles for different line types
75− addStyle := lipgloss.NewStyle().Foreground(styles.Green)
76− removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
77− headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
78− contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
79−
80− // Process each line
81− for _, line := range lines {
82− if strings.HasPrefix(line, "+") {
83− formattedLines = append(formattedLines, addStyle.Render(line))
84− } else if strings.HasPrefix(line, "-") {
85− formattedLines = append(formattedLines, removeStyle.Render(line))
86− } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
87− formattedLines = append(formattedLines, headerStyle.Render(line))
88− } else if strings.HasPrefix(line, " ") {
89− formattedLines = append(formattedLines, contextStyle.Render(line))
90− } else {
91− formattedLines = append(formattedLines, line)
92− }
93− }
94−
95− // Join all formatted lines
96− return strings.Join(formattedLines, "\n")
97}
89}
98
90
99func (p *permissionDialogCmp) Init() tea.Cmd {
91func (p *permissionDialogCmp) Init() tea.Cmd {
100− return nil
92+ return p.contentViewPort.Init()
101}
93}
102
94
103func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -106,373 +98,363 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106 switch msg := msg.(type) {
98 switch msg := msg.(type) {
107 case tea.WindowSizeMsg:
99 case tea.WindowSizeMsg:
108 p.windowSize = msg
100 p.windowSize = msg
101+ p.SetSize()
102+ p.markdownCache = make(map[string]string)
103+ p.diffCache = make(map[string]string)
109 case tea.KeyMsg:
104 case tea.KeyMsg:
110− if key.Matches(msg, keyMapValue.ChangeFocus) {
105+ switch {
111− p.isViewportFocus = !p.isViewportFocus
106+ case key.Matches(msg, permissionsKeys.LeftRight) || key.Matches(msg, permissionsKeys.Tab):
112− if p.isViewportFocus {
107+ // Change selected option
113− p.selectOption.Blur()
108+ p.selectedOption = (p.selectedOption + 1) % 3
114− // Add a visual indicator for focus change
109+ return p, nil
115− cmds = append(cmds, tea.Batch(
110+ case key.Matches(msg, permissionsKeys.EnterSpace):
116− util.ReportInfo("Viewing content - use arrow keys to scroll"),
111+ // Select current option
117− ))
112+ return p, p.selectCurrentOption()
118− } else {
113+ case key.Matches(msg, permissionsKeys.Allow):
119− p.selectOption.Focus()
114+ // Select Allow
120− // Add a visual indicator for focus change
115+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
121− cmds = append(cmds, tea.Batch(
116+ case key.Matches(msg, permissionsKeys.AllowSession):
122− util.CmdHandler(util.ReportInfo("Select an action")),
117+ // Select Allow for session
123− ))
118+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
124− }
119+ case key.Matches(msg, permissionsKeys.Deny):
125− return p, tea.Batch(cmds...)
120+ // Select Deny
126− }
121+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
127− }
122+ default:
128−
123+ // Pass other keys to viewport
129− if p.isViewportFocus {
130− viewPort, cmd := p.contentViewPort.Update(msg)
124+ viewPort, cmd := p.contentViewPort.Update(msg)
131− p.contentViewPort = viewPort
125+ p.contentViewPort = viewPort
132− cmds = append(cmds, cmd)
133− } else {
134− form, cmd := p.form.Update(msg)
135− if f, ok := form.(*huh.Form); ok {
136− p.form = f
137 cmds = append(cmds, cmd)
126 cmds = append(cmds, cmd)
138 }
127 }
139−
140− if p.form.State == huh.StateCompleted {
141− // Get the selected action
142− action := p.form.GetString("action")
143−
144− // Close the dialog and return the response
145− return p, tea.Batch(
146− util.CmdHandler(core.DialogCloseMsg{}),
147− util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
148− )
149− }
150 }
128 }
129+
151 return p, tea.Batch(cmds...)
130 return p, tea.Batch(cmds...)
152}
131}
153
132
154−func (p *permissionDialogCmp) render() string {
133+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
155− keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
134+ var action PermissionAction
156− valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
157
135
158− form := p.form.View()
136+ switch p.selectedOption {
159−
137+ case 0:
160− headerParts := []string{
138+ action = PermissionAllow
161− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
139+ case 1:
162− " ",
140+ action = PermissionAllowForSession
163− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
141+ case 2:
164− " ",
142+ action = PermissionDeny
165 }
143 }
166
144
167− // Create the header content first so it can be used in all cases
145+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
168− headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
146+}
169−
170− r, _ := glamour.NewTermRenderer(
171− glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
172− glamour.WithWordWrap(p.width-10),
173− glamour.WithEmoji(),
174− )
175−
176− // Handle different tool types
177− switch p.permission.ToolName {
178− case tools.BashToolName:
179− pr := p.permission.Params.(tools.BashPermissionsParams)
180− headerParts = append(headerParts, keyStyle.Render("Command:"))
181− content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
182−
183− renderedContent, _ := r.Render(content)
184− p.contentViewPort.Width = p.width - 2 - 2
185−
186− // Calculate content height dynamically based on content
187− contentLines := len(strings.Split(renderedContent, "\n"))
188− // Set a reasonable min/max for the viewport height
189− minContentHeight := 3
190− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
191−
192− // Add some padding to the content lines
193− contentHeight := contentLines + 2
194− contentHeight = max(contentHeight, minContentHeight)
195− contentHeight = min(contentHeight, maxContentHeight)
196− p.contentViewPort.Height = contentHeight
197−
198− p.contentViewPort.SetContent(renderedContent)
199
147
148+func (p *permissionDialogCmp) renderButtons() string {
149+ allowStyle := styles.BaseStyle
150+ allowSessionStyle := styles.BaseStyle
151+ denyStyle := styles.BaseStyle
152+ spacerStyle := styles.BaseStyle.Background(styles.Background)
153+
200− // Style the viewport
154+ // Style the selected button
201− var contentBorder lipgloss.Border
155+ switch p.selectedOption {
202− var borderColor lipgloss.TerminalColor
156+ case 0:
157+ allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
158+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
159+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
160+ case 1:
161+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
162+ allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
163+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
164+ case 2:
165+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
166+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
167+ denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
168+ }
203
169
204− if p.isViewportFocus {
170+ allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
205− contentBorder = lipgloss.DoubleBorder()
171+ allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
206− borderColor = styles.Blue
172+ denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
207− } else {
173+
208− contentBorder = lipgloss.RoundedBorder()
174+ content := lipgloss.JoinHorizontal(
209− borderColor = styles.Flamingo
175+ lipgloss.Left,
210− }
176+ allowButton,
177+ spacerStyle.Render(" "),
178+ allowSessionButton,
179+ spacerStyle.Render(" "),
180+ denyButton,
181+ spacerStyle.Render(" "),
182+ )
211
183
212− contentStyle := lipgloss.NewStyle().
184+ remainingWidth := p.width - lipgloss.Width(content)
213− MarginTop(1).
185+ if remainingWidth > 0 {
214− Padding(0, 1).
186+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
215− Border(contentBorder).
187+ }
216− BorderForeground(borderColor)
188+ return content
189+}
217
190
218− if p.isViewportFocus {
191+func (p *permissionDialogCmp) renderHeader() string {
219− contentStyle = contentStyle.BorderBackground(styles.Surface0)
192+ toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
220− }
193+ toolValue := styles.BaseStyle.
194+ Foreground(styles.Forground).
195+ Width(p.width - lipgloss.Width(toolKey)).
196+ Render(fmt.Sprintf(": %s", p.permission.ToolName))
221
197
222− contentFinal := contentStyle.Render(p.contentViewPort.View())
198+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
199+ pathValue := styles.BaseStyle.
200+ Foreground(styles.Forground).
201+ Width(p.width - lipgloss.Width(pathKey)).
202+ Render(fmt.Sprintf(": %s", p.permission.Path))
223
203
224− return lipgloss.JoinVertical(
204+ headerParts := []string{
205+ lipgloss.JoinHorizontal(
206+ lipgloss.Left,
207+ toolKey,
208+ toolValue,
209+ ),
210+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
211+ lipgloss.JoinHorizontal(
225− lipgloss.Top,
212+ lipgloss.Left,
226− headerContent,
213+ pathKey,
227− contentFinal,
214+ pathValue,
228− form,
215+ ),
229− )
216+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
217+ }
230
218
219+ // Add tool-specific header information
220+ switch p.permission.ToolName {
221+ case tools.BashToolName:
222+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
231 case tools.EditToolName:
223 case tools.EditToolName:
232− pr := p.permission.Params.(tools.EditPermissionsParams)
224+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
233− headerParts = append(headerParts, keyStyle.Render("Update"))
225+ case tools.WriteToolName:
234− // Recreate header content with the updated headerParts
226+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
235− headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
227+ case tools.FetchToolName:
236−
228+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
237− // Format the diff with colors
238−
239− // Set up viewport for the diff content
240− p.contentViewPort.Width = p.width - 2 - 2
241−
242− // Calculate content height dynamically based on window size
243− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
244− p.contentViewPort.Height = maxContentHeight
245− diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
246− if err != nil {
247− diff = fmt.Sprintf("Error formatting diff: %v", err)
248− }
229+ }
249− p.contentViewPort.SetContent(diff)
250
230
251− // Style the viewport
231+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
252− var contentBorder lipgloss.Border
232+}
253− var borderColor lipgloss.TerminalColor
254
233
255− if p.isViewportFocus {
234+func (p *permissionDialogCmp) renderBashContent() string {
256− contentBorder = lipgloss.DoubleBorder()
235+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
257− borderColor = styles.Blue
236+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
258− } else {
259− contentBorder = lipgloss.RoundedBorder()
260− borderColor = styles.Flamingo
261− }
262
237
263− contentStyle := lipgloss.NewStyle().
238+ // Use the cache for markdown rendering
264− MarginTop(1).
239+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
265− Padding(0, 1).
240+ r, _ := glamour.NewTermRenderer(
266− Border(contentBorder).
241+ glamour.WithStyles(styles.MarkdownTheme(true)),
267− BorderForeground(borderColor)
242+ glamour.WithWordWrap(p.width-10),
243+ )
244+ s, err := r.Render(content)
245+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
246+ })
247+
248+ finalContent := styles.BaseStyle.
249+ Width(p.contentViewPort.Width).
250+ Render(renderedContent)
251+ p.contentViewPort.SetContent(finalContent)
252+ return p.styleViewport()
253+ }
254+ return ""
255+}
268
256
269− if p.isViewportFocus {
257+func (p *permissionDialogCmp) renderEditContent() string {
270− contentStyle = contentStyle.BorderBackground(styles.Surface0)
258+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
259+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
260+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
271− }
261+ })
272
262
273− contentFinal := contentStyle.Render(p.contentViewPort.View())
263+ p.contentViewPort.SetContent(diff)
264+ return p.styleViewport()
265+ }
266+ return ""
267+}
274
268
275− return lipgloss.JoinVertical(
269+func (p *permissionDialogCmp) renderWriteContent() string {
276− lipgloss.Top,
270+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
277− headerContent,
271+ // Use the cache for diff rendering
278− contentFinal,
272+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
279− form,
273+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
280− )
274+ })
281
275
282− case tools.WriteToolName:
283− pr := p.permission.Params.(tools.WritePermissionsParams)
284− headerParts = append(headerParts, keyStyle.Render("Content"))
285− // Recreate header content with the updated headerParts
286− headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
287−
288− // Set up viewport for the content
289− p.contentViewPort.Width = p.width - 2 - 2
290−
291− // Calculate content height dynamically based on window size
292− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
293− p.contentViewPort.Height = maxContentHeight
294− diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
295− if err != nil {
296− diff = fmt.Sprintf("Error formatting diff: %v", err)
297− }
298 p.contentViewPort.SetContent(diff)
276 p.contentViewPort.SetContent(diff)
277+ return p.styleViewport()
278+ }
279+ return ""
280+}
299
281
300− // Style the viewport
282+func (p *permissionDialogCmp) renderFetchContent() string {
301− var contentBorder lipgloss.Border
283+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
302− var borderColor lipgloss.TerminalColor
284+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
303
285
304− if p.isViewportFocus {
286+ // Use the cache for markdown rendering
305− contentBorder = lipgloss.DoubleBorder()
287+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
306− borderColor = styles.Blue
288+ r, _ := glamour.NewTermRenderer(
307− } else {
289+ glamour.WithStyles(styles.MarkdownTheme(true)),
308− contentBorder = lipgloss.RoundedBorder()
290+ glamour.WithWordWrap(p.width-10),
309− borderColor = styles.Flamingo
291+ )
310− }
292+ s, err := r.Render(content)
311−
293+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
312− contentStyle := lipgloss.NewStyle().
294+ })
313− MarginTop(1).
314− Padding(0, 1).
315− Border(contentBorder).
316− BorderForeground(borderColor)
317
295
318− if p.isViewportFocus {
296+ p.contentViewPort.SetContent(renderedContent)
319− contentStyle = contentStyle.BorderBackground(styles.Surface0)
297+ return p.styleViewport()
298+ }
299+ return ""
320− }
300+}
321
301
322− contentFinal := contentStyle.Render(p.contentViewPort.View())
302+func (p *permissionDialogCmp) renderDefaultContent() string {
303+ content := p.permission.Description
323
304
324− return lipgloss.JoinVertical(
305+ // Use the cache for markdown rendering
325− lipgloss.Top,
306+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
326− headerContent,
307+ r, _ := glamour.NewTermRenderer(
327− contentFinal,
308+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
328− form,
309+ glamour.WithWordWrap(p.width-10),
329 )
310 )
311+ s, err := r.Render(content)
312+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
313+ })
330
314
331− case tools.FetchToolName:
315+ p.contentViewPort.SetContent(renderedContent)
332− pr := p.permission.Params.(tools.FetchPermissionsParams)
333− headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
334− content := p.permission.Description
335
316
336− renderedContent, _ := r.Render(content)
317+ if renderedContent == "" {
337− p.contentViewPort.Width = p.width - 2 - 2
318+ return ""
338− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
319+ }
339− p.contentViewPort.SetContent(renderedContent)
340
320
341− // Style the viewport
321+ return p.styleViewport()
342− contentStyle := lipgloss.NewStyle().
322+}
343− MarginTop(1).
344− Padding(0, 1).
345− Border(lipgloss.RoundedBorder()).
346− BorderForeground(styles.Flamingo)
347
323
348− contentFinal := contentStyle.Render(p.contentViewPort.View())
324+func (p *permissionDialogCmp) styleViewport() string {
349− if renderedContent == "" {
325+ contentStyle := lipgloss.NewStyle().
350− contentFinal = ""
326+ Background(styles.Background)
351− }
352
327
353− return lipgloss.JoinVertical(
328+ return contentStyle.Render(p.contentViewPort.View())
354− lipgloss.Top,
329+}
355− headerContent,
356− contentFinal,
357− form,
358− )
359
330
331+func (p *permissionDialogCmp) render() string {
332+ title := styles.BaseStyle.
333+ Bold(true).
334+ Width(p.width - 4).
335+ Foreground(styles.PrimaryColor).
336+ Render("Permission Required")
337+ // Render header
338+ headerContent := p.renderHeader()
339+ // Render buttons
340+ buttons := p.renderButtons()
341+
342+ // Calculate content height dynamically based on window size
343+ p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
344+ p.contentViewPort.Width = p.width - 4
345+
346+ // Render content based on tool type
347+ var contentFinal string
348+ switch p.permission.ToolName {
349+ case tools.BashToolName:
350+ contentFinal = p.renderBashContent()
351+ case tools.EditToolName:
352+ contentFinal = p.renderEditContent()
353+ case tools.WriteToolName:
354+ contentFinal = p.renderWriteContent()
355+ case tools.FetchToolName:
356+ contentFinal = p.renderFetchContent()
360 default:
357 default:
361− content := p.permission.Description
358+ contentFinal = p.renderDefaultContent()
362−
359+ }
363− renderedContent, _ := r.Render(content)
364− p.contentViewPort.Width = p.width - 2 - 2
365− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
366− p.contentViewPort.SetContent(renderedContent)
367−
368− // Style the viewport
369− contentStyle := lipgloss.NewStyle().
370− MarginTop(1).
371− Padding(0, 1).
372− Border(lipgloss.RoundedBorder()).
373− BorderForeground(styles.Flamingo)
374
360
375− contentFinal := contentStyle.Render(p.contentViewPort.View())
361+ content := lipgloss.JoinVertical(
376− if renderedContent == "" {
362+ lipgloss.Top,
363+ title,
364+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
365+ headerContent,
377− contentFinal = ""
366+ contentFinal,
378− }
367+ buttons,
368+ )
379
369
380− return lipgloss.JoinVertical(
370+ return styles.BaseStyle.
371+ Padding(1, 0, 0, 1).
381− lipgloss.Top,
372+ Border(lipgloss.RoundedBorder()).
382− headerContent,
373+ BorderBackground(styles.Background).
383− contentFinal,
374+ BorderForeground(styles.ForgroundDim).
384− form,
375+ Width(p.width).
376+ Height(p.height).
377+ Render(
378+ content,
385 )
379 )
386− }
387}
380}
388
381
389func (p *permissionDialogCmp) View() string {
382func (p *permissionDialogCmp) View() string {
390 return p.render()
383 return p.render()
391}
384}
392
385
393−func (p *permissionDialogCmp) GetSize() (int, int) {
386+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
394− return p.width, p.height
387+ return layout.KeyMapToSlice(helpKeys)
395}
388}
396
389
397−func (p *permissionDialogCmp) SetSize(width int, height int) {
390+func (p *permissionDialogCmp) SetSize() {
398− p.width = width
391+ if p.permission.ID == "" {
399− p.height = height
392+ return
400− p.form = p.form.WithWidth(width)
393+ }
394+ switch p.permission.ToolName {
395+ case tools.BashToolName:
396+ p.width = int(float64(p.windowSize.Width) * 0.4)
397+ p.height = int(float64(p.windowSize.Height) * 0.3)
398+ case tools.EditToolName:
399+ p.width = int(float64(p.windowSize.Width) * 0.8)
400+ p.height = int(float64(p.windowSize.Height) * 0.8)
401+ case tools.WriteToolName:
402+ p.width = int(float64(p.windowSize.Width) * 0.8)
403+ p.height = int(float64(p.windowSize.Height) * 0.8)
404+ case tools.FetchToolName:
405+ p.width = int(float64(p.windowSize.Width) * 0.4)
406+ p.height = int(float64(p.windowSize.Height) * 0.3)
407+ default:
408+ p.width = int(float64(p.windowSize.Width) * 0.7)
409+ p.height = int(float64(p.windowSize.Height) * 0.5)
410+ }
401}
411}
402
412
403−func (p *permissionDialogCmp) BindingKeys() []key.Binding {
413+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
404− return p.form.KeyBinds()
414+ p.permission = permission
415+ p.SetSize()
405}
416}
406
417
407−func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
418+// Helper to get or set cached diff content
408− // Create a note field for displaying the content
419+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
420+ if cached, ok := c.diffCache[key]; ok {
421+ return cached
422+ }
409
423
410− // Create select field for the permission options
424+ content, err := generator()
411− selectOption := huh.NewSelect[string]().
425+ if err != nil {
412− Key("action").
426+ return fmt.Sprintf("Error formatting diff: %v", err)
413− Options(
427+ }
414− huh.NewOption("Allow", string(PermissionAllow)),
415− huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
416− huh.NewOption("Deny", string(PermissionDeny)),
417− ).
418− Title("Select an action")
419
428
420− // Apply theme
429+ c.diffCache[key] = content
421− theme := styles.HuhTheme()
422
430
423− // Setup form width and height
431+ return content
424− form := huh.NewForm(huh.NewGroup(selectOption)).
432+}
425− WithShowHelp(false).
426− WithTheme(theme).
427− WithShowErrors(false)
428
433
429− // Focus the form for immediate interaction
434+// Helper to get or set cached markdown content
430− selectOption.Focus()
435+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
436+ if cached, ok := c.markdownCache[key]; ok {
437+ return cached
438+ }
431
439
432− return &permissionDialogCmp{
440+ content, err := generator()
433− permission: permission,
441+ if err != nil {
434− form: form,
442+ return fmt.Sprintf("Error rendering markdown: %v", err)
435− selectOption: selectOption,
436 }
443 }
437−}
438
444
439−// NewPermissionDialogCmd creates a new permission dialog command
445+ c.markdownCache[key] = content
440−func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
441− permDialog := newPermissionDialogCmp(permission)
442−
443− // Create the dialog layout
444− dialogPane := layout.NewSinglePane(
445− permDialog.(*permissionDialogCmp),
446− layout.WithSinglePaneBordered(true),
447− layout.WithSinglePaneFocusable(true),
448− layout.WithSinglePaneActiveColor(styles.Warning),
449− layout.WithSinglePaneBorderText(map[layout.BorderPosition]string{
450− layout.TopMiddleBorder: " Permission Required ",
451− }),
452− )
453
446
454− // Focus the dialog
447+ return content
455− dialogPane.Focus()
448+}
456− widthRatio := 0.7
457− heightRatio := 0.6
458− minWidth := 100
459− minHeight := 30
460
449
461− // Make the dialog size more appropriate for different tools
450+func NewPermissionDialogCmp() PermissionDialogCmp {
462− switch permission.ToolName {
451+ // Create viewport for content
463− case tools.BashToolName:
452+ contentViewport := viewport.New(0, 0)
464− // For bash commands, use a more compact dialog
453+
465− widthRatio = 0.7
454+ return &permissionDialogCmp{
466− heightRatio = 0.4 // Reduced from 0.5
455+ contentViewPort: contentViewport,
467− minWidth = 100
456+ selectedOption: 0, // Default to "Allow"
468− minHeight = 20 // Reduced from 30
457+ diffCache: make(map[string]string),
458+ markdownCache: make(map[string]string),
469 }
459 }
470− // Return the dialog command
471− return util.CmdHandler(core.DialogMsg{
472− Content: dialogPane,
473− WidthRatio: widthRatio,
474− HeightRatio: heightRatio,
475− MinWidth: minWidth,
476− MinHeight: minHeight,
477− })
478}
460}
internal/tui/components/dialog/quit.go+91−65
@@ -1,28 +1,58 @@
1package dialog
1package dialog
2
2
3import (
3import (
4+ "strings"
5+
4 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
5 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
6− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
8+ "github.com/charmbracelet/lipgloss"
7 "github.com/kujtimiihoxha/termai/internal/tui/layout"
9 "github.com/kujtimiihoxha/termai/internal/tui/layout"
8 "github.com/kujtimiihoxha/termai/internal/tui/styles"
10 "github.com/kujtimiihoxha/termai/internal/tui/styles"
9 "github.com/kujtimiihoxha/termai/internal/tui/util"
11 "github.com/kujtimiihoxha/termai/internal/tui/util"
10−
11− "github.com/charmbracelet/huh"
12)
12)
13
13
14const question = "Are you sure you want to quit?"
14const question = "Are you sure you want to quit?"
15
15
16+type CloseQuitMsg struct{}
17+
16type QuitDialog interface {
18type QuitDialog interface {
17 tea.Model
19 tea.Model
18− layout.Sizeable
19 layout.Bindings
20 layout.Bindings
20}
21}
21
22
22type quitDialogCmp struct {
23type quitDialogCmp struct {
23− form *huh.Form
24+ selectedNo bool
24− width int
25+}
25− height int
26+
27+type helpMapping struct {
28+ LeftRight key.Binding
29+ EnterSpace key.Binding
30+ Yes key.Binding
31+ No key.Binding
32+ Tab key.Binding
33+}
34+
35+var helpKeys = helpMapping{
36+ LeftRight: key.NewBinding(
37+ key.WithKeys("left", "right"),
38+ key.WithHelp("←/→", "switch options"),
39+ ),
40+ EnterSpace: key.NewBinding(
41+ key.WithKeys("enter", " "),
42+ key.WithHelp("enter/space", "confirm"),
43+ ),
44+ Yes: key.NewBinding(
45+ key.WithKeys("y", "Y"),
46+ key.WithHelp("y/Y", "yes"),
47+ ),
48+ No: key.NewBinding(
49+ key.WithKeys("n", "N"),
50+ key.WithHelp("n/N", "no"),
51+ ),
52+ Tab: key.NewBinding(
53+ key.WithKeys("tab"),
54+ key.WithHelp("tab", "switch options"),
55+ ),
26}
56}
27
57
28func (q *quitDialogCmp) Init() tea.Cmd {
58func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
30}
60}
31
61
32func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33− var cmds []tea.Cmd
63+ switch msg := msg.(type) {
34− form, cmd := q.form.Update(msg)
64+ case tea.KeyMsg:
35− if f, ok := form.(*huh.Form); ok {
65+ switch {
36− q.form = f
66+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
37− cmds = append(cmds, cmd)
67+ q.selectedNo = !q.selectedNo
38− }
68+ return q, nil
39−
69+ case key.Matches(msg, helpKeys.EnterSpace):
40− if q.form.State == huh.StateCompleted {
70+ if !q.selectedNo {
41− v := q.form.GetBool("quit")
71+ return q, tea.Quit
42− if v {
72+ }
73+ return q, util.CmdHandler(CloseQuitMsg{})
74+ case key.Matches(msg, helpKeys.Yes):
43 return q, tea.Quit
75 return q, tea.Quit
76+ case key.Matches(msg, helpKeys.No):
77+ return q, util.CmdHandler(CloseQuitMsg{})
44 }
78 }
45− cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
46 }
79 }
47−
48− return q, tea.Batch(cmds...)
80+ return q, nil
49}
81}
50
82
51func (q *quitDialogCmp) View() string {
83func (q *quitDialogCmp) View() string {
52− return q.form.View()
84+ yesStyle := styles.BaseStyle
85+ noStyle := styles.BaseStyle
86+ spacerStyle := styles.BaseStyle.Background(styles.Background)
87+
88+ if q.selectedNo {
89+ noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
90+ yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
91+ } else {
92+ yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
93+ noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
53−}
94+ }
54
95
55−func (q *quitDialogCmp) GetSize() (int, int) {
96+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
56− return q.width, q.height
97+ noButton := noStyle.Padding(0, 1).Render("No")
98+
99+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
100+
101+ width := lipgloss.Width(question)
102+ remainingWidth := width - lipgloss.Width(buttons)
103+ if remainingWidth > 0 {
104+ buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
57−}
105+ }
58
106
59−func (q *quitDialogCmp) SetSize(width int, height int) {
107+ content := styles.BaseStyle.Render(
60− q.width = width
108+ lipgloss.JoinVertical(
61− q.height = height
109+ lipgloss.Center,
62− q.form = q.form.WithWidth(width).WithHeight(height)
110+ question,
111+ "",
112+ buttons,
113+ ),
114+ )
115+
116+ return styles.BaseStyle.Padding(1, 2).
117+ Border(lipgloss.RoundedBorder()).
118+ BorderBackground(styles.Background).
119+ BorderForeground(styles.ForgroundDim).
120+ Width(lipgloss.Width(content) + 4).
121+ Render(content)
63}
122}
64
123
65func (q *quitDialogCmp) BindingKeys() []key.Binding {
124func (q *quitDialogCmp) BindingKeys() []key.Binding {
66− return q.form.KeyBinds()
125+ return layout.KeyMapToSlice(helpKeys)
67}
126}
68
127
69−func newQuitDialogCmp() QuitDialog {
128+func NewQuitCmp() QuitDialog {
70− confirm := huh.NewConfirm().
71− Title(question).
72− Affirmative("Yes!").
73− Key("quit").
74− Negative("No.")
75−
76− theme := styles.HuhTheme()
77− theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
78− theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
79− form := huh.NewForm(huh.NewGroup(confirm)).
80− WithShowHelp(false).
81− WithWidth(0).
82− WithHeight(0).
83− WithTheme(theme).
84− WithShowErrors(false)
85− confirm.Focus()
86 return &quitDialogCmp{
129 return &quitDialogCmp{
87− form: form,
130+ selectedNo: true,
88 }
131 }
89}
132}
90−
91−func NewQuitDialogCmd() tea.Cmd {
92− content := layout.NewSinglePane(
93− newQuitDialogCmp().(*quitDialogCmp),
94− layout.WithSinglePaneBordered(true),
95− layout.WithSinglePaneFocusable(true),
96− layout.WithSinglePaneActiveColor(styles.Warning),
97− )
98− content.Focus()
99− return util.CmdHandler(core.DialogMsg{
100− Content: content,
101− WidthRatio: 0.2,
102− HeightRatio: 0.1,
103− MinWidth: 40,
104− MinHeight: 5,
105− })
106−}
internal/tui/components/logs/details.go−2
@@ -16,10 +16,8 @@ import (
16
16
17type DetailComponent interface {
17type DetailComponent interface {
18 tea.Model
18 tea.Model
19− layout.Focusable
20 layout.Sizeable
19 layout.Sizeable
21 layout.Bindings
20 layout.Bindings
22− layout.Bordered
23}
21}
24
22
25type detailCmp struct {
23type detailCmp struct {
internal/tui/components/logs/table.go−22
@@ -16,22 +16,14 @@ import (
16
16
17type TableComponent interface {
17type TableComponent interface {
18 tea.Model
18 tea.Model
19− layout.Focusable
20 layout.Sizeable
19 layout.Sizeable
21 layout.Bindings
20 layout.Bindings
22− layout.Bordered
23}
21}
24
22
25type tableCmp struct {
23type tableCmp struct {
26 table table.Model
24 table table.Model
27}
25}
28
26
29−func (i *tableCmp) BorderText() map[layout.BorderPosition]string {
30− return map[layout.BorderPosition]string{
31− layout.TopLeftBorder: "Logs",
32− }
33−}
34−
35type selectedLogMsg logging.LogMessage
27type selectedLogMsg logging.LogMessage
36
28
37func (i *tableCmp) Init() tea.Cmd {
29func (i *tableCmp) Init() tea.Cmd {
@@ -74,20 +66,6 @@ func (i *tableCmp) View() string {
74 return i.table.View()
66 return i.table.View()
75}
67}
76
68
77−func (i *tableCmp) Blur() tea.Cmd {
78− i.table.Blur()
79− return nil
80−}
81−
82−func (i *tableCmp) Focus() tea.Cmd {
83− i.table.Focus()
84− return nil
85−}
86−
87−func (i *tableCmp) IsFocused() bool {
88− return i.table.Focused()
89−}
90−
91func (i *tableCmp) GetSize() (int, int) {
69func (i *tableCmp) GetSize() (int, int) {
92 return i.table.Width(), i.table.Height()
70 return i.table.Width(), i.table.Height()
93}
71}
internal/tui/components/repl/editor.godeleted−201
@@ -1,201 +0,0 @@
1−package repl
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9− "github.com/kujtimiihoxha/termai/internal/app"
10− "github.com/kujtimiihoxha/termai/internal/tui/layout"
11− "github.com/kujtimiihoxha/termai/internal/tui/styles"
12− "github.com/kujtimiihoxha/termai/internal/tui/util"
13− "github.com/kujtimiihoxha/vimtea"
14− "golang.org/x/net/context"
15−)
16−
17−type EditorCmp interface {
18− tea.Model
19− layout.Focusable
20− layout.Sizeable
21− layout.Bordered
22− layout.Bindings
23−}
24−
25−type editorCmp struct {
26− app *app.App
27− editor vimtea.Editor
28− editorMode vimtea.EditorMode
29− sessionID string
30− focused bool
31− width int
32− height int
33− cancelMessage context.CancelFunc
34−}
35−
36−type editorKeyMap struct {
37− SendMessage key.Binding
38− SendMessageI key.Binding
39− CancelMessage key.Binding
40− InsertMode key.Binding
41− NormaMode key.Binding
42− VisualMode key.Binding
43− VisualLineMode key.Binding
44−}
45−
46−var editorKeyMapValue = editorKeyMap{
47− SendMessage: key.NewBinding(
48− key.WithKeys("enter"),
49− key.WithHelp("enter", "send message normal mode"),
50− ),
51− SendMessageI: key.NewBinding(
52− key.WithKeys("ctrl+s"),
53− key.WithHelp("ctrl+s", "send message insert mode"),
54− ),
55− CancelMessage: key.NewBinding(
56− key.WithKeys("ctrl+x"),
57− key.WithHelp("ctrl+x", "cancel current message"),
58− ),
59− InsertMode: key.NewBinding(
60− key.WithKeys("i"),
61− key.WithHelp("i", "insert mode"),
62− ),
63− NormaMode: key.NewBinding(
64− key.WithKeys("esc"),
65− key.WithHelp("esc", "normal mode"),
66− ),
67− VisualMode: key.NewBinding(
68− key.WithKeys("v"),
69− key.WithHelp("v", "visual mode"),
70− ),
71− VisualLineMode: key.NewBinding(
72− key.WithKeys("V"),
73− key.WithHelp("V", "visual line mode"),
74− ),
75−}
76−
77−func (m *editorCmp) Init() tea.Cmd {
78− return m.editor.Init()
79−}
80−
81−func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82− switch msg := msg.(type) {
83− case vimtea.EditorModeMsg:
84− m.editorMode = msg.Mode
85− case SelectedSessionMsg:
86− if msg.SessionID != m.sessionID {
87− m.sessionID = msg.SessionID
88− }
89− }
90− if m.IsFocused() {
91− switch msg := msg.(type) {
92− case tea.KeyMsg:
93− switch {
94− case key.Matches(msg, editorKeyMapValue.SendMessage):
95− if m.editorMode == vimtea.ModeNormal {
96− return m, m.Send()
97− }
98− case key.Matches(msg, editorKeyMapValue.SendMessageI):
99− if m.editorMode == vimtea.ModeInsert {
100− return m, m.Send()
101− }
102− case key.Matches(msg, editorKeyMapValue.CancelMessage):
103− return m, m.Cancel()
104− }
105− }
106− u, cmd := m.editor.Update(msg)
107− m.editor = u.(vimtea.Editor)
108− return m, cmd
109− }
110− return m, nil
111−}
112−
113−func (m *editorCmp) Blur() tea.Cmd {
114− m.focused = false
115− return nil
116−}
117−
118−func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
119− title := "New Message"
120− if m.focused {
121− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
122− }
123− return map[layout.BorderPosition]string{
124− layout.BottomLeftBorder: title,
125− }
126−}
127−
128−func (m *editorCmp) Focus() tea.Cmd {
129− m.focused = true
130− return m.editor.Tick()
131−}
132−
133−func (m *editorCmp) GetSize() (int, int) {
134− return m.width, m.height
135−}
136−
137−func (m *editorCmp) IsFocused() bool {
138− return m.focused
139−}
140−
141−func (m *editorCmp) SetSize(width int, height int) {
142− m.width = width
143− m.height = height
144− m.editor.SetSize(width, height)
145−}
146−
147−func (m *editorCmp) Cancel() tea.Cmd {
148− if m.cancelMessage == nil {
149− return util.ReportWarn("No message to cancel")
150− }
151−
152− m.cancelMessage()
153− m.cancelMessage = nil
154− return util.ReportWarn("Message cancelled")
155−}
156−
157−func (m *editorCmp) Send() tea.Cmd {
158− if m.cancelMessage != nil {
159− return util.ReportWarn("Assistant is still working on the previous message")
160− }
161−
162− messages, err := m.app.Messages.List(context.Background(), m.sessionID)
163− if err != nil {
164− return util.ReportError(err)
165− }
166− if hasUnfinishedMessages(messages) {
167− return util.ReportWarn("Assistant is still working on the previous message")
168− }
169−
170− content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
171− if len(content) == 0 {
172− return util.ReportWarn("Message is empty")
173− }
174− ctx, cancel := context.WithCancel(context.Background())
175− m.cancelMessage = cancel
176− go func() {
177− defer cancel()
178− m.app.CoderAgent.Generate(ctx, m.sessionID, content)
179− m.cancelMessage = nil
180− }()
181−
182− return m.editor.Reset()
183−}
184−
185−func (m *editorCmp) View() string {
186− return m.editor.View()
187−}
188−
189−func (m *editorCmp) BindingKeys() []key.Binding {
190− return layout.KeyMapToSlice(editorKeyMapValue)
191−}
192−
193−func NewEditorCmp(app *app.App) EditorCmp {
194− editor := vimtea.NewEditor(
195− vimtea.WithFileName("message.md"),
196− )
197− return &editorCmp{
198− app: app,
199− editor: editor,
200− }
201−}
internal/tui/components/repl/messages.godeleted−513
@@ -1,513 +0,0 @@
1−package repl
2−
3−import (
4− "context"
5− "encoding/json"
6− "fmt"
7− "sort"
8− "strings"
9−
10− "github.com/charmbracelet/bubbles/key"
11− "github.com/charmbracelet/bubbles/viewport"
12− tea "github.com/charmbracelet/bubbletea"
13− "github.com/charmbracelet/glamour"
14− "github.com/charmbracelet/lipgloss"
15− "github.com/kujtimiihoxha/termai/internal/app"
16− "github.com/kujtimiihoxha/termai/internal/llm/agent"
17− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
18− "github.com/kujtimiihoxha/termai/internal/message"
19− "github.com/kujtimiihoxha/termai/internal/pubsub"
20− "github.com/kujtimiihoxha/termai/internal/session"
21− "github.com/kujtimiihoxha/termai/internal/tui/layout"
22− "github.com/kujtimiihoxha/termai/internal/tui/styles"
23−)
24−
25−type MessagesCmp interface {
26− tea.Model
27− layout.Focusable
28− layout.Bordered
29− layout.Sizeable
30− layout.Bindings
31−}
32−
33−type messagesCmp struct {
34− app *app.App
35− messages []message.Message
36− selectedMsgIdx int // Index of the selected message
37− session session.Session
38− viewport viewport.Model
39− mdRenderer *glamour.TermRenderer
40− width int
41− height int
42− focused bool
43− cachedView string
44−}
45−
46−func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47− switch msg := msg.(type) {
48− case pubsub.Event[message.Message]:
49− if msg.Type == pubsub.CreatedEvent {
50− if msg.Payload.SessionID == m.session.ID {
51− m.messages = append(m.messages, msg.Payload)
52− m.renderView()
53− m.viewport.GotoBottom()
54− }
55− for _, v := range m.messages {
56− for _, c := range v.ToolCalls() {
57− // the message is being added to the session of a tool called
58− if c.ID == msg.Payload.SessionID {
59− m.renderView()
60− m.viewport.GotoBottom()
61− }
62− }
63− }
64− } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
65− for i, v := range m.messages {
66− if v.ID == msg.Payload.ID {
67− m.messages[i] = msg.Payload
68− m.renderView()
69− if i == len(m.messages)-1 {
70− m.viewport.GotoBottom()
71− }
72− break
73− }
74− }
75− }
76− case pubsub.Event[session.Session]:
77− if msg.Type == pubsub.UpdatedEvent && m.session.ID == msg.Payload.ID {
78− m.session = msg.Payload
79− }
80− case SelectedSessionMsg:
81− m.session, _ = m.app.Sessions.Get(context.Background(), msg.SessionID)
82− m.messages, _ = m.app.Messages.List(context.Background(), m.session.ID)
83− m.renderView()
84− m.viewport.GotoBottom()
85− }
86− if m.focused {
87− u, cmd := m.viewport.Update(msg)
88− m.viewport = u
89− return m, cmd
90− }
91− return m, nil
92−}
93−
94−func borderColor(role message.MessageRole) lipgloss.TerminalColor {
95− switch role {
96− case message.Assistant:
97− return styles.Mauve
98− case message.User:
99− return styles.Rosewater
100− }
101− return styles.Blue
102−}
103−
104−func borderText(msgRole message.MessageRole, currentMessage int) map[layout.BorderPosition]string {
105− role := ""
106− icon := ""
107− switch msgRole {
108− case message.Assistant:
109− role = "Assistant"
110− icon = styles.BotIcon
111− case message.User:
112− role = "User"
113− icon = styles.UserIcon
114− }
115− return map[layout.BorderPosition]string{
116− layout.TopLeftBorder: lipgloss.NewStyle().
117− Padding(0, 1).
118− Bold(true).
119− Foreground(styles.Crust).
120− Background(borderColor(msgRole)).
121− Render(fmt.Sprintf("%s %s ", role, icon)),
122− layout.TopRightBorder: lipgloss.NewStyle().
123− Padding(0, 1).
124− Bold(true).
125− Foreground(styles.Crust).
126− Background(borderColor(msgRole)).
127− Render(fmt.Sprintf("#%d ", currentMessage)),
128− }
129−}
130−
131−func hasUnfinishedMessages(messages []message.Message) bool {
132− if len(messages) == 0 {
133− return false
134− }
135− for _, msg := range messages {
136− if !msg.IsFinished() {
137− return true
138− }
139− }
140− return false
141−}
142−
143−func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.ToolCall, futureMessages []message.Message) string {
144− allParts := []string{content}
145−
146− leftPaddingValue := 4
147− connectorStyle := lipgloss.NewStyle().
148− Foreground(styles.Peach).
149− Bold(true)
150−
151− toolCallStyle := lipgloss.NewStyle().
152− Border(lipgloss.RoundedBorder()).
153− BorderForeground(styles.Peach).
154− Width(m.width-leftPaddingValue-5).
155− Padding(0, 1)
156−
157− toolResultStyle := lipgloss.NewStyle().
158− Border(lipgloss.RoundedBorder()).
159− BorderForeground(styles.Green).
160− Width(m.width-leftPaddingValue-5).
161− Padding(0, 1)
162−
163− leftPadding := lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue)
164−
165− runningStyle := lipgloss.NewStyle().
166− Foreground(styles.Peach).
167− Bold(true)
168−
169− renderTool := func(toolCall message.ToolCall) string {
170− toolHeader := lipgloss.NewStyle().
171− Bold(true).
172− Foreground(styles.Blue).
173− Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
174−
175− var paramLines []string
176− var args map[string]interface{}
177− var paramOrder []string
178−
179− json.Unmarshal([]byte(toolCall.Input), &args)
180−
181− for key := range args {
182− paramOrder = append(paramOrder, key)
183− }
184− sort.Strings(paramOrder)
185−
186− for _, name := range paramOrder {
187− value := args[name]
188− paramName := lipgloss.NewStyle().
189− Foreground(styles.Peach).
190− Bold(true).
191− Render(name)
192−
193− truncate := m.width - leftPaddingValue*2 - 10
194− if len(fmt.Sprintf("%v", value)) > truncate {
195− value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
196− }
197− paramValue := fmt.Sprintf("%v", value)
198− paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
199− }
200−
201− paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
202−
203− toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
204− return toolCallStyle.Render(toolContent)
205− }
206−
207− findToolResult := func(toolCallID string, messages []message.Message) *message.ToolResult {
208− for _, msg := range messages {
209− if msg.Role == message.Tool {
210− for _, result := range msg.ToolResults() {
211− if result.ToolCallID == toolCallID {
212− return &result
213− }
214− }
215− }
216− }
217− return nil
218− }
219−
220− renderToolResult := func(result message.ToolResult) string {
221− resultHeader := lipgloss.NewStyle().
222− Bold(true).
223− Foreground(styles.Green).
224− Render(fmt.Sprintf("%s Result", styles.CheckIcon))
225−
226− // Use the same style for both header and border if it's an error
227− borderColor := styles.Green
228− if result.IsError {
229− resultHeader = lipgloss.NewStyle().
230− Bold(true).
231− Foreground(styles.Red).
232− Render(fmt.Sprintf("%s Error", styles.ErrorIcon))
233− borderColor = styles.Red
234− }
235−
236− truncate := 200
237− content := result.Content
238− if len(content) > truncate {
239− content = content[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
240− }
241−
242− resultContent := lipgloss.JoinVertical(lipgloss.Left, resultHeader, content)
243− return toolResultStyle.BorderForeground(borderColor).Render(resultContent)
244− }
245−
246− connector := connectorStyle.Render("└─> Tool Calls:")
247− allParts = append(allParts, connector)
248−
249− for _, toolCall := range tools {
250− toolOutput := renderTool(toolCall)
251− allParts = append(allParts, leftPadding.Render(toolOutput))
252−
253− result := findToolResult(toolCall.ID, futureMessages)
254− if result != nil {
255−
256− resultOutput := renderToolResult(*result)
257− allParts = append(allParts, leftPadding.Render(resultOutput))
258−
259− } else if toolCall.Name == agent.AgentToolName {
260−
261− runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
262− allParts = append(allParts, leftPadding.Render(runningIndicator))
263− taskSessionMessages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
264− for _, msg := range taskSessionMessages {
265− if msg.Role == message.Assistant {
266− for _, toolCall := range msg.ToolCalls() {
267− toolHeader := lipgloss.NewStyle().
268− Bold(true).
269− Foreground(styles.Blue).
270− Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
271−
272− var paramLines []string
273− var args map[string]interface{}
274− var paramOrder []string
275−
276− json.Unmarshal([]byte(toolCall.Input), &args)
277−
278− for key := range args {
279− paramOrder = append(paramOrder, key)
280− }
281− sort.Strings(paramOrder)
282−
283− for _, name := range paramOrder {
284− value := args[name]
285− paramName := lipgloss.NewStyle().
286− Foreground(styles.Peach).
287− Bold(true).
288− Render(name)
289−
290− truncate := 50
291− if len(fmt.Sprintf("%v", value)) > truncate {
292− value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
293− }
294− paramValue := fmt.Sprintf("%v", value)
295− paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
296− }
297−
298− paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
299− toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
300− toolOutput := toolCallStyle.BorderForeground(styles.Teal).MaxWidth(m.width - leftPaddingValue*2 - 2).Render(toolContent)
301− allParts = append(allParts, lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue*2).Render(toolOutput))
302− }
303− }
304− }
305−
306− } else {
307− runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
308− allParts = append(allParts, " "+runningIndicator)
309− }
310− }
311−
312− for _, msg := range futureMessages {
313− if msg.Content().String() != "" || msg.FinishReason() == "canceled" {
314− break
315− }
316−
317− for _, toolCall := range msg.ToolCalls() {
318− toolOutput := renderTool(toolCall)
319− allParts = append(allParts, " "+strings.ReplaceAll(toolOutput, "\n", "\n "))
320−
321− result := findToolResult(toolCall.ID, futureMessages)
322− if result != nil {
323− resultOutput := renderToolResult(*result)
324− allParts = append(allParts, " "+strings.ReplaceAll(resultOutput, "\n", "\n "))
325− } else {
326− runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
327− allParts = append(allParts, " "+runningIndicator)
328− }
329− }
330− }
331−
332− return lipgloss.JoinVertical(lipgloss.Left, allParts...)
333−}
334−
335−func (m *messagesCmp) renderView() {
336− stringMessages := make([]string, 0)
337− r, _ := glamour.NewTermRenderer(
338− glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
339− glamour.WithWordWrap(m.width-20),
340− glamour.WithEmoji(),
341− )
342− textStyle := lipgloss.NewStyle().Width(m.width - 4)
343− currentMessage := 1
344− displayedMsgCount := 0 // Track the actual displayed messages count
345−
346− prevMessageWasUser := false
347− for inx, msg := range m.messages {
348− content := msg.Content().String()
349− if content != "" || prevMessageWasUser || msg.FinishReason() == "canceled" {
350− if msg.ReasoningContent().String() != "" && content == "" {
351− content = msg.ReasoningContent().String()
352− } else if content == "" {
353− content = "..."
354− }
355− if msg.FinishReason() == "canceled" {
356− content, _ = r.Render(content)
357− content += lipgloss.NewStyle().Padding(1, 0, 0, 1).Foreground(styles.Error).Render(styles.ErrorIcon + " Canceled")
358− } else {
359− content, _ = r.Render(content)
360− }
361−
362− isSelected := inx == m.selectedMsgIdx
363−
364− border := lipgloss.DoubleBorder()
365− activeColor := borderColor(msg.Role)
366−
367− if isSelected {
368− activeColor = styles.Primary // Use primary color for selected message
369− }
370−
371− content = layout.Borderize(
372− textStyle.Render(content),
373− layout.BorderOptions{
374− InactiveBorder: border,
375− ActiveBorder: border,
376− ActiveColor: activeColor,
377− InactiveColor: borderColor(msg.Role),
378− EmbeddedText: borderText(msg.Role, currentMessage),
379− },
380− )
381− if len(msg.ToolCalls()) > 0 {
382− content = m.renderMessageWithToolCall(content, msg.ToolCalls(), m.messages[inx+1:])
383− }
384− stringMessages = append(stringMessages, content)
385− currentMessage++
386− displayedMsgCount++
387− }
388− if msg.Role == message.User && msg.Content().String() != "" {
389− prevMessageWasUser = true
390− } else {
391− prevMessageWasUser = false
392− }
393− }
394− m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
395−}
396−
397−func (m *messagesCmp) View() string {
398− return lipgloss.NewStyle().Padding(1).Render(m.viewport.View())
399−}
400−
401−func (m *messagesCmp) BindingKeys() []key.Binding {
402− keys := layout.KeyMapToSlice(m.viewport.KeyMap)
403−
404− return keys
405−}
406−
407−func (m *messagesCmp) Blur() tea.Cmd {
408− m.focused = false
409− return nil
410−}
411−
412−func (m *messagesCmp) projectDiagnostics() string {
413− errorDiagnostics := []protocol.Diagnostic{}
414− warnDiagnostics := []protocol.Diagnostic{}
415− hintDiagnostics := []protocol.Diagnostic{}
416− infoDiagnostics := []protocol.Diagnostic{}
417− for _, client := range m.app.LSPClients {
418− for _, d := range client.GetDiagnostics() {
419− for _, diag := range d {
420− switch diag.Severity {
421− case protocol.SeverityError:
422− errorDiagnostics = append(errorDiagnostics, diag)
423− case protocol.SeverityWarning:
424− warnDiagnostics = append(warnDiagnostics, diag)
425− case protocol.SeverityHint:
426− hintDiagnostics = append(hintDiagnostics, diag)
427− case protocol.SeverityInformation:
428− infoDiagnostics = append(infoDiagnostics, diag)
429− }
430− }
431− }
432− }
433−
434− if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
435− return "No diagnostics"
436− }
437−
438− diagnostics := []string{}
439−
440− if len(errorDiagnostics) > 0 {
441− errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
442− diagnostics = append(diagnostics, errStr)
443− }
444− if len(warnDiagnostics) > 0 {
445− warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
446− diagnostics = append(diagnostics, warnStr)
447− }
448− if len(hintDiagnostics) > 0 {
449− hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
450− diagnostics = append(diagnostics, hintStr)
451− }
452− if len(infoDiagnostics) > 0 {
453− infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
454− diagnostics = append(diagnostics, infoStr)
455− }
456−
457− return strings.Join(diagnostics, " ")
458−}
459−
460−func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
461− title := m.session.Title
462− titleWidth := m.width / 2
463− if len(title) > titleWidth {
464− title = title[:titleWidth] + "..."
465− }
466− if m.focused {
467− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
468− }
469− borderTest := map[layout.BorderPosition]string{
470− layout.TopLeftBorder: title,
471− layout.BottomRightBorder: m.projectDiagnostics(),
472− }
473− if hasUnfinishedMessages(m.messages) {
474− borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Peach).Render("Thinking...")
475− } else {
476− borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Text).Render("Sleeping " + styles.SleepIcon + " ")
477− }
478−
479− return borderTest
480−}
481−
482−func (m *messagesCmp) Focus() tea.Cmd {
483− m.focused = true
484− return nil
485−}
486−
487−func (m *messagesCmp) GetSize() (int, int) {
488− return m.width, m.height
489−}
490−
491−func (m *messagesCmp) IsFocused() bool {
492− return m.focused
493−}
494−
495−func (m *messagesCmp) SetSize(width int, height int) {
496− m.width = width
497− m.height = height
498− m.viewport.Width = width - 2 // padding
499− m.viewport.Height = height - 2 // padding
500− m.renderView()
501−}
502−
503−func (m *messagesCmp) Init() tea.Cmd {
504− return nil
505−}
506−
507−func NewMessagesCmp(app *app.App) MessagesCmp {
508− return &messagesCmp{
509− app: app,
510− messages: []message.Message{},
511− viewport: viewport.New(0, 0),
512− }
513−}
internal/tui/components/repl/sessions.godeleted−249
@@ -1,249 +0,0 @@
1−package repl
2−
3−import (
4− "context"
5− "fmt"
6− "strings"
7−
8− "github.com/charmbracelet/bubbles/key"
9− "github.com/charmbracelet/bubbles/list"
10− tea "github.com/charmbracelet/bubbletea"
11− "github.com/charmbracelet/lipgloss"
12− "github.com/kujtimiihoxha/termai/internal/app"
13− "github.com/kujtimiihoxha/termai/internal/pubsub"
14− "github.com/kujtimiihoxha/termai/internal/session"
15− "github.com/kujtimiihoxha/termai/internal/tui/layout"
16− "github.com/kujtimiihoxha/termai/internal/tui/styles"
17− "github.com/kujtimiihoxha/termai/internal/tui/util"
18−)
19−
20−type SessionsCmp interface {
21− tea.Model
22− layout.Sizeable
23− layout.Focusable
24− layout.Bordered
25− layout.Bindings
26−}
27−type sessionsCmp struct {
28− app *app.App
29− list list.Model
30− focused bool
31−}
32−
33−type listItem struct {
34− id, title, desc string
35−}
36−
37−func (i listItem) Title() string { return i.title }
38−func (i listItem) Description() string { return i.desc }
39−func (i listItem) FilterValue() string { return i.title }
40−
41−type InsertSessionsMsg struct {
42− sessions []session.Session
43−}
44−
45−type SelectedSessionMsg struct {
46− SessionID string
47−}
48−
49−type sessionsKeyMap struct {
50− Select key.Binding
51−}
52−
53−var sessionKeyMapValue = sessionsKeyMap{
54− Select: key.NewBinding(
55− key.WithKeys("enter", " "),
56− key.WithHelp("enter/space", "select session"),
57− ),
58−}
59−
60−func (i *sessionsCmp) Init() tea.Cmd {
61− existing, err := i.app.Sessions.List(context.Background())
62− if err != nil {
63− return util.ReportError(err)
64− }
65− if len(existing) == 0 || existing[0].MessageCount > 0 {
66− newSession, err := i.app.Sessions.Create(
67− context.Background(),
68− "New Session",
69− )
70− if err != nil {
71− return util.ReportError(err)
72− }
73− existing = append([]session.Session{newSession}, existing...)
74− }
75− return tea.Batch(
76− util.CmdHandler(InsertSessionsMsg{existing}),
77− util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
78− )
79−}
80−
81−func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82− switch msg := msg.(type) {
83− case InsertSessionsMsg:
84− items := make([]list.Item, len(msg.sessions))
85− for i, s := range msg.sessions {
86− items[i] = listItem{
87− id: s.ID,
88− title: s.Title,
89− desc: formatTokensAndCost(s.PromptTokens+s.CompletionTokens, s.Cost),
90− }
91− }
92− return i, i.list.SetItems(items)
93− case pubsub.Event[session.Session]:
94− if msg.Type == pubsub.CreatedEvent && msg.Payload.ParentSessionID == "" {
95− // Check if the session is already in the list
96− items := i.list.Items()
97− for _, item := range items {
98− s := item.(listItem)
99− if s.id == msg.Payload.ID {
100− return i, nil
101− }
102− }
103− // insert the new session at the top of the list
104− items = append([]list.Item{listItem{
105− id: msg.Payload.ID,
106− title: msg.Payload.Title,
107− desc: formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost),
108− }}, items...)
109− return i, i.list.SetItems(items)
110− } else if msg.Type == pubsub.UpdatedEvent {
111− // update the session in the list
112− items := i.list.Items()
113− for idx, item := range items {
114− s := item.(listItem)
115− if s.id == msg.Payload.ID {
116− s.title = msg.Payload.Title
117− s.desc = formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost)
118− items[idx] = s
119− break
120− }
121− }
122− return i, i.list.SetItems(items)
123− }
124−
125− case tea.KeyMsg:
126− switch {
127− case key.Matches(msg, sessionKeyMapValue.Select):
128− selected := i.list.SelectedItem()
129− if selected == nil {
130− return i, nil
131− }
132− return i, util.CmdHandler(SelectedSessionMsg{selected.(listItem).id})
133− }
134− }
135− if i.focused {
136− u, cmd := i.list.Update(msg)
137− i.list = u
138− return i, cmd
139− }
140− return i, nil
141−}
142−
143−func (i *sessionsCmp) View() string {
144− return i.list.View()
145−}
146−
147−func (i *sessionsCmp) Blur() tea.Cmd {
148− i.focused = false
149− return nil
150−}
151−
152−func (i *sessionsCmp) Focus() tea.Cmd {
153− i.focused = true
154− return nil
155−}
156−
157−func (i *sessionsCmp) GetSize() (int, int) {
158− return i.list.Width(), i.list.Height()
159−}
160−
161−func (i *sessionsCmp) IsFocused() bool {
162− return i.focused
163−}
164−
165−func (i *sessionsCmp) SetSize(width int, height int) {
166− i.list.SetSize(width, height)
167−}
168−
169−func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
170− totalCount := len(i.list.Items())
171− itemsPerPage := i.list.Paginator.PerPage
172− currentPage := i.list.Paginator.Page
173−
174− current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
175−
176− pageInfo := fmt.Sprintf(
177− "%d-%d of %d",
178− currentPage*itemsPerPage+1,
179− current,
180− totalCount,
181− )
182−
183− title := "Sessions"
184− if i.focused {
185− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
186− }
187− return map[layout.BorderPosition]string{
188− layout.TopMiddleBorder: title,
189− layout.BottomMiddleBorder: pageInfo,
190− }
191−}
192−
193−func (i *sessionsCmp) BindingKeys() []key.Binding {
194− return append(layout.KeyMapToSlice(i.list.KeyMap), sessionKeyMapValue.Select)
195−}
196−
197−func formatTokensAndCost(tokens int64, cost float64) string {
198− // Format tokens in human-readable format (e.g., 110K, 1.2M)
199− var formattedTokens string
200− switch {
201− case tokens >= 1_000_000:
202− formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
203− case tokens >= 1_000:
204− formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
205− default:
206− formattedTokens = fmt.Sprintf("%d", tokens)
207− }
208−
209− // Remove .0 suffix if present
210− if strings.HasSuffix(formattedTokens, ".0K") {
211− formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
212− }
213− if strings.HasSuffix(formattedTokens, ".0M") {
214− formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
215− }
216−
217− // Format cost with $ symbol and 2 decimal places
218− formattedCost := fmt.Sprintf("$%.2f", cost)
219−
220− return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
221−}
222−
223−func NewSessionsCmp(app *app.App) SessionsCmp {
224− listDelegate := list.NewDefaultDelegate()
225− defaultItemStyle := list.NewDefaultItemStyles()
226− defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
227− defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
228−
229− defaultStyle := list.DefaultStyles()
230− defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
231− defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
232−
233− listDelegate.Styles = defaultItemStyle
234−
235− listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
236− listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
237− listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
238− listComponent.SetShowTitle(false)
239− listComponent.SetShowPagination(false)
240− listComponent.SetShowHelp(false)
241− listComponent.SetShowStatusBar(false)
242− listComponent.DisableQuitKeybindings()
243−
244− return &sessionsCmp{
245− app: app,
246− list: listComponent,
247− focused: false,
248− }
249−}
internal/tui/layout/overlay.go+6−5
@@ -5,6 +5,7 @@ import (
5 "strings"
5 "strings"
6
6
7 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
8 "github.com/kujtimiihoxha/termai/internal/tui/util"
9 "github.com/kujtimiihoxha/termai/internal/tui/util"
9 "github.com/mattn/go-runewidth"
10 "github.com/mattn/go-runewidth"
10 "github.com/muesli/ansi"
11 "github.com/muesli/ansi"
@@ -45,13 +46,15 @@ func PlaceOverlay(
45 if shadow {
46 if shadow {
46 var shadowbg string = ""
47 var shadowbg string = ""
47 shadowchar := lipgloss.NewStyle().
48 shadowchar := lipgloss.NewStyle().
48− Foreground(lipgloss.Color("#333333")).
49+ Background(styles.BackgroundDarker).
50+ Foreground(styles.Background).
49 Render("░")
51 Render("░")
52+ bgchar := styles.BaseStyle.Render(" ")
50 for i := 0; i <= fgHeight; i++ {
53 for i := 0; i <= fgHeight; i++ {
51 if i == 0 {
54 if i == 0 {
52− shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
55+ shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
53 } else {
56 } else {
54− shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
57+ shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
55 }
58 }
56 }
59 }
57
60
@@ -159,8 +162,6 @@ func max(a, b int) int {
159 return b
162 return b
160}
163}
161
164
162−
163−
164type whitespace struct {
165type whitespace struct {
165 style termenv.Style
166 style termenv.Style
166 chars string
167 chars string
internal/tui/layout/split.go+1
@@ -10,6 +10,7 @@ import (
10type SplitPaneLayout interface {
10type SplitPaneLayout interface {
11 tea.Model
11 tea.Model
12 Sizeable
12 Sizeable
13+ Bindings
13 SetLeftPanel(panel Container)
14 SetLeftPanel(panel Container)
14 SetRightPanel(panel Container)
15 SetRightPanel(panel Container)
15 SetBottomPanel(panel Container)
16 SetBottomPanel(panel Container)
internal/tui/page/chat.go+25−7
@@ -37,7 +37,6 @@ var keyMap = ChatKeyMap{
37}
37}
38
38
39func (p *chatPage) Init() tea.Cmd {
39func (p *chatPage) Init() tea.Cmd {
40− // TODO: remove
41 cmds := []tea.Cmd{
40 cmds := []tea.Cmd{
42 p.layout.Init(),
41 p.layout.Init(),
43 }
42 }
@@ -48,9 +47,7 @@ func (p *chatPage) Init() tea.Cmd {
48 cmd := p.setSidebar()
47 cmd := p.setSidebar()
49 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(p.session)), cmd)
48 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(p.session)), cmd)
50 }
49 }
51− return tea.Batch(
50+ return tea.Batch(cmds...)
52− cmds...,
53− )
54}
51}
55
52
56func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -68,6 +65,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68 p.session = session.Session{}
65 p.session = session.Session{}
69 p.clearSidebar()
66 p.clearSidebar()
70 return p, util.CmdHandler(chat.SessionClearedMsg{})
67 return p, util.CmdHandler(chat.SessionClearedMsg{})
68+ case key.Matches(msg, keyMap.Cancel):
69+ if p.session.ID != "" {
70+ // Cancel the current session's generation process
71+ // This allows users to interrupt long-running operations
72+ p.app.CoderAgent.Cancel(p.session.ID)
73+ return p, nil
74+ }
71 }
75 }
72 }
76 }
73 u, cmd := p.layout.Update(msg)
77 u, cmd := p.layout.Update(msg)
@@ -80,7 +84,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80
84
81func (p *chatPage) setSidebar() tea.Cmd {
85func (p *chatPage) setSidebar() tea.Cmd {
82 sidebarContainer := layout.NewContainer(
86 sidebarContainer := layout.NewContainer(
83− chat.NewSidebarCmp(p.session),
87+ chat.NewSidebarCmp(p.session, p.app.History),
84 layout.WithPadding(1, 1, 1, 1),
88 layout.WithPadding(1, 1, 1, 1),
85 )
89 )
86 p.layout.SetRightPanel(sidebarContainer)
90 p.layout.SetRightPanel(sidebarContainer)
@@ -111,14 +115,28 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
111 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
115 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
112 }
116 }
113
117
114− p.app.CoderAgent.Generate(context.Background(), p.session.ID, text)
118+ p.app.CoderAgent.Run(context.Background(), p.session.ID, text)
115 return tea.Batch(cmds...)
119 return tea.Batch(cmds...)
116}
120}
117
121
122+func (p *chatPage) SetSize(width, height int) {
123+ p.layout.SetSize(width, height)
124+}
125+
126+func (p *chatPage) GetSize() (int, int) {
127+ return p.layout.GetSize()
128+}
129+
118func (p *chatPage) View() string {
130func (p *chatPage) View() string {
119 return p.layout.View()
131 return p.layout.View()
120}
132}
121
133
134+func (p *chatPage) BindingKeys() []key.Binding {
135+ bindings := layout.KeyMapToSlice(keyMap)
136+ bindings = append(bindings, p.layout.BindingKeys()...)
137+ return bindings
138+}
139+
122func NewChatPage(app *app.App) tea.Model {
140func NewChatPage(app *app.App) tea.Model {
123 messagesContainer := layout.NewContainer(
141 messagesContainer := layout.NewContainer(
124 chat.NewMessagesCmp(app),
142 chat.NewMessagesCmp(app),
@@ -126,7 +144,7 @@ func NewChatPage(app *app.App) tea.Model {
126 )
144 )
127
145
128 editorContainer := layout.NewContainer(
146 editorContainer := layout.NewContainer(
129− chat.NewEditorCmp(),
147+ chat.NewEditorCmp(app),
130 layout.WithBorder(true, false, false, false),
148 layout.WithBorder(true, false, false, false),
131 )
149 )
132 return &chatPage{
150 return &chatPage{
internal/tui/page/init.godeleted−308
@@ -1,308 +0,0 @@
1−package page
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "strconv"
8−
9− "github.com/charmbracelet/bubbles/key"
10− tea "github.com/charmbracelet/bubbletea"
11− "github.com/charmbracelet/huh"
12− "github.com/charmbracelet/lipgloss"
13− "github.com/kujtimiihoxha/termai/internal/llm/models"
14− "github.com/kujtimiihoxha/termai/internal/tui/layout"
15− "github.com/kujtimiihoxha/termai/internal/tui/styles"
16− "github.com/kujtimiihoxha/termai/internal/tui/util"
17− "github.com/spf13/viper"
18−)
19−
20−var InitPage PageID = "init"
21−
22−type configSaved struct{}
23−
24−type initPage struct {
25− form *huh.Form
26− width int
27− height int
28− saved bool
29− errorMsg string
30− statusMsg string
31− modelOpts []huh.Option[string]
32− bigModel string
33− smallModel string
34− openAIKey string
35− anthropicKey string
36− groqKey string
37− maxTokens string
38− dataDir string
39− agent string
40−}
41−
42−func (i *initPage) Init() tea.Cmd {
43− return i.form.Init()
44−}
45−
46−func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47− var cmds []tea.Cmd
48−
49− switch msg := msg.(type) {
50− case tea.WindowSizeMsg:
51− i.width = msg.Width - 4 // Account for border
52− i.height = msg.Height - 4
53− i.form = i.form.WithWidth(i.width).WithHeight(i.height)
54− return i, nil
55−
56− case configSaved:
57− i.saved = true
58− i.statusMsg = "Configuration saved successfully. Press any key to continue."
59− return i, nil
60− }
61−
62− if i.saved {
63− switch msg.(type) {
64− case tea.KeyMsg:
65− return i, util.CmdHandler(PageChangeMsg{ID: ReplPage})
66− }
67− return i, nil
68− }
69−
70− // Process the form
71− form, cmd := i.form.Update(msg)
72− if f, ok := form.(*huh.Form); ok {
73− i.form = f
74− cmds = append(cmds, cmd)
75− }
76−
77− if i.form.State == huh.StateCompleted {
78− // Save configuration to file
79− configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
80− maxTokens, _ := strconv.Atoi(i.maxTokens)
81− config := map[string]any{
82− "models": map[string]string{
83− "big": i.bigModel,
84− "small": i.smallModel,
85− },
86− "providers": map[string]any{
87− "openai": map[string]string{
88− "key": i.openAIKey,
89− },
90− "anthropic": map[string]string{
91− "key": i.anthropicKey,
92− },
93− "groq": map[string]string{
94− "key": i.groqKey,
95− },
96− "common": map[string]int{
97− "max_tokens": maxTokens,
98− },
99− },
100− "data": map[string]string{
101− "dir": i.dataDir,
102− },
103− "agents": map[string]string{
104− "default": i.agent,
105− },
106− "log": map[string]string{
107− "level": "info",
108− },
109− }
110−
111− // Write config to viper
112− for k, v := range config {
113− viper.Set(k, v)
114− }
115−
116− // Save configuration
117− err := viper.WriteConfigAs(configPath)
118− if err != nil {
119− i.errorMsg = fmt.Sprintf("Failed to save configuration: %s", err)
120− return i, nil
121− }
122−
123− // Return to main page
124− return i, util.CmdHandler(configSaved{})
125− }
126−
127− return i, tea.Batch(cmds...)
128−}
129−
130−func (i *initPage) View() string {
131− if i.saved {
132− return lipgloss.NewStyle().
133− Width(i.width).
134− Height(i.height).
135− Align(lipgloss.Center, lipgloss.Center).
136− Render(lipgloss.JoinVertical(
137− lipgloss.Center,
138− lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
139− "",
140− lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
141− ))
142− }
143−
144− view := i.form.View()
145− if i.errorMsg != "" {
146− errorBox := lipgloss.NewStyle().
147− Padding(1).
148− Border(lipgloss.RoundedBorder()).
149− BorderForeground(styles.Red).
150− Width(i.width - 4).
151− Render(i.errorMsg)
152− view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
153− }
154− return view
155−}
156−
157−func (i *initPage) GetSize() (int, int) {
158− return i.width, i.height
159−}
160−
161−func (i *initPage) SetSize(width int, height int) {
162− i.width = width
163− i.height = height
164− i.form = i.form.WithWidth(width).WithHeight(height)
165−}
166−
167−func (i *initPage) BindingKeys() []key.Binding {
168− if i.saved {
169− return []key.Binding{
170− key.NewBinding(
171− key.WithKeys("enter", "space", "esc"),
172− key.WithHelp("any key", "continue"),
173− ),
174− }
175− }
176− return i.form.KeyBinds()
177−}
178−
179−func NewInitPage() tea.Model {
180− // Create model options
181− var modelOpts []huh.Option[string]
182− for id, model := range models.SupportedModels {
183− modelOpts = append(modelOpts, huh.NewOption(model.Name, string(id)))
184− }
185−
186− // Create agent options
187− agentOpts := []huh.Option[string]{
188− huh.NewOption("Coder", "coder"),
189− huh.NewOption("Assistant", "assistant"),
190− }
191−
192− // Init page with form
193− initModel := &initPage{
194− modelOpts: modelOpts,
195− bigModel: string(models.Claude37Sonnet),
196− smallModel: string(models.Claude37Sonnet),
197− maxTokens: "4000",
198− dataDir: ".termai",
199− agent: "coder",
200− }
201−
202− // API Keys group
203− apiKeysGroup := huh.NewGroup(
204− huh.NewNote().
205− Title("API Keys").
206− Description("You need to provide at least one API key to use termai"),
207−
208− huh.NewInput().
209− Title("OpenAI API Key").
210− Placeholder("sk-...").
211− Key("openai_key").
212− Value(&initModel.openAIKey),
213−
214− huh.NewInput().
215− Title("Anthropic API Key").
216− Placeholder("sk-ant-...").
217− Key("anthropic_key").
218− Value(&initModel.anthropicKey),
219−
220− huh.NewInput().
221− Title("Groq API Key").
222− Placeholder("gsk_...").
223− Key("groq_key").
224− Value(&initModel.groqKey),
225− )
226−
227− // Model configuration group
228− modelsGroup := huh.NewGroup(
229− huh.NewNote().
230− Title("Model Configuration").
231− Description("Select which models to use"),
232−
233− huh.NewSelect[string]().
234− Title("Big Model").
235− Options(modelOpts...).
236− Key("big_model").
237− Value(&initModel.bigModel),
238−
239− huh.NewSelect[string]().
240− Title("Small Model").
241− Options(modelOpts...).
242− Key("small_model").
243− Value(&initModel.smallModel),
244−
245− huh.NewInput().
246− Title("Max Tokens").
247− Placeholder("4000").
248− Key("max_tokens").
249− CharLimit(5).
250− Validate(func(s string) error {
251− var n int
252− _, err := fmt.Sscanf(s, "%d", &n)
253− if err != nil || n <= 0 {
254− return fmt.Errorf("must be a positive number")
255− }
256− initModel.maxTokens = s
257− return nil
258− }).
259− Value(&initModel.maxTokens),
260− )
261−
262− // General settings group
263− generalGroup := huh.NewGroup(
264− huh.NewNote().
265− Title("General Settings").
266− Description("Configure general termai settings"),
267−
268− huh.NewInput().
269− Title("Data Directory").
270− Placeholder(".termai").
271− Key("data_dir").
272− Value(&initModel.dataDir),
273−
274− huh.NewSelect[string]().
275− Title("Default Agent").
276− Options(agentOpts...).
277− Key("agent").
278− Value(&initModel.agent),
279−
280− huh.NewConfirm().
281− Title("Save Configuration").
282− Affirmative("Save").
283− Negative("Cancel"),
284− )
285−
286− // Create form with theme
287− form := huh.NewForm(
288− apiKeysGroup,
289− modelsGroup,
290− generalGroup,
291− ).WithTheme(styles.HuhTheme()).
292− WithShowHelp(true).
293− WithShowErrors(true)
294−
295− // Set the form in the model
296− initModel.form = form
297−
298− return layout.NewSinglePane(
299− initModel,
300− layout.WithSinglePaneFocusable(true),
301− layout.WithSinglePaneBordered(true),
302− layout.WithSinglePaneBorderText(
303− map[layout.BorderPosition]string{
304− layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
305− },
306− ),
307− )
308−}
internal/tui/page/logs.go+17
@@ -8,6 +8,23 @@ import (
8
8
9var LogsPage PageID = "logs"
9var LogsPage PageID = "logs"
10
10
11+type logsPage struct {
12+ table logs.TableComponent
13+ details logs.DetailComponent
14+}
15+
16+func (p *logsPage) Init() tea.Cmd {
17+ return nil
18+}
19+
20+func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
21+ return p, nil
22+}
23+
24+func (p *logsPage) View() string {
25+ return p.table.View() + "\n" + p.details.View()
26+}
27+
11func NewLogsPage() tea.Model {
28func NewLogsPage() tea.Model {
12 return layout.NewBentoLayout(
29 return layout.NewBentoLayout(
13 layout.BentoPanes{
30 layout.BentoPanes{
internal/tui/page/repl.godeleted−21
@@ -1,21 +0,0 @@
1−package page
2−
3−import (
4− tea "github.com/charmbracelet/bubbletea"
5− "github.com/kujtimiihoxha/termai/internal/app"
6− "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
7− "github.com/kujtimiihoxha/termai/internal/tui/layout"
8−)
9−
10−var ReplPage PageID = "repl"
11−
12−func NewReplPage(app *app.App) tea.Model {
13− return layout.NewBentoLayout(
14− layout.BentoPanes{
15− layout.BentoLeftPane: repl.NewSessionsCmp(app),
16− layout.BentoRightTopPane: repl.NewMessagesCmp(app),
17− layout.BentoRightBottomPane: repl.NewEditorCmp(app),
18− },
19− layout.WithBentoLayoutCurrentPane(layout.BentoRightBottomPane),
20− )
21−}
internal/tui/tui.go+141−136
@@ -1,8 +1,6 @@
1package tui
1package tui
2
2
3import (
3import (
4− "context"
5−
6 "github.com/charmbracelet/bubbles/key"
4 "github.com/charmbracelet/bubbles/key"
7 tea "github.com/charmbracelet/bubbletea"
5 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
6 "github.com/charmbracelet/lipgloss"
@@ -12,47 +10,41 @@ import (
12 "github.com/kujtimiihoxha/termai/internal/pubsub"
10 "github.com/kujtimiihoxha/termai/internal/pubsub"
13 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
11 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
14 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
12 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
15− "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
16 "github.com/kujtimiihoxha/termai/internal/tui/layout"
13 "github.com/kujtimiihoxha/termai/internal/tui/layout"
17 "github.com/kujtimiihoxha/termai/internal/tui/page"
14 "github.com/kujtimiihoxha/termai/internal/tui/page"
18 "github.com/kujtimiihoxha/termai/internal/tui/util"
15 "github.com/kujtimiihoxha/termai/internal/tui/util"
19− "github.com/kujtimiihoxha/vimtea"
20)
16)
21
17
22type keyMap struct {
18type keyMap struct {
23− Logs key.Binding
24− Return key.Binding
25− Back key.Binding
19+ Logs key.Binding
26− Quit key.Binding
20+ Quit key.Binding
27− Help key.Binding
21+ Help key.Binding
28}
22}
29
23
30var keys = keyMap{
24var keys = keyMap{
31 Logs: key.NewBinding(
25 Logs: key.NewBinding(
32− key.WithKeys("L"),
26+ key.WithKeys("ctrl+l"),
33− key.WithHelp("L", "logs"),
27+ key.WithHelp("ctrl+L", "logs"),
34− ),
35− Return: key.NewBinding(
36− key.WithKeys("esc"),
37− key.WithHelp("esc", "close"),
38− ),
39− Back: key.NewBinding(
40− key.WithKeys("backspace"),
41− key.WithHelp("backspace", "back"),
42 ),
28 ),
29+
43 Quit: key.NewBinding(
30 Quit: key.NewBinding(
44− key.WithKeys("ctrl+c", "q"),
31+ key.WithKeys("ctrl+c"),
45− key.WithHelp("ctrl+c/q", "quit"),
32+ key.WithHelp("ctrl+c", "quit"),
46 ),
33 ),
47 Help: key.NewBinding(
34 Help: key.NewBinding(
48− key.WithKeys("?"),
35+ key.WithKeys("ctrl+_"),
49− key.WithHelp("?", "toggle help"),
36+ key.WithHelp("ctrl+?", "toggle help"),
50 ),
37 ),
51}
38}
52
39
53−var replKeyMap = key.NewBinding(
40+var returnKey = key.NewBinding(
54− key.WithKeys("N"),
41+ key.WithKeys("esc"),
55− key.WithHelp("N", "new session"),
42+ key.WithHelp("esc", "close"),
43+)
44+
45+var logsKeyReturnKey = key.NewBinding(
46+ key.WithKeys("backspace"),
47+ key.WithHelp("backspace", "go back"),
56)
48)
57
49
58type appModel struct {
50type appModel struct {
@@ -62,18 +54,30 @@ type appModel struct {
62 pages map[page.PageID]tea.Model
54 pages map[page.PageID]tea.Model
63 loadedPages map[page.PageID]bool
55 loadedPages map[page.PageID]bool
64 status tea.Model
56 status tea.Model
65− help core.HelpCmp
66− dialog core.DialogCmp
67 app *app.App
57 app *app.App
68− dialogVisible bool
58+
69− editorMode vimtea.EditorMode
59+ showPermissions bool
60+ permissions dialog.PermissionDialogCmp
61+
70− showHelp bool
62+ showHelp bool
63+ help dialog.HelpCmp
64+
65+ showQuit bool
66+ quit dialog.QuitDialog
71}
67}
72
68
73func (a appModel) Init() tea.Cmd {
69func (a appModel) Init() tea.Cmd {
70+ var cmds []tea.Cmd
74 cmd := a.pages[a.currentPage].Init()
71 cmd := a.pages[a.currentPage].Init()
75 a.loadedPages[a.currentPage] = true
72 a.loadedPages[a.currentPage] = true
73+ cmds = append(cmds, cmd)
74+ cmd = a.status.Init()
75+ cmds = append(cmds, cmd)
76+ cmd = a.quit.Init()
77+ cmds = append(cmds, cmd)
78+ cmd = a.help.Init()
79+ cmds = append(cmds, cmd)
76− return cmd
80+ return tea.Batch(cmds...)
77}
81}
78
82
79func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
83func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -81,22 +85,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81 var cmd tea.Cmd
85 var cmd tea.Cmd
82 switch msg := msg.(type) {
86 switch msg := msg.(type) {
83 case tea.WindowSizeMsg:
87 case tea.WindowSizeMsg:
84− var cmds []tea.Cmd
85 msg.Height -= 1 // Make space for the status bar
88 msg.Height -= 1 // Make space for the status bar
86 a.width, a.height = msg.Width, msg.Height
89 a.width, a.height = msg.Width, msg.Height
87
90
88 a.status, _ = a.status.Update(msg)
91 a.status, _ = a.status.Update(msg)
89−
90− uh, _ := a.help.Update(msg)
91− a.help = uh.(core.HelpCmp)
92−
93− p, cmd := a.pages[a.currentPage].Update(msg)
92+ a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
94 cmds = append(cmds, cmd)
93 cmds = append(cmds, cmd)
95− a.pages[a.currentPage] = p
96
94
97− d, cmd := a.dialog.Update(msg)
95+ prm, permCmd := a.permissions.Update(msg)
96+ a.permissions = prm.(dialog.PermissionDialogCmp)
98− cmds = append(cmds, cmd)
97+ cmds = append(cmds, permCmd)
99− a.dialog = d.(core.DialogCmp)
98+
99+ help, helpCmd := a.help.Update(msg)
100+ a.help = help.(dialog.HelpCmp)
101+ cmds = append(cmds, helpCmd)
100
102
101 return a, tea.Batch(cmds...)
103 return a, tea.Batch(cmds...)
102
104
@@ -141,7 +143,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141
143
142 // Permission
144 // Permission
143 case pubsub.Event[permission.PermissionRequest]:
145 case pubsub.Event[permission.PermissionRequest]:
144− return a, dialog.NewPermissionDialogCmd(msg.Payload)
146+ a.showPermissions = true
147+ a.permissions.SetPermissions(msg.Payload)
148+ return a, nil
145 case dialog.PermissionResponseMsg:
149 case dialog.PermissionResponseMsg:
146 switch msg.Action {
150 switch msg.Action {
147 case dialog.PermissionAllow:
151 case dialog.PermissionAllow:
@@ -151,91 +155,71 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151 case dialog.PermissionDeny:
155 case dialog.PermissionDeny:
152 a.app.Permissions.Deny(msg.Permission)
156 a.app.Permissions.Deny(msg.Permission)
153 }
157 }
154−
158+ a.showPermissions = false
155− // Dialog
156− case core.DialogMsg:
157− d, cmd := a.dialog.Update(msg)
158− a.dialog = d.(core.DialogCmp)
159− a.dialogVisible = true
160− return a, cmd
161− case core.DialogCloseMsg:
162− d, cmd := a.dialog.Update(msg)
163− a.dialog = d.(core.DialogCmp)
164− a.dialogVisible = false
165− return a, cmd
159+ return a, nil
166−
167− // Editor
168− case vimtea.EditorModeMsg:
169− a.editorMode = msg.Mode
170
160
171 case page.PageChangeMsg:
161 case page.PageChangeMsg:
172 return a, a.moveToPage(msg.ID)
162 return a, a.moveToPage(msg.ID)
163+
164+ case dialog.CloseQuitMsg:
165+ a.showQuit = false
166+ return a, nil
167+
173 case tea.KeyMsg:
168 case tea.KeyMsg:
174− if a.editorMode == vimtea.ModeNormal {
175− switch {
169+ switch {
176− case key.Matches(msg, keys.Quit):
170+ case key.Matches(msg, keys.Quit):
177− return a, dialog.NewQuitDialogCmd()
171+ a.showQuit = !a.showQuit
178− case key.Matches(msg, keys.Back):
172+ if a.showHelp {
179− if a.previousPage != "" {
180− return a, a.moveToPage(a.previousPage)
181− }
182− case key.Matches(msg, keys.Return):
183− if a.showHelp {
173+ a.showHelp = false
184− a.ToggleHelp()
174+ }
185− return a, nil
175+ return a, nil
186− }
187− case key.Matches(msg, replKeyMap):
176+ case key.Matches(msg, logsKeyReturnKey):
188− if a.currentPage == page.ReplPage {
177+ if a.currentPage == page.LogsPage {
189− sessions, err := a.app.Sessions.List(context.Background())
178+ return a, a.moveToPage(page.ChatPage)
190− if err != nil {
191− return a, util.CmdHandler(util.ReportError(err))
192− }
179+ }
193− lastSession := sessions[0]
180+ case key.Matches(msg, returnKey):
194− if lastSession.MessageCount == 0 {
181+ if a.showQuit {
195− return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: lastSession.ID})
182+ a.showQuit = !a.showQuit
183+ return a, nil
196− }
184+ }
197− s, err := a.app.Sessions.Create(context.Background(), "New Session")
185+ if a.showHelp {
198− if err != nil {
186+ a.showHelp = !a.showHelp
199− return a, util.CmdHandler(util.ReportError(err))
187+ return a, nil
200− }
201− return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
202− }
188+ }
203− // case key.Matches(msg, keys.Logs):
189+ case key.Matches(msg, keys.Logs):
204− // return a, a.moveToPage(page.LogsPage)
190+ return a, a.moveToPage(page.LogsPage)
205− case msg.String() == "O":
206− return a, a.moveToPage(page.ReplPage)
207− case key.Matches(msg, keys.Help):
191+ case key.Matches(msg, keys.Help):
208− a.ToggleHelp()
192+ if a.showQuit {
209 return a, nil
193 return a, nil
210 }
194 }
195+ a.showHelp = !a.showHelp
196+ return a, nil
211 }
197 }
212 }
198 }
213
199
214− if a.dialogVisible {
200+ if a.showQuit {
215− d, cmd := a.dialog.Update(msg)
201+ q, quitCmd := a.quit.Update(msg)
216− a.dialog = d.(core.DialogCmp)
202+ a.quit = q.(dialog.QuitDialog)
203+ cmds = append(cmds, quitCmd)
204+ // Only block key messages send all other messages down
205+ if _, ok := msg.(tea.KeyMsg); ok {
206+ return a, tea.Batch(cmds...)
207+ }
208+ }
209+ if a.showPermissions {
210+ d, permissionsCmd := a.permissions.Update(msg)
211+ a.permissions = d.(dialog.PermissionDialogCmp)
217− cmds = append(cmds, cmd)
212+ cmds = append(cmds, permissionsCmd)
213+ // Only block key messages send all other messages down
214+ if _, ok := msg.(tea.KeyMsg); ok {
218− return a, tea.Batch(cmds...)
215+ return a, tea.Batch(cmds...)
216+ }
219 }
217 }
220 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
218 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
221 cmds = append(cmds, cmd)
219 cmds = append(cmds, cmd)
222 return a, tea.Batch(cmds...)
220 return a, tea.Batch(cmds...)
223}
221}
224
222
225−func (a *appModel) ToggleHelp() {
226− if a.showHelp {
227− a.showHelp = false
228− a.height += a.help.Height()
229− } else {
230− a.showHelp = true
231− a.height -= a.help.Height()
232− }
233−
234− if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
235− sizable.SetSize(a.width, a.height)
236− }
237−}
238−
239func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
223func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
240 var cmd tea.Cmd
224 var cmd tea.Cmd
241 if _, ok := a.loadedPages[pageID]; !ok {
225 if _, ok := a.loadedPages[pageID]; !ok {
@@ -256,27 +240,55 @@ func (a appModel) View() string {
256 a.pages[a.currentPage].View(),
240 a.pages[a.currentPage].View(),
257 }
241 }
258
242
243+ components = append(components, a.status.View())
244+
245+ appView := lipgloss.JoinVertical(lipgloss.Top, components...)
246+
247+ if a.showPermissions {
248+ overlay := a.permissions.View()
249+ row := lipgloss.Height(appView) / 2
250+ row -= lipgloss.Height(overlay) / 2
251+ col := lipgloss.Width(appView) / 2
252+ col -= lipgloss.Width(overlay) / 2
253+ appView = layout.PlaceOverlay(
254+ col,
255+ row,
256+ overlay,
257+ appView,
258+ true,
259+ )
260+ }
261+
259 if a.showHelp {
262 if a.showHelp {
260 bindings := layout.KeyMapToSlice(keys)
263 bindings := layout.KeyMapToSlice(keys)
261 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
264 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
262 bindings = append(bindings, p.BindingKeys()...)
265 bindings = append(bindings, p.BindingKeys()...)
263 }
266 }
264− if a.dialogVisible {
267+ if a.showPermissions {
265− bindings = append(bindings, a.dialog.BindingKeys()...)
268+ bindings = append(bindings, a.permissions.BindingKeys()...)
266 }
269 }
267− if a.currentPage == page.ReplPage {
270+ if a.currentPage == page.LogsPage {
268− bindings = append(bindings, replKeyMap)
271+ bindings = append(bindings, logsKeyReturnKey)
269 }
272 }
270− a.help.SetBindings(bindings)
271− components = append(components, a.help.View())
272− }
273
273
274− components = append(components, a.status.View())
274+ a.help.SetBindings(bindings)
275
275
276− appView := lipgloss.JoinVertical(lipgloss.Top, components...)
276+ overlay := a.help.View()
277+ row := lipgloss.Height(appView) / 2
278+ row -= lipgloss.Height(overlay) / 2
279+ col := lipgloss.Width(appView) / 2
280+ col -= lipgloss.Width(overlay) / 2
281+ appView = layout.PlaceOverlay(
282+ col,
283+ row,
284+ overlay,
285+ appView,
286+ true,
287+ )
288+ }
277
289
278− if a.dialogVisible {
290+ if a.showQuit {
279− overlay := a.dialog.View()
291+ overlay := a.quit.View()
280 row := lipgloss.Height(appView) / 2
292 row := lipgloss.Height(appView) / 2
281 row -= lipgloss.Height(overlay) / 2
293 row -= lipgloss.Height(overlay) / 2
282 col := lipgloss.Width(appView) / 2
294 col := lipgloss.Width(appView) / 2
@@ -289,30 +301,23 @@ func (a appModel) View() string {
289 true,
301 true,
290 )
302 )
291 }
303 }
304+
292 return appView
305 return appView
293}
306}
294
307
295func New(app *app.App) tea.Model {
308func New(app *app.App) tea.Model {
296− // homedir, _ := os.UserHomeDir()
297− // configPath := filepath.Join(homedir, ".termai.yaml")
298− //
299 startPage := page.ChatPage
309 startPage := page.ChatPage
300− // if _, err := os.Stat(configPath); os.IsNotExist(err) {
301− // startPage = page.InitPage
302− // }
303−
304 return &appModel{
310 return &appModel{
305 currentPage: startPage,
311 currentPage: startPage,
306 loadedPages: make(map[page.PageID]bool),
312 loadedPages: make(map[page.PageID]bool),
307− status: core.NewStatusCmp(),
313+ status: core.NewStatusCmp(app.LSPClients),
308− help: core.NewHelpCmp(),
314+ help: dialog.NewHelpCmp(),
309− dialog: core.NewDialogCmp(),
315+ quit: dialog.NewQuitCmp(),
316+ permissions: dialog.NewPermissionDialogCmp(),
310 app: app,
317 app: app,
311 pages: map[page.PageID]tea.Model{
318 pages: map[page.PageID]tea.Model{
312 page.ChatPage: page.NewChatPage(app),
319 page.ChatPage: page.NewChatPage(app),
313 page.LogsPage: page.NewLogsPage(),
320 page.LogsPage: page.NewLogsPage(),
314− page.InitPage: page.NewInitPage(),
315− page.ReplPage: page.NewReplPage(app),
316 },
321 },
317 }
322 }
318}
323}
main.go+7
@@ -2,8 +2,15 @@ package main
2
2
3import (
3import (
4 "github.com/kujtimiihoxha/termai/cmd"
4 "github.com/kujtimiihoxha/termai/cmd"
5+ "github.com/kujtimiihoxha/termai/internal/logging"
5)
6)
6
7
7func main() {
8func main() {
9+ // Set up panic recovery for the main function
10+ defer logging.RecoverPanic("main", func() {
11+ // Perform any necessary cleanup before exit
12+ logging.ErrorPersist("Application terminated due to unhandled panic")
13+ })
14+
8 cmd.Execute()
15 cmd.Execute()
9}
16}